How to create a Dynamic Pager View for onboardings

A pager view in SwiftUI like we know UIPageViewController in UIKit didn’t exist until iOS 14 and macOS 11.0. Using the PageTabViewStyle on a TabView will result in a swipeable set of pages. However, what if you want to support iOS 13? And how would you do something similar on macOS Catalina?

On top of that, onboarding often needs to be dynamic. You have existing users to which you only want to show new features while you want to show the full onboarding for first-time users. By creating an enum-based onboarding, we allow ourselves to set up a pager view in SwiftUI dynamically.

A SwiftUI pager onboarding example

Before we start diving into the code, I’d like to share what I’ve been building for the new version of RocketSim, a tool to enhance the Simulator with recording and design comparing functionality.

The onboarding had to be dynamic and contains the following pages:

  • A welcome screen for new users
  • 3 pages explaining new features of RocketSim 4.0
  • For users that didn’t provide access to Xcode yet, a “Select Xcode” page
  • Screen Recording Permissions request page if needed
  • Sales page for non-pro users

As you can see, the onboarding of RocketSim is quite dynamic. You don’t want to ask for Screen Recording Permissions if the user already gave permissions in a previous version.

Visually, an early version of this onboarding looked as follows:

An early version of a SwiftUI Pager Onboarding in RocketSim.

Creating an enum based onboarding

A dynamic onboarding starts with defining its pages inside an enum:

 enum OnboardingPage: CaseIterable {
     case welcome
     case newFeature
     case permissions
     case sales
     
     static let fullOnboarding = OnboardingPage.allCases
 } 

We’ve conformed to CaseIterable which makes it easy to define a static fullOnboarding variable to initialize a full onboarding sequence.

As you can see in the above video, some pages contain the next button while others don’t. This is defined in the enum as well:

 enum OnboardingPage: CaseIterable {
     case welcome
     case newFeature
     case permissions
     case sales
     
     static let fullOnboarding = OnboardingPage.allCases
     
     var shouldShowNextButton: Bool {
         switch self {
         case .welcome, .newFeature:
             return true
         default:
             return false
         }
     }
 } 

Lastly, we need a View factory method, which returns a specific view for each case. The method takes an action handler which can be executed on completion to go to the next page. The full enum looks as follows:

 enum OnboardingPage: CaseIterable {
     case welcome
     case newFeature
     case permissions
     case sales
     
     static let fullOnboarding = OnboardingPage.allCases
     
     var shouldShowNextButton: Bool {
         switch self {
         case .welcome, .newFeature:
             return true
         default:
             return false
         }
     }
     
     @ViewBuilder
     func view(action: @escaping () -> Void) -> some View {
         switch self {
         case .welcome:
             Text("Welcome")
         case .newFeature:
             Text("See this new feature!")
         case .permissions:
             HStack {
                 Text("We need permissions")
                 
                 // This button should only be enabled once permissions are set:
                 Button(action: action, label: {
                     Text("Continue")
                 })
             }
         case .sales:
             Text("Become PRO for even more features")
         }
     }
 } 

Currently, we have straightforward views defined within the enum, but it’s obviously smarter to define these in separate files to create more advanced implementations of views.

Creating the dynamic pager view

Now that we’ve created our enumeration, we can set up the pager view itself. I’ll do this by sharing the full code implementation, after which I’ll highlight some details.

The full code looks as follows:

 struct OnboardingView: View {
     
     @State private var currentPage: OnboardingPage = .welcome
     private let pages: [OnboardingPage]
     
     init(pages: [OnboardingPage]) {
         self.pages = pages
     }
     
     var body: some View {
         VStack {
             ForEach(pages, id: \.self) { page in
                 if page == currentPage {
                     page.view(action: showNextPage)
                         .frame(maxWidth: .infinity, maxHeight: .infinity)
                         .transition(AnyTransition.asymmetric(
                                         insertion: .move(edge: .trailing),
                                         removal: .move(edge: .leading))
                                     )
                         .animation(.default)
                 }
             }
             
             if currentPage.shouldShowNextButton {
                 HStack {
                     Spacer()
                     Button(action: showNextPage, label: {
                         Text("Next")
                     })
                 }
                 .padding(EdgeInsets(top: 0, leading: 50, bottom: 50, trailing: 50))
                 .transition(AnyTransition.asymmetric(
                                 insertion: .move(edge: .trailing),
                                 removal: .move(edge: .leading))
                             )
                 .animation(.default)
             }
         }
             .frame(width: 800, height: 600)
             .onAppear {
                 self.currentPage = pages.first!
             }
     }
     
     private func showNextPage() {
         guard let currentIndex = pages.firstIndex(of: currentPage), pages.count > currentIndex + 1 else {
             return
         }
         currentPage = pages[currentIndex + 1]
     }
 } 

The OnboardingView is created with a set of onboarding pages. As you can see, we can pass in any collection of OnboardingPage enum cases. This means you can dynamically set up your onboarding based on conditions.

The view itself builds up in two parts: the page itself and the next button. The pages are added using a ForEach:

ForEach(pages, id: .self) { page in
     if page == currentPage {
         page.view(action: showNextPage)
             .frame(maxWidth: .infinity, maxHeight: .infinity)
             .transition(
                 AnyTransition.asymmetric(
                     insertion: .move(edge: .trailing),
                     removal: .move(edge: .leading)
                 )
             )
             .animation(.default)
     }
 }

We iterate over all cases, and we only show a page if it equals the currently selected page. This will either add or remove a page using the configured transition.

This transition is key to what we will visually see. We define a move direction for when a page is added to our view and when a page is removed from our view. This way, we’ll be able to create a paging experience.

The Next button is defined within the container view so that it only animates with the page animation if it’s no longer needed.

if currentPage.shouldShowNextButton {
     HStack {
         Spacer()
         Button(action: showNextPage, label: {
             Text("Next")
         })
     }
     .padding(EdgeInsets(top: 0, leading: 50, bottom: 50, trailing: 50))
     .transition(AnyTransition.asymmetric(
                     insertion: .move(edge: .trailing),
                     removal: .move(edge: .leading))
                 )
     .animation(.default)
 } 

The button will animate only if needed and will use the same transition as the pages themselves. This creates a clean experience for the user.

Lastly, we set the size of the window directly using a frame modifier and on appear we make sure the right page is loaded:

 .frame(width: 800, height: 600)
 .onAppear {
     self.currentPage = pages.first!
 } 

This is all we need to create a dynamic onboarding pager view in SwiftUI.

Presenting the onboarding in AppKit

The only thing left to do is to present this onboarding view to our users. In AppKit, we can make use of the NSHostingController available in SwiftUI:

 func applicationDidFinishLaunching(_ aNotification: Notification) {
     let viewController = NSHostingController(rootView: OnboardingView(pages: OnboardingPage.fullOnboarding))
     let window = NSWindow(contentViewController: viewController)
     NSApplication.shared.runModal(for: window)
 } 

Obviously, we’re now setting up the onboarding using all pages, but you can create a dynamic set of OnboardingPage cases based on the current state of your user.

Conclusion

Using an enum combined with a dynamic View in SwiftUI allows us to quickly set up an onboarding experience that works for iOS 13+ and macOS Catalina+. Although the onboarding is relatively simple without support for things like dragging, it does show the power of enum-based onboardings.

If you like to improve your SwiftUI knowledge, even more, check out the SwiftUI category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!