Integrating SwiftUI into UIKit Without Storyboards – A Clean, Scalable Onboarding Flow

Modern iOS development doesn’t have to be an “either UIKit or SwiftUI” decision. You can combine both and use each where it shines. In this project, I implemented a complete SwiftUI onboarding experience inside a UIKit project without any storyboard, keeping UIKit for navigation and structure while using SwiftUI for building beautiful, fast, and declarative UI.

This approach gives you:

  • Full control over your app lifecycle
  • No storyboard conflicts
  • A clean architecture
  • The best of UIKit + SwiftUI together

And since I’m using Sanity as a backend CMS, I can document everything with proper code blocks and developer-focused content.

1. UIKit Project Setup (No Storyboard)

First, I removed the storyboard completely:

  • Deleted Main.storyboard
  • Removed Main Interface from Info.plist

Then I configured the app entry point from SceneDelegate:

SceneDelegate.swift
1var window: UIWindow?
2
3func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
4           options connectionOptions: UIScene.ConnectionOptions) {
5
6    guard let windowScene = (scene as? UIWindowScene) else { return }
7    window = UIWindow(windowScene: windowScene)
8    
9    let navigationController = UINavigationController(rootViewController: HomeVC())
10    window?.rootViewController = navigationController
11    window?.makeKeyAndVisible()
12}

This makes UIKit fully programmatic and gives total control over navigation.

2. Hosting SwiftUI Inside UIKit

HomeVC is the bridge between UIKit and SwiftUI. Here I embed a SwiftUI onboarding view using UIHostingController.

HomeVC.swift
1import UIKit
2import SwiftUI
3
4class HomeVC: UIViewController {
5
6    override func viewDidLoad() {
7        super.viewDidLoad()
8        view.backgroundColor = .blue
9        addOnboardingView()
10    }
11
12    func addOnboardingView() {
13        let hostView = UIHostingController(rootView: RootView(loginTapped: {
14            self.navigateToLogin()
15        }))
16
17        addChild(hostView)
18        view.addSubview(hostView.view)
19
20        hostView.view.translatesAutoresizingMaskIntoConstraints = false
21        NSLayoutConstraint.activate([
22            hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
23            hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
24            hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
25            hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
26        ])
27
28        hostView.didMove(toParent: self)
29    }
30
31    func navigateToLogin() {
32        let loginVC = LoginVC()
33        navigationController?.pushViewController(loginVC, animated: true)
34    }
35}

This allows SwiftUI to live inside UIKit like a normal view controller.

3. Onboarding Data Model

I created a model layer for SwiftUI using a simple struct:

OnBoardingModel.swift
1import SwiftUI
2
3struct OnboardingModel: Identifiable {
4    var id = UUID()
5    var title: String
6    var description: String
7    var background: ImageResource
8    var icon: ImageResource
9    var tag: Int
10}
11
12extension OnboardingModel {
13    static var onboardingData: [OnboardingModel] = [
14        .init(
15            title: "Just Discover Pets You’ll Love",
16            description: "Browse verified pets from responsible breeders and trusted communities around you.",
17            background: .onboarding1,
18            icon: .iconOnboarding1,
19            tag: 0
20        ),
21        .init(
22            title: "Transparency You Can Trust",
23            description: "Every pet comes with health records, vaccines, and breeder verification.",
24            background: .onboarding2,
25            icon: .iconOnboarding2,
26            tag: 1
27        ),
28        .init(
29            title: "Safe & Confident Pet Buying",
30            description: "We guide you through every step for a secure experience.",
31            background: .onboarding3,
32            icon: .iconOnboarding3,
33            tag: 2
34        )
35    ]
36}

This makes the onboarding fully data-driven and easy to scale.

4. Root SwiftUI View

The RootView handles pagination and progress indicators:

RootView.swift
1struct RootView: View {
2    @State var currentIndex: Int = 0
3    let data = OnboardingModel.onboardingData
4    var loginTapped: () -> Void
5
6    var body: some View {
7        TabView(selection: $currentIndex) {
8            ForEach(data) { item in
9                OnboardingItem(
10                    currentIndex: $currentIndex,
11                    onboardingItem: item,
12                    loginTapped: loginTapped
13                )
14                .tag(item.tag)
15            }
16        }
17        .overlay(alignment: .top) {
18            HStack {
19                ForEach(0..<3) { index in
20                    Capsule()
21                        .fill(index == currentIndex ? .white : .gray)
22                        .frame(width: 50, height: 4)
23                }
24            }
25            .padding(.top, 60)
26        }
27        .tabViewStyle(.page(indexDisplayMode: .never))
28        .ignoresSafeArea()
29    }
30}

5. Individual Onboarding Screen

Each page is a SwiftUI view:

OnboardingItem.swift
1struct OnboardingItem: View {
2    @Binding var currentIndex: Int
3    var onboardingItem: OnboardingModel
4    var loginTapped: () -> Void
5
6    var body: some View {
7        ZStack {
8            Image(onboardingItem.background)
9                .resizable()
10                .ignoresSafeArea()
11
12            VStack(alignment: .leading) {
13                Spacer()
14                Image(onboardingItem.icon)
15
16                VStack(alignment: .leading, spacing: 12) {
17                    Text(onboardingItem.title)
18                        .font(.largeTitle)
19                        .fontWeight(.black)
20
21                    Text(onboardingItem.description)
22                        .font(.body)
23                }
24                .foregroundStyle(.white)
25
26                HStack {
27                    if currentIndex != 2 {
28                        Button("Skip") { loginTapped() }
29                        Button("Next") {
30                            withAnimation { currentIndex += 1 }
31                        }
32                    } else {
33                        Button("Login") { loginTapped() }
34                    }
35                }
36                .padding(.vertical)
37            }
38            .padding()
39        }
40    }
41}

6. Final Screen in Pure UIKit (Login)

After onboarding, navigation returns to UIKit:

LoginVC.swift
1//
2//  LoginVC.swift
3//  piepaw
4//
5//  Created by Pardip Bhatti on 13/01/26.
6//
7
8import UIKit
9
10class LoginVC: UIViewController {
11    
12    var uiImageView: UIImageView = {
13      let imageView = UIImageView()
14        imageView.image = UIImage(resource: .getStarted)
15        imageView.contentMode = .scaleAspectFill
16        return imageView
17    }()
18    
19    var topView = UIView()
20    
21    var logoView: UIImageView = {
22        let imageView = UIImageView()
23        imageView.image = UIImage(resource: .logoPet)
24        return imageView
25    }()
26    
27    let titleLabel: UILabel = {
28        let label = UILabel()
29        label.text = "ADOPT WITH US"
30        label.font = UIFont.systemFont(ofSize: 32, weight: .black)
31        label.textColor = .black
32        return label
33    }()
34    
35    let descLabel: UILabel = {
36        let label = UILabel()
37        label.text = "Discover healthy, verified pets from trusted sellers."
38        label.font = UIFont.systemFont(ofSize: 18, weight: .regular)
39        label.textAlignment = .center
40        label.numberOfLines = 0
41        label.textColor = .black
42        return label
43    }()
44    
45    let loginButton: UIButton = {
46        let button = UIButton()
47        button.setTitle("Sign in to continue", for: .normal)
48        button.setTitleColor(.white, for: .normal)
49        button.backgroundColor = .loginButton
50        button.layer.cornerRadius = 10
51        return button
52    }()
53
54    override func viewDidLoad() {
55        super.viewDidLoad()
56
57        // Do any additional setup after loading the view.
58        view.backgroundColor = .blue
59        configureBG()
60        configureTopView()
61        createLoginButton()
62    }
63    
64    func configureBG() {
65        view.addSubview(uiImageView)
66        uiImageView.translatesAutoresizingMaskIntoConstraints = false
67        
68        NSLayoutConstraint.activate([
69            uiImageView.topAnchor.constraint(equalTo: view.topAnchor),
70            uiImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
71            uiImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
72            uiImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
73        ])
74    }
75    
76    func configureTopView() {
77        view.addSubview(topView)
78        topView.translatesAutoresizingMaskIntoConstraints = false
79        
80        topView.addSubview(logoView)
81        topView.addSubview(titleLabel)
82        topView.addSubview(descLabel)
83
84        logoView.translatesAutoresizingMaskIntoConstraints = false
85        titleLabel.translatesAutoresizingMaskIntoConstraints = false
86        descLabel.translatesAutoresizingMaskIntoConstraints = false
87      
88        NSLayoutConstraint.activate([
89            topView.topAnchor.constraint(equalTo: view.topAnchor),
90            topView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
91            topView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
92            
93            logoView.topAnchor.constraint(equalTo: topView.topAnchor, constant: 100),
94            logoView.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
95            logoView.widthAnchor.constraint(equalToConstant: 80),
96            logoView.heightAnchor.constraint(equalToConstant: 80),
97            
98            titleLabel.topAnchor.constraint(equalTo: logoView.bottomAnchor, constant: 16),
99            titleLabel.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
100            
101            descLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
102            descLabel.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
103            descLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
104            descLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
105        ])
106    }
107    
108    func createLoginButton() {
109        view.addSubview(loginButton)
110        loginButton.translatesAutoresizingMaskIntoConstraints = false
111        loginButton.addTarget(self, action: #selector(loginButtonTapped),for: .touchUpInside)
112        
113        NSLayoutConstraint.activate([
114            loginButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50),
115            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
116            loginButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
117            loginButton.heightAnchor.constraint(equalToConstant: 50)
118        ])
119    }
120    
121    @objc func loginButtonTapped() {
122        print("Siging in...")
123    }
124}
125
126#Preview {
127    LoginVC()
128}
129

Why This Architecture Works

  • UIKit handles:
    • Navigation
    • App lifecycle
    • Controllers
  • SwiftUI handles:
    • Visual-heavy screens
    • Animations
    • Onboarding UX
  • No storyboard conflicts
  • Easy to maintain and extend
  • Perfect for production apps

This pattern is extremely powerful when migrating gradually from UIKit to SwiftUI.