SwiftUI Onboarding in a UIKit App

The new onboarding screen shown after updating Squirrel.

I recently refactored the welcome screen that appears when you first open Squirrel, my hobby iOS app for keeping track of completing tasks. The initial version needed improvement, and I wanted to reuse the view for a new features screen that would appear after an update. I’m sharing my process for how this came together since shipping the changes.

In this post, I’ll describe how I created this new onboarding screen using SwiftUI in a UIKit-based app.

A note to the wise: WWDC 2021 begins tomorrow at the time of writing, and these directions could be out of date when you’re reading this! That said, this works for iOS 13 and 14, and presumably any updates to SwiftUI we see this week will only work on the latest versions of Apple’s operating systems.

To start, I designed my SwiftUI view and view model for the onboarding screen. I wanted to style it similar to some of the welcome screens that Apple uses in their applications, and as such, it needed a title, list of features, optional text, and the title for a button.

Rather than hardcoding the strings and items in the view (as I did in my first pass), I created a simple model to provide to our onboarding view. The view model contains a custom initializer that accepts what type of screen to display, allowing the data to be built into our model.

enum OnboardingType {
    case start, features
} 

struct OnboardingViewModel {
    let title: String
    let items: [OnboardingItem]
    let extraText: String?
    let buttonLabel: String
    let navigationController: UINavigationController?

    init(type: OnboardingType, navigationController: UINavigationController?=nil) {
        switch type {
        case .start:
            // Assign values here
        case .features:
            // Same
        }
    }
}

struct OnboardingItem {
    let id = UUID()
    let image: String
    let text: String
    let accentColor: Color
}

Since we’ll be presenting our onboarding screen modally from UIKit, we’ll need to embed our SwiftUI view in a UINavigationController. To ensure the modal can be dismissed from SwiftUI, I included a place for us to specify our navigation controller in the model.

For the view itself, I created three distinct sections: a title section, a ScrollView for our list of features, and a section for the button that will dismiss the navigation controller. Designing the screen in this manner ensures the title and the button are always on screen and allows for the onboarding features list to be scrollable if it doesn’t fit on the device’s screen. All of these sections will be placed in a VStack.

Each item in the list of features contains an Image and Text view (in iOS 14 and other OS versions released last year, you may be able to use Label to achieve this). The Image is hidden from accessibility features like VoiceOver since the symbol is supplementary to the text.

struct OnboardingListView: View {
    let items: [OnboardingItem]

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(items, id: \.id) { item in
                HStack(alignment: .center) {
                    Image(systemName: item.image)
                        .foregroundColor(item.accentColor)
                        .padding()
                        .font(.title)
                        .accessibility(hidden: true)
                    Text(item.text)
                        .font(.headline)
                        .fixedSize(horizontal: false, vertical: true)
                }
            }.padding([.leading, .trailing, .bottom])
        }
    }
}

With the list view finished, we can place it in the ScrollView belonging to our onboarding screen. In the code below, you’ll see our (mostly) completed OnboardingView, with each of its sections built out. The button appears at the end of the VStack, with its action accessing the UINavigationController we specified in our model and dismissing it.

struct OnboardingView: View {
    let model: OnboardingViewModel

    var body: some View {
        ZStack {
            Color(.systemGroupedBackground)
                .edgesIgnoringSafeArea(.all)
            VStack {
                Text(model.title)
                    .font(.largeTitle)
                    .bold()
                    .padding(.top, 20)
                ScrollView {
                    OnboardingListView(items: model.items)
                    if let extraText = model.extraText {
                        Text(extraText)
                            .font(.headline)
                            .padding([.leading, .trailing, .bottom])
                            .multilineTextAlignment(.center)
                    }
                }
                Button(action: {
                    model.navigationController?.dismiss(animated: true)
                }, label: {
                    Text(model.buttonLabel).padding()
                })
                    .background(Color(.systemBlue))
                    .cornerRadius(15)
                    .accentColor(.white)
                    .padding([.bottom, .leading, .trailing])
            }
        }
    }
}

The final touch to the OnboardingView will be to have our list of items centered vertically on the screen. Adding a GeometryReader and embedding our items and extra text in a VStack will ensure these views take all of the space in the ScrollView. In other words, the list will be centered on the screen.

GeometryReader { geo in
    ScrollView {
        VStack {
            OnboardingListView(items: model.items)
            if let extraText = model.extraText {
                Text(extraText)
                    .font(.headline)
                    .padding([.leading, .trailing, .bottom])
                    .multilineTextAlignment(.center)
            }
        }
            .frame(minHeight: geo.size.height)
            .frame(width: geo.size.width)
    }
}

Once we’re done writing our SwiftUI code, we can turn to presenting the screen. Once you’ve confirmed you want to show one of these screens (perhaps after checking a UserDefaults value), we can embed our new SwiftUI view into a UIHostingController. Because we’ve included the title in our view, we’ll also hide the navigation controller’s navigation bar, although you can customize this to however you see fit.

// We've already checked that we want to show the user the onboarding screen.

let navigation = UINavigationController()
let model = OnboardingViewModel(type: .start, navigationController: navigation)
let viewController = UIHostingController(rootView: OnboardingView(model: model))

navigation.viewControllers = [viewController]
viewController.navigationController?.navigationBar.isHidden = true

present(navigation, animated: true)

// After presenting the onboarding screen, ensure we won't show it again.

After presenting the view, make sure to record that you’ve shown the user the screen (again, UserDefaults is a good starting point) so the user doesn’t see the screen more than once.

This is just one example of how you can go about creating an onboarding screen in SwiftUI and presenting it from a UIKit-based app. If you’ve found this article useful, please let me know or share it with others!

Next
Next

Color Pickers and Combine in iOS 14