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:
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let navigationController = UINavigationController(rootViewController: HomeVC())
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
}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.
import UIKit
import SwiftUI
class HomeVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .blue
addOnboardingView()
}
func addOnboardingView() {
let hostView = UIHostingController(rootView: RootView(loginTapped: {
self.navigateToLogin()
}))
addChild(hostView)
view.addSubview(hostView.view)
hostView.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostView.view.topAnchor.constraint(equalTo: view.topAnchor),
hostView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
hostView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
])
hostView.didMove(toParent: self)
}
func navigateToLogin() {
let loginVC = LoginVC()
navigationController?.pushViewController(loginVC, animated: true)
}
}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:
import SwiftUI
struct OnboardingModel: Identifiable {
var id = UUID()
var title: String
var description: String
var background: ImageResource
var icon: ImageResource
var tag: Int
}
extension OnboardingModel {
static var onboardingData: [OnboardingModel] = [
.init(
title: "Just Discover Pets You’ll Love",
description: "Browse verified pets from responsible breeders and trusted communities around you.",
background: .onboarding1,
icon: .iconOnboarding1,
tag: 0
),
.init(
title: "Transparency You Can Trust",
description: "Every pet comes with health records, vaccines, and breeder verification.",
background: .onboarding2,
icon: .iconOnboarding2,
tag: 1
),
.init(
title: "Safe & Confident Pet Buying",
description: "We guide you through every step for a secure experience.",
background: .onboarding3,
icon: .iconOnboarding3,
tag: 2
)
]
}This makes the onboarding fully data-driven and easy to scale.
4. Root SwiftUI View
The RootView handles pagination and progress indicators:
struct RootView: View {
@State var currentIndex: Int = 0
let data = OnboardingModel.onboardingData
var loginTapped: () -> Void
var body: some View {
TabView(selection: $currentIndex) {
ForEach(data) { item in
OnboardingItem(
currentIndex: $currentIndex,
onboardingItem: item,
loginTapped: loginTapped
)
.tag(item.tag)
}
}
.overlay(alignment: .top) {
HStack {
ForEach(0..<3) { index in
Capsule()
.fill(index == currentIndex ? .white : .gray)
.frame(width: 50, height: 4)
}
}
.padding(.top, 60)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.ignoresSafeArea()
}
}5. Individual Onboarding Screen
Each page is a SwiftUI view:
struct OnboardingItem: View {
@Binding var currentIndex: Int
var onboardingItem: OnboardingModel
var loginTapped: () -> Void
var body: some View {
ZStack {
Image(onboardingItem.background)
.resizable()
.ignoresSafeArea()
VStack(alignment: .leading) {
Spacer()
Image(onboardingItem.icon)
VStack(alignment: .leading, spacing: 12) {
Text(onboardingItem.title)
.font(.largeTitle)
.fontWeight(.black)
Text(onboardingItem.description)
.font(.body)
}
.foregroundStyle(.white)
HStack {
if currentIndex != 2 {
Button("Skip") { loginTapped() }
Button("Next") {
withAnimation { currentIndex += 1 }
}
} else {
Button("Login") { loginTapped() }
}
}
.padding(.vertical)
}
.padding()
}
}
}6. Final Screen in Pure UIKit (Login)
After onboarding, navigation returns to UIKit:
//
// LoginVC.swift
// piepaw
//
// Created by Pardip Bhatti on 13/01/26.
//
import UIKit
class LoginVC: UIViewController {
var uiImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(resource: .getStarted)
imageView.contentMode = .scaleAspectFill
return imageView
}()
var topView = UIView()
var logoView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(resource: .logoPet)
return imageView
}()
let titleLabel: UILabel = {
let label = UILabel()
label.text = "ADOPT WITH US"
label.font = UIFont.systemFont(ofSize: 32, weight: .black)
label.textColor = .black
return label
}()
let descLabel: UILabel = {
let label = UILabel()
label.text = "Discover healthy, verified pets from trusted sellers."
label.font = UIFont.systemFont(ofSize: 18, weight: .regular)
label.textAlignment = .center
label.numberOfLines = 0
label.textColor = .black
return label
}()
let loginButton: UIButton = {
let button = UIButton()
button.setTitle("Sign in to continue", for: .normal)
button.setTitleColor(.white, for: .normal)
button.backgroundColor = .loginButton
button.layer.cornerRadius = 10
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
view.backgroundColor = .blue
configureBG()
configureTopView()
createLoginButton()
}
func configureBG() {
view.addSubview(uiImageView)
uiImageView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
uiImageView.topAnchor.constraint(equalTo: view.topAnchor),
uiImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
uiImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
uiImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
func configureTopView() {
view.addSubview(topView)
topView.translatesAutoresizingMaskIntoConstraints = false
topView.addSubview(logoView)
topView.addSubview(titleLabel)
topView.addSubview(descLabel)
logoView.translatesAutoresizingMaskIntoConstraints = false
titleLabel.translatesAutoresizingMaskIntoConstraints = false
descLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topView.topAnchor.constraint(equalTo: view.topAnchor),
topView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
topView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
logoView.topAnchor.constraint(equalTo: topView.topAnchor, constant: 100),
logoView.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
logoView.widthAnchor.constraint(equalToConstant: 80),
logoView.heightAnchor.constraint(equalToConstant: 80),
titleLabel.topAnchor.constraint(equalTo: logoView.bottomAnchor, constant: 16),
titleLabel.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
descLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
descLabel.centerXAnchor.constraint(equalTo: topView.centerXAnchor),
descLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
descLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
])
}
func createLoginButton() {
view.addSubview(loginButton)
loginButton.translatesAutoresizingMaskIntoConstraints = false
loginButton.addTarget(self, action: #selector(loginButtonTapped),for: .touchUpInside)
NSLayoutConstraint.activate([
loginButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -50),
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
loginButton.heightAnchor.constraint(equalToConstant: 50)
])
}
@objc func loginButtonTapped() {
print("Siging in...")
}
}
#Preview {
LoginVC()
}
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.

Pardip Bhatti
My expertise spans modern frameworks and technologies, including React, React Native, Swift, SwiftUI, UIKIT, MVVM Architecture.