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