Build a Maps App with MapKit: Hiking Trail Finder with Routing


Imagine an app that drops you right at the trailhead of Paradise Falls, plots a winding route through Radiator Springs Canyon, and calculates exactly how long it takes to hike up Monsters Inc Mountain. That is what you are about to build.

In this tutorial, you will build Up! Adventure Trails — a hiking trail finder app themed around Pixar’s Up. The app displays a curated list of fictional hiking trails named after iconic Pixar locations, plots them on an interactive map with custom markers and annotations, draws route polylines between waypoints, and calculates walking directions using Apple’s routing engine. Along the way, you will master the modern SwiftUI MapKit APIs introduced in iOS 17, including Map, Marker, Annotation, MapPolyline, MKDirections, and MapCameraPosition.

Prerequisites

Contents

Getting Started

Start by creating a fresh Xcode project.

  1. Open Xcode and select File > New > Project.
  2. Choose the App template under iOS and click Next.
  3. Set the product name to UpAdventureTrails.
  4. Make sure Interface is set to SwiftUI and Language to Swift.
  5. Choose a location and click Create.

No additional capabilities are required for this tutorial. MapKit works out of the box for displaying maps and calculating routes. If you wanted to show the user’s real-time location you would need to configure the location usage description in Info.plist, but our trail finder uses predefined coordinates, so we can skip that step for now.

Note: The modern SwiftUI MapKit APIs (Map, Marker, Annotation, MapPolyline) require iOS 17 or later. We are targeting iOS 18 to ensure access to all the latest refinements.

Step 1: Defining the Trail Data Model

Every great adventure starts with a map, and every map starts with data. We need a model that represents a hiking trail with a name, difficulty rating, coordinates for the trailhead, and a series of waypoints that define the route.

Create a new Swift file at Models/Trail.swift and add the following:

import Foundation
import CoreLocation

struct Trail: Identifiable, Hashable {
    let id = UUID()
    let name: String
    let description: String
    let difficulty: TrailDifficulty
    let distanceKm: Double
    let elevationGainMeters: Int
    let trailhead: CLLocationCoordinate2D
    let waypoints: [CLLocationCoordinate2D]
    let iconSystemName: String

    // Hashable conformance using id since CLLocationCoordinate2D
    // does not conform to Hashable
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: Trail, rhs: Trail) -> Bool {
        lhs.id == rhs.id
    }
}

enum TrailDifficulty: String, CaseIterable {
    case easy = "Easy"
    case moderate = "Moderate"
    case hard = "Hard"

    var color: String {
        switch self {
        case .easy: return "green"
        case .moderate: return "orange"
        case .hard: return "red"
        }
    }

    var icon: String {
        switch self {
        case .easy: return "figure.walk"
        case .moderate: return "figure.hiking"
        case .hard: return "figure.climbing"
        }
    }
}

The Trail struct holds everything the map needs: a trailhead coordinate for the starting marker, and a waypoints array that defines the full route path. We use CLLocationCoordinate2D from Core Location for geographic coordinates. The TrailDifficulty enum gives us a clean way to display difficulty badges with appropriate colors and SF Symbols.

Step 2: Creating the Trail Data Store

Now let us populate the app with Pixar-themed trails. In a production app, this data would come from a server or a local database. For our purposes, a static data store keeps the focus on MapKit.

Create a new file at Models/TrailStore.swift:

import CoreLocation

struct TrailStore {
    static let trails: [Trail] = [
        Trail(
            name: "Paradise Falls Trail",
            description: "Follow Carl and Ellie's dream route to the legendary tepui. A gentle path through lush meadows with stunning waterfall views.",
            difficulty: .easy,
            distanceKm: 3.2,
            elevationGainMeters: 150,
            trailhead: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
            waypoints: [
                CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
                CLLocationCoordinate2D(latitude: 37.7760, longitude: -122.4170),
                CLLocationCoordinate2D(latitude: 37.7785, longitude: -122.4155),
                CLLocationCoordinate2D(latitude: 37.7800, longitude: -122.4130)
            ],
            iconSystemName: "cloud.fill"
        ),
        Trail(
            name: "Monsters Inc Mountain Loop",
            description: "A challenging loop that climbs through the Scare Floor foothills. Watch out for Abominable Snowman territory at the summit!",
            difficulty: .hard,
            distanceKm: 8.7,
            elevationGainMeters: 620,
            trailhead: CLLocationCoordinate2D(latitude: 37.7850, longitude: -122.4094),
            waypoints: [
                CLLocationCoordinate2D(latitude: 37.7850, longitude: -122.4094),
                CLLocationCoordinate2D(latitude: 37.7870, longitude: -122.4060),
                CLLocationCoordinate2D(latitude: 37.7900, longitude: -122.4040),
                CLLocationCoordinate2D(latitude: 37.7920, longitude: -122.4070),
                CLLocationCoordinate2D(latitude: 37.7900, longitude: -122.4094),
                CLLocationCoordinate2D(latitude: 37.7850, longitude: -122.4094)
            ],
            iconSystemName: "bolt.fill"
        ),
        Trail(
            name: "Radiator Springs Canyon",
            description: "Cruise along the desert canyon path inspired by Route 66. A moderate hike with red rock formations and wide-open skies.",
            difficulty: .moderate,
            distanceKm: 5.4,
            elevationGainMeters: 310,
            trailhead: CLLocationCoordinate2D(latitude: 37.7700, longitude: -122.4300),
            waypoints: [
                CLLocationCoordinate2D(latitude: 37.7700, longitude: -122.4300),
                CLLocationCoordinate2D(latitude: 37.7720, longitude: -122.4270),
                CLLocationCoordinate2D(latitude: 37.7750, longitude: -122.4250),
                CLLocationCoordinate2D(latitude: 37.7770, longitude: -122.4230)
            ],
            iconSystemName: "car.fill"
        ),
        Trail(
            name: "Nemo Reef Coastal Path",
            description: "A beachside stroll following the coastline where the East Australian Current meets the shore. Family-friendly and flat.",
            difficulty: .easy,
            distanceKm: 2.1,
            elevationGainMeters: 30,
            trailhead: CLLocationCoordinate2D(latitude: 37.7620, longitude: -122.4350),
            waypoints: [
                CLLocationCoordinate2D(latitude: 37.7620, longitude: -122.4350),
                CLLocationCoordinate2D(latitude: 37.7635, longitude: -122.4330),
                CLLocationCoordinate2D(latitude: 37.7650, longitude: -122.4310)
            ],
            iconSystemName: "fish.fill"
        ),
        Trail(
            name: "Buzz Lightyear Summit",
            description: "To infinity and beyond! A steep climb to the highest viewpoint in the park. The descent rewards you with panoramic views of Star Command.",
            difficulty: .hard,
            distanceKm: 6.8,
            elevationGainMeters: 540,
            trailhead: CLLocationCoordinate2D(latitude: 37.7830, longitude: -122.4200),
            waypoints: [
                CLLocationCoordinate2D(latitude: 37.7830, longitude: -122.4200),
                CLLocationCoordinate2D(latitude: 37.7860, longitude: -122.4180),
                CLLocationCoordinate2D(latitude: 37.7890, longitude: -122.4150),
                CLLocationCoordinate2D(latitude: 37.7910, longitude: -122.4130),
                CLLocationCoordinate2D(latitude: 37.7930, longitude: -122.4110)
            ],
            iconSystemName: "star.fill"
        )
    ]
}

We placed the trails around San Francisco so they appear on a real map with recognizable geography. Each trail has a unique theme: Paradise Falls from Up, Monsters Inc from Monsters, Inc., Radiator Springs from Cars, Nemo Reef from Finding Nemo, and Buzz Lightyear Summit from Toy Story.

Checkpoint: Your project should compile without errors at this point. The data model and store are pure Swift — no UI yet, but the foundation is solid. You should have two files under Models/: Trail.swift and TrailStore.swift.

Step 3: Building the Trail List View

Before we touch the map, let us build a list that shows all available trails. This gives users a way to browse and select a trail before viewing it on the map.

Create a new file at Views/TrailListView.swift:

import SwiftUI

struct TrailListView: View {
    let trails = TrailStore.trails
    @Binding var selectedTrail: Trail?

    var body: some View {
        List(trails, selection: $selectedTrail) { trail in
            TrailRowView(trail: trail)
                .tag(trail)
        }
        .navigationTitle("Up! Adventure Trails")
    }
}

Now create the row view in Views/TrailRowView.swift:

import SwiftUI

struct TrailRowView: View {
    let trail: Trail

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: trail.iconSystemName)
                .font(.title2)
                .foregroundStyle(difficultyColor)
                .frame(width: 40, height: 40)
                .background(difficultyColor.opacity(0.15))
                .clipShape(Circle())

            VStack(alignment: .leading, spacing: 4) {
                Text(trail.name)
                    .font(.headline)

                HStack(spacing: 8) {
                    Label(trail.difficulty.rawValue, systemImage: trail.difficulty.icon)
                        .font(.caption)
                        .foregroundStyle(difficultyColor)

                    Text("·")
                        .foregroundStyle(.secondary)

                    Text(String(format: "%.1f km", trail.distanceKm))
                        .font(.caption)
                        .foregroundStyle(.secondary)

                    Text("·")
                        .foregroundStyle(.secondary)

                    Text("\(trail.elevationGainMeters)m gain")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }

            Spacer()

            Image(systemName: "chevron.right")
                .font(.caption)
                .foregroundStyle(.tertiary)
        }
        .padding(.vertical, 4)
    }

    private var difficultyColor: Color {
        switch trail.difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }
}

Each row displays the trail’s icon, name, difficulty badge, distance, and elevation gain. The difficulty color helps users scan the list quickly — green for easy, orange for moderate, red for hard.

Step 4: Displaying the Map with Markers

Here is where the magic begins. We will create the main map view that drops a Marker at each trailhead location.

Create a new file at Views/TrailMapView.swift:

import SwiftUI
import MapKit

struct TrailMapView: View {
    let trails: [Trail]
    @Binding var selectedTrail: Trail?

    var body: some View {
        Map(selection: $selectedTrail) {
            ForEach(trails) { trail in
                Marker(
                    trail.name,
                    systemImage: trail.iconSystemName,
                    coordinate: trail.trailhead
                )
                .tint(markerColor(for: trail.difficulty))
                .tag(trail)
            }
        }
        .mapStyle(.standard(elevation: .realistic))
    }

    private func markerColor(for difficulty: TrailDifficulty) -> Color {
        switch difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }
}

The Map view accepts a selection binding that syncs with the trail list. Each Marker is placed at the trail’s trailhead coordinate and tinted based on difficulty. The .mapStyle(.standard(elevation: .realistic)) gives us a beautiful terrain look with 3D elevation — perfect for a hiking app.

Now update ContentView.swift to wire everything together using a NavigationSplitView:

import SwiftUI

struct ContentView: View {
    @State private var selectedTrail: Trail?

    var body: some View {
        NavigationSplitView {
            TrailListView(
                selectedTrail: $selectedTrail
            )
        } detail: {
            TrailMapView(
                trails: TrailStore.trails,
                selectedTrail: $selectedTrail
            )
        }
    }
}

Checkpoint: Build and run the app. You should see a split view with the trail list on the left (or as a sidebar on iPad) and a map of San Francisco on the right. Five colored markers should appear on the map: Paradise Falls in green, Monsters Inc Mountain in red, Radiator Springs Canyon in orange, Nemo Reef Coastal Path in green, and Buzz Lightyear Summit in red. Tapping a marker on the map or a trail in the list should highlight the selection.

Step 5: Adding Custom Annotations

Markers are great for simple pins, but custom Annotation views let us create richer, more informative callouts. Let us replace our markers with custom annotations that show the trail name and a difficulty badge right on the map.

Update Views/TrailMapView.swift, replacing the Marker in the Map content with an Annotation:

import SwiftUI
import MapKit

struct TrailMapView: View {
    let trails: [Trail]
    @Binding var selectedTrail: Trail?

    var body: some View {
        Map(selection: $selectedTrail) {
            ForEach(trails) { trail in
                Annotation(
                    trail.name,
                    coordinate: trail.trailhead,
                    anchor: .bottom
                ) {
                    TrailAnnotationView(
                        trail: trail,
                        isSelected: selectedTrail == trail
                    )
                    .tag(trail)
                }
            }
        }
        .mapStyle(.standard(elevation: .realistic))
    }
}

Now create the annotation view in Views/TrailAnnotationView.swift:

import SwiftUI

struct TrailAnnotationView: View {
    let trail: Trail
    let isSelected: Bool

    var body: some View {
        VStack(spacing: 0) {
            // Bubble
            HStack(spacing: 6) {
                Image(systemName: trail.iconSystemName)
                    .font(.caption)

                if isSelected {
                    Text(trail.name)
                        .font(.caption2)
                        .fontWeight(.semibold)
                        .lineLimit(1)
                }
            }
            .padding(.horizontal, isSelected ? 10 : 8)
            .padding(.vertical, 6)
            .background(difficultyColor)
            .foregroundStyle(.white)
            .clipShape(Capsule())

            // Pointer triangle
            Triangle()
                .fill(difficultyColor)
                .frame(width: 12, height: 6)
        }
        .animation(.spring(duration: 0.3), value: isSelected)
    }

    private var difficultyColor: Color {
        switch trail.difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }
}

struct Triangle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
            path.closeSubpath()
        }
    }
}

The annotation collapses to a compact icon bubble when unselected and expands to reveal the trail name when tapped. The spring animation makes the transition feel natural and playful — just like the bouncy house in Up lifting off the ground.

Checkpoint: Build and run. The map now shows colored capsule-shaped annotations instead of standard markers. Tap an annotation and watch it expand to show the trail name — “Paradise Falls Trail” in a green bubble, “Monsters Inc Mountain Loop” in a red bubble, and so on.

Step 6: Controlling the Camera Position

When a user selects a trail, the map should fly to that trailhead. We will use MapCameraPosition to programmatically control the map’s viewport.

Update Views/TrailMapView.swift to add camera control:

import SwiftUI
import MapKit

struct TrailMapView: View {
    let trails: [Trail]
    @Binding var selectedTrail: Trail?
    @State private var cameraPosition: MapCameraPosition = .automatic

    var body: some View {
        Map(position: $cameraPosition, selection: $selectedTrail) {
            ForEach(trails) { trail in
                Annotation(
                    trail.name,
                    coordinate: trail.trailhead,
                    anchor: .bottom
                ) {
                    TrailAnnotationView(
                        trail: trail,
                        isSelected: selectedTrail == trail
                    )
                    .tag(trail)
                }
            }
        }
        .mapStyle(.standard(elevation: .realistic))
        .onChange(of: selectedTrail) { oldValue, newValue in
            guard let trail = newValue else {
                // Zoom out to show all trails
                withAnimation(.easeInOut(duration: 0.8)) {
                    cameraPosition = .automatic
                }
                return
            }
            // Fly to the selected trailhead
            withAnimation(.easeInOut(duration: 0.8)) {
                cameraPosition = .region(
                    MKCoordinateRegion(
                        center: trail.trailhead,
                        span: MKCoordinateSpan(
                            latitudeDelta: 0.015,
                            longitudeDelta: 0.015
                        )
                    )
                )
            }
        }
    }
}

The key addition is the cameraPosition state variable. We initialize it to .automatic, which tells MapKit to calculate a region that fits all annotations. When the user selects a trail, we animate the camera to a zoomed-in region centered on that trailhead. When the selection is cleared, we fly back out to show everything. The 0.015 span value provides a comfortable zoom level that shows the trailhead and surrounding area.

Tip: MapKit provides several MapCameraPosition options beyond .region. You can use .camera(MapCamera(...)) for precise pitch and heading control, or .userLocation(fallback:) to center on the user. See the MapCameraPosition documentation for the full list.

Step 7: Drawing Trail Routes with MapPolyline

A hiking app is not complete without route lines. We will use MapPolyline to draw each trail’s path on the map using the waypoints defined in our data model.

Update the Map content in Views/TrailMapView.swift to include polylines:

Map(position: $cameraPosition, selection: $selectedTrail) {
    // Draw trail routes
    ForEach(trails) { trail in
        MapPolyline(
            coordinates: trail.waypoints
        )
        .stroke(
            strokeColor(for: trail),
            style: StrokeStyle(
                lineWidth: selectedTrail == trail ? 5 : 3,
                lineCap: .round,
                lineJoin: .round
            )
        )
    }

    // Trail annotations
    ForEach(trails) { trail in
        Annotation(
            trail.name,
            coordinate: trail.trailhead,
            anchor: .bottom
        ) {
            TrailAnnotationView(
                trail: trail,
                isSelected: selectedTrail == trail
            )
            .tag(trail)
        }
    }
}

// Add this helper below the body
private func strokeColor(for trail: Trail) -> Color {
    if let selected = selectedTrail {
        return trail == selected
            ? difficultyColorFor(trail.difficulty)
            : difficultyColorFor(trail.difficulty).opacity(0.3)
    }
    return difficultyColorFor(trail.difficulty).opacity(0.7)
}

private func difficultyColorFor(_ difficulty: TrailDifficulty) -> Color {
    switch difficulty {
    case .easy: return .green
    case .moderate: return .orange
    case .hard: return .red
    }
}

Notice the visual hierarchy: when a trail is selected, its polyline becomes thicker and fully opaque while other trails fade to 30% opacity. This directs the user’s attention to the active trail — like a spotlight on Woody during the opening scene of Toy Story. The .round line cap and join create smooth, natural-looking paths that follow the terrain.

Checkpoint: Build and run. You should now see colored lines connecting the waypoints for each trail. Select “Radiator Springs Canyon” from the list — the map should fly to that trail, its orange route line should become thick and vivid, and all other trails should fade into the background. The route follows the waypoints defined in our data store, winding through San Francisco like Route 66 through the desert.

Step 8: Calculating Walking Directions with MKDirections

Static polylines based on coordinates are great for visualization, but real hiking apps show routes that follow actual streets and paths. Let us use MKDirections to calculate walking directions between waypoints.

Create a new file at Services/DirectionsService.swift:

import MapKit

struct RouteInfo {
    let polyline: MKPolyline
    let distance: CLLocationDistance // in meters
    let expectedTravelTime: TimeInterval // in seconds
}

@Observable
class DirectionsService {
    var calculatedRoute: RouteInfo?
    var isCalculating = false
    var errorMessage: String?

    func calculateRoute(for trail: Trail) async {
        guard trail.waypoints.count >= 2 else { return }

        isCalculating = true
        errorMessage = nil
        calculatedRoute = nil

        let source = trail.waypoints.first!
        let destination = trail.waypoints.last!

        let request = MKDirections.Request()
        request.source = MKMapItem(
            placemark: MKPlacemark(coordinate: source)
        )
        request.destination = MKMapItem(
            placemark: MKPlacemark(coordinate: destination)
        )
        request.transportType = .walking

        let directions = MKDirections(request: request)

        do {
            let response = try await directions.calculate()
            if let route = response.routes.first {
                calculatedRoute = RouteInfo(
                    polyline: route.polyline,
                    distance: route.distance,
                    expectedTravelTime: route.expectedTravelTime
                )
            }
        } catch {
            errorMessage = "Could not calculate route: \(error.localizedDescription)"
        }

        isCalculating = false
    }
}

The DirectionsService takes a trail, creates an MKDirections.Request with the first waypoint as source and the last as destination, sets the transport type to .walking, and calls calculate() asynchronously. The response includes a polyline that follows real walkable paths plus distance and travel time estimates.

Note: MKDirections makes a network call to Apple’s servers. It requires an internet connection and is subject to rate limits. In production, you should cache results and handle offline scenarios gracefully.

Step 9: Showing Route Details and Estimated Time

Now let us connect the DirectionsService to the map view and display the calculated route with distance and time information.

Update Views/TrailMapView.swift to use the directions service. Here is the complete updated file:

import SwiftUI
import MapKit

struct TrailMapView: View {
    let trails: [Trail]
    @Binding var selectedTrail: Trail?
    @State private var cameraPosition: MapCameraPosition = .automatic
    @State private var directionsService = DirectionsService()

    var body: some View {
        ZStack(alignment: .bottom) {
            Map(position: $cameraPosition, selection: $selectedTrail) {
                // Static trail polylines
                ForEach(trails) { trail in
                    MapPolyline(coordinates: trail.waypoints)
                        .stroke(
                            strokeColor(for: trail),
                            style: StrokeStyle(
                                lineWidth: selectedTrail == trail ? 5 : 3,
                                lineCap: .round,
                                lineJoin: .round
                            )
                        )
                }

                // Calculated route overlay (dashed blue line)
                if let routeInfo = directionsService.calculatedRoute {
                    MapPolyline(routeInfo.polyline)
                        .stroke(
                            .blue,
                            style: StrokeStyle(
                                lineWidth: 4,
                                lineCap: .round,
                                lineJoin: .round,
                                dash: [8, 6]
                            )
                        )
                }

                // Trail annotations
                ForEach(trails) { trail in
                    Annotation(
                        trail.name,
                        coordinate: trail.trailhead,
                        anchor: .bottom
                    ) {
                        TrailAnnotationView(
                            trail: trail,
                            isSelected: selectedTrail == trail
                        )
                        .tag(trail)
                    }
                }
            }
            .mapStyle(.standard(elevation: .realistic))
            .onChange(of: selectedTrail) { oldValue, newValue in
                handleTrailSelection(newValue)
            }

            // Route info card
            if let trail = selectedTrail {
                RouteInfoCard(
                    trail: trail,
                    routeInfo: directionsService.calculatedRoute,
                    isCalculating: directionsService.isCalculating
                )
                .padding()
                .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .animation(.easeInOut(duration: 0.3), value: selectedTrail)
    }

    private func handleTrailSelection(_ trail: Trail?) {
        guard let trail else {
            withAnimation(.easeInOut(duration: 0.8)) {
                cameraPosition = .automatic
            }
            directionsService.calculatedRoute = nil
            return
        }

        withAnimation(.easeInOut(duration: 0.8)) {
            cameraPosition = .region(
                MKCoordinateRegion(
                    center: trail.trailhead,
                    span: MKCoordinateSpan(
                        latitudeDelta: 0.015,
                        longitudeDelta: 0.015
                    )
                )
            )
        }

        Task {
            await directionsService.calculateRoute(for: trail)
        }
    }

    private func strokeColor(for trail: Trail) -> Color {
        if let selected = selectedTrail {
            return trail == selected
                ? difficultyColorFor(trail.difficulty)
                : difficultyColorFor(trail.difficulty).opacity(0.3)
        }
        return difficultyColorFor(trail.difficulty).opacity(0.7)
    }

    private func difficultyColorFor(
        _ difficulty: TrailDifficulty
    ) -> Color {
        switch difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }
}

Now create the route info card at Views/RouteInfoCard.swift:

import SwiftUI

struct RouteInfoCard: View {
    let trail: Trail
    let routeInfo: RouteInfo?
    let isCalculating: Bool

    var body: some View {
        VStack(spacing: 12) {
            // Trail header
            HStack {
                Image(systemName: trail.iconSystemName)
                    .font(.title3)
                    .foregroundStyle(difficultyColor)

                Text(trail.name)
                    .font(.headline)

                Spacer()

                Text(trail.difficulty.rawValue)
                    .font(.caption)
                    .fontWeight(.semibold)
                    .padding(.horizontal, 8)
                    .padding(.vertical, 4)
                    .background(difficultyColor.opacity(0.15))
                    .foregroundStyle(difficultyColor)
                    .clipShape(Capsule())
            }

            Divider()

            // Stats row
            HStack(spacing: 20) {
                StatView(
                    icon: "map",
                    value: String(format: "%.1f km", trail.distanceKm),
                    label: "Distance"
                )

                StatView(
                    icon: "arrow.up.right",
                    value: "\(trail.elevationGainMeters)m",
                    label: "Elevation"
                )

                if isCalculating {
                    ProgressView()
                        .frame(maxWidth: .infinity)
                } else if let routeInfo {
                    StatView(
                        icon: "clock",
                        value: formattedTime(routeInfo.expectedTravelTime),
                        label: "Est. Time"
                    )
                }
            }
        }
        .padding()
        .background(.ultraThinMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(radius: 8, y: 4)
    }

    private var difficultyColor: Color {
        switch trail.difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }

    private func formattedTime(_ seconds: TimeInterval) -> String {
        let hours = Int(seconds) / 3600
        let minutes = (Int(seconds) % 3600) / 60
        if hours > 0 {
            return "\(hours)h \(minutes)m"
        }
        return "\(minutes) min"
    }
}

struct StatView: View {
    let icon: String
    let value: String
    let label: String

    var body: some View {
        VStack(spacing: 4) {
            Image(systemName: icon)
                .font(.caption)
                .foregroundStyle(.secondary)
            Text(value)
                .font(.subheadline)
                .fontWeight(.semibold)
            Text(label)
                .font(.caption2)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
    }
}

The RouteInfoCard slides up from the bottom of the map when a trail is selected, displaying the trail name, difficulty badge, distance, elevation gain, and — once the directions API returns — the estimated walking time. While the route calculates, a ProgressView spinner keeps the user informed. The .ultraThinMaterial background lets the map bleed through subtly, keeping the card visually connected to the map beneath it.

Checkpoint: Build and run. Select “Paradise Falls Trail” from the list. The map should fly to San Francisco, display the green trail route, overlay a dashed blue line showing the actual walking route, and show a floating card at the bottom with trail stats. The “Est. Time” field should appear after a moment with the calculated walking time. Selecting “Buzz Lightyear Summit” should show a longer estimated time since it covers more distance. It is like Russell’s GPS Adventure Book, but on your phone.

Step 10: Adding Map Style Controls

A good hiking app lets users switch between map styles. Hikers often prefer satellite imagery to spot terrain features. Let us add a map style picker that switches between standard, satellite, and hybrid views.

Create Views/MapStylePicker.swift:

import SwiftUI
import MapKit

enum AppMapStyle: String, CaseIterable {
    case standard = "Standard"
    case satellite = "Satellite"
    case hybrid = "Hybrid"

    var mapStyle: MapStyle {
        switch self {
        case .standard:
            return .standard(elevation: .realistic)
        case .satellite:
            return .imagery(elevation: .realistic)
        case .hybrid:
            return .hybrid(elevation: .realistic)
        }
    }

    var icon: String {
        switch self {
        case .standard: return "map"
        case .satellite: return "globe.americas"
        case .hybrid: return "square.on.square"
        }
    }
}

struct MapStylePicker: View {
    @Binding var selectedStyle: AppMapStyle

    var body: some View {
        Menu {
            ForEach(AppMapStyle.allCases, id: \.self) { style in
                Button {
                    selectedStyle = style
                } label: {
                    Label(style.rawValue, systemImage: style.icon)
                }
            }
        } label: {
            Image(systemName: "map.circle.fill")
                .font(.title2)
                .padding(10)
                .background(.ultraThinMaterial)
                .clipShape(Circle())
                .shadow(radius: 4)
        }
    }
}

Now update Views/TrailMapView.swift to include the map style state and the picker. Add a new state variable at the top of the struct:

@State private var mapStyle: AppMapStyle = .standard

Replace the .mapStyle(.standard(elevation: .realistic)) modifier with:

.mapStyle(mapStyle.mapStyle)

Then add the map style picker inside the ZStack, positioned at the top-right corner. Wrap the existing ZStack contents so the final layout looks like this:

ZStack(alignment: .bottom) {
    Map(position: $cameraPosition, selection: $selectedTrail) {
        // ... existing map content ...
    }
    .mapStyle(mapStyle.mapStyle)
    .onChange(of: selectedTrail) { oldValue, newValue in
        handleTrailSelection(newValue)
    }
    .overlay(alignment: .topTrailing) {
        MapStylePicker(selectedStyle: $mapStyle)
            .padding()
    }

    // Route info card
    if let trail = selectedTrail {
        RouteInfoCard(
            trail: trail,
            routeInfo: directionsService.calculatedRoute,
            isCalculating: directionsService.isCalculating
        )
        .padding()
        .transition(.move(edge: .bottom).combined(with: .opacity))
    }
}

The MapStyle API provides several options. We use .standard for the default street map look, .imagery for satellite photography, and .hybrid for satellite with street labels overlaid. All three use .realistic elevation so the terrain stays 3D.

Tip: In production, you might want to persist the user’s preferred map style using @AppStorage so it survives app launches. Think of it like Kevin the bird remembering his favorite nest.

Step 11: Polishing the UI with a Trail Detail Sheet

Let us add one final touch: a detail sheet that appears when a user taps the route info card. This sheet shows the full trail description, route steps, and a large map preview.

Create Views/TrailDetailSheet.swift:

import SwiftUI
import MapKit

struct TrailDetailSheet: View {
    let trail: Trail
    let routeInfo: RouteInfo?
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 20) {
                    // Map preview
                    Map {
                        MapPolyline(coordinates: trail.waypoints)
                            .stroke(
                                difficultyColor,
                                style: StrokeStyle(
                                    lineWidth: 4,
                                    lineCap: .round,
                                    lineJoin: .round
                                )
                            )

                        Annotation(
                            "Start",
                            coordinate: trail.waypoints.first
                                ?? trail.trailhead,
                            anchor: .bottom
                        ) {
                            Image(systemName: "flag.fill")
                                .foregroundStyle(.green)
                                .padding(6)
                                .background(.white)
                                .clipShape(Circle())
                                .shadow(radius: 2)
                        }

                        if let last = trail.waypoints.last,
                           trail.waypoints.count > 1 {
                            Annotation(
                                "End",
                                coordinate: last,
                                anchor: .bottom
                            ) {
                                Image(systemName: "flag.checkered")
                                    .foregroundStyle(.red)
                                    .padding(6)
                                    .background(.white)
                                    .clipShape(Circle())
                                    .shadow(radius: 2)
                            }
                        }
                    }
                    .frame(height: 250)
                    .clipShape(RoundedRectangle(cornerRadius: 12))

                    // Description
                    Text(trail.description)
                        .font(.body)
                        .foregroundStyle(.secondary)

                    // Stats grid
                    LazyVGrid(
                        columns: [
                            GridItem(.flexible()),
                            GridItem(.flexible()),
                            GridItem(.flexible())
                        ],
                        spacing: 16
                    ) {
                        DetailStatCard(
                            icon: "map",
                            value: String(
                                format: "%.1f km",
                                trail.distanceKm
                            ),
                            label: "Distance"
                        )
                        DetailStatCard(
                            icon: "arrow.up.right",
                            value: "\(trail.elevationGainMeters)m",
                            label: "Elevation"
                        )
                        DetailStatCard(
                            icon: trail.difficulty.icon,
                            value: trail.difficulty.rawValue,
                            label: "Difficulty",
                            color: difficultyColor
                        )
                    }

                    // Route info
                    if let routeInfo {
                        routeDetailsSection(routeInfo)
                    }

                    // Packing list
                    packingListSection
                }
                .padding()
            }
            .navigationTitle(trail.name)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Done") {
                        dismiss()
                    }
                }
            }
        }
    }

    @ViewBuilder
    private func routeDetailsSection(
        _ routeInfo: RouteInfo
    ) -> some View {
        GroupBox("Route Details") {
            VStack(alignment: .leading, spacing: 12) {
                HStack {
                    Image(systemName: "clock")
                        .foregroundStyle(.blue)
                    Text("Estimated walking time:")
                    Spacer()
                    Text(formattedTime(
                        routeInfo.expectedTravelTime
                    ))
                    .fontWeight(.semibold)
                }

                HStack {
                    Image(systemName: "point.topleft.down.to.point.bottomright.curvepath")
                        .foregroundStyle(.blue)
                    Text("Route distance:")
                    Spacer()
                    Text(formattedDistance(
                        routeInfo.distance
                    ))
                    .fontWeight(.semibold)
                }
            }
            .font(.subheadline)
        }
    }

    private var packingListSection: some View {
        GroupBox("Adventure Packing List") {
            VStack(alignment: .leading, spacing: 8) {
                PackingItem(
                    icon: "waterbottle",
                    text: "Water bottle (as Dory says: just keep drinking)"
                )
                PackingItem(icon: "sun.max", text: "Sunscreen")
                PackingItem(
                    icon: "shoe.2",
                    text: "Sturdy hiking shoes"
                )
                PackingItem(
                    icon: "map",
                    text: "Trail map (this app!)"
                )
                if trail.difficulty == .hard {
                    PackingItem(
                        icon: "figure.climbing",
                        text: "Trekking poles (for the tough sections)"
                    )
                }
            }
        }
    }

    private var difficultyColor: Color {
        switch trail.difficulty {
        case .easy: return .green
        case .moderate: return .orange
        case .hard: return .red
        }
    }

    private func formattedTime(
        _ seconds: TimeInterval
    ) -> String {
        let hours = Int(seconds) / 3600
        let minutes = (Int(seconds) % 3600) / 60
        if hours > 0 {
            return "\(hours)h \(minutes)m"
        }
        return "\(minutes) min"
    }

    private func formattedDistance(
        _ meters: CLLocationDistance
    ) -> String {
        let km = meters / 1000
        return String(format: "%.1f km", km)
    }
}

struct DetailStatCard: View {
    let icon: String
    let value: String
    let label: String
    var color: Color = .blue

    var body: some View {
        VStack(spacing: 8) {
            Image(systemName: icon)
                .font(.title3)
                .foregroundStyle(color)
            Text(value)
                .font(.headline)
            Text(label)
                .font(.caption)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12)
        .background(Color(.systemGray6))
        .clipShape(RoundedRectangle(cornerRadius: 10))
    }
}

struct PackingItem: View {
    let icon: String
    let text: String

    var body: some View {
        HStack(spacing: 10) {
            Image(systemName: icon)
                .foregroundStyle(.orange)
                .frame(width: 20)
            Text(text)
                .font(.subheadline)
        }
    }
}

Now hook it up in TrailMapView.swift. Add a new state variable:

@State private var showingDetail = false

Then make the RouteInfoCard tappable by wrapping it:

// Route info card
if let trail = selectedTrail {
    RouteInfoCard(
        trail: trail,
        routeInfo: directionsService.calculatedRoute,
        isCalculating: directionsService.isCalculating
    )
    .padding()
    .transition(.move(edge: .bottom).combined(with: .opacity))
    .onTapGesture {
        showingDetail = true
    }
}

Finally, add the sheet modifier to the outermost ZStack:

.sheet(isPresented: $showingDetail) {
    if let trail = selectedTrail {
        TrailDetailSheet(
            trail: trail,
            routeInfo: directionsService.calculatedRoute
        )
        .presentationDetents([.medium, .large])
    }
}

The detail sheet uses presentationDetents so users can peek at the info at half height or drag to full screen. The packing list adds a fun, practical touch — and the Dory reference is there to remind you to stay hydrated on the trail.

Checkpoint: Build and run the complete app. Select any trail from the list. The map should fly to that trail with the camera animation, display the colored route line and a dashed blue calculated route, and show the info card at the bottom. Tap the info card to open the detail sheet. You should see a map preview with start and end flags, the trail description (“Follow Carl and Ellie’s dream route…” for Paradise Falls), stats, route details with walking time, and the packing list. Try switching between Standard, Satellite, and Hybrid map styles using the button in the top-right corner. The whole experience should feel like opening Carl Fredricksen’s Adventure Book — each trail is a new page of discovery.

Where to Go From Here?

Congratulations! You have built Up! Adventure Trails — a fully functional hiking trail finder app with interactive maps, custom annotations, polyline routes, walking directions, and a polished detail view.

Here is what you learned:

  • Displaying an interactive map with the SwiftUI Map view
  • Placing markers and custom Annotation views at geographic coordinates
  • Controlling the map camera with MapCameraPosition and animated transitions
  • Drawing route paths with MapPolyline
  • Calculating real walking directions using MKDirections with async/await
  • Switching between map styles using MapStyle
  • Building a trail detail sheet with presentation detents

Ideas for extending this project:

  • Add the user’s real-time location using CLLocationManager and show distance to the nearest trailhead. See Core Location Modern API for guidance.
  • Integrate WeatherKit to show current weather conditions at each trailhead — hikers always want to know if it will rain on Paradise Falls.
  • Add search functionality with MKLocalSearch to let users find real trails near their location.
  • Save favorite trails using SwiftData for persistent storage.
  • Add turn-by-turn navigation with MKDirections step-by-step instructions, displaying each maneuver as users hike the route.
  • Support offline maps by caching map tiles for areas with poor cell service — because adventures to Paradise Falls do not always have Wi-Fi.