Мой лучший рассказ
“Моя малышка любит самолеты — и я сделал для нее радар” — это, безусловно, лучшая вещь, которую я написал. Мне удалось попасть в ту самую «золотую середину» между полезным и техническим, и мое искреннее удовольствие от проекта просвечивало в рассказе.
Казалось бы, как предприимчивый инди-разработчик, я должен был сразу же вскочить на волну хайпа, выпустить очередной релиз и начать мечтать о монетизации.
Но тут возник камень преткновения! К сожалению, Aviator в начале декабря сломался. В OpenSky Network произошел инцидент, в результате которого все мои пользователи получили ошибку.
После нескольких недель упорных попыток не думать об этом, случилось чудо. Я снова открыл приложение, ожидая, что буду мучиться с постоянным сообщением о 502 ошибке, когда услышал характерное бип-бип самолетов на радаре.
Оно снова работало!
Воодушевленный этим запоздалым рождественским чудом, я провел два напряженных вечера (конечно же, после того, как укладывал малышку спать) по доработке Aviator с учетом идей, которые роились в моем мозгу.
Представляем Aviator 2.0
Эта версия включает в себя несколько новых отличных функций, а также решает несколько проблем, с которыми мы с дочерью столкнулись, играя с оригинальной версией.
Новый пользовательский интерфейс
Я переделал пользовательский интерфейс, теперь элементы управления находятся в отдельном меню. При этом используется прогрессивное раскрытие, чтобы основные элементы управления были в центре внимания, а дополнительные, реже используемые инструменты — ниже.
Это меню реализовано как модальное с двумя положениями:
- основное, занимающее 0.15 высоты экрана
- расширенное, занимающее 0.4 высоты экрана
Оно также имеет индикатор перетаскивания, чтобы пользователи знали, что они могут расширить меню управления, и обеспечивает фоновое взаимодействие, чтобы мы не закрывали тенью основной пользовательский интерфейс радара.
// ControlsView.swift var body: some View { // ... .presentationDetents(availableDetents, selection: $selectedDetent) .presentationDragIndicator(.visible) .presentationCornerRadius(24) .presentationBackground { LinearGradient(colors: [.gray, .spaceGrey], startPoint: .top, endPoint: .bottom) .padding(-2) .embossEffect() } .presentationBackgroundInteraction(.enabled)
Я планирую использовать новый TipKit в версии 2.1, чтобы это прогрессивное раскрытие информации не было потеряно для наших молодых, менее опытных поклонников Apple.
Дальше presentationBackground
завершает еще одну часть полировки приложения — я использовал эффект тиснения Пола Хадсона. Это еще один шейдер Metal, который накладывает на меню красивую металлическую текстуру, еще больше улучшая скеоморфизм и делая меню похожим на панель управления реальным радаром.
Если вы хотите узнать больше о создании собственных шейдеров в SwiftUI, прочитайте мой учебник “Metal в SwiftUI: Как писать шейдеры”.
Уровни масштабирования
Во время создания версии 1.0 я столкнулся с постоянной проблемой. Радар был чертовски хорош.
Точнее, он улавливал детали полета самолетов, которые я не надеялся увидеть — они были либо далеко за горизонтом, либо скрыты за местностью. Это стало настоящей проблемой в пригородных районах Лондона.
Однако это была не просто проблема чувствительности — когда я выходил на прекрасное открытое пространство, я мог видеть самолеты на многие и многие мили.
Решение было очевидным: внедрить увеличение (зум).
В MapKit SDK есть концепция камеры, которая парит над планетой, показывая вашу карту. Вы можете указать ей расстояние (в метрах), и она увеличивает или уменьшает масштаб земной поверхности в зависимости от этого.
// AviatorView.swift @AppStorage("zoomed") var zoomed: Bool = false // ... cameraPosition = .camera(MapCamera(centerCoordinate: coordinate, distance: (zoomed ? 70 : 100) * 1_000, heading: angle))
Я также, торопясь выпустить грамотный релиз в выходные, просто убрал один из кругов радара, чтобы подчеркнуть увеличенный пользовательский интерфейс.
// RadarView.swift private var radarCircles: some View { GeometryReader { geometry in let diameter = min(geometry.size.width, geometry.size.height) let middleDiameter = diameter * (2.0 / 3.0) let innerDiameter = diameter * (1.0 / 3.0) ForEach(zoomed ? [diameter, middleDiameter] : [diameter, middleDiameter, innerDiameter], id: \.self) { // drawing the radar circles ... }
Изначально у меня был Slider
, который управлял уровнем масштабирования, но даже мой проворный чип A17 не смог справиться с этим при разумной частоте кадров. Пока что я использую двоичный переключатель масштаба, но могу подумать над альтернативными способами работы с этим интерфейсом.
Информация о полете
OpenSky Network API собирает множество информации с транспондеров самолетов, чем я не воспользовался в версии 1.0. Это помогло создать множество новых функций в версии 2.0.
Прежде всего, это возможность переключения флагов — отображение стран вылета для каждого самолета в виде простого эмодзи под каждой иконкой.
Для этого использовалось свойство origin_country из OpenSky Network API. Оно предоставляет отправную точку самолета в виде простой текстовой строки. Используя обширный список названий стран, найденный в Интернете, я смог преобразовать строку в двухсимвольный код страны, превратить его в скаляры юникода и, в конечном итоге, создать эмодзи флага.
// Country.swift extension Flight { var flag: String { guard let countryCode = Country(name: origin_country)?.abbreviation else { return "" } return countryCode.uppercased().unicodeScalars.reduce(into: "") { if let scalar = UnicodeScalar(UInt32(127397) + $1.value) { $0.unicodeScalars.append(scalar) } } } }
Затем было просто добавить текст в аннотацию MapKit, когда пользователь включал эту функцию.
@AppStorage("showFlags") var showFlags: Bool = false private var planeMapAnnotations: some MapContent { ForEach(flights.filter, id: \.icao24) { flight in Annotation(showFlags ? flight.flag : "", coordinate: flight.coordinate) { // custom annotation view ... } } }
Режим облачности
Еще одна проблема, с которой я регулярно сталкивался — следствие моей жизни в солнечной Британии.
Хотя Aviator показывает все самолеты, которые может найти, в моросящие дни многие из них скрыты облачностью, что заставляет вас вглядываться во мрак в поисках мигающих огней.
Чтобы решить эту проблему, я добавил еще одну кнопку для скрытия этих самолетов — переключение между солнечным и дождливым режимами.
Это было очень просто реализовать, используя данные, полученные из OpenSky Network. Я использовал 2.5 км в качестве контрольного уровня для низкой облачности.
struct Flight { // ... var isLowAltitude: Bool { geo_altitude < 2500.0 } }
Это можно использовать для отсеивания высоколетящих рейсов в пользовательском интерфейсе:
@AppStorage("cloudy") var cloudy: Bool = false private var planeMapAnnotations: some MapContent { ForEach(flights.filter { cloudy ? $0.isLowAltitude : true }, id: \.icao24) { flight in // MapKit annotation ... } }
Лучшие иконки
Мы с моим малышом обнаружили еще одну проблему с Aviator, когда проводили пользовательское тестирование в парке. Мы заметили вертолет, пролетающий прямо над нами, но Aviator отобразил его как обычный самолет.
К счастью, в API OpenSky Network есть еще одна информация, которая может нам помочь. Мы можем добавить параметр запроса, чтобы получить специальное свойство category
, которое обозначает тип самолета, у которого стоит транспондер. Это, например:
- Легкий самолет (менее 15 500 фунтов, 7 тонн)
- Тяжелые самолеты (более 300 000 фунтов, 136 тонн)
- Роторный самолет (например, вертолет)
- Космический / внеатмосферный аппарат
- Парашютист / скайдайвер (!)
В последнем обновлении Aviator теперь различает размерные классы самолетов и даже выделяет спутники и вертолеты в отдельные иконки.
Сначала мне нужно было внести несколько простых изменений в код enum AircraftCategory
.
// Flight.swift enum AircraftCategory: Int { // ... var image: Image { switch self { case .rotorcraft: return Image(systemName: "xmark.circle.fill") default: return Image(systemName: "airplane") } } var emoji: String? { switch self { case .spaceTransatmospheric: return "🛰️" default: return nil } } var scaling: Double { switch self { case .small: return 0.6 case .large: return 1 case .heavy: return 1.5 // ... default: return 1.0 } } }
Изначально я хотел использовать эмодзи вертолета, но эмодзи 🚁 при преобразовании в один цвет выглядели слишком плохо, поэтому я использовал более абстрактный SFSymbol в виде креста в круге.
После этого я мог настроить внешний вид аннотаций для полетов, которые представляют собой значки, отображаемые на радаре. Было немного сложно заставить эмодзи вести себя как изображения — я использовал масштабированное представление Text в качестве маски над простым Rectangle, чтобы можно было применить окраску и эффекты CRT-экрана.
// FlightAnnotationView.swift var body: some View { aircraft .scaleEffect(flight.category.scaling) // other effects ... } @ViewBuilder private var aircraft: some View { if let emoji { GeometryReader { geo in Rectangle() .mask( Text(emoji) .font(.system(size: min(geo.size.width, geo.size.height))) .frame(width: geo.size.width, height: geo.size.height, alignment: .center) ) } } else { image } }
Логин в OpenSky Network
Я реализовал множество улучшений, используя данные из API OpenSky Network. Но как насчет улучшений в использовании самого API?
Возможно, самой востребованной функцией после версии для Android (Я все еще жду, когда предприимчивый Android-инженер сам портирует его — я уже достаточно подробно описал все в своих статьях! Пожалуйста, сделайте это, только сделайте это бесплатным для всех.) является использование собственных учетных данных для API в надежде избежать ограничения скорости, которое используется в неаутентифицированной версии.
Поэтому в меню управления я теперь позволяю пользователям вводить свои собственные учетные данные, а также даю ссылку на страницу регистрации.
Это было очень просто создать, используя стандартные текстовые поля SwiftUI TextField и SecureField.
// ControlsView.swift @AppStorage("username") var username: String = "" @AppStorage("password") var password: String = "" TextField("Username", text: $username) SecureField("Password", text: $password)
В своем стремлении как можно скорее выпустить версию 2.0, я был немного небрежен, поместив пароль в User Defaults. В следующем выпуске я добавлю его в Keychain, а пока избегайте повторного использования любых секретных паролей . Я также исправлю автокапитализацию.
Наконец, я закодировал данные в base-64, чтобы украсить API-запрос базовой HTTP-аутентификацией.
// FlightAPI.swift func fetchLocalFlightData(coordinate: CLLocationCoordinate2D) async throws -> [Flight] { // flight data url from coordinate ... var request = URLRequest(url: url) if let base64LoginString = getBase64LoginString() { request.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") } let data = try await session.data(for: request).0 let flightData = try decoder.decode(FlightData.self, from: data) return flightData.states } func getBase64LoginString() -> String? { guard let username = UserDefaults.standard.string(forKey: "username"), let password = UserDefaults.standard.string(forKey: "password") else { return nil } let loginString = String(format: "%@:%@", username, password) guard let loginData = loginString.data(using: String.Encoding.utf8) else { return nil } return loginData.base64EncodedString() }
Заключение
Как всегда, очень приятно создавать что-то, с чем моя дочь хочет играть. Я с нетерпением жду, когда у нее появится еще много интересов — если повезет, скоро она увлечется платформерами или хэви-металлом.
Если у кого-то есть идеи или советы для ASO, пожалуйста, пишите в комментариях. И самое главное, скачайте Aviator — Radar на свой Phone 2.0 сегодня (и оставьте отзыв, пожалуйста)!