Custom MapKit annotations and why the defaults aren't enough

City Search-Navi shows restaurants, landmarks, parks, and all sorts of points of interest on a map. When I was prototyping with a handful of test pins, MKMarkerAnnotationView looked fine. Clean, familiar, readable. Then I loaded real data for San Francisco and dropped 200 pins onto the viewport. The map turned into a wall of overlapping red teardrops. You couldn't tell a taqueria from a trailhead.

I needed something purpose-built.

Category-colored circles

I replaced the default markers with small circles, each colored by category. Restaurants get a warm orange. Parks are green. Landmarks are a muted blue. Inside each circle sits a tiny SF Symbol matching the category -- a fork, a tree, a building. The circles are sized proportionally to a relevance score I calculate based on user preferences, ratings, and distance. A highly relevant spot gets a slightly larger circle; a marginal one shrinks to nearly a dot.

For the user's current selection, I added a subtle scale and fade pulse animation using UIView.animate with autoreverse and repeat. It's gentle enough that it doesn't look like a loading spinner but distinct enough that you can immediately spot which pin you've tapped. Getting the timing right took more iterations than I expected. Anything faster than about 1.8 seconds per cycle felt frantic.

The clustering problem

MapKit's built-in MKClusterAnnotation handles the basic logic well. It groups nearby pins and shows a count. But the visual transition when zooming drove me crazy. You'd zoom in, and a cluster would just vanish and get replaced by individual pins popping into existence. Zoom out and the pins disappear, replaced by a cluster. Felt jumpy.

I wrote a custom animation layer on top. When a cluster splits, I calculate the screen positions of the child annotations, place them all at the cluster's position, and animate each one outward to its final location using a spring timing curve. Merging reverses it -- individual pins slide inward toward the cluster center before the cluster appears. Maybe 80 lines of code in the MKMapViewDelegate callbacks and it made the whole map feel way more polished.

MKMapView vs. SwiftUI Map

I started with the Map view introduced in SwiftUI. Seemed like the obvious choice for a SwiftUI app. But I hit walls fast. You can't use fully custom annotation views -- you're limited to MapMarker and MapAnnotation with SwiftUI content, and the performance with hundreds of annotations was poor. Gesture handling was limited too. I couldn't intercept region change events with enough granularity to drive my filter logic.

I wrapped MKMapView in a UIViewRepresentable instead. More boilerplate, but I got full control over annotation views, delegate callbacks, and rendering performance. The coordinator pattern for handling delegate methods is verbose but predictable once you've set it up. I don't regret the switch.

Combining search sources

City Search-Navi's search runs MKLocalSearch and queries my own curated database in parallel. Apple's POI data is solid for chain restaurants, major landmarks, and well-known businesses. Less reliable for the kind of places locals actually recommend -- the bakery that doesn't have a website, the park with the good sunset view, the bookstore that moved last year. For every city the app supports, I maintain a curated set of spots that fill those gaps. Results from both sources merge into a single ranked list, deduplicated by proximity and name similarity.

Filtering by zoom level

Early testers told me the map was overwhelming. They were right. Showing restaurants, parks, landmarks, nightlife, and shops all at once turned the screen into confetti. I added a horizontal filter bar at the top of the map. It defaults to the category most relevant for the current zoom level -- zoomed out, you see landmarks and parks; zoomed in, restaurants and shops appear. Users can override this and pick whatever categories they want, but the smart default means most people never touch it.

Offline tiles

City Search-Navi caches map tiles for cities a user has browsed. First time you explore a city, the app pre-fetches tiles for a few surrounding zoom levels using MKTileOverlay backed by a local cache directory. If connectivity drops -- underground, on a plane, in a rural dead zone -- the cached tiles render seamlessly and the curated POI data is already stored locally in SwiftData. You lose live search results from Apple, but the core map experience keeps working.

The caching layer checks tile age on each launch and evicts anything older than 30 days. Storage stays reasonable, usually under 150MB even for users who've browsed a dozen cities.


-- Daniel