Staybnb M1 — the map and listing annotationsswift-6.4/ios-26
Lesson 2 / 6
Unit 27 · Staybnb

Staybnb M1 — the map and listing annotations

Coming from
Note

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.

Tip

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.

Coming from Kotlin

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…
}
Tip

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 a selected binding.
  • 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.