Site icon AppTractor

Моя малышка все еще любит самолеты — и я улучшил для нее радар

Мой лучший рассказ

Моя малышка любит самолеты — и я сделал для нее радар” — это, безусловно, лучшая вещь, которую я написал. Мне удалось попасть в ту самую «золотую середину» между полезным и техническим, и мое искреннее удовольствие от проекта просвечивало в рассказе.

Казалось бы, как предприимчивый инди-разработчик, я должен был сразу же вскочить на волну хайпа, выпустить очередной релиз и начать мечтать о монетизации.

Но тут возник камень преткновения! К сожалению, Aviator в начале декабря сломался. В OpenSky Network произошел инцидент, в результате которого все мои пользователи получили ошибку.

Отключение OpenSky Network.

После нескольких недель упорных попыток не думать об этом, случилось чудо. Я снова открыл приложение, ожидая, что буду мучиться с постоянным сообщением о 502 ошибке, когда услышал характерное бип-бип самолетов на радаре.

Оно снова работало!

Воодушевленный этим запоздалым рождественским чудом, я провел два напряженных вечера (конечно же, после того, как укладывал малышку спать) по доработке Aviator с учетом идей, которые роились в моем мозгу.

Представляем Aviator 2.0

Эта версия включает в себя несколько новых отличных функций, а также решает несколько проблем, с которыми мы с дочерью столкнулись, играя с оригинальной версией.

Новый пользовательский интерфейс

Я переделал пользовательский интерфейс, теперь элементы управления находятся в отдельном меню. При этом используется прогрессивное раскрытие, чтобы основные элементы управления были в центре внимания, а дополнительные, реже используемые инструменты — ниже.

Да, выбор цвета — это абсолютно точно один из 3 основных элементов управления. Моей дочери он нравится больше, чем самолеты.

Это меню реализовано как модальное с двумя положениями:

Оно также имеет индикатор перетаскивания, чтобы пользователи знали, что они могут расширить меню управления, и обеспечивает фоновое взаимодействие, чтобы мы не закрывали тенью основной пользовательский интерфейс радара.

// 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, которое обозначает тип самолета, у которого стоит транспондер. Это, например:

В последнем обновлении 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 сегодня (и оставьте отзыв, пожалуйста)!

 Источник

Exit mobile version