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
- Xcode 16+ with iOS 18 deployment target
- Familiarity with MapKit for SwiftUI
- Familiarity with Core Location Modern API
- Familiarity with SwiftUI state management
Contents
- Getting Started
- Step 1: Defining the Trail Data Model
- Step 2: Creating the Trail Data Store
- Step 3: Building the Trail List View
- Step 4: Displaying the Map with Markers
- Step 5: Adding Custom Annotations
- Step 6: Controlling the Camera Position
- Step 7: Drawing Trail Routes with MapPolyline
- Step 8: Calculating Walking Directions
- Step 9: Showing Route Details and Estimated Time
- Step 10: Adding Map Style Controls
- Step 11: Polishing the UI with a Trail Detail Sheet
- Where to Go From Here?
Getting Started
Start by creating a fresh Xcode project.
- Open Xcode and select File > New > Project.
- Choose the App template under iOS and click Next.
- Set the product name to UpAdventureTrails.
- Make sure Interface is set to SwiftUI and Language to Swift.
- 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.swiftandTrailStore.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
MapCameraPositionoptions 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:
MKDirectionsmakes 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
@AppStorageso 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
Mapview - Placing markers and custom
Annotationviews at geographic coordinates - Controlling the map camera with
MapCameraPositionand animated transitions - Drawing route paths with
MapPolyline - Calculating real walking directions using
MKDirectionswith 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
CLLocationManagerand 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
MKLocalSearchto let users find real trails near their location. - Save favorite trails using SwiftData for persistent storage.
- Add turn-by-turn navigation with
MKDirectionsstep-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.