UIKit Patterns (Pure UIKit)
This skill covers pure UIKit patterns for building iOS applications. No SwiftUI bridging - all patterns use native UIKit approaches.
State Management (Without SwiftUI)
Delegate Pattern
The classic UIKit approach for communication between components.
// MARK: - Protocol Definition
protocol UserProfileDelegate: AnyObject {
func userProfileDidUpdate(_ profile: UserProfile)
func userProfileDidRequestLogout()
func userProfileDidFail(with error: Error)
}
// MARK: - Delegate Implementation
final class UserProfileViewController: UIViewController {
weak var delegate: UserProfileDelegate?
private func handleProfileUpdate() {
delegate?.userProfileDidUpdate(currentProfile)
}
@objc private func logoutTapped() {
delegate?.userProfileDidRequestLogout()
}
}
// MARK: - Parent ViewController
final class SettingsViewController: UIViewController, UserProfileDelegate {
override func viewDidLoad() {
super.viewDidLoad()
let profileVC = UserProfileViewController()
profileVC.delegate = self
}
func userProfileDidUpdate(_ profile: UserProfile) {
// Handle profile update
}
func userProfileDidRequestLogout() {
// Handle logout
}
func userProfileDidFail(with error: Error) {
showAlert(for: error)
}
}
Closure-Based Binding
Modern UIKit approach using closures for reactive-style updates.
// MARK: - ViewModel with Closure Bindings
final class UserListViewModel {
// MARK: - State
private(set) var users: [User] = [] {
didSet { onUsersChanged?(users) }
}
private(set) var isLoading: Bool = false {
didSet { onLoadingChanged?(isLoading) }
}
private(set) var error: Error? {
didSet {
if let error = error {
onError?(error)
}
}
}
// MARK: - Bindings
var onUsersChanged: (([User]) -> Void)?
var onLoadingChanged: ((Bool) -> Void)?
var onError: ((Error) -> Void)?
// MARK: - Actions
func loadUsers() {
isLoading = true
Task { @MainActor in
defer { isLoading = false }
do {
users = try await userService.fetchAll()
} catch {
self.error = error
}
}
}
}
// MARK: - ViewController Binding
final class UserListViewController: UIViewController {
private var viewModel = UserListViewModel()
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
viewModel.loadUsers()
}
private func bindViewModel() {
viewModel.onUsersChanged = { [weak self] users in
self?.tableView.reloadData()
}
viewModel.onLoadingChanged = { [weak self] isLoading in
self?.loadingIndicator.isHidden = !isLoading
if isLoading {
self?.loadingIndicator.startAnimating()
} else {
self?.loadingIndicator.stopAnimating()
}
}
viewModel.onError = { [weak self] error in
self?.showError(error)
}
}
}
Property Observers (didSet)
Use didSet for simple property-driven updates.
final class ProfileHeaderView: UIView {
var user: User? {
didSet {
updateUI()
}
}
var isEditing: Bool = false {
didSet {
editButton.isHidden = isEditing
saveButton.isHidden = !isEditing
nameTextField.isEnabled = isEditing
}
}
private func updateUI() {
guard let user = user else { return }
nameLabel.text = user.name
emailLabel.text = user.email
avatarImageView.load(from: user.avatarURL)
}
}
NotificationCenter for Broadcasts
Use for app-wide events that multiple components need to observe.
// MARK: - Notification Names
extension Notification.Name {
static let userDidLogin = Notification.Name("userDidLogin")
static let userDidLogout = Notification.Name("userDidLogout")
static let cartDidUpdate = Notification.Name("cartDidUpdate")
}
// MARK: - Posting Notifications
final class AuthService {
func login(user: User) {
// ... login logic
NotificationCenter.default.post(
name: .userDidLogin,
object: nil,
userInfo: ["user": user]
)
}
func logout() {
NotificationCenter.default.post(name: .userDidLogout, object: nil)
}
}
// MARK: - Observing Notifications
final class DashboardViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupNotificationObservers()
}
private func setupNotificationObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUserLogin),
name: .userDidLogin,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleUserLogout),
name: .userDidLogout,
object: nil
)
}
@objc private func handleUserLogin(_ notification: Notification) {
guard let user = notification.userInfo?["user"] as? User else { return }
updateUIForUser(user)
}
@objc private func handleUserLogout(_ notification: Notification) {
resetUI()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
KVO (Key-Value Observing)
For observing properties on NSObject subclasses.
final class DownloadManager: NSObject {
@objc dynamic var progress: Double = 0.0
@objc dynamic var isDownloading: Bool = false
}
final class DownloadViewController: UIViewController {
private let downloadManager = DownloadManager()
private var progressObservation: NSKeyValueObservation?
private var downloadingObservation: NSKeyValueObservation?
override func viewDidLoad() {
super.viewDidLoad()
setupObservations()
}
private func setupObservations() {
progressObservation = downloadManager.observe(\.progress, options: [.new]) { [weak self] _, change in
guard let progress = change.newValue else { return }
DispatchQueue.main.async {
self?.progressView.progress = Float(progress)
}
}
downloadingObservation = downloadManager.observe(\.isDownloading, options: [.new]) { [weak self] _, change in
guard let isDownloading = change.newValue else { return }
DispatchQueue.main.async {
self?.downloadButton.isEnabled = !isDownloading
}
}
}
}
SwiftUI → UIKit Mappings
When converting SwiftUI code to UIKit, use these mappings:
State Management
| SwiftUI | UIKit Equivalent |
|---|---|
@State |
Instance property + didSet |
@Binding |
Delegate or closure callback |
@ObservedObject |
Protocol delegate + closure bindings |
@Observable |
Class with closure bindings |
@Environment |
Dependency injection via init |
@EnvironmentObject |
Shared singleton or passed reference |
View Components
| SwiftUI | UIKit |
|---|---|
NavigationStack |
UINavigationController |
NavigationLink |
pushViewController(_:animated:) |
List |
UITableView |
LazyVGrid |
UICollectionView with compositional layout |
ScrollView |
UIScrollView |
VStack |
UIStackView(axis: .vertical) |
HStack |
UIStackView(axis: .horizontal) |
ZStack |
Multiple subviews with constraints |
Button |
UIButton |
Text |
UILabel |
TextField |
UITextField |
TextEditor |
UITextView |
Image |
UIImageView |
Toggle |
UISwitch |
Slider |
UISlider |
Picker |
UIPickerView or UISegmentedControl |
DatePicker |
UIDatePicker |
ProgressView |
UIProgressView or UIActivityIndicatorView |
Modifiers → Methods/Properties
| SwiftUI Modifier | UIKit |
|---|---|
.font() |
label.font = |
.foregroundStyle() |
label.textColor = |
.background() |
view.backgroundColor = |
.frame() |
NSLayoutConstraint |
.padding() |
layoutMargins or constraint constants |
.cornerRadius() |
layer.cornerRadius |
.shadow() |
layer.shadow* properties |
.opacity() |
view.alpha = |
.hidden() |
view.isHidden = |
.disabled() |
control.isEnabled = |
.onTapGesture() |
UITapGestureRecognizer |
.onAppear() |
viewWillAppear(_:) |
.onDisappear() |
viewWillDisappear(_:) |
.sheet() |
present(_:animated:) |
.alert() |
UIAlertController |
UIKit View Lifecycle
init(coder:) / init(nibName:bundle:)
↓
loadView()
↓
viewDidLoad() ← Setup UI, bind ViewModel
↓
viewWillAppear(_:) ← Refresh data, start animations
↓
viewWillLayoutSubviews()
↓
viewDidLayoutSubviews() ← Adjust for final frame sizes
↓
viewDidAppear(_:) ← Analytics, start tracking
↓
[User interaction]
↓
viewWillDisappear(_:) ← Pause tasks
↓
viewDidDisappear(_:) ← Stop expensive operations
↓
deinit ← Clean up observers
Data Binding Without SwiftUI
Protocol-Based ViewModel
// MARK: - ViewModel Protocol
protocol ProductDetailViewModelProtocol {
var product: Product { get }
var isInCart: Bool { get }
var formattedPrice: String { get }
var onProductUpdated: (() -> Void)? { get set }
var onCartStatusChanged: ((Bool) -> Void)? { get set }
var onError: ((Error) -> Void)? { get set }
func addToCart()
func removeFromCart()
func refresh()
}
// MARK: - Implementation
final class ProductDetailViewModel: ProductDetailViewModelProtocol {
private(set) var product: Product {
didSet { onProductUpdated?() }
}
private(set) var isInCart: Bool = false {
didSet { onCartStatusChanged?(isInCart) }
}
var formattedPrice: String {
currencyFormatter.string(from: NSNumber(value: product.price)) ?? ""
}
var onProductUpdated: (() -> Void)?
var onCartStatusChanged: ((Bool) -> Void)?
var onError: ((Error) -> Void)?
private let cartService: CartServiceProtocol
private let currencyFormatter: NumberFormatter
init(product: Product, cartService: CartServiceProtocol) {
self.product = product
self.cartService = cartService
self.currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
checkCartStatus()
}
func addToCart() {
Task { @MainActor in
do {
try await cartService.add(product)
isInCart = true
} catch {
onError?(error)
}
}
}
func removeFromCart() {
Task { @MainActor in
do {
try await cartService.remove(product)
isInCart = false
} catch {
onError?(error)
}
}
}
func refresh() {
checkCartStatus()
}
private func checkCartStatus() {
Task { @MainActor in
isInCart = await cartService.contains(product)
}
}
}
Combine with UIKit (Optional)
If using Combine without SwiftUI:
import Combine
final class SearchViewModel {
@Published private(set) var results: [SearchResult] = []
@Published private(set) var isSearching = false
private var cancellables = Set<AnyCancellable>()
func search(query: String) {
isSearching = true
searchService.search(query: query)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isSearching = false
if case .failure(let error) = completion {
// Handle error
}
},
receiveValue: { [weak self] results in
self?.results = results
}
)
.store(in: &cancellables)
}
}
final class SearchViewController: UIViewController {
private var viewModel = SearchViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
}
private func bindViewModel() {
viewModel.$results
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)
viewModel.$isSearching
.receive(on: DispatchQueue.main)
.sink { [weak self] isSearching in
self?.activityIndicator.isHidden = !isSearching
}
.store(in: &cancellables)
}
}
NO SwiftUI Bridging
IMPORTANT: Do NOT use these patterns in pure UIKit projects:
// ❌ DO NOT USE - UIHostingController
let hostingController = UIHostingController(rootView: SomeSwiftUIView())
// ❌ DO NOT USE - UIViewRepresentable
struct MyUIKitView: UIViewRepresentable { ... }
// ❌ DO NOT USE - UIViewControllerRepresentable
struct MyUIKitViewController: UIViewControllerRepresentable { ... }
Instead, use pure UIKit alternatives:
// ✅ USE - Direct UIKit
let viewController = MyUIKitViewController()
navigationController?.pushViewController(viewController, animated: true)
// ✅ USE - Container view controller
addChild(childVC)
view.addSubview(childVC.view)
childVC.view.frame = containerView.bounds
childVC.didMove(toParent: self)
Memory Management
Weak References in Closures
// ❌ WRONG - Strong reference cycle
button.addAction(UIAction { _ in
self.handleTap() // Strong capture
}, for: .touchUpInside)
// ✅ CORRECT - Weak capture
button.addAction(UIAction { [weak self] _ in
self?.handleTap()
}, for: .touchUpInside)
Weak Delegates
// Always use weak for delegate references
weak var delegate: SomeDelegate?
Observation Cleanup
deinit {
NotificationCenter.default.removeObserver(self)
// NSKeyValueObservation automatically invalidates in deinit
}
Navigation Patterns
Coordinator Pattern
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get }
var childCoordinators: [Coordinator] { get set }
func start()
}
final class AppCoordinator: Coordinator {
var navigationController: UINavigationController
var childCoordinators: [Coordinator] = []
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let loginCoordinator = LoginCoordinator(navigationController: navigationController)
loginCoordinator.delegate = self
childCoordinators.append(loginCoordinator)
loginCoordinator.start()
}
}
extension AppCoordinator: LoginCoordinatorDelegate {
func loginCoordinatorDidFinish(_ coordinator: LoginCoordinator) {
childCoordinators.removeAll { $0 === coordinator }
showMainFlow()
}
}
Router Pattern (VIP)
See the VIP+W Architecture skill for Router implementation.