Building an Onboarding Flow in SwiftUI

In SwiftUI, creating an engaging and informative onboarding flow is essential for a great user experience. An onboarding flow introduces new users to the core features of your app, helps them understand the value proposition, and guides them through the initial setup process. This blog post provides a comprehensive guide to building an effective onboarding flow in SwiftUI.

What is an Onboarding Flow?

An onboarding flow is a sequence of screens designed to educate and engage new users when they first launch your app. It typically includes welcome messages, feature highlights, tutorials, and permission requests, all aimed at making the initial experience as smooth and informative as possible.

Why is an Onboarding Flow Important?

  • First Impressions: It sets the tone for the user’s overall experience.
  • Feature Discovery: Highlights key features and benefits.
  • User Retention: Encourages users to explore and use the app’s functionality, increasing retention.
  • Permission Requests: Guides users through necessary permission grants in a contextually relevant manner.

How to Build an Onboarding Flow in SwiftUI

Here’s a step-by-step guide to building an onboarding flow using SwiftUI.

Step 1: Project Setup

Create a new Xcode project and choose the “App” template with SwiftUI as the interface. Name your project appropriately.

Step 2: Create Onboarding Screens

Each onboarding screen should focus on a specific feature or piece of information. Create separate SwiftUI views for each screen.


import SwiftUI

struct OnboardingScreen1: View {
    var body: some View {
        VStack {
            Image(systemName: "heart.fill")
                .font(.system(size: 60))
                .foregroundColor(.red)
                .padding()
            
            Text("Welcome to Our App!")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.bottom)
            
            Text("Discover amazing features and get started with ease.")
                .font(.title2)
                .multilineTextAlignment(.center)
                .padding()
        }
        .padding()
    }
}

struct OnboardingScreen2: View {
    var body: some View {
        VStack {
            Image(systemName: "list.bullet.rectangle")
                .font(.system(size: 60))
                .foregroundColor(.blue)
                .padding()
            
            Text("Organize Your Tasks")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.bottom)
            
            Text("Keep track of your daily tasks and manage them efficiently.")
                .font(.title2)
                .multilineTextAlignment(.center)
                .padding()
        }
        .padding()
    }
}

struct OnboardingScreen3: View {
    var body: some View {
        VStack {
            Image(systemName: "bell.fill")
                .font(.system(size: 60))
                .foregroundColor(.green)
                .padding()
            
            Text("Stay Notified")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.bottom)
            
            Text("Receive timely notifications to keep you on track.")
                .font(.title2)
                .multilineTextAlignment(.center)
                .padding()
        }
        .padding()
    }
}

These screens are designed to introduce different features with clear text and relevant icons.

Step 3: Create a Data Model

Define a struct or class to hold the data for each onboarding screen. This helps in managing and displaying the data dynamically.


struct OnboardingItem {
    let imageName: String
    let title: String
    let description: String
}

Step 4: Implement the Onboarding View

Create a primary OnboardingView that uses a TabView to display each onboarding screen. This allows users to swipe through the screens.


import SwiftUI

struct OnboardingView: View {
    @State private var currentPageIndex = 0
    @Environment(\.presentationMode) var presentationMode

    let onboardingItems = [
        OnboardingItem(imageName: "heart.fill", title: "Welcome to Our App!", description: "Discover amazing features and get started with ease."),
        OnboardingItem(imageName: "list.bullet.rectangle", title: "Organize Your Tasks", description: "Keep track of your daily tasks and manage them efficiently."),
        OnboardingItem(imageName: "bell.fill", title: "Stay Notified", description: "Receive timely notifications to keep you on track.")
    ]
    
    var body: some View {
        VStack {
            TabView(selection: $currentPageIndex) {
                ForEach(0 ..< onboardingItems.count, id: \.self) { index in
                    OnboardingScreen(item: onboardingItems[index])
                        .tag(index)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
            .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))

            Button(action: {
                if currentPageIndex < onboardingItems.count - 1 {
                    currentPageIndex += 1
                } else {
                    // Dismiss onboarding flow and save state
                    UserDefaults.standard.set(true, forKey: "hasSeenOnboarding")
                    presentationMode.wrappedValue.dismiss()
                }
            }) {
                Text(currentPageIndex < onboardingItems.count - 1 ? "Next" : "Get Started")
                    .font(.headline)
                    .foregroundColor(.white)
                    .padding()
                    .background(Color.blue)
                    .cornerRadius(10)
            }
            .padding()
        }
    }
}

struct OnboardingScreen: View {
    let item: OnboardingItem
    
    var body: some View {
        VStack {
            Image(systemName: item.imageName)
                .font(.system(size: 60))
                .foregroundColor(.accentColor)
                .padding()
            
            Text(item.title)
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.bottom)
            
            Text(item.description)
                .font(.title2)
                .multilineTextAlignment(.center)
                .padding()
        }
        .padding()
    }
}

This implementation uses a TabView with the PageTabViewStyle to create a swipeable onboarding experience. A button navigates through the pages, and the final page dismisses the onboarding flow while setting a UserDefaults flag to prevent it from showing again.

Step 5: Determine First Launch

Check if the user has already seen the onboarding screen on the app's first launch. If not, present the OnboardingView.


import SwiftUI

@main
struct YourAppNameApp: App {
    @AppStorage("hasSeenOnboarding") var hasSeenOnboarding: Bool = false

    var body: some Scene {
        WindowGroup {
            if hasSeenOnboarding {
                ContentView() // Your main app content
            } else {
                OnboardingView()
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        Text("Main App Content")
            .padding()
    }
}

This code checks UserDefaults for a flag indicating whether the user has completed the onboarding process. If the flag is not set, the OnboardingView is displayed; otherwise, the main app content is shown.

Adding Animations and Transitions

Enhance the user experience by adding animations and transitions between onboarding screens. SwiftUI provides several built-in transition effects.


import SwiftUI

struct AnimatedOnboardingView: View {
    @State private var currentPageIndex = 0
    @Environment(\.presentationMode) var presentationMode

    let onboardingItems = [
        OnboardingItem(imageName: "heart.fill", title: "Welcome to Our App!", description: "Discover amazing features and get started with ease."),
        OnboardingItem(imageName: "list.bullet.rectangle", title: "Organize Your Tasks", description: "Keep track of your daily tasks and manage them efficiently."),
        OnboardingItem(imageName: "bell.fill", title: "Stay Notified", description: "Receive timely notifications to keep you on track.")
    ]
    
    var body: some View {
        VStack {
            TabView(selection: $currentPageIndex) {
                ForEach(0 ..< onboardingItems.count, id: \.self) { index in
                    OnboardingScreen(item: onboardingItems[index])
                        .tag(index)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
            .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
            .transition(.slide) // Add transition

            Button(action: {
                withAnimation {
                    if currentPageIndex < onboardingItems.count - 1 {
                        currentPageIndex += 1
                    } else {
                        // Dismiss onboarding flow and save state
                        UserDefaults.standard.set(true, forKey: "hasSeenOnboarding")
                        presentationMode.wrappedValue.dismiss()
                    }
                }
            }) {
                Text(currentPageIndex < onboardingItems.count - 1 ? "Next" : "Get Started")
                    .font(.headline)
                    .foregroundColor(.white)
                    .padding()
                    .background(Color.blue)
                    .cornerRadius(10)
            }
            .padding()
        }
    }
}

The .transition(.slide) modifier adds a sliding transition effect between pages.

Handling Permissions Requests

If your app requires permissions such as location, notifications, or camera access, integrate permission requests within the onboarding flow. This context helps users understand why the permissions are necessary.


import SwiftUI
import CoreLocation
import UserNotifications

class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    @Published var authorizationStatus: CLAuthorizationStatus = .notDetermined

    override init() {
        super.init()
        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
    }

    func requestPermission() {
        locationManager.requestWhenInUseAuthorization()
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        authorizationStatus = manager.authorizationStatus
    }
}

class NotificationManager: ObservableObject {
    func requestPermission() {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in
            if success {
                print("Notification authorization granted.")
            } else if let error = error {
                print(error.localizedDescription)
            }
        }
    }
}

struct PermissionRequestView: View {
    @ObservedObject var locationManager = LocationManager()
    let notificationManager = NotificationManager()

    var body: some View {
        VStack {
            if locationManager.authorizationStatus == .notDetermined {
                Button("Request Location Permission") {
                    locationManager.requestPermission()
                }
            } else {
                Text("Location permission status: \(locationManager.authorizationStatus.description)")
            }
            
            Button("Request Notification Permission") {
                notificationManager.requestPermission()
            }
        }
    }
}

extension CLAuthorizationStatus: CustomStringConvertible {
    public var description: String {
        switch self {
        case .notDetermined:
            return "Not Determined"
        case .restricted:
            return "Restricted"
        case .denied:
            return "Denied"
        case .authorizedAlways:
            return "Authorized Always"
        case .authorizedWhenInUse:
            return "Authorized When In Use"
        @unknown default:
            return "Unknown"
        }
    }
}

Incorporate the PermissionRequestView in your OnboardingView to handle the permission requests.

Conclusion

Building an effective onboarding flow in SwiftUI is essential for guiding new users and setting a positive tone for their app experience. By creating informative screens, implementing swipeable navigation, and thoughtfully handling permission requests, you can significantly improve user engagement and retention. With SwiftUI's powerful tools and intuitive syntax, developing a compelling onboarding flow is both efficient and enjoyable.