Connect with us

Разработка

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

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

Фото аватара

Опубликовано

/

     
     

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

Хотите перейти к финальному продукту? Загрузите Aviator — Radar on your Phone на свой телефон из App Store прямо сейчас!

Вдохновение

Этим летом я повез свою малышку за границу.

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

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

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

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

Возникла проблема

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

Я нашел программу FlightRadar24, которая показывает положение самолетов на карте. Это неплохо работает, но немного раздражает необходимость ориентироваться, чтобы понять, куда смотреть в небе.

Моя малышка любит самолеты - и я сделал для нее радар

Можете ли вы найти аэропорт Хитроу?

Кроме того, на двухмерной плоскости (plane) довольно сложно понять, где находится самолет (plane) (каламбур!). Самолет Learjet на высоте 40,000 футов отображается так же, как и AirBus, только что взлетевший из аэропорта London City, однако в реальности, конечно, гораздо легче обнаружить большой реактивный самолет.

И, наконец, самое главное: мой ребенок не понимает и не интересуется картой. Она просто хотела смотреть на самолеты.

Так что проблемы у нас есть.

Ориентация.

Размер.

Юзабилити.

Будучи афизиологичным мобильным тех-лидом, я не знал, с чего начать создание лошадки-качалки для своего ребенка. Но ничто не могло помешать мне сделать для нее классное приложение.

Идея обретает форму

У нас была идея для нашего приложения:

Показывать на радаре ближайшие самолеты.

В соответствии с требованиями, которые мы сформировали в ходе исследования:

  1. Приложение должно правильно ориентироваться, вращаться вместе с устройством, чтобы показывать самолеты в правильном направлении.
  2. Приложение должно показывать самолеты больше или меньше в зависимости от их высоты.
  3. Приложение должно быть забавным и больше напоминать детскую ретро-игрушку, чем серьезное приложение для бизнеса.

Эти требования привели к появлению нескольких частей, которые и сформировали начальный proof of concept:

  1. Указание направления — основное требование, отличающее продукт, поскольку этого не хватает в существующих решениях. Я не занимаюсь подробной информацией о полетах — я просто хочу сделать классный радар! В iOS Core Location API это реализовано, в нем есть колбек делегата каждый раз, когда пользователь переориентирует свое устройство.
  2. Наиболее важным компонентом, конечно, является API данных о полетах. У OpenSky Network есть именно то, что мне нужно. Простой REST API, бесплатный для некоммерческого использования, с данными о полетах в том или ином районе. Мы хотим пинговать эту конечную точку каждые несколько секунд для получения реалистичной развертки радара.
  3. Для вызова API нам нужны данные о местоположении. Core Location снова нас подстраховывает — чтобы получить количество близлежащих самолетов, мы можем запросить местоположение пользователя с точностью до 0.1 градуса (около 10 км), чтобы обеспечить достаточную маскировку местоположения пользователя. Кроме того, нам необходимо получать эти данные только один раз за сессию.
  4. И наконец, что самое сложное, нам необходимо вспомнить навыки тригонометрии, чтобы сравнить данные о местоположении самолета с нашими собственными ориентированными координатами. Это позволит нам выводить на экран ближайшие самолеты в нужном месте, в соответствии с их относительным положением относительно нас в небе.

Поскольку я не собираюсь строить на этом приложении бизнес — опять же, OpenSky Network API ограничен для некоммерческого использования, — я, скорее всего, буду использовать для SwiftUI чертовски простую MV-архитектуру. Я оставлю часть бизнес-логики в представлениях, буду полагаться на встроенные в SwiftUI API для выполнения основной работы, а основные сервисы, такие как API и Location, выведу вовне.

Как только я сделаю рабочий концеп, я смогу приступить к самой интересной части работы — превращению его в классный радар и тестированию его на моей малышке!

Доказательство концепции

Прежде всего.

В качестве маскота я представляю себе карикатуру на мою дочь в симпатичной шлеме-авиаторе. Так что название приложения у нас уже есть: Aviator.

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

Ориентация

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

Не обращая пока внимания на файлы шаблонов для AviatorApp и ContentView, я создаю синглтон LocationManager и подключаю к нему метод didUpdateHeading из CLLocationManagerDelegate.

В навигации Heading — это направление по компасу, в котором движется судно — или, в данном случае, iPhone.

Мой LocationManager также выполняет начальную настройку: запрашивает разрешения на определение местоположения, устанавливает делегата и сообщает Core Location, что нужно начать отправлять информацию об ориентации.

final class LocationManager: CLLocationManager, CLLocationManagerDelegate {
        
    static let shared = LocationManager()
    
    private(set) var rotationAngleSubject = CurrentValueSubject<Double, Never>(0)
    
    override private init() {
        super.init()
        requestWhenInUseAuthorization()
        delegate = self
        startUpdatingHeading()
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
        rotationAngleSubject.send(-newHeading.magneticHeading)
    }
}

Чтобы все это хорошо сочеталось с представлением SwiftUI, я собираюсь отправлять информацию об ориентации через издателя Combine, rotationAngleSubject. Это означает, что я могу реактивно обработать ее в своем представлении с помощью .onReceive и установить локальное @State свойство rotationAngle.

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

@State private var rotationAngle: Angle = .degrees(0)

var body: some View {
    ZStack {
        ForEach(0..<36) {
            let angle = Angle.degrees(Double($0 * 10)) + rotationAngle
            Rectangle()
                .frame(width: $0 == 0 ? 16 : 8, height: $0 == 0 ? 3 : 2)
                .foregroundColor($0 == 0 ? .red : .blue)
                .rotationEffect(angle)
                .offset(x: 120 * cos(CGFloat(angle.radians)), y: 120 * sin(CGFloat(angle.radians)))
                .animation(.bouncy, value: rotationAngle)
        }
    }
    .onReceive(LocationManager.shared.rotationAngleSubject) { angle in
        rotationAngle = Angle.degrees(angle)
    }
}

Тестирование на моем устройстве показало, что он выглядит очень хорошо и отлично реагирует на мое реальное местоположение!

Моя малышка любит самолеты - и я сделал для нее радар

В связи с этим возникает вопрос: почему Google Maps никогда не может определить, в каком направлении я нахожусь?

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

Flight Data API

Разминка закончена.

Далее следует действительно важная часть: парсинг данных из OpenSky Network API.

Он позволяет указать диапазон широты и долготы и возвращает массив местных полетов в этом диапазоне с помощью простого GET-запроса — то есть вы можете просто вставить это в браузер, чтобы узнать, какие полеты я вижу над головой:

https://opensky-network.org/api/states/all?lamin=51.0&lamax=52.0&lomin=-0.5&lomax=0.5

REST API хорошо документирован, но имеет неключевую структуру, то есть данные представляются в виде списка свойств по порядку.

Моя малышка любит самолеты - и я сделал для нее радар

Документация для JSON-ответа от OpenSky Network API

Для его декодирования нам необходимо использовать UnkeyedContainer, который предназначен для разбора полей из JSON-ответа по порядку.

struct Flight: Decodable {

    let icao24: String 
    let callsign: String?
    let origin_country: String? 
    let time_position: Int?
    let last_contact: Int
    let longitude: Double
    let latitude: Double

    // ... 

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        icao24 = try container.decode(String.self)
        callsign = try? container.decode(String?.self)
        origin_country = try container.decode(String.self)
        time_position = try? container.decode(Int?.self)
        last_contact = try container.decode(Int.self)
        longitude = try container.decode(Double.self)
        latitude = try container.decode(Double.self)

        // ...
    }
}

Мы можем написать простой API, выполняющий GET-запрос на основе координат местоположения пользователя.

final class FlightAPI {
    
    func fetchLocalFlightData(coordinate: CLLocationCoordinate2D) async throws -> [Flight] {
        
        let lamin = String(format: "%.1f", coordinate.latitude - 0.25)
        let lamax = String(format: "%.1f", coordinate.latitude + 0.25)
        let lomin = String(format: "%.1f", coordinate.longitude - 0.5)
        let lomax = String(format: "%.1f", coordinate.longitude + 0.5)

        let url = URL(string: "https://opensky-network.org/api/states/all?lamin=\(lamin)&lamax=\(lamax)&lomin=\(lomin)&lomax=\(lomax)")!
        let data = try await URLSession.shared.data(from: url).0
        return try JSONDecoder().decode([Flight].self, from: data)
    }
}

Вы можете заметить, что в данном вызове API я использовал диапазон в 1 градус долготы, но только 0.5 градуса широты. Это связано с тем, что на моей широте, в Великобритании, прямоугольник размером 0.5 градуса широты на 1 градус долготы отображается примерно как квадрат.

Ну вот, мы и добрались до цели!

Данные о полетах хорошо разобраны в массив объектов Flight, хранящийся в памяти, с которыми теперь легко и просто работать.

Отрисовка самолетов

Довольно просто внести изменения в LocationManager, чтобы отслеживать значительные изменения местоположения и отправлять эти координаты через издателя.

И снова, в чистом стиле MV-архитектуры, мое представление слушает координаты через .onReceive и вызывает мой новый FlightAPI с этими координатами. Результат? Данные о пролетающих самолетах над вашим локальным участком неба.

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

В первой итерации я использовал тупой инструмент: умножил относительные широту и долготу на жестко прописанное значение.

@State private var coordinates: CLLocationCoordinate2D?
@State private var flights: [Flight] = []

private var airplanes: some View {
    ForEach(flights, id: \.icao24) { flight in
        let latDiff = coordinate.latitude - (flight.latitude ?? 0)
        let lngDiff = coordinate.longitude - (flight.longitude ?? 0)
        Image(systemName: "airplane")
            .resizable()
            .frame(width: 20, height: 20)
            .rotationEffect(.degrees(flight.true_track ?? 0))
            .foregroundColor(.red)
            .offset(x: 250 * latDiff, y: 250 * lngDiff)
    }
}

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

Первые результаты

Как проверить точность отрисовки самолетов?

Я могу нарисовать карту под всем!

Теперь в моем AviatorView есть 3 слоя: Компас сверху, самолеты, нарисованные на экране, и под всем этим — карта SwiftUI без украшений.

@State private var cameraPosition: MapCameraPosition = .camera(MapCamera(
        centerCoordinate: CLLocationCoordinate2D(latitude: 51.0, longitude: 0.0),
        distance: 100_000,
        heading: 0))

var body: some View {
    ZStack {
        Map(position: $cameraPosition) { } 
        airplanes
        compass
    }
}

Вот результат моего первого ночного хакатона в сравнении с прогнозом FlightRadar как источником истины.

Моя малышка любит самолеты - и я сделал для нее радар

Результаты первого дня. Слева — мое приложение, справа — FlightRadar.

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

И вдруг — очередной прилив вдохновения. Все так просто. Не могу поверить, что не додумался до этого раньше.

Нужно нарисовать самолеты на карте с помощью аннотаций!

MVP

Идея рождалась весь день: мы будем использовать карту и рисовать на ней аннотации в виде самолетов в точных географических точках.

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

Аннотации на карте

В iOS 17, на которую я планирую ориентироваться, рисовать аннотации на карте — проще простого. Давайте переделаем FlightMapView.

import MapKit
import SwiftUI

struct FlightMapView: View {
    
    @Binding var cameraPosition: MapCameraPosition
    
    let flights: [Flight]

    var body: some View {
        Map(position: $cameraPosition) {
            planeMapAnnotations
        }
        .mapStyle(.imagery)
        .allowsHitTesting(false)
    }
}

Здесь, для целей радара, мы хотим предотвратить тестирование на касания для карты — т.е. мы не хотим, чтобы карта была интерактивной. В нашем идеальном мире карта невидима, и пользователь видит только рейсы и их местоположение.

Масштабирование самолетов

После ориентации следующей основной проблемой, с которой существующие решения справлялись не очень хорошо, был размер.

Я добавил к аннотациям на карте простое масштабирование, используя высоту полета, чтобы самолеты, расположенные выше, отображались на экране более крупными.

Кроме того, я использовал свойство true_track самолета в сочетании с ориентацией пользователя из Core Location, чтобы показать самолет в правильном направлении.

@State private var rotationAngle: Angle = .degrees(0)

private var planeMapAnnotations: some MapContent {
    ForEach(flights, id: \.icao24) { flight in
        Annotation(flight.icao24, coordinate: flight.coordinate) {
            let rotation = rotationAngle.degrees + flight.true_track
            let scale = min(2, max(log10(height + 1), 0.5))
            Image(systemName: "airplane")
                .rotationEffect(.degrees(rotation))
                .scaleEffect(scale)
            }
        }
        .tint(.white)
    }
}

Исследование на пользователе

Настало время для окончательного испытания, чтобы выяснить, действительно ли работает мой MVP.

Я собираюсь отправиться с дочерью на plane-spotting.

У нас есть реальные аннотации к карте, мы показываем пользователю местоположение и направление на карте.

Самое главное, что приложение точно находит самолеты!

Моя малышка любит самолеты - и я сделал для нее радар

Первый самолет, который мы заметили с помощью Aviator, имел меткое название 3c65d4.

MVP прошел с большим успехом, так как мы с дочерью увидели самолет, который был виден и в приложении!

Это первоначальное тестирование также позволило получить два важных замечания.

Во-первых, моя логика масштабирования обратна — посмотрите на крошечный самолет на земле в аэропорту Лондон-Сити. Поскольку смысл приложения заключается в поиске самолетов в небе, нам необходимо изменить масштаб. Более низкие самолеты должны отображаться крупнее, поскольку мы используем глаза, чтобы их обнаруживать.

Во-вторых, моей малышке не важны карты, только самолеты. Мне нужно было убрать карту, чтобы убрать шум и сосредоточиться на обнаружении самолетов. И начать строить свой радар!

Обновленная логика масштабирования

Я исправил логику масштабирования для самолетов.

После некоторых проб и ошибок — чтобы понять, что хорошо выглядит на экране и дает разумный разброс размеров, — я остановился на этом варианте масштабирования:

min(2, max(4.7 - log10(flight.geo_altitude + 1), 0.7))

Эти масштабы получены из моего локального опыта работы с приложением:

Scale:  1.0835408863965839
Scale:  0.8330645861650874
Scale:  1.095791123396205
Scale:  1.1077242935783653
Scale:  2.0
Scale:  1.4864702267977097
Scale:  0.7

Это распределение работает довольно хорошо — если не считать NOx, то получается вполне полезно для жизни в авиационном узле.

Создание радара

Я был почти готов к созданию радара, который я себе представлял. Но возникла проблема.

Надежность API

API OpenSky с открытым исходным кодом постоянно сбивался по таймауту, выдавал ошибку 502 bad gateway, а иногда просто выдавал ответ 200 с нулевыми данными.

Честно говоря, меня это вполне устраивает — это не корпоративное приложение для бизнеса, и этот замечательный API мне ничего не стоит. У них нет SLA, и я не чувствую себя вправе претендовать на надежную работу.

Чтобы повысить надежность на стороне клиента, я реализовал базовую логику повторных попыток в вызове API.

private func fetchFlights(at coordinate: CLLocationCoordinate2D, retries: Int = 3) async {
    do {
        try await api.fetchLocalFlightData(coordinate: coordinate)

    } catch {
        if retries > 0 {
            try await fetchFlights(at: coordinate, retries: retries - 1)
        }
    }
}

На следующий день API работал нормально в течение всего дня — похоже, что в основном он работает нормально, за исключением некоторых периодов высокой загруженности.

Закрытие карты

Самая важная задача по снижению шума — сделать невидимой саму карту. Без этого радар работать не будет.

Мне удалось сделать это с помощью плоского MapPolygon — якобы он создан для того, чтобы можно было размещать на нем наложения для выделения участков карты. Но я хотел использовать его для скрытия всего, кроме наших аннотаций.

struct FlightMapView: View {

    var body: some View {
        Map(position: $cameraPosition) {
            planeMapAnnotations
            MapPolygon(overlay(coordinate: coordinate))
        }
        .mapStyle(.imagery)
        .allowsHitTesting(false)
    }

    // ...
    
    private func rectangle(around coordinate: CLLocationCoordinate2D) -> [CLLocationCoordinate2D] {
        [
            CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude - 1),
            CLLocationCoordinate2D(latitude: coordinate.latitude - 1, longitude: coordinate.longitude + 1),
            CLLocationCoordinate2D(latitude: coordinate.latitude + 1, longitude: coordinate.longitude + 1),
            CLLocationCoordinate2D(latitude: coordinate.latitude + 1, longitude: coordinate.longitude - 1)
        ]
    }
    
    private func overlay(coordinate: CLLocationCoordinate2D) -> MKPolygon {
        let rectangle = rectangle(around: coordinate)
        return MKPolygon(coordinates: rectangle, count: rectangle.count)
    }
}

Используя мои истощающиеся резервы удачи, этот подход сработал на ура! Теперь мы могли видеть самолеты, но без карты, как мы и хотели!

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

Отрисовка радара

Последним элементом моих основных требований было представление радара.

По сути, это был набор линий, концентрических окружностей и 20 градусов вращающегося углового градиента. Для такого знатока SwiftUI, как я, это было проще простого.

Проверка поздним вечером

Посмотрим, как далеко мы продвинулись.

С сегодняшними основными визуальными изменениями — скрытием карты с помощью оверлея и несколькими строками вида SwiftUI для радара — мы быстро приближались к нашему первоначальному видению.

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

Моя малышка любит самолеты - и я сделал для нее радар

Результаты третьего дня — отображение полетов над Сидкапом.

Исследование на пользователе №2

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

Моя малышка любит самолеты - и я сделал для нее радар

Мы увидели самолеты, которые он обнаруживал! Впрочем, вам придется поверить мне на слово, ибо камера на моем iPhone уже устарела и не видит их.

Мы доказали правильность концепции и создали MVP, который достиг основных первоначальных целей, которые мы ставили перед собой.

Теперь мы можем задуматься о публикации приложения в App Store.

От MVP к продукту

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

Создание радара

Я гордился эффектом, который получился на радаре.

Моя малышка любит самолеты - и я сделал для нее радар

Улучшение с реалистичным эффектом затухания на радаре.

Реализацию я бы назвал «тупо гениальным».

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

Но потом я понял, что моя линия — это просто угловой градиент шириной 20 градусов, переходящий от зеленого к прозрачному.

А что, если бы это был угловой градиент шириной 360 градусов?

А что, если этот градиент переходит из зеленого в прозрачный, из прозрачного в черный?

private var radarLine: some View {
    Circle()
        .fill(
            AngularGradient(
                gradient: Gradient(colors: [
                    Color.black, Color.black, Color.black, Color.black,
                    Color.black.opacity(0.8), Color.black.opacity(0.6),
                    Color.black.opacity(0.4), Color.black.opacity(0.2),
                    Color.clear, Color.clear, Color.clear, Color.clear,
                    Color.clear, Color.clear, Color.clear, Color.clear,
                    Color.clear, Color.clear, Color.clear, Color.green]),
                center: .center,
                startAngle: .degrees(rotationDegree),
                endAngle: .degrees(rotationDegree + 360)
            )
        )
        .rotationEffect(Angle(degrees: rotationDegree))
        .animation(.linear(duration: 6).repeatForever(autoreverses: false), value: rotationDegree)
}

Чаще всего простое и грубое решение работает лучше.

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

Проблему решило создание черного контура для View радара с помощью обратной маски (т.е. черный прямоугольник с круглым отверстием для радара).

Создание действительно выдающегося радара

Сейчас наш пользовательский интерфейс выглядит довольно опрятно. Но я бы еще не назвал его ретро.

Мне хотелось добавить эффект ЭЛТ-экрана с телевизионными сканлиниями, чтобы приложение выглядело так, будто его действительно взяли со старого радара.

В iOS 17 появилась поддержка шейдеров Metal, встроенных в colorEffect, поэтому реализовать этот эффект стало проще, чем когда-либо.

#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4 crtScreen(
    float2 position,
    half4 color,
    float time
) {
    
    if (all(abs(color.rgb - half3(0.0, 0.0, 0.0)) < half3(0.01, 0.01, 0.01))) {
        return color;
    }
    
    const half scanlineIntensity = 0.2;
    const half scanlineFrequency = 400.0;
    half scanlineValue = sin((position.y + time * 10.0) * scanlineFrequency * 3.14159h) * scanlineIntensity;
    return half4(color.rgb - scanlineValue, color.a);
}

Возможно, я оставлю копание в C++ для другой статьи. Не стесняйтесь воровать — главное, я создал модификатор вида, который может применить CRT-эффект к любому представлению, которое нам нравится!

extension View {
    
    func crtScreenEffect(startTime: Date) -> some View {
        modifier(CRTScreen(startTime: startTime))
    }
}

struct CRTScreen: ViewModifier {
    
    let startTime: Date
    
    func body(content: Content) -> some View {
        content
            .colorEffect(
                ShaderLibrary.crtScreen(
                    .float(startTime.timeIntervalSinceNow)
                )
            )
    }
}

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

Моя малышка любит самолеты - и я сделал для нее радар

На самом деле я записал и сделал gif-изображение до реализации временной модуляции — смотрите ниже!

Некоммерческое использование

Хотя на сайте OpenSky Network все предельно ясно, я решил быть вежливым и отправил записку, чтобы убедиться, что мое размещение в App Store будет соответствовать их политике.

Они очень любезно ответили в течение 20 минут!

Моя малышка любит самолеты - и я сделал для нее радар

За пределами визуальных эффектов

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

private func fetchFlights(coordinate: Coordinate, retries: Int = 2) async {
    do {
        let flights = try await api.fetchLocalFlightData(coordinate: coordinate)
        await MainActor.run {
            self.flights = flights
            AudioServicesPlaySystemSound(1052)
            hapticTrigger.toggle()
        }

    // ...

}

Вместе с новым модификатором sensoryFeedback на главном экране для некоторых тактильных ощущений:

.sensoryFeedback(.levelChange, trigger: hapticTrigger)

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

Элементы управления и настройка

Во-первых, необходимо добавить бесшумный режим.

А также, возможно, несколько других простых настроек с помощью @AppStorage.

@AppStorage("silent") var silentMode: Bool = false
@AppStorage("showMap") var showMap: Bool = false
@AppStorage("userColor") var userColor: Color = .green

Теперь можно отключить звук и даже отключить наложение радара, чтобы видеть под ним карту.

Но самое главное, поскольку я делаю это для своего ребенка, выбор цвета для радара с помощью программы выбора цвета SwiftUI является абсолютно обязательным.

И наконец, какая жизнь без анимированного SFSymbol или даже двух?

private func toggleableIcon(state: Bool, iconTrue: String, iconFalse: String) -> some View {
        Image(systemName: state ? iconTrue : iconFalse)
            .contentTransition(.symbolEffect(.replace))
    // ...
}

Я считаю, что наше приложение уже готово к работе.

Моя малышка любит самолеты - и я сделал для нее радар

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

Теперь AviatorView верхнего уровня выглядит примерно так:

// @State properties ...

var body: some View {
    ZStack {
        if let coordinate = locationManager.coordinateSubject.value {
            FlightMapView(
                cameraPosition: $cameraPosition,
                flights: flights,
                rotationAngle: rotationAngle,
                coordinate: coordinate
            )
        }
    
        TimelineView(.animation) { context in
            RadarView()
                .crtScreenEffect()
                .negativeHighlight()
        }
    
        ControlsView(errorMessage: errorMessage)
    }

    // onRecieve modifiers ...
}

Маскот Авиатор

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

К счастью, мне удалось сфотографировать свою дочь в шлеме-авиаторе, а это именно тот образ, который я хотел получить!

Моя малышка любит самолеты - и я сделал для нее радар

Логотип приложения Aviator, созданный с помощью Gencraft.

Это также привело к созданию моего самого успешного твита в истории.

Моя малышка любит самолеты - и я сделал для нее радар

В App Store!

Лично я уже несколько лет не плачу за программу для разработчиков Apple.

Посмотрите на это кладбище заброшенных побочных проектов.

Моя малышка любит самолеты - и я сделал для нее радар

Ну что ж. Минус 79 фунтов стерлингов и я готов к публикации.

Моя малышка любит самолеты - и я сделал для нее радар

Интересный факт: я ориентируюсь только на iOS 17. Но мне все еще нужно предоставить скриншоты для 6.5-дюймового и 5.5-дюймового iPhone. Последний 5.5-дюймовый iPhone? Это 8 Plus. На котором установлена максимальная версия iOS 16. Ага. К счастью, добрые люди из AppScreens позволили мне экспортировать снимки для всех размеров. Но не перемасштабировать видео.

Наши финальные тесты

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

Моя малышка любит самолеты - и я сделал для нее радар

Моя малышка любит самолеты - и я сделал для нее радар

Моя малышка любит самолеты - и я сделал для нее радар

Опубликовано!

Моя малышка любит самолеты - и я сделал для нее радар

Хотите скачать приложение? Перейдите на страницу Aviator — Radar на вашем телефоне прямо сейчас (и не забудьте поставить оценку)!

Следующие шаги

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

После написания этой статьи я наметил несколько функций в своем мини-плане для следующего релиза:

  • Добавить уровни масштабирования карты, чтобы ограничить работу радара только близкими самолетами.
  • Использовать расширенную версию OpenSky Network API для отображения вертолетов, спутников и размеров самолетов.
  • Переключение отображения страны отправления и назначения на самолетах.
  • Улучшить эффект ЭЛТ-экрана с помощью более совершенных шейдеров Metal.
  • Реорганизация всех элементов управления в изменяемый по размеру выдвижной модальный экран с прогрессивным раскрытием и фиксаторами.
  • Реализовать ползунковые регуляторы для фильтрации определенных расстояний и высот — например, чтобы скрыть все низколетящие и далеко летящие самолеты.
  • Реализовать режим «zany mode», в котором на радаре отображаются НЛО, гигантские жуки и инопланетяне.

Если у вас есть какие-то свои идеи или просто пожелания, пожалуйста, сообщите мне об этом в комментариях!

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Advertisement

Наши партнеры:

LEGALBET

Мобильные приложения для ставок на спорт
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: