<!--
{
  "availability" : [
    "iOS: 27.0.0 -",
    "iPadOS: 27.0.0 -",
    "Xcode: 27.0.0 -"
  ],
  "documentType" : "article",
  "framework" : "SwiftUI",
  "identifier" : "/documentation/SwiftUI/wishlist-planning-travel-in-a-swiftui-app",
  "metadataVersion" : "0.1.0",
  "role" : "sampleCode",
  "title" : "Wishlist: Planning travel in a SwiftUI app"
}
-->

# Wishlist: Planning travel in a SwiftUI app

Build a travel planning app that organizes trips into collections
and tracks activity completion.

## Overview

The Wishlist sample app helps people organize travel plans by grouping trips into seasonal collections.
Within each trip, people can create activities and mark them complete as they explore.
The app rewards progress with achievement badges,
tracking milestones like completing a first trip or reaching an activity milestone across all adventures.

The sample project demonstrates how to:

- Compose custom views.
- Manage state with the <doc://com.apple.documentation/documentation/Observation/Observable()> macro.
- Customize navigation title appearance.
- Animate view changes.
- Create zoom transitions to navigation destinations and between buttons and sheets.

### Compose custom views

SwiftUI views conform to the [`View`](/documentation/SwiftUI/View) protocol and define their content through a computed `body` property. Each view returns a description of what appears on screen, and SwiftUI handles the rendering.

Wishlist builds custom views by combining built-in components like [`VStack`](/documentation/SwiftUI/VStack), [`HStack`](/documentation/SwiftUI/HStack), [`Text`](/documentation/SwiftUI/Text), [`Image`](/documentation/SwiftUI/Image), and [`Button`](/documentation/SwiftUI/Button):

```swift
struct TripCard: View {
    var trip: Trip
    var size: Size

    var body: some View {
        VStack(alignment: .leading, spacing: 5) {
            TripImageView(url: trip.photoURL)
                .scaledToFill()
                .frame(width: size.width, height: size.height)
                .clipShape(.rect(cornerRadius: 16))

            VStack(alignment: .leading, spacing: 0) {
                Text(trip.name)
                    .font(.body)

                if let subtitle = trip.subtitle {
                    Text(subtitle)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
            }
        }
    }
}
```

The outer `VStack` stacks the image and text vertically, aligning content to the leading edge.
Apply transformations sequentially by stacking modifiers,
with each modifier wrapping the previous view in a new view with modified behavior.

### Manage state with an observable macro

SwiftUI updates views automatically when their dependencies change.
Mark model classes with <doc://com.apple.documentation/documentation/Observation/Observable()> to opt into automatic change tracking.
In Wishlist, the `@Observable` macro synthesizes the necessary code
to publish changes made to any stored property:

```swift
@Observable
class DataSource {
    var trips: [Trip.ID: Trip] {
        didSet {
            updateGoalAchievements()
        }
    }
    var searchText = ""
}
```

The `DataSource` class stores trips in a <doc://com.apple.documentation/documentation/Swift/Dictionary> keyed by trip ID for efficient lookup.
The `didSet` property observer calls `updateGoalAchievements()`
whenever the `trips` dictionary changes, keeping goal progress synchronized with trip completion.
Any views that read from the `trips` dictionary, like `RecentTripsPageView`,
automatically update when the dictionary changes, such as when adding or removing a trip.

To share this data across the sample app, Wishlist creates a state with
the [`State`](/documentation/SwiftUI/State) property wrapper inside the [`App`](/documentation/SwiftUI/App) struct,
then injects the data into the view hierarchy with the [`environment(_:)`](/documentation/SwiftUI/View/environment(_:)) modifier:

```swift
@main
struct WishlistApp: App {
    @State private var dataSource = DataSource()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(dataSource)
        }
    }
}
```

Inside a view, Wishlist gets the observable object using its type, then creates a property and provides the object’s type to the [`Environment`](/documentation/SwiftUI/Environment) property wrapper:

```swift
struct WishlistView: View {
    @Environment(DataSource.self) private var dataSource

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(alignment: .leading, spacing: 10) {
                    RecentTripsPageView()
                    ForEach(TripCollection.allCases) { tripCollection in
                        TripCollectionView(
                            tripCollection: tripCollection,
                            cardSize: tripCollection.cardSize,
                            namespace: namespace
                        )
                    }
                }
            }
        }
    }
}
```

The `@Environment` property wrapper retrieves the data source from the environment
and establishes a dependency relationship between the view and the observable model.
By default, reading an object from the environment returns a non-optional object
when using the object type as the key.
This default behavior assumes that a view in the current hierarchy previously stored
a non-optional instance of the type using the `environment(_:)` modifier.
If a view attempts to retrieve an object using its type and that object isn’t in the environment,
SwiftUI throws an exception.

SwiftUI automatically tracks property access within the view’s body.
When any observed property changes, SwiftUI updates any parts of the view that depend on the value.

Some built-in views and modifiers in SwiftUI, like [`Toggle`](/documentation/SwiftUI/Toggle)
and [`searchable(text:placement:prompt:)`](/documentation/SwiftUI/View/searchable(text:placement:prompt:)), take a [`Binding`](/documentation/SwiftUI/Binding) to a property.
This lets these views and modifiers write back changes to the property.
Use the [`Bindable`](/documentation/SwiftUI/Bindable) property wrapper to create bindings to properties of an `Observable` object.
This includes global variables, properties that exist outside of SwiftUI types, or even local variables.
For example, the sample app creates a `@Bindable` variable within a view’s body:

```swift
struct SearchView: View {
    @Environment(DataSource.self) private var dataSource

    var body: some View {
        @Bindable var dataSource = dataSource

        NavigationStack {
            SearchResultsListView()
                .searchable(text: $dataSource.searchText)
        }
    }
}
```

The `@Bindable` property wrapper exposes a binding projection prefix `$`
to produce a `Binding` value. In the above example, `$dataSource.searchText`
creates a `Binding<String>` that connects the search field to the data source.
When someone types in the search field, SwiftUI writes the new value through
the binding, updating the observable property and invalidating dependent views.

### Customize navigation title appearance

SwiftUI separates visual presentation from semantic meaning when displaying navigation titles.

In the sample app, the `WishlistView` view uses a [`ToolbarItem`](/documentation/SwiftUI/ToolbarItem) and [`title`](/documentation/SwiftUI/ToolbarItemPlacement/title) `toolbarItem` placement to customize the title’s visual appearance.

```swift
NavigationStack {
    ScrollView {
        // Content
    }
    .toolbar {
        ToolbarItem(placement: .title) {
            ExpandedNavigationTitle(title: "Wishlist")
        }
    }
    .navigationTitle("Wishlist")
    .toolbarTitleDisplayMode(.inline)
}
```

The `ExpandedNavigationTitle` view renders the title with a custom font. Always add a navigation title using [`navigationTitle(_:)`](/documentation/SwiftUI/View/navigationTitle(_:)), which the system uses for the accessibility label when someone navigates back from a detail view. The [`toolbarTitleDisplayMode(_:)`](/documentation/SwiftUI/View/toolbarTitleDisplayMode(_:)) modifier that specifies [`inline`](/documentation/SwiftUI/ToolbarTitleDisplayMode/inline) tells SwiftUI to display the title in the navigation bar’s inline position rather than as a large title.

For content that extends into the safe area, use [`largeTitle`](/documentation/SwiftUI/ToolbarItemPlacement/largeTitle) placement instead.

### Animate view changes

To direct people’s attention to state changes, add animation in one of the following ways:

- Animate all of the visual changes for a state change by changing the state inside a call to the [`withAnimation(_:_:)`](/documentation/SwiftUI/withAnimation(_:_:)) global function.
- Add animation to a particular view when a specific value changes by applying the [`animation(_:value:)`](/documentation/SwiftUI/View/animation(_:value:)) view modifier to the view.

SwiftUI animates the effects that many built-in view modifiers produce, like those that set a scale or opacity value. You can animate other values by making your custom views conform to the [`Animatable`](/documentation/SwiftUI/Animatable) protocol. Use the [`Animatable()`](/documentation/SwiftUI/Animatable()) macro to do this.

Choose the right approach based on whether you’re animating a discrete action
or responding to specific property changes.

In Wishlist, when a person deletes an activity, `withAnimation` ensures the removal animates smoothly:

```swift
Button("Delete", role: .destructive) {
    withAnimation {
        model.removeActivity(activity)
    }
}
```

The `withAnimation` block establishes an animation transaction.
Any view changes that `removeActivity(_:)` triggers animate automatically,
sharing the same animation curve and timing.

For finer control, apply animations to specific views using the `animation(_:value:)` modifier.
This value-based approach creates a targeted animation that only triggers when the specified value changes,
affecting only the view hierarchy where the modifier appears.
In the activity completion button in Wishlist, the checkmark icon animates its appearance
only when `isComplete` toggles, leaving other view changes unanimated:

```swift
Image(systemName: activity.isComplete ? "checkmark.circle.fill" : "circle")
    .foregroundStyle(activity.isComplete ? Color.accentColor : .gray)
    .contentTransition(.symbolEffect)
    .animation(.snappy, value: activity.isComplete)
```

The `animation(.snappy, value: activity.isComplete)` modifier
tells SwiftUI to animate this image when `activity.isComplete` changes, using a snappy spring curve.
If other properties change, like the view’s position or opacity, those changes won’t animate
unless they also depend on `isComplete`.

To learn more about animations, check out WWDC23 session [10156: Explore SwiftUI animation](https://developer.apple.com/videos/play/wwdc2023/10156/).

### Create zoom transitions between navigation destinations

To customize animated transitions between views, apply [`matchedTransitionSource(id:in:)`](/documentation/SwiftUI/View/matchedTransitionSource(id:in:)) to the source view and [`navigationTransition(_:)`](/documentation/SwiftUI/View/navigationTransition(_:)) with the transition  [`zoom(sourceID:in:)`](/documentation/SwiftUI/NavigationTransition/zoom(sourceID:in:)) to the destination view using matching identifiers. Use the [`Namespace`](/documentation/SwiftUI/Namespace) property wrapper to create a unique value that associates the source and destination.

In Wishlist, when someone taps a trip card, it smoothly zooms and expands into the trip detail screen,
maintaining visual continuity throughout the navigation.
Dismissing the detail view reverses the animation, zooming back down into the original card position.

```swift
struct WishlistView: View {
    @Namespace private var namespace

    var body: some View {
        NavigationStack {
            ...
            ForEach(TripCollection.allCases) { tripCollection in
                TripCollectionView(
                    tripCollection: tripCollection,
                    namespace: namespace
                )
            }
        }
    }
}

struct TripCollectionView: View {
    var tripCollection: TripCollection
    var namespace: Namespace.ID

    var body: some View {
        ...
        ForEach(dataSource.trips(in: tripCollection)) { trip in
            NavigationLink {
                TripDetailView(trip: trip)
                    .navigationTransition(.zoom(sourceID: trip.id, in: namespace))
            } label: {
                TripCard(trip: trip, size: cardSize)
                    .matchedTransitionSource(id: trip.id, in: namespace)
            }
        }
        ...
    }
}
```

The `@Namespace` property wrapper creates a unique identifier space that SwiftUI uses to coordinate the transition.
Pass the same namespace to both the source and destination views to establish their relationship.

Each `TripCard` in the trip collection receives `matchedTransitionSource(id:in:)` with the trip’s ID as the identifier.
When someone taps a card, SwiftUI captures its position, size, and corner radius as the transition’s starting point.
The trip detail view applies `navigationTransition(.zoom(sourceID:in:))` with the matching identifier,
declaring itself as the zoom target. SwiftUI interpolates between the two geometries,
seamlessly morphing one into the other.

Check out WWDC24 session [10145: Enhance your UI animations and transitions](https://developer.apple.com/videos/play/wwdc2024/10145/)
to explore how to adopt the zoom transition in navigation and presentations in your app.

---

Copyright &copy; 2026 Apple Inc. All rights reserved. | [Terms of Use](https://www.apple.com/legal/internet-services/terms/site.html) | [Privacy Policy](https://www.apple.com/privacy/privacy-policy)