22 Minutes
UIKit: The Complete Guide for iOS Developers
Fix Bugs Faster! Log Collection Made Easy
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.
Table of Contents
- What is UIKit?
- How to start using UIKit in Xcode
- How UIKit works at runtime
- Understanding views and the UIView hierarchy
- View controllers in UIKit
- The UIKit view controller lifecycle
- Layout in UIKit: Auto Layout and constraints
- Storyboards
- Handling user interaction and events
- Navigation in UIKit apps
- Data display with UITableView and UICollectionView
- Runtime behavior and stability in UIKit
- Debugging and logging UIKit apps in production
- UIKit vs SwiftUI: when UIKit still makes sense
- Frequently Asked Questions
- Final thoughts
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 hierarchyUIViewController– 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:
- Open Xcode and click Create a new Xcode project.
- Select iOS → App, then click Next.
- Set Interface to Storyboard and Language to Swift.
- Choose a project name (UIKitDemo) and save location, then click Create.
- Open
ViewController.swiftand write your UIKit code there. - 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.

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 handles | What that means for you |
|---|---|
| App and window setup | Your first screen is created automatically |
| View controller lifecycle | viewDidLoad, viewDidAppear, etc. are called by the system |
| Layout passes | Frames are calculated after constraints resolve |
| Rendering | The view hierarchy is drawn to screen |
| Event delivery | Taps and gestures arrive on the main thread |
| Main run loop scheduling | UI 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.
frameis external positioning.boundsdefines internal layout space.
| Concept | Example / syntax |
|---|---|
| Frame | view.frame = CGRect(x: 20, y: 40, width: 200, height: 100) positions the view inside its parent |
| Bounds | view.bounds = CGRect(x: 0, y: 0, width: 200, height: 100) defines the view’s internal coordinate space |
| Parent space | childView.frame.origin is relative to childView.superview |
| Local space | subview.frame.origin is relative to its parent’s bounds |
| Converting coordinates | view.convert(point, to: otherView) moves points between coordinate systems |
| Auto Layout timing | Frames 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 does | What does not belong |
|---|---|
| Configure and update views | Business rules and domain logic |
| React to lifecycle events | Networking and persistence |
| Handle user interaction | Data processing and validation |
| Trigger navigation and presentation | Global state management |
| Bind models to UI elements | Long-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.
| Method | When it runs | Common use |
|---|---|---|
| viewDidLoad | Once after the view loads into memory | Initial setup |
| viewWillAppear | Before the screen becomes visible | Refresh data |
| viewDidAppear | After the screen is visible | Start animations or tracking |
| viewDidLayoutSubviews | After layout pass | Safely read frame values |
| viewWillDisappear | Before the screen leaves | Cleanup or save state |
| viewDidDisappear | After the screen is gone | Stop 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.
| Mistake | Why it causes problems |
|---|---|
| Forgetting to disable autoresizing masks | UIKit generates conflicting constraints automatically |
| Reading frames too early | Frames are not final until layout has completed |
| Anchoring to raw frames | Breaks across devices and orientations |
| Missing or ambiguous constraints | UIKit cannot infer size or position |
| Relying on default priorities | Leads to unpredictable conflict resolution |
| Forcing layout manually | Masks 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.
| Storyboards | Programmatic layout |
|---|---|
| Visual and quick to prototype | Explicit and defined in code |
| Layout created in Interface Builder | Layout created using Auto Layout APIs |
| Easier for small or simple screens | Easier to scale in large codebases |
| Prone to merge conflicts | Works well with version control |
| Harder to refactor and search | Easier to refactor and review |
| Mixes layout and navigation visually | Keeps 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.
// 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 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 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
selfstrongly unless specified otherwise - Delegates should often be declared
weakto 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 log | Why it matters |
|---|---|
| Screen identifier | Reconstruct which view controller was active |
| Lifecycle event | Understand timing issues |
| Navigation action | Detect incorrect push/pop sequences |
| User action | See what triggered the issue |
| Network result | Identify backend or decoding failures |
| State snapshot | Capture 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