Skip to content
UIKit: The Complete Guide for iOS Developers

22 Minutes

UIKit: The Complete Guide for iOS Developers

Fix Bugs Faster! Log Collection Made Easy

Get started

UIKit is Apple’s primary framework for building user interfaces on iPhone and iPad.

If you’ve read that it’s about to be deprecated, don’t believe the reports. In 2026 UIKit remains as integral to production apps as it’s ever been.

In this guide we’ll focus on how UIKit actually works. The lifecycle timing, the navigation structure, the memory management and (our favorite) the production debugging.

You’ll find it useful if you’re:

  • A developer learning UIKit from the ground up
  • An iOS engineer working with existing UIKit codebases
  • A team building or maintaining UIKit-based apps

Here’s the full table of contents.

What is UIKit?

UIKit is Apple’s imperative UI framework for building iOS application interfaces.

It was introduced by Apple in 2008 alongside the original iPhone SDK, becoming the foundation of iOS app development.

Crucially, UIKit follows an imperative model. That means you don’t simply describe what the UI should look like for a given state. You explicitly:

  • Create and configure views
  • Define layout relationships
  • React to lifecycle events
  • Update visible elements when state changes

UIKit is built around a few core abstractions.

  • UIView – visual elements arranged in a hierarchy
  • UIViewController – screen coordination and lifecycle management
  • Auto Layout – constraint-based layout system
  • Event handling – touch delivery, control actions, and gestures

How to start using UIKit in Xcode

To start using UIKit in Xcode, it’s all about these six essential steps:

  1. Open Xcode and click Create a new Xcode project.
  2. Select iOS → App, then click Next.
  3. Set Interface to Storyboard and Language to Swift.
  4. Choose a project name (UIKitDemo) and save location, then click Create.
  5. Open ViewController.swift and write your UIKit code there.
  6. Press Run (⌘R) to launch the app in the simulator.

Xcode automatically configures AppDelegate, SceneDelegate, a UIWindow and a root UIViewController. You’re free to focus on building your UIKit interface immediately.

Xcode editor showing the UIKitDemo project with ViewController.swift selected, displaying a default UIViewController subclass with viewDidLoad and super.viewDidLoad() on line 13, targeted at iPhone 16 Pro simulator

Basic UIKit example (Copy-paste ready)

We all prefer a practical example to theoretical explanation, right? So here’s one for you.

Paste the following code into the default ViewController.swift of a new UIKit project.

This will subclass UIViewController, override viewDidLoad(), create a UILabel and position it using a frame.

import UIKit

// Define a screen (view controller) for our app
final class ViewController: UIViewController {

    // Called once after the screen's view has loaded into memory
    override func viewDidLoad() {
        super.viewDidLoad() // Always call the parent implementation first

        // Set background color of the screen
        view.backgroundColor = .systemBackground

        // Create a label (a piece of text on screen)
        let label = UILabel()

        // Set label text
        label.text = "Hello UIKit"

        // Manually set size and position (frame-based layout)
        label.frame = CGRect(x: 80, y: 200, width: 200, height: 40)

        // Add label to the screen
        view.addSubview(label)
    }
}

Now run the app and you’ll see “Hello UIKit” on a blank screen.

This is the core UIKit pattern in practice: subclass → configure in viewDidLoad() → add subviews → set size and position.

How UIKit works at runtime

Crucial point to note here: despite the name, UIKit is not just UI components. It controls when and how your code runs.

UIKit handlesWhat that means for you
App and window setupYour first screen is created automatically
View controller lifecycleviewDidLoad, viewDidAppear, etc. are called by the system
Layout passesFrames are calculated after constraints resolve
RenderingThe view hierarchy is drawn to screen
Event deliveryTaps and gestures arrive on the main thread
Main run loop schedulingUI must stay lightweight to avoid freezes

Your code does not control timing. UIKit does.

Understanding views and the UIView hierarchy

In UIKit, everything visible on screen is a UIView.

Buttons, labels, images, containers… they all inherit from this view. And every screen is structured as a hierarchy of views: a root view contains subviews, and these subviews can contain their own children.

This hierarchy determines:

  • What is visible
  • The drawing order (front to back)
  • How touches are routed
  • How layout updates propagate

If something is not visible, clipped, or not receiving touches, the issue is often structural: wrong parent, wrong constraints, or incorrect bounds.

As you progress to debugging and issue-hunting, you’ll find that understanding the hierarchy will be more useful than tweaking layout numbers.

Frames, bounds, and coordinate systems

In most UI frameworks, a view is just a single box. But UIKit splits the view into two independent properties: frame and bounds.

They describe size and position, but in different coordinate spaces.

  • frame is external positioning.
  • bounds defines internal layout space.
ConceptExample / syntax
Frameview.frame = CGRect(x: 20, y: 40, width: 200, height: 100) positions the view inside its parent
Boundsview.bounds = CGRect(x: 0, y: 0, width: 200, height: 100) defines the view’s internal coordinate space
Parent spacechildView.frame.origin is relative to childView.superview
Local spacesubview.frame.origin is relative to its parent’s bounds
Converting coordinatesview.convert(point, to: otherView) moves points between coordinate systems
Auto Layout timingFrames are valid after layoutSubviews() or viewDidLayoutSubviews()

View controllers in UIKit

A view controller represents a single screen in a UIKit app. And it isn’t just a ‘controller’ as you might typically imagine: it sits between the view hierarchy and the rest of the app, coordinating what appears on screen, reacting to user input, and responding to lifecycle events like viewDidLoad and viewWillAppear.

UIKit is imperative, so view controllers usually build or wire UI, then update it when something happens. The key is timing: UIKit calls lifecycle methods, then the controller reacts.

Navigation is also coordinated by view controllers, but it depends on container controllers like UINavigationController, which we’ll cover later.

import UIKit

final class ViewController: UIViewController {

    private let label = UILabel()
    private let button = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground
        title = "Profile"

        // Configure label
        label.text = "Waiting for input"
        label.translatesAutoresizingMaskIntoConstraints = false

        // Configure button
        button.setTitle("Tap me", for: .normal)
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)

        // Add views
        view.addSubview(label)
        view.addSubview(button)

        // Layout
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),

            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 16)
        ])
    }

    @objc private func didTapButton() {
        // View controllers typically react to events by updating the UI
        label.text = "Tapped at \\(Date().formatted(date: .omitted, time: .standard))"
    }
}

Responsibilities and common mistakes

A view controller’s role is to coordinate, not to contain application logic.

It sits between views and the rest of the app, reacting to lifecycle events, wiring data to the UI and handling user interaction.

When view controllers take on responsibilities outside this scope, they become difficult to reason about and fragile over time.

What a view controller doesWhat does not belong
Configure and update viewsBusiness rules and domain logic
React to lifecycle eventsNetworking and persistence
Handle user interactionData processing and validation
Trigger navigation and presentationGlobal state management
Bind models to UI elementsLong-running or blocking work

Keeping this boundary clear makes UIKit codebases easier to maintain and debug.

The UIKit view controller lifecycle

The UIKit lifecycle defines when a screen loads, appears, updates layout, and disappears. Again, understanding this sequence will help you understand many timing-related bugs.

MethodWhen it runsCommon use
viewDidLoadOnce after the view loads into memoryInitial setup
viewWillAppearBefore the screen becomes visibleRefresh data
viewDidAppearAfter the screen is visibleStart animations or tracking
viewDidLayoutSubviewsAfter layout passSafely read frame values
viewWillDisappearBefore the screen leavesCleanup or save state
viewDidDisappearAfter the screen is goneStop observers, cancel tasks

You’ll find that most layout and timing bugs happen when logic runs in the wrong lifecycle method.

Why lifecycle timing breaks in production

Navigation paths are predictable when our apps are in development. But when we release them to the wild, our users:

  • switch apps mid-transition
  • receive phone calls
  • trigger background state changes
  • navigate faster than expected

Lifecycle assumptions that work locally can break under real usage. That’s why understanding timing, not just method names, matters.

Layout in UIKit: Auto Layout and constraints

When you set a frame, you hardcode size and position. It may look correct on one device, but it will not adapt to different screen sizes, rotation, or Dynamic Type.

Auto Layout defines relationships instead of fixed coordinates. UIKit calculates the final frames during the layout pass, adapting automatically to safe areas, orientation changes, and content size.

Without Auto Layout, you risk:

  • Broken layouts on iPad or rotation
  • Clipped or overlapping content
  • Ignored safe areas
  • Device-specific layout fixes

Frame-based layouts work for demos. Constraint-based layouts work in production.

Programmatic Auto Layout example

Another quick example for you, and one that’s really served us well at Bugfender.

The code below creates a UILabel and adds it to the screen. Instead of setting a fixed frame, it uses Auto Layout constraints.

After disabling translatesAutoresizingMaskIntoConstraints, we define how the label relates to its parent view. The label is positioned:

  • 16 points below the safe area
  • 20 points from the left edge
  • 20 points from the right edge

UIKit calculates the final size and position during the layout pass. This allows the layout to adapt automatically to different screen sizes and orientation changes.

import UIKit

final class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .systemBackground

        // Create the label
        let titleLabel = UILabel()
        titleLabel.text = "Auto Layout Example"
        titleLabel.numberOfLines = 0
        titleLabel.textAlignment = .center

        // Disable autoresizing mask translation
        // Required when adding constraints in code
        titleLabel.translatesAutoresizingMaskIntoConstraints = false

        // Add to view hierarchy
        view.addSubview(titleLabel)

        // Activate constraints
        NSLayoutConstraint.activate([
            // Position below the safe area
            titleLabel.topAnchor.constraint(
                equalTo: view.safeAreaLayoutGuide.topAnchor,
                constant: 16
            ),

            // Left padding
            titleLabel.leadingAnchor.constraint(
                equalTo: view.leadingAnchor,
                constant: 20
            ),

            // Right padding
            titleLabel.trailingAnchor.constraint(
                equalTo: view.trailingAnchor,
                constant: -20
            )
        ])
    }
}

Common Auto Layout mistakes

Most Auto Layout issues are not caused by complex constraints, but by incorrect assumptions about timing and responsibility.

MistakeWhy it causes problems
Forgetting to disable autoresizing masksUIKit generates conflicting constraints automatically
Reading frames too earlyFrames are not final until layout has completed
Anchoring to raw framesBreaks across devices and orientations
Missing or ambiguous constraintsUIKit cannot infer size or position
Relying on default prioritiesLeads to unpredictable conflict resolution
Forcing layout manuallyMasks underlying constraint issues instead of fixing them

Auto Layout warnings are early indicators of these problems, so be sure to fix them when they first appear. Trust us, this will help you prevent those maddening layout bugs that only surface on specific devices or screen sizes.

Storyboards

Storyboards aren’t unique to UIKit per se, but they have a specific role and purpose in the UIKit ecosystem.

Storyboards let you build UIKit screens visually in Interface Builder (views, constraints, and navigation) and they’re great for speeding up small flows, although larger projects often switch to programmatic UI.

A typical storyboard-based setup looks like this in code:

// Load a view controller from Main.storyboard
let storyboard = UIStoryboard(name: "Main", bundle: nil)

let profileViewController = storyboard.instantiateViewController(
    withIdentifier: "ProfileViewController"
) as! ProfileViewController

// Present or push the view controller
navigationController?.pushViewController(profileViewController, animated: true)

Here, the layout and connections live in the storyboard, while code is responsible for instantiation and navigation.

Storyboards vs programmatic layout

At this point we’d probably better touch on the difference between storyboards and programmatic layout. And the real difference comes down to workflow and maintainability, not capability: specifically, how layout is authored, reviewed, and evolved over time.

The comparison below focuses on those practical trade-offs.

StoryboardsProgrammatic layout
Visual and quick to prototypeExplicit and defined in code
Layout created in Interface BuilderLayout created using Auto Layout APIs
Easier for small or simple screensEasier to scale in large codebases
Prone to merge conflictsWorks well with version control
Harder to refactor and searchEasier to refactor and review
Mixes layout and navigation visuallyKeeps layout and navigation explicit in code

Handling user interaction and events

One of our favorite aspects of UIKit is how it combines the following interaction mechanisms:

  • target-action for controls like buttons and switches
  • delegates for complex components (tables, text fields)
  • gesture recognizers for taps, swipes, and custom gestures

When a user taps, swipes, or interacts with the screen, UIKit detects the event, determines the target, and calls the appropriate handler method on the main thread. View controllers usually act as the overall coordinator, deciding how the app should respond.

Example: handling a button tap

// Create a system-style button
let button = UIButton(type: .system)

// Configure the button title
button.setTitle("Save", for: .normal)

// Register a target-action pair
// UIKit will call the selector when the event occurs
button.addTarget(
    self,
    action: #selector(didTapSaveButton),
    for: .touchUpInside
)

// Action method must be exposed to Objective-C runtime
@objc private func didTapSaveButton() {
    // This method is executed on the main thread
    // in response to the user's tap
    print("Save button tapped")
}

Note that the UI element detects the interaction, UIKit routes the event, and the view controller decides what happens next. Keeping this flow explicit makes interaction bugs easier to trace and fix.

Navigation in UIKit apps

Navigation in UIKit is container-based and hierarchical. Individual screens do not manage global flow. Instead, container controllers define how screens are organized and how users move between them.

To understand navigation clearly, remember that:

  • A screen is just a UIViewController
  • A container controller manages how screens are stacked or grouped
  • The navigation controller stores the stack of screens
  • The back button is managed by the navigation controller, not by the screen itself
  • Pushing adds a screen to the top of the stack
  • Popping removes the top screen and reveals the previous one
  • Modal presentation temporarily overlays a screen

If you take away one nugget of knowledge from this section, remember: navigation is about managing controller hierarchy correctly. When the container structure is clear, transitions become predictable and easier to maintain.

UINavigationController and UITabBarController

UINavigationController manages a stack of screens. Pushing a view controller places it on top of the stack and automatically provides a back button. UITabBarController manages multiple top-level sections, where each tab usually contains its own navigation stack.

// Stack-based navigation
let homeVC = HomeViewController()
let navController = UINavigationController(rootViewController: homeVC)

// Push a new screen
let detailVC = DetailViewController()
navController.pushViewController(detailVC, animated: true)

// Tab-based navigation
let homeNav = UINavigationController(rootViewController: HomeViewController())
homeNav.tabBarItem = UITabBarItem(title: "Home", image: nil, tag: 0)

let settingsNav = UINavigationController(rootViewController: SettingsViewController())
settingsNav.tabBarItem = UITabBarItem(title: "Settings", image: nil, tag: 1)

let tabBarController = UITabBarController()
tabBarController.viewControllers = [homeNav, settingsNav]

Navigation controllers define depth. Tab bar controllers define breadth. Combining both creates predictable app structure.

Data display with UITableView and UICollectionView

UITableView and UICollectionView are UIKit’s main tools for displaying repeating data.

A table view is optimized for vertical lists, while a collection view supports grids and more flexible layouts. Both reuse cells for performance and rely on a data source to provide content and a delegate to handle interaction.

final class ItemsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    private let tableView = UITableView()
    private let items = ["One", "Two", "Three"]

    override func viewDidLoad() {
        super.viewDidLoad()

				view.addSubview(tableView)
				tableView.frame = view.bounds
				tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        tableView.dataSource = self
        tableView.delegate = self

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
    }

    // Number of rows
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.count
    }

    // Cell for each row
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = items[indexPath.row]
        return cell
    }

    // Handle selection
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Selected:", items[indexPath.row])
    }
}

The same data-source and reuse pattern applies to UICollectionView, with layout handled through a collection view layout object.

Runtime behavior and stability in UIKit

Once the app launches, UIKit controls the runtime: lifecycle timing, layout passes, event delivery, and when objects are released.

Most production issues are not caused by incorrect syntax. They happen because we misunderstand:

  • where state is stored
  • who owns an object
  • when a piece of code actually runs
  • what is executing on the main thread

If these boundaries are unclear, screens behave inconsistently, memory grows unexpectedly, or scrolling starts to lag.

Stable UIKit apps share one trait: state is predictable, ownership is explicit, and heavy work stays off the main thread.

Managing state

In UIKit, state changes should be explicit and traceable. A view controller should react to state updates, not silently derive or duplicate them across lifecycle methods.

Example:

final class ProfileViewController: UIViewController {

    // Screen-specific data needed for rendering
    private var profile: Profile?

    // External layer provides the data
    func configure(with profile: Profile) {
        self.profile = profile   // store latest value
        render()                 // reflect change immediately
    }

    private func render() {
        // Update visible UI based on current state
        title = profile?.name
    }
}

What matters in practice:

  • Treat rendering as a reaction to state changes
  • Keep one clear update path instead of scattered mutations
  • Avoid re-deriving the same value in multiple lifecycle methods
  • Make it obvious where a value changes

When state updates flow in one direction, UI behavior becomes easier to follow and debug.

Memory management

UIKit uses Automatic Reference Counting (ARC) to manage memory. In practice, this means that every object keeps track of how many strong references point to it. When that count reaches zero, the object is deallocated.

Problems occur when two objects hold strong references to each other. This is called a retain cycle, and it prevents memory from being released.

A common case involves closures:

// service keeps a strong reference to this closure
service.onUpdate = {
    // self is captured strongly by default
    self.updateUI()
}

If the service is long-lived and the closure captures self strongly, the view controller may never be released.

Safer version:

service.onUpdate = { [weak self] in
    // self is now optional and does not create a retain cycle
    self?.updateUI()
}

What to watch for:

  • Closures capture self strongly unless specified otherwise
  • Delegates should often be declared weak to avoid cycles
  • Long-lived services should not strongly own view controllers

Memory leaks in UIKit usually show up as screens that never disappear from memory, even after dismissal.

Performance considerations

Like most UI frameworks, UIKit renders the UI on the main thread. So if heavy work runs there, your app will stutter and taps will feel delayed.

As a general rule of thumb, we’d recommend you do work off the main thread, then update the UI on the main thread.

Also, watch out for these common pitfalls:

  • Performing expensive work inside cellForRowAt
  • Decoding large images during scrolling
  • Triggering repeated layout recalculations
  • Running network requests or JSON parsing on the main thread

(Quick mention for Swift’s structured concurrency model, which makes it safer to move work off the main thread. Want more detail? We explain it in detail in our guide to Swift concurrency).

And here’s a quick example:

Task {
    // Perform asynchronous work (e.g., network request)
    let data = await fetchData()

    // Switch back to the main thread to update the UI
    await MainActor.run {
        self.updateUI(with: data)
    }
}

Debugging and logging UIKit apps in production

Many UIKit tutorials would have stopped at the last section. Syntax done, lesson over.

But as debugging specialists, we’ve found that real-world apps rarely fail on the syntax. They fail at runtime.

Lifecycle timing, navigation order, memory pressure, and asynchronous state changes may work perfectly on your machine but break under real user behavior.

Once the app is released:

  • You can’t attach breakpoints
  • You can’t inspect the view hierarchy live
  • You often can’t reproduce the exact sequence

That’s why structured logging becomes essential.

Instead of logging everything, log transitions: the moments that change state, screen, or flow.

Example: logging lifecycle and state

Look at these two code examples for logging user interactions.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    Logger.log(
        screen: "ProfileViewController",
        event: "viewDidAppear",
        state: [
            "isLoggedIn": session.isLoggedIn,
            "itemsCount": items.count
        ]
    )
}
@objc private func didTapSaveButton() {
    Logger.log(
        screen: "ProfileViewController",
        event: "tap_save",
        metadata: ["network": networkStatus]
    )
}

Notice what’s being captured:

  • Screen name
  • Lifecycle event
  • User action
  • Minimal state snapshot

This information is gold. It will allow you to reconstruct a timeline later.

What to log in production

What to logWhy it matters
Screen identifierReconstruct which view controller was active
Lifecycle eventUnderstand timing issues
Navigation actionDetect incorrect push/pop sequences
User actionSee what triggered the issue
Network resultIdentify backend or decoding failures
State snapshotCapture the app condition before failure

Production logs are most useful when they are consistent and structured.

You don’t need every variable. In fact, logging every variable can lead to information overload.

You need enough signal to rebuild the sequence.

When logs consistently include screen, lifecycle event, navigation action, and minimal state, you get readable timelines instead of guesswork.

Production debugging without guesswork

Quick self-flex here: Our own product, Bugfender, lets you collect structured logs from real iOS devices in production.

You can include lifecycle events, navigation transitions, and runtime state, so UIKit issues are debuggable even when they can’t be reproduced locally.

Want to try? Get set up with your free account. It only takes a few minutes and you don’t need a credit card: https://dashboard.bugfender.com/signup

UIKit vs SwiftUI: when UIKit still makes sense

We thought we’d drop this in, because many readers will be asking how the two frameworks fit together (as mentioned at the top, some reports have falsely suggested that UIKit is being phased out and replaced by SwiftUI).

As a declarative framework, SwiftUI carries some obvious speed and simplicity advantages. But UIKit still makes sense when you’re:

  • working in large, existing codebases built on UIKit
  • relying on mature APIs that are more complete in UIKit
  • requiring fine-grained UI customization
  • needing predictable behavior across many iOS versions

Many production apps combine both frameworks, using UIKit as the foundation and SwiftUI where it fits.

For a deeper comparison, see our dedicated SwiftUI vs UIKit guide.

Frequently Asked Questions

Is UIKit still relevant in 2026?

Yes. UIKit remains a core part of iOS development, especially for production apps, complex UIs, and long-lived codebases.

Can I build a new app entirely with UIKit?

Yes. You can build a new iOS app entirely with UIKit. Many teams still do, especially when control, customization, or backward compatibility matters.

Do I need UIKit if I use SwiftUI?

Often, yes. UIKit is commonly used behind the scenes or for features SwiftUI doesn’t fully cover yet.

Is UIKit harder than SwiftUI?

UIKit has a steeper learning curve, but it offers more explicit control. SwiftUI is faster to start; UIKit is more predictable at scale.

Will Apple remove UIKit?

Highly unlikely. UIKit underpins many system apps and remains actively maintained by Apple.

Final thoughts

UIKit is not a legacy framework. It remains core infrastructure for iOS apps in 2026, despite some of the rumours you may have read.

Understanding UIKit helps you:

  • Read existing codebases
  • Make better architectural decisions
  • Choose the right UI approach for each project
  • Avoid costly rewrites later

Even as iOS evolves, UIKit remains a stable foundation for serious, production-grade apps. We hope this guide will help you maximize its benefits.

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Aleix Ventayol

Aleix Ventayol is CEO and co-founder of Bugfender, with 20 years' experience building apps and solutions for clients like AVG, Qustodio, Primavera Sound and Levi's. As a former CTO and full-stack developer, Aleix is passionate about building tools that solve the real problems of app development and help teams build better software.

Join thousands of developers
and start fixing bugs faster than ever.