Мой лучший рассказ
“Моя малышка любит самолеты — и я сделал для нее радар” — это, безусловно, лучшая вещь, которую я написал. Мне удалось попасть в ту самую «золотую середину» между полезным и техническим, и мое искреннее удовольствие от проекта просвечивало в рассказе.
Казалось бы, как предприимчивый инди-разработчик, я должен был сразу же вскочить на волну хайпа, выпустить очередной релиз и начать мечтать о монетизации.
Но тут возник камень преткновения! К сожалению, Aviator в начале декабря сломался. В OpenSky Network произошел инцидент, в результате которого все мои пользователи получили ошибку.
Отключение OpenSky Network.
После нескольких недель упорных попыток не думать об этом, случилось чудо. Я снова открыл приложение, ожидая, что буду мучиться с постоянным сообщением о 502 ошибке, когда услышал характерное бип-бип самолетов на радаре.
Оно снова работало!
Воодушевленный этим запоздалым рождественским чудом, я провел два напряженных вечера (конечно же, после того, как укладывал малышку спать) по доработке Aviator с учетом идей, которые роились в моем мозгу.
Представляем Aviator 2.0
Эта версия включает в себя несколько новых отличных функций, а также решает несколько проблем, с которыми мы с дочерью столкнулись, играя с оригинальной версией.
Новый пользовательский интерфейс
Я переделал пользовательский интерфейс, теперь элементы управления находятся в отдельном меню. При этом используется прогрессивное раскрытие, чтобы основные элементы управления были в центре внимания, а дополнительные, реже используемые инструменты — ниже.
Да, выбор цвета — это абсолютно точно один из 3 основных элементов управления. Моей дочери он нравится больше, чем самолеты.
Это меню реализовано как модальное с двумя положениями:
- основное, занимающее 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 я столкнулся с постоянной проблемой. Радар был чертовски хорош.
Точнее, он улавливал детали полета самолетов, которые я не надеялся увидеть — они были либо далеко за горизонтом, либо скрыты за местностью. Это стало настоящей проблемой в пригородных районах Лондона.
Однако это была не просто проблема чувствительности — когда я выходил на прекрасное открытое пространство, я мог видеть самолеты на многие и многие мили.
Решение было очевидным: внедрить увеличение (зум).
Вид Aviator на Лондон, по умолчанию (слева) и в увеличенном масштабе (справа)
В 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 теперь различает размерные классы самолетов и даже выделяет спутники и вертолеты в отдельные иконки.
Возможно, в версии 3.0 я буду отображать парашютистов отдельно, чтобы мои пользователи не пугались, когда самолет падает прямо в землю.
Сначала мне нужно было внести несколько простых изменений в код 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 в надежде избежать ограничения скорости, которое используется в неаутентифицированной версии.
Поэтому в меню управления я теперь позволяю пользователям вводить свои собственные учетные данные, а также даю ссылку на страницу регистрации.
Вход в OpenSky Network, ссылка на экран регистрации на их сайте.
Это было очень просто создать, используя стандартные текстовые поля 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 сегодня (и оставьте отзыв, пожалуйста)!

