Staybnb M1 — the map and listing annotations
Unit 27 · Staybnb Milestone 1. You put listings on a map: annotations, camera control, and enough clustering that a hundred pins do not become a smear.
MapKit in SwiftUI is a declarative Map view: you give it a camera and a set of content, and
it renders. This milestone gets listings onto the map as tappable annotations, wires the
camera so it follows your results, and clusters pins so the map stays readable when zoomed out.
The listing model
A map annotation needs a coordinate, so the listing carries one. Keep it a Sendable value
type — it flows from the network and, later, from an edge function.
import Foundation import CoreLocation struct Listing: Identifiable, Codable, Sendable, Equatable { let id: UUID let title: String let pricePerNight: Int let guests: Int let amenities: Set<String> let latitude: Double let longitude: Double var coordinate: CLLocationCoordinate2D { CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } }
CLLocationCoordinate2D is not Codable, which is why the raw latitude/longitude are
stored and the coordinate is computed. amenities as a Set<String> makes the "has wifi"
check a cheap contains in Milestone 2.
Rendering the map
The modern Map takes a MapCameraPosition binding and a content builder. Emit a marker or a
custom annotation per listing.
import SwiftUI import MapKit struct ListingsMap: View { let listings: [Listing] @Binding var selected: Listing.ID? @State private var camera: MapCameraPosition = .automatic var body: some View { Map(position: $camera, selection: $selected) { ForEach(listings) { listing in Annotation(listing.title, coordinate: listing.coordinate) { PricePin(price: listing.pricePerNight, isSelected: selected == listing.id) } .tag(listing.id) } } } }
selection: binds the tapped annotation's tag back to selected, so tapping a pin drives the
rest of the UI — the split view in Milestone 3 uses exactly this. PricePin is a small custom
view (a capsule showing the nightly price), which reads far better than a generic marker for a
stays app.
Prefer a custom Annotation with a lightweight SwiftUI label over a stock Marker when the
pin needs to convey data (a price, a rating). Keep the label cheap — it is drawn once per
visible listing, and heavy pin views make panning stutter.
Driving the camera
When results change — a new filter, a new search — frame them. Set the camera to the region that bounds the results rather than snapping to a hard-coded center.
func frame(_ listings: [Listing]) { guard !listings.isEmpty else { return } let coords = listings.map(\.coordinate) let region = MKCoordinateRegion(coordinates: coords) // fit all results camera = .region(region) }
Use .automatic for the initial load (MapKit frames the content for you) and switch to an
explicit .region when you want to follow a result set. Avoid fighting the user: do not
re-center on every gesture, only when the results change.
Clustering
A dense catalog needs clustering or the pins overlap into noise. The pragmatic approach in SwiftUI: bucket listings by a grid whose cell size depends on the current zoom, and render one cluster pin per non-empty bucket showing the count. Tapping a cluster zooms in.
func cluster(_ listings: [Listing], cellDegrees: Double) -> [Cluster] { var buckets: [GridKey: [Listing]] = [:] for listing in listings { let key = GridKey( lat: (listing.latitude / cellDegrees).rounded(.down), lon: (listing.longitude / cellDegrees).rounded(.down) ) buckets[key, default: []].append(listing) } return buckets.map { Cluster(listings: $0.value) } }
cellDegrees shrinks as the user zooms in, so clusters break apart into individual pins. The
key move for performance: cluster before building annotations, so ForEach renders one view
per cluster, not one per listing. On a thousand-listing catalog that is the difference between
smooth and unusable.
On Android you would lean on the Maps SDK's ClusterManager; here you own the bucketing, which
is a few lines and keeps the data flow explicit. The SwiftUI upside is that clusters are just
another Identifiable collection you ForEach over — the same rendering path as the listings
themselves.
The listing card and detail
Most people meet a listing as a card in the explore feed, not a pin. A card is a paging photo gallery with a heart, then location, rating, and price:
struct ListingCard: View { let listing: Listing var body: some View { VStack(alignment: .leading, spacing: 8) { PhotoGallery(listing: listing, height: 300) // a TabView(.page) of photos + a heart .clipShape(RoundedRectangle(cornerRadius: 16)) HStack { Text(listing.location).font(.subheadline.bold()) Spacer() RatingLabel(rating: listing.rating, reviews: nil) } Text(listing.title).font(.subheadline).foregroundStyle(.secondary) (Text("$\(listing.pricePerNight)").bold() + Text(" night")).font(.subheadline) } } }
The gallery is a TabView in page style — swipeable photos with dots for free — and the heart
toggles the wishlist (Milestone 2's store):
struct PhotoGallery: View { let listing: Listing var height: CGFloat = 300 @Environment(Wishlist.self) private var wishlist var body: some View { ZStack(alignment: .topTrailing) { TabView { ForEach(listing.photoColors, id: \.self) { PhotoTile(colorIndex: $0) } } .tabViewStyle(.page) .frame(height: height) Button { withAnimation(.snappy) { wishlist.toggle(listing.id) } } label: { Image(systemName: wishlist.contains(listing.id) ? "heart.fill" : "heart") .foregroundStyle(wishlist.contains(listing.id) ? .red : .white) .padding(10).background(.ultraThinMaterial, in: Circle()) // material for legibility over any photo } .padding(12) } } }
Tapping a card pushes the detail: the gallery again, the title and rating, a host row, the room
specs, an amenities list, a couple of reviews, a "where you'll be" Map, and a sticky reserve
bar via safeAreaInset(edge: .bottom). The reserve button presents the booking sheet, where the
total is derived, never stored — nights × price plus fees:
struct BookingSheet: View { let listing: Listing @State private var nights = 5 @State private var guests = 2 private var subtotal: Int { listing.pricePerNight * nights } private let cleaning = 85 private var service: Int { Int(Double(subtotal) * 0.14) } private var total: Int { subtotal + cleaning + service } // …Steppers for nights/guests, a price-breakdown section, and a Confirm button… }
Every number in the booking sheet is computed from nights, guests, and the listing — nothing
stored, nothing to keep in sync. It's the exact "store the inputs, derive the outputs" discipline
from Project 1's tip splitter, one tier up: the same rule scales from a warm-up to a checkout.
Checkpoint: your app should now…
- Show a category explore of listing cards (paging gallery + heart + price) and a listing detail (host, amenities, reviews, a where-you'll-be map, a reserve bar).
- Present a booking sheet whose total is derived from nights, guests, and fees.
- Show listings as tappable price pins on a
Map, with tap driving aselectedbinding. - Frame the camera to the current results and re-frame when results change (not on every pan).
- Cluster pins when zoomed out so a dense catalog stays legible, expanding on zoom-in.
Stretch goals
- Animate cluster expand/collapse across zoom levels.
- Add a "search this area" button that refetches for the visible region on demand.
Knowledge check
Q: Why store latitude/longitude and compute coordinate rather than store the coordinate?
CLLocationCoordinate2D is not Codable, so it cannot be decoded from the network directly.
Storing the two Doubles keeps the model Codable and Sendable; the coordinate is a trivial
computed property.
Q: Why cluster before building annotations instead of after?
So ForEach renders one view per cluster rather than one per listing. Clustering the data first
keeps the number of SwiftUI annotation views small, which is what keeps panning and zooming
smooth on a large catalog.