Advanced Git, Second Edition

Git is key to great version control and collaboration on software projects.
Stop struggling with Git and spend more time on the stuff that matters!

Home iOS & Swift Tutorials

SwiftUI Progressive Disclosures Tutorial

Learn how to dress up your iOS apps with SwiftUI views containing progressive disclosures using state variables, animations and transitions.

4.5/5 2 Ratings

Version

  • Swift 5.5, iOS 15, Xcode 13

While iPhone and iPad screen sizes have increased over time, you still have a limited display field for your app. Save space and screen real estate by keeping part of your UI hidden and only showing it when the user wants or needs to see it. Progressive disclosure is the process of showing more views in response to user action. This tutorial illustrates several ways to build SwiftUI progressive disclosures, including:

  • Using state properties to track visibility.
  • Progressively disclosing content in cells.
  • Using animation to improve SwiftUI progressive disclosures.
  • Using custom transitions to improve SwiftUI progressive disclosures.
  • Using toast views and sheets for SwiftUI progressive disclosures.
Note: This tutorial requires at least Xcode 13 and iOS 15.

Getting Started

In this tutorial, you’ll modify the DogLife app. DogLife displays a catalog containing pictures and basic information about dog breeds. Download the starter project by clicking Download Materials at the top or bottom of this tutorial. Open DogLife.xcodeproj from the starter folder. Build and run. You’ll see a list of dogs that all have the same picture.

DogLife app with the same picture of a black dog for each category

As currently configured, APIs from Unsplash and Wikipedia supply the app with data. While the Wikipedia API is free and unauthenticated, you’ll need to create a free developer account to use Unsplash.

To get a free developer account, go to https://unsplash.com/developers and sign up. Click the New Application link and accept the terms and conditions presented on the next page — if they’re agreeable to you. :] Name your application and provide a brief description on the next page. After entering that information and proceeding to the next page, scroll down to find the Access Key in the Keys section of the app info page. Copy this key.

Return to Xcode, and in Unsplash.swift, find req.setValue("Client-ID YOUR ACCESS KEY", forHTTPHeaderField: "Authorization"). Replace only the text YOUR ACCESS KEY with the Access Key copied from Unsplash — no extra quotes or escaping required.

Pro Tip: With your cursor in the editor window, press Control-/. Xcode will kindly highlight just the text you need to replace.

Build and run, and you’ll see images of the different dog breeds.

DogLife app with different pictures for the different dog breeds

Note: Prior to modifying YOUR ACCESS KEY with your Unsplash Access Key, Xcode will generate a number of warnings in the debug console area when running the app. Once you’ve supplied the Access Key, these warnings should disappear — other than some SSL log metrics notifications.

Inserting a View in SwiftUI

The basic idea of SwiftUI progressive disclosures is that it conditionally reveals a view. Use SwiftUI’s @State property wrapper to make this process easy to implement. The @State wrapper enables you to change values inside a struct, which normally can’t occur. To illustrate this feature, you’ll add an Easter egg to the app. When you tap the title at the top of the screen, the app will give you the option to select a secret list of cats instead of dogs.

DogLife app view switched to cats, showing four different cat breeds

Adding a State Property to Track Selections

Open MainTable.swift. The MainTable view is the main view of the app and currently only shows a grid of dogs. Add the following instance property to MainTable directly under @EnvironmentObject:

@State private var selectedAnimal = 0

This creates a new state property in the view. A state value is changeable and observable. When that value changes, the view updates anything in its hierarchy that uses it. In this case, it represents the selection of dogs or cats.

Next, add this helper method to MainTable after the code for var body: some View:

private func loadAnimals(_ selection: Int) {
  Task {
    if selection == 0 {
      try? await dataModel.loadDogs()
    } else {
      try? await dataModel.loadCats()
    }
  }
}

This uses an async task to call the DataModel object to load either dogs or cats. Task allows the loading to happen asynchronously on a separate thread.

Inserting a Picker Control View

Finally, modify body in MainTable by adding a picker control between the first Text and LazyVGrid in VStack:

// 1
Picker("Change Animal", selection: $selectedAnimal) {
  // 2
  Text("Dogs").tag(0)
  Text("Cats").tag(1)
}
// 3
.pickerStyle(SegmentedPickerStyle())
// 4
.onChange(of: selectedAnimal) { newSelection in
  loadAnimals(newSelection)
}

Picker allows the user to select between cats and dogs. Here’s how the code works:

  1. Picker displays different values defined in the block. selection tells the view what state property to update when the user taps one of the picker options.
  2. The two Text entries specify the labels presented to the user in the picker, and the tag corresponds to the state value.
  3. SegmentedPickerStyle sets the style to what it says — it’s a segmented picker, rather than a wheel or table.
  4. When selectedAnimal changes in response to a selection by the user, onChange calls the helper method you just added.

Build and run, and you’ll see the picker. Changing the selection changes the display from dogs to cats.

DogLife app view switched to cats, showing four different cat breeds

This is cool, but it’s not exactly hidden — yet. You’ll fix it by adding another state to the view.

Note: You can generate a view with both dogs and cats in the UI by quickly switching between dogs and cats using the picker. Uh oh! This results from having two asynchronous requests working at the same time trying to populate the view with information. Dealing with concurrency conditions involving multiple async/await calls is beyond the scope of this tutorial. For more detail about the use of async/await in SwiftUI, see async/await in SwiftUI.

Adding Another State property to Track Visibility

Add the following instance property below var selectedAnimal:

@State private var pickerVisible = false

Now, wrap the picker and the modifiers you added in an if block, as shown below:

if pickerVisible {
  Picker("Change Animal", selection: $selectedAnimal) {
    Text("Dogs").tag(0)
    Text("Cats").tag(1)
  }
  .pickerStyle(SegmentedPickerStyle())
  .onChange(of: selectedAnimal) { newSelection in
    loadAnimals(newSelection)
  }
}

This only displays the picker if pickerVisible is true. To change the visibility, replace Text("Dogs!") and its fonts modifier, located above the if statement, with the following code:

Button {
  pickerVisible.toggle()
} label: {
  Text(selectedAnimal == 0 ? "Dogs!" : "Cats?")
    .font(.headline)
    .foregroundColor(.primary)
}

This replaces the text label with a button. The button’s action then toggles the visibility of the picker. The foreground color helps keep the button a secret. :] Build and run. Now you have a view that comes and goes based on a user’s actions.

Picker appearing and disappearing by tapping the title.

While it works, you can add some more polish to make the picker disappear after the user makes a selection. In the picker’s onChange block, add the following below the call to loadAnimals:

pickerVisible = false

Now, the view automatically disappears when the user makes a selection.

Animating SwiftUI Progressive Disclosures

Tapping the button moves the grid down and displays the picker with a sharp transition. You can do better than this by adding a simple animation. Fortunately, SwiftUI makes adding an animation quite easy.

In the Button action block, find:

pickerVisible.toggle()

And replace it with:

withAnimation {
  pickerVisible.toggle()
}

A withAnimation block tells SwiftUI to animate any UI or layout changes in response to any state changing within the block. Build and run, and you’ll see a smoother transition with a little slide and fade-in animation.

Menu shifting down with animation

You can further customize your view by specifying the animation type and speed in a withAnimation block. For instance, try replacing the previous withAnimation block with the following:

withAnimation(.easeIn(duration: 1)) {
  pickerVisible.toggle()
}

This specifies an easeIn animation curve over one second. “Ease in” means the animation starts slow and gets faster toward the end of the specified duration.

The animated picker, this time slowed down

While you’ll see more animations later in this tutorial, you can also check out Getting Started With SwiftUI Animations for a deeper dive into SwiftUI animation.

Adding SwiftUI Progressive Disclosures to a Cell

The basic process of adding views upon state change comprises:

  1. Creating a new state property to store when you should show the view.
  2. Adding the view to the hierarchy conditionally upon changes to the state property.
  3. Adding an action to update the state property.

You’ll now do the same for LazyVGrid‘s cells. When the user taps a cell, the app will show additional information about the particular dog or cat breed selected.

Adding Another State Property to Track Cell Selection

At the top of MainTable, add this state property:

@State var openCell: String = ""

This property keeps track of which cell is currently expanded. It’s a string because it’s important to track whether a cell has been expanded as well as which cell is expanded. As before, add another helper method after loadAnimals(_:) in MainTable:

func isOpen(_ animal: Animal) -> Bool { openCell == animal.id }

Next, you’ll use this helper to determine if a cell should expand or not. It does this by comparing the saved openCell state with the id of an animal object.

Conditionally Adding a New View

To use isOpen to conditionally show a detail view, add the following code after Spacer() in ForEach inside VStack:

if isOpen(animal) {
  ShortDetails(animal: animal)
}

When the user taps a cell, this adds a ShortDetails view to the view hierarchy. This view shows a description loaded from Wikipedia’s abstract for the particular animal displayed, as well as a share button that’s nonfunctional — for now. ShortDetails takes advantage of a helper function, format(_:), to format the description of the dog/cat breed for display in the cell.

Adding a Gesture to Trigger the View

Finally, after the VStack closing brace, add this modifier:

.onTapGesture {
  openCell = isOpen(animal) ? "" : animal.id
}

This creates a tap gesture, which runs the block whenever the user taps one of the animals displayed. This block sets the openCell state to either:

  • empty if the cell is already open, causing it to close.
  • The new animal’s id, which expands that cell and closes any other open one.

Build and run to see it in action. Try tapping around the main list to see details about the different breeds.

The MainTable expanded to show the new details using SwiftUI

Like with the Easter egg picker, tapping a cell expands the cell’s row to make room for the new view: a description text.

Note: Many different gestures and options are available. For instance, you could pass a count parameter to the tap gesture, such as .onTapGesture(count: 2) to make it responsive to double-taps. Alternatively, use .onLongPressGesture to display the text with a long-press instead of the tap gesture. Specify the number of seconds needed to trigger the long-press by using .onLongPressGesture(mimimumDuration: Int).

Adding Animations to Improve the User Experience

While the new code works, the view appears without any animation, so it’s a jarring apparition. To get the animation going, first wrap the state change in a withAnimation block. Replace the onTapGesture you just added with:

.onTapGesture {
  withAnimation {
    openCell = isOpen(animal) ? "" : animal.id
  }
}

Build and run. Notice the image row below the selected cell slides down, and the detailed description fades into place.

When a picture is clicked, pictures below it move down and text appears

Adding Transitions for More Customization

There’s no reason to stop there. Through the use of a transition modifier, SwiftUI lets you further customize view insertions and removals. For instance, you can try a slide transition, which slides the view in from the leading edge. To accomplish this, add the following modifier to ShortDetails:

.transition(.slide)

The transition modifier describes how the view transitions in and out of the hierarchy. The slide specifies a leading-edge slide.

Build and run. Now you can see the detailed text view slide in from the left and out to the right.

When a picture is clicked, pictures below it move down and text slides in from the left then out to the right

While this is a fun animation, sliding in from the side isn’t associated with the context of the selected cell. The cell expands downward to make room for the text details, but the details slide in from the left. Instead of using the slide, you can use a move transition to choose which edge the view slides in from.

Matching Transitions to Animations

Replace the previously added transition with:

.transition(.move(edge: .top))

This line moves the text view in from the top when it transitions. Build and run. Notice that the view now slides down from the top. It also disappears by sliding back up. This motion more appropriately matches the expansion animation.

Replacing the slide transition with a move one using SwiftUI

The new animation is a slight improvement, but you can see the text behind the other views on the screen as it slides in from the top — yuck.

Combining Animations and Transitions in SwiftUI

Fortunately, transition has some additional tricks up its sleeve to make this better. You can customize the animation experience by combining different transitions provided through SwiftUI. To make a nicer effect, replace the previous ShortDetails with the following:

ShortDetails(animal: animal)
  .transition(
    // 1
    .move(edge: .top)
      // 2
      .combined(
        // 3
        with: .asymmetric(
          // 4
          insertion: .opacity
            // 5
            .animation(.easeIn(duration: 0.6)),
          // 6
          removal: .opacity
            // 7
            .animation(.easeOut(duration: 0.1)))))

This code mixes together a few different transitions with some animations to get a clean effect. Here’s what the different pieces do:

  1. This is the same move transition as before. Overall, the main goal is to get the detailed text to slide in with the row expansion of the selected cell.
  2. A combined transition allows you to stack two transitions together. You can include any number of transitions just by repeating combined.
  3. An asymmetric transition lets you specify one transition for the insertion of the view and a separate transition for removal of the view. Here, it specifies different timings so the view disappears much faster than it appears, which looks less weird.
  4. The insertion transition here is an opacity one. Opacity will go from completely transparent to opaque over the course of the animation.
  5. animation is a modifier you can specify on any transition to change its timing curve. Here, you’re using an easeIn animation that starts slow and gets faster during the course of the specified transition period, 0.6 seconds in this case.
  6. Here, the removal transition is also opacity. This will hide the view over the course of the animation.
  7. The easeOut animation will start fast and slow down at the end, but this time it’s a fast 0.1 seconds in duration.

Admiring SwiftUI Progressive Disclosures

The new view is now inserted by sliding down and slowly fading in, but it fades out quickly while it slides back up. By changing the duration of just the opacity, the move transition happens at the same speed as the cell expansion. Build and run again. You’ll see the animation is less jarring than before.

When a picture is clicked, the others slide down and text slides down from the clicked picture

Overall, there’s still some room to improve here, but that would require a custom transition. Check out the SwiftUI Animation course for more information on custom animations.

Showing Sheets

So far, you’ve seen views expand to fit new content that gets inserted into the hierarchy. Another common way of showing new information or controls on-screen is temporarily overlaying the content.

One tool provided by SwiftUI is a sheet. This modal view covers your main content with a new view. This is useful to present a dialog with controls or a full screen of content. In your DogLife app, a sheet presents a selection of pictures to share when the share button, mentioned above, is pressed.

The visible share sheet with some images selected

You Could Use More State

If you’re thinking that state is involved in displaying a sheet, you’re starting to understand SwiftUI. If there’s one consistent theme in SwiftUI, it’s the use of state. In the same way you used a state property to show the dog/cat picker or the breed details, you’ll again use a state property to control whether the sheet view is displayed.

This time you’ll work in RootView.swift. RootView, in this case, represents the primary — and only — window and contains the MainTable you previously modified. This is where you’ll add the sheet and its accompanying logic. While a sheet’s behavior is the same regardless of what view it’s attached to, setting it at the top gives it a clean look. In addition, since you’ll work with a global state to trigger the sheet, the root view is the view equivalent of a global since it contains all the subviews in this app.

To better understand this, add the following modifier after the closing task brace in RootView.swift:

.sheet(isPresented: $alertCenter.showShare) {
  if let animal = alertCenter.animalToShare {
    ShareSheet(animal: animal)
  }
}

alertCenter is the global state used here. It’s an environment variable, meaning a single instance of it gets passed around through the view hierarchy. showShare is set whenever the share sheet is ready for showing. Note that ShareSheet is a custom modal dialog and not the iOS built-in share sheet — see AlertCenter.swift for the custom implementation.

Viewing the State Sheet

Build and run. Now, when you tap a cell, tap the share icon in the expanded detail view. This brings up a sheet where you can select some images to send to a friend.

The visible share sheet with some images selected

It’s worth further explaining how this works, since the action is decoupled from the view changes:

  1. In ShortDetails.swift, the button action alertCenter.animalToShare = animal updates the animalToShare value.
  2. In AlertCenter, the animalToShare‘s didSet updates the showShare property.
  3. Since showShare is Published, it sends an update to all its listeners.
  4. Because RootView is using AlertCenter as an @EnvironmentObject, it’s listening for changes.
  5. The sheet is using showShare as the control for isPresented in RootView. When that changes, the modified view modally presents the sheet.
  6. The send button on the share sheet will set showShare to false by setting animalToShare to nil — see the button code in ShareSheet.swift. Dismissing the view by sliding it down also sets showShare to false through the sheet’s built-in behavior.

All you have to do here is update the state, and sheet takes care of its display and dismissal. This makes it an easy way to get a view on-screen. However, you can only have one active sheet at a time. If your sheets need to disclose more views, you’ll either have to insert them into your sheet or use a navigation view to push more views onto the sheet.

Adding Toast Views

Now that you’re one “sheet” to the wind, you can “toast” for more SwiftUI progressive disclosures. In particular, you can display views on top of your content by inserting them into the view hierarchy instead of presenting them as a modal dialog. This is useful for temporary banners, a custom modal, or a popup or tooltip. Basically, this is a good technique to use anytime you want to temporarily display a message to the user.

A view popping up from the bottom — or top — of the screen is sometimes called a toast. The view gets its name because it’s like a piece of bread ejected upward from an old-timey toaster. In DogLife, you can use a toast view to show status to the user, such as displaying a successful sharing of photos.

DogLife app showing the toast message at the bottom of the screen

Conditionally Displaying a View in a ZStack

In SwiftUI, ZStack views let you arrange views on top of each other. It’s useful here, as it allows the toast view to slide up over the main content. Looking at RootView.swift, MainTable is already wrapped in ZStack. Now, add the following below MainTable() but still inside ZStack:

// 1
if alertCenter.showToast {
  // 2
  alertCenter.buildToast()
    // 3
    .transition(.move(edge: .bottom))
}

This code reuses patterns you’ve seen above:

  1. showToast lets the view conditionally add the toast to the hierarchy. This is a published value of alertCenter, so the view will automatically update when it changes.
  2. buildToast() is a helper method that returns the view with the user message.
  3. As you’ve already seen, a move transition will slide the view into the scene, in this case from the bottom.

Seeing Your Toast in Action

The share button is already wired up to trigger the toast message, so when you build and run, you can make some toast. To do that, tap a dog breed to display both the detailed text and the share button. Then, tap the share button, tap one or more photos presented on the sheet and then tap Send. The toast then slides up and shows the message at the bottom of the screen. Tapping the x button hides the toast view again through the same move transition you added in the third step above.

DogLife app showing the toast message at the bottom of the screen

Where to Go From Here?

You can download the completed version of the project by clicking Download Materials at the top or bottom of this tutorial.

Now you’ve seen several tactics for showing new views in the hierarchy based on user-generated state changes. You can combine these techniques with all sorts of SwiftUI concepts to make more complex layouts and view compositions. By combining conditional views with a robust layout, you can make a really special app.

To learn more about SwiftUI animations and transitions, read Getting Started with SwiftUI Animations, watch the SwiftUI Animation video course or read the book SwiftUI Apprentice.

I hope you enjoyed this introduction to SwiftUI progressive disclosures. If you have any comments or questions, feel free to join the discussion below.

Average Rating

4.5/5

Add a rating for this content

2 ratings

More like this

Contributors

Comments