This is part indie dev story, part release notes, and part technical documentation as I detail the long-awaited upgrade to Aviator — Radar on your Phone!
My best ever story
My Toddler Loves Planes, So I Built Her A Radar was by far the best thing I’ve written. I managed to hit that sweet spot of wholesome and technical, and my sheer enjoyment of the project shined through in my narrative.

One would think, as an enterprising indie developer, I’d jump straight on top of the hype train, shoot out another release, and begin dreaming up monetisation opportunities.
But there was a stumbling block! Unfortunately, Aviator had been broken since early December. OpenSky Network had been having an incident which gave all my users an error.

After trying very hard not to think about this for several weeks, a miracle happened. I opened the app again, expecting to torture myself with the persistent 502 error message, when I heard the characteristic beep-boop of planes detected on the radar.
It was working again!
Newly energised by this belated Christmas miracle, I dove headfirst into two intense evenings* of overhauling Aviator with the ideas that had been brewing in the back of my brain.
*After getting my toddler to bed, of course.
Introducing Aviator 2.0
This release includes several shiny new features, and solves several problems my daughter and I experienced while we played with the original.
New UI
I’ve overhauled the UI so the controls now live in a separate menu. This utilises progressive disclosure to keep the major controls front-and-centre, and the advanced, less frequently accessed tools below the fold.

This menu is implemented as a modal with two presentation detents:
primarywhich takes up0.15of the screen’s heightextendedwhich takes up0.4of the screen’s height
It also has a presentation drag indicator so users know they can extend the controls menu, and vitally enables background interaction so we don’t cover the main radar UI in shadow.
// 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)I’m planning to utilise the new TipKit in 2.1 to ensure this progressive disclosure isn’t lost on our younger, less experienced Apple fans.
This presentationBackground wraps up another piece of polish: I used Paul Hudson’s emboss effect. This is another Metal shader which applies a lovely metallic texture to the menu, further improving on the skeuomorphism of a control panel for a real-life radar.
If you want to learn more about making your own shaders in SwiftUI, read my tutorial Metal in SwiftUI: How to Write Shaders.
Zoom levels
While building 1.0, I experienced a persistent problem. The radar was too damn good.
More specifically, it was capturing flight details of aircraft I didn’t have a hope of seeing, either far beyond the horizon or hidden behind terrain. This became a real pain-point in the terraced archipelago of suburban London.
This wasn’t just a sensitivity issue, however — when I went out to a lovely open space, I could see planes for miles and miles.
The solution was clear: implement zoom.
The MapKit SDK has the concept of a camera which hovers above the planet, showing your map. You can feed it a distance (in metres) and it zooms in and out of the Earth’s surface based on this.
// AviatorView.swift
@AppStorage("zoomed") var zoomed: Bool = false
// ...
cameraPosition = .camera(MapCamera(centerCoordinate: coordinate,
distance: (zoomed ? 70 : 100) * 1_000,
heading: angle))I also, in my rush to get a competent release out over the weekend, simply removed one of the radar circles to emphasise the zoomed-in UI.
// 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 ...
}Originally, I had a Slider component which handled zoom levels, however even my nifty A17 chip couldn’t quite handle this at a reasonable frame-rate. I’m using a binary zoom toggle for now, but might brainstorm alternative ways to handle this interface.
Flight information
The OpenSky Network API gathers a wealth of information from aircraft transponders, which I was not taking advantage of in 1.0. These helped feed into many of the new features in 2.0.
Foremost of these is the ability to toggle flags — displaying the countries of origin for each plane, as a simple emoji underneath each icon.
This utilised the origin_country property from the OpenSky Network API. This gave the plane start point as a straightforward text string. Using a comprehensive list of country names found online, I could convert the string into a 2-character country code, turn this into unicode scalars, and ultimately construct a flag emoji.
// 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)
}
}
}
}It was then simple to add text to the MapKit annotation when the user toggled this feature on.
@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 ...
}
}
}




