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:
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.
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:
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:
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:
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:
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}
129Why 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.
