Pardip
Bhatti
Back to blog
swiftuiuikitiosUIHostingControlleronboarding

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

Pardip Bhatti·Jan 24, 2026·10 min read

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
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.

HomeVC.swift
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:

OnBoardingModel.swift
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:

RootView.swift
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:

OnboardingItem.swift
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
//
//  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

Pardip Bhatti

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

Enjoyed this article?

Let's talk iOS, Swift, and building great apps.

Get in touch