Jacob’s Tech Tavern

Jacob’s Tech Tavern

My Toddler Loves Planes, So I Built Her A Radar

An adorable side project featuring SwiftUI, MapKit, and open-source APIs

Jacob Bartlett's avatar
Jacob Bartlett
Nov 27, 2023

This is a tale of inspiration, of a vision taking shape, and of rapid feedback from that most discerning of customers: a 2-year-old girl. Keen to skip ahead to the final product? Go ahead and download Aviator — Radar on your Phone from the App Store now!


Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every week.

Inspired

I took my toddler abroad this summer.

She was so excited. But, in order to make sure she could handle the 3-hour flight, my wife and I made sure to hype up the airplane journey. So much so, that my toddler was shocked when we had to get into a cab for the airport — she expected to walk straight from our house onto a plane.

Once we boarded the flight, things took an incredible turn — it turns out, if the crew spots you with a cute plane-obsessed toddler, they invite you in to check out the cockpit.

This kindled my daughter’s obsession with airplanes. She keeps adorably asking me to find planes for her in the sky, and becomes delighted when I spot one for her.

Last week, we spent an hour in the garden, with her on my shoulders, spotting planes twinkling in the evening sky, one after the other.

A Problem Statement Emerges

While it’s always great to play with one’s daughter, I knew we could be applying a more efficient approach.

I found FlightRadar24, which shows the positions of planes overlaid on a map. It worked pretty well, but it was a little annoying to have to orient myself to work out where to look in the sky.

Can you spot Heathrow airport?

It’s also pretty tough to spot aircraft on a 2-dimensional plane (pun intended). A Learjet at 40,000 feet shows up the same as an AirBus that just took off at London City Airport, however it’s a lot easier to spot the jumbo jet in the real sky.

Finally, and most importantly, my toddler doesn’t really understand or care what a map is. She just wanted to look at planes.

So we have our problems.

Orientation.

Sizing.

Usability.

As a mobile tech lead, I don’t exactly have a Schwarzenegger physique. I wouldn’t know where to start building my kid a rocking horse. But nothing was going to stand in the way of me making her a cool app.

A Vision Takes Shape

We had the idea for our app:

Show nearby flights on a radar.

In keeping with the requirements we’d created via our research:

  1. The app needs to remain oriented correctly, rotating with the device so that it shows airplanes in the correct direction.

  2. The app has to show aircraft as bigger or smaller depending on how high they are.

  3. The app must be fun, and feel more like a retro kid’s toy than a serious business app.

These requirements led to a few moving parts which form the proof of concept:

  1. Maintaining Orientation is a core differentiating product requirement, since this is missing from the existing solutions. I’m not in the business of detailed flight information — I just want to make a cool radar! The iOS Core Location API has us covered, offering a delegate callback every time the user re-orients their device.

  2. The most important component, of course, is a Flight Data API. OpenSky Network has exactly what I need. A simple REST API, free for non-commercial use, with live data of flights in an area. We’d want to ping this endpoint every few seconds for realistic radar sweeps.

  3. To call the API, we need some Location data. Core Location has us covered again — to get a good number of nearby flights, we could query +/- 1 degree of latitude from the user’s location, with a precision of 0.1 degrees (about 10km) to ensure a user’s location is sufficiently obfuscated. We also only need to fetch this data once per session.

  4. Finally, and like most difficult, we need to dust off our Trigonometry skills to compare flight location data with our own, oriented, coordinates. This will allow us to draw nearby aircraft to the screen in the correct place, according to their relative position from us in the sky.

Since I don’t intend to build a business atop this app — again, the OpenSky Network API is restricted to non-commercial use — I’ll probably use the dead-simple MV architecture for SwiftUI. I’ll leave a bit of business logic in the views, rely on SwiftUI’s built-in APIs for the heavy lifting, and factor out core services such as API and Location.

Once I prove the concept, I can get to work on the really fun part — turning it into a cool radar and testing it with my toddler!

The Proof of Concept

First things first.

For the mascot, I’m picturing a cartoon of my daughter in a cute aviator hat. So we have our app name already: Aviator.

Drawing on my infinite willpower, I’m not going to waste time with an app icon until the MVP is complete. But I now have a project name with which to get started.

Orientation

The first of my key differentiating product requirements is maintaining orientation — to be useful, the objects on-screen need to correspond to their real-life location. Therefore, When the user rotates, the screen itself rotates and keeps pointing North.

Ignoring the template files for AviatorApp and ContentView for now, I whip up a singleton LocationManager and wire up the didUpdateHeading method from CLLocationManagerDelegate.

In navigation, the Heading is the compass direction in which a vessel — or in this case, an iPhone — is pointed.

My LocationManager also handles the initial setup of requesting location permissions, setting the delegate, and telling Core Location to start sending orientation info.

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)
    }
}

To make things play nicely with a SwiftUI view, I’m going to send the orientation information via a Combine publisher, rotationAngleSubject. This means I can reactively handle it in my view with .onReceive, and set a local @State property, rotationAngle.

In my view, to get a nice compass effect, I draw a set of rectangles that vary with this 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)
    }
}

Testing on my device, it looks pretty good, and responds perfectly to my real-world location!

Which begs the question, why can’t Google Maps ever work out which direction I am facing?

You’ll notice a funny visual glitch due to the animation logic treating 0 degrees and 360 degrees as separate numbers — all the rectangles decide to spin around when I go past true North — but it’s fine for the PoC (as I’m unlikely to actually keep this UI anyway).

Flight Data API

Now my warm-up is over.

The really important piece is next: Parsing out data from the OpenSky Network API.

It allows you to specify a range of latitude and longitudes, and returns an array of local flights in that range via a simple GET request — meaning you can simply paste this into your browser to find out what flights I can see overhead:

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

The REST API is documented well, but has an un-keyed structure, meaning the data is presented as a list properties in order.

Documentation for JSON response from OpenSky Network API

We need to use an UnkeyedContainer to decode it, which is designed to parse out fields from the JSON response in order.

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)

        // ...
    }
}

We can write a simple API that performs a GET request based on the user’s location coordinates.

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)
    }
}

You might notice that I used a range of 1 degree of longitude, but only 0.5 degrees of latitude in this API call. That’s because at my latitude, the UK, a 0.5 latitude by 1 longitude rectangle shows up, approximately, as a square.

Now we’re getting somewhere!

The flight data is nicely parsed into an array of in-memory Flight objects which are now nice and easy to deal with.

Drawing Airplanes

It’s pretty trivial to amend my LocationManager to listen for significant location changes and send these coordinates via a publisher.

Again, in pure MV architectural style, my view listens to coordinates via .onReceive and calls my new FlightAPI with these coordinates. The result? Data about the overhead airplanes in your local slice of sky.

Now, we arrive at the hardest part of my initial proof of concept: actually displaying the airplane icons in their correct locations, relative to my own location.

My first iteration was a blunt instrument: I multiplied the relative lat and long by a hardcoded on-screen points value.

@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)
    }
}

There was, of course, no way this would be accurate, since the absolute distance of a degree of latitude or longitude varies with your geolocation. But again, it’s a good place to begin.

Initial Results

How do I actually test the accuracy of my airplane drawings?

I could draw a map underneath everything!

Now my AviatorView has 3 layers: The compass on top, airplanes drawn to the screen, and an unadorned SwiftUI Map view under it all.

@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
    }
}

Here’s the result of my first late-night hackathon, compared against the FlightRadar projection as a source of truth.

Results of Day #1, my app on the left, compared against FlightRadar on the right

I’m certainly on to something, since the number and clustering of airplanes in the sky looks about right — however the positioning is pretty far off.

Suddenly, another flash of inspiration. It’s so simple. I can’t believe I didn’t think of it before.

I need to draw the airplanes on the map using annotations!

The MVP

The idea has been brewing all day: we’ll use a Map, and draw aircraft-shaped annotations on top at their precice geolocations.

Eventually, I want to find a way to hide the actual map, and only display the airplanes as markers on the radar position. This should get us the cool, fully-oriented radar effect we’re aiming at.

Map Annotations

In iOS 17, which I intend to target, drawing annotations on a map is a breeze. Let’s refactor out a 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)
    }
}

Here, for the purposes of a radar, we want to prevent hit-testing — i.e., we don’t want the map to be interactive. In our ideal world, the map is invisible, and the user just sees flights and their positions.

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture