UIKit Patterns · v2.0.0 · 2026-03-29 · sha256 bc988ad8f973340b
UIKit Patterns v2.0.0A
Immutable. This exact content is served forever at /api/v1/blob/bc988ad8f973340b.
---
name: UIKit Patterns
description: Pure UIKit patterns for state management, data binding, and component design without SwiftUI bridging
version: 2.0.0
---
# 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.
```swift
// 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.
```swift
// 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.
```swift
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.
```swift
// 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.
```swift
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
```swift
// 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:
```swift
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:
```swift
// ❌ 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:
```swift
// ✅ 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
```swift
// ❌ 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
```swift
// Always use weak for delegate references
weak var delegate: SomeDelegate?
```
### Observation Cleanup
```swift
deinit {
NotificationCenter.default.removeObserver(self)
// NSKeyValueObservation automatically invalidates in deinit
}
```
## Navigation Patterns
### Coordinator Pattern
```swift
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.