Skip to content

UIKit Framework Guide

Framework: UIKit (iOS 12+) Language: Swift 5.0+ Type: Imperative UI Framework Platform: iOS, tvOS, Mac Catalyst


Overview

UIKit is Apple's traditional imperative UI framework for building iOS applications. It provides fine-grained control over UI elements and is the foundation of iOS app development.

Use UIKit when: - Supporting older iOS versions (< iOS 14) - Need fine-grained control over animations - Working with complex custom UI - Integrating with existing UIKit codebases - Advanced collection view layouts - Complex gesture handling

Consider alternatives when: - Building new apps targeting iOS 15+ (consider SwiftUI) - Simple CRUD applications - Need rapid prototyping


Project Structure

MyApp/
├── MyApp.xcodeproj
├── MyApp/
│   ├── Application/
│   │   ├── AppDelegate.swift
│   │   ├── SceneDelegate.swift
│   │   └── AppCoordinator.swift
│   ├── Features/
│   │   ├── Authentication/
│   │   │   ├── Controllers/
│   │   │   │   ├── LoginViewController.swift
│   │   │   │   └── SignUpViewController.swift
│   │   │   ├── ViewModels/
│   │   │   │   └── AuthViewModel.swift
│   │   │   ├── Views/
│   │   │   │   └── LoginFormView.swift
│   │   │   └── Coordinator/
│   │   │       └── AuthCoordinator.swift
│   │   ├── Home/
│   │   │   ├── Controllers/
│   │   │   ├── ViewModels/
│   │   │   ├── Views/
│   │   │   └── Cells/
│   │   └── Profile/
│   ├── Core/
│   │   ├── Network/
│   │   ├── Storage/
│   │   ├── Extensions/
│   │   └── Utilities/
│   ├── Shared/
│   │   ├── Views/
│   │   ├── Cells/
│   │   └── Protocols/
│   └── Resources/
│       ├── Assets.xcassets
│       ├── Storyboards/
│       └── Localizable.strings
├── MyAppTests/
└── MyAppUITests/

Application Lifecycle

SceneDelegate (iOS 13+)

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    var appCoordinator: AppCoordinator?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        let window = UIWindow(windowScene: windowScene)
        self.window = window

        // Setup coordinator
        let navigationController = UINavigationController()
        appCoordinator = AppCoordinator(navigationController: navigationController)

        window.rootViewController = navigationController
        window.makeKeyAndVisible()

        appCoordinator?.start()
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // Save state
        CoreDataManager.shared.saveContext()
    }
}

AppDelegate

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Configure appearance
        configureAppearance()

        // Configure third-party SDKs
        configureAnalytics()

        return true
    }

    private func configureAppearance() {
        // Navigation bar appearance
        let navBarAppearance = UINavigationBarAppearance()
        navBarAppearance.configureWithOpaqueBackground()
        navBarAppearance.backgroundColor = .systemBackground
        navBarAppearance.titleTextAttributes = [.foregroundColor: UIColor.label]

        UINavigationBar.appearance().standardAppearance = navBarAppearance
        UINavigationBar.appearance().scrollEdgeAppearance = navBarAppearance

        // Tab bar appearance
        let tabBarAppearance = UITabBarAppearance()
        tabBarAppearance.configureWithOpaqueBackground()
        UITabBar.appearance().standardAppearance = tabBarAppearance
        UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance
    }

    // MARK: - UISceneSession Lifecycle

    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

View Controllers

Base View Controller

import UIKit

class BaseViewController: UIViewController {
    // MARK: - Properties

    private lazy var loadingView: LoadingView = {
        let view = LoadingView()
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupConstraints()
        setupBindings()
    }

    // MARK: - Setup Methods (Override in subclasses)

    func setupUI() {
        view.backgroundColor = .systemBackground
    }

    func setupConstraints() {
        // Override in subclasses
    }

    func setupBindings() {
        // Override in subclasses
    }

    // MARK: - Loading State

    func showLoading() {
        view.addSubview(loadingView)
        NSLayoutConstraint.activate([
            loadingView.topAnchor.constraint(equalTo: view.topAnchor),
            loadingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            loadingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            loadingView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        loadingView.startAnimating()
    }

    func hideLoading() {
        loadingView.stopAnimating()
        loadingView.removeFromSuperview()
    }

    // MARK: - Error Handling

    func showError(_ error: Error) {
        let alert = UIAlertController(
            title: "Error",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

Feature View Controller

import UIKit
import Combine

final class UserListViewController: BaseViewController {
    // MARK: - Properties

    private let viewModel: UserListViewModel
    private var cancellables = Set<AnyCancellable>()

    weak var coordinator: UserCoordinator?

    // MARK: - UI Elements

    private lazy var tableView: UITableView = {
        let table = UITableView(frame: .zero, style: .plain)
        table.translatesAutoresizingMaskIntoConstraints = false
        table.register(UserCell.self, forCellReuseIdentifier: UserCell.identifier)
        table.delegate = self
        table.dataSource = self
        table.rowHeight = UITableView.automaticDimension
        table.estimatedRowHeight = 80
        table.refreshControl = refreshControl
        return table
    }()

    private lazy var refreshControl: UIRefreshControl = {
        let control = UIRefreshControl()
        control.addTarget(self, action: #selector(handleRefresh), for: .valueChanged)
        return control
    }()

    private lazy var emptyStateView: EmptyStateView = {
        let view = EmptyStateView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.configure(
            image: UIImage(systemName: "person.3"),
            title: "No Users",
            message: "There are no users to display"
        )
        view.isHidden = true
        return view
    }()

    // MARK: - Initialization

    init(viewModel: UserListViewModel = UserListViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Setup

    override func setupUI() {
        super.setupUI()
        title = "Users"

        view.addSubview(tableView)
        view.addSubview(emptyStateView)

        navigationItem.rightBarButtonItem = UIBarButtonItem(
            barButtonSystemItem: .add,
            target: self,
            action: #selector(addUserTapped)
        )
    }

    override func setupConstraints() {
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            emptyStateView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            emptyStateView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            emptyStateView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 32),
            emptyStateView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -32)
        ])
    }

    override func setupBindings() {
        viewModel.$users
            .receive(on: DispatchQueue.main)
            .sink { [weak self] users in
                self?.tableView.reloadData()
                self?.emptyStateView.isHidden = !users.isEmpty
            }
            .store(in: &cancellables)

        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                if isLoading {
                    self?.showLoading()
                } else {
                    self?.hideLoading()
                    self?.refreshControl.endRefreshing()
                }
            }
            .store(in: &cancellables)

        viewModel.$error
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] error in
                self?.showError(error)
            }
            .store(in: &cancellables)
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        viewModel.loadUsers()
    }

    // MARK: - Actions

    @objc private func handleRefresh() {
        viewModel.loadUsers()
    }

    @objc private func addUserTapped() {
        coordinator?.showAddUser()
    }
}

// MARK: - UITableViewDataSource

extension UserListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.users.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(
            withIdentifier: UserCell.identifier,
            for: indexPath
        ) as? UserCell else {
            return UITableViewCell()
        }

        let user = viewModel.users[indexPath.row]
        cell.configure(with: user)
        return cell
    }
}

// MARK: - UITableViewDelegate

extension UserListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let user = viewModel.users[indexPath.row]
        coordinator?.showUserDetail(user)
    }

    func tableView(
        _ tableView: UITableView,
        trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
    ) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in
            self?.viewModel.deleteUser(at: indexPath.row)
            completion(true)
        }
        deleteAction.image = UIImage(systemName: "trash")

        return UISwipeActionsConfiguration(actions: [deleteAction])
    }
}

MVVM Architecture

View Model with Combine

import Foundation
import Combine

final class UserListViewModel {
    // MARK: - Published Properties

    @Published private(set) var users: [User] = []
    @Published private(set) var isLoading = false
    @Published private(set) var error: Error?

    // MARK: - Dependencies

    private let userService: UserServiceProtocol
    private var cancellables = Set<AnyCancellable>()

    // MARK: - Initialization

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
    }

    // MARK: - Public Methods

    func loadUsers() {
        isLoading = true
        error = nil

        Task { @MainActor in
            do {
                users = try await userService.fetchUsers()
            } catch {
                self.error = error
            }
            isLoading = false
        }
    }

    func deleteUser(at index: Int) {
        let user = users[index]
        users.remove(at: index)

        Task {
            do {
                try await userService.deleteUser(id: user.id)
            } catch {
                // Restore on failure
                await MainActor.run {
                    users.insert(user, at: index)
                    self.error = error
                }
            }
        }
    }

    func searchUsers(query: String) {
        if query.isEmpty {
            loadUsers()
            return
        }

        Task { @MainActor in
            do {
                users = try await userService.searchUsers(query: query)
            } catch {
                self.error = error
            }
        }
    }
}

View Model with Closures (Legacy)

import Foundation

final class UserDetailViewModel {
    // MARK: - Output Closures

    var onUserLoaded: ((User) -> Void)?
    var onLoadingStateChanged: ((Bool) -> Void)?
    var onError: ((Error) -> Void)?

    // MARK: - Properties

    private(set) var user: User?
    private let userService: UserServiceProtocol
    private let userId: String

    // MARK: - Initialization

    init(userId: String, userService: UserServiceProtocol = UserService()) {
        self.userId = userId
        self.userService = userService
    }

    // MARK: - Public Methods

    func loadUser() {
        onLoadingStateChanged?(true)

        Task { @MainActor in
            do {
                let user = try await userService.fetchUser(id: userId)
                self.user = user
                onUserLoaded?(user)
            } catch {
                onError?(error)
            }
            onLoadingStateChanged?(false)
        }
    }
}

Coordinator Pattern

Protocol Definition

import UIKit

protocol Coordinator: AnyObject {
    var childCoordinators: [Coordinator] { get set }
    var navigationController: UINavigationController { get set }

    func start()
}

extension Coordinator {
    func addChild(_ coordinator: Coordinator) {
        childCoordinators.append(coordinator)
    }

    func removeChild(_ coordinator: Coordinator) {
        childCoordinators.removeAll { $0 === coordinator }
    }
}

App Coordinator

import UIKit

final class AppCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController

    private let authManager: AuthManager

    init(navigationController: UINavigationController, authManager: AuthManager = .shared) {
        self.navigationController = navigationController
        self.authManager = authManager
    }

    func start() {
        if authManager.isAuthenticated {
            showMainFlow()
        } else {
            showAuthFlow()
        }
    }

    private func showAuthFlow() {
        let coordinator = AuthCoordinator(navigationController: navigationController)
        coordinator.delegate = self
        addChild(coordinator)
        coordinator.start()
    }

    private func showMainFlow() {
        let coordinator = MainTabCoordinator(navigationController: navigationController)
        addChild(coordinator)
        coordinator.start()
    }
}

extension AppCoordinator: AuthCoordinatorDelegate {
    func authCoordinatorDidFinish(_ coordinator: AuthCoordinator) {
        removeChild(coordinator)
        showMainFlow()
    }
}

Feature Coordinator

import UIKit

protocol UserCoordinatorDelegate: AnyObject {
    func userCoordinatorDidFinish(_ coordinator: UserCoordinator)
}

final class UserCoordinator: Coordinator {
    var childCoordinators: [Coordinator] = []
    var navigationController: UINavigationController

    weak var delegate: UserCoordinatorDelegate?

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
        let viewModel = UserListViewModel()
        let viewController = UserListViewController(viewModel: viewModel)
        viewController.coordinator = self
        navigationController.pushViewController(viewController, animated: true)
    }

    func showUserDetail(_ user: User) {
        let viewModel = UserDetailViewModel(userId: user.id)
        let viewController = UserDetailViewController(viewModel: viewModel)
        viewController.coordinator = self
        navigationController.pushViewController(viewController, animated: true)
    }

    func showAddUser() {
        let viewModel = AddUserViewModel()
        let viewController = AddUserViewController(viewModel: viewModel)
        viewController.delegate = self

        let navController = UINavigationController(rootViewController: viewController)
        navigationController.present(navController, animated: true)
    }

    func showEditUser(_ user: User) {
        let viewModel = EditUserViewModel(user: user)
        let viewController = EditUserViewController(viewModel: viewModel)
        viewController.delegate = self
        navigationController.pushViewController(viewController, animated: true)
    }
}

extension UserCoordinator: AddUserViewControllerDelegate {
    func addUserViewControllerDidAddUser(_ controller: AddUserViewController, user: User) {
        controller.dismiss(animated: true)
        // Notify or refresh
    }

    func addUserViewControllerDidCancel(_ controller: AddUserViewController) {
        controller.dismiss(animated: true)
    }
}

Custom Views

Programmatic View

import UIKit

final class UserCell: UITableViewCell {
    static let identifier = "UserCell"

    // MARK: - UI Elements

    private let avatarImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.backgroundColor = .systemGray5
        imageView.layer.cornerRadius = 24
        return imageView
    }()

    private let nameLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .headline)
        label.textColor = .label
        return label
    }()

    private let emailLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .subheadline)
        label.textColor = .secondaryLabel
        return label
    }()

    private let stackView: UIStackView = {
        let stack = UIStackView()
        stack.translatesAutoresizingMaskIntoConstraints = false
        stack.axis = .vertical
        stack.spacing = 4
        return stack
    }()

    // MARK: - Initialization

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
        setupConstraints()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Setup

    private func setupUI() {
        contentView.addSubview(avatarImageView)
        contentView.addSubview(stackView)

        stackView.addArrangedSubview(nameLabel)
        stackView.addArrangedSubview(emailLabel)

        accessoryType = .disclosureIndicator
    }

    private func setupConstraints() {
        NSLayoutConstraint.activate([
            avatarImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            avatarImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            avatarImageView.widthAnchor.constraint(equalToConstant: 48),
            avatarImageView.heightAnchor.constraint(equalToConstant: 48),
            avatarImageView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, constant: 12),
            avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -12),

            stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 12),
            stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
            stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ])
    }

    // MARK: - Configuration

    func configure(with user: User) {
        nameLabel.text = user.name
        emailLabel.text = user.email

        // Load avatar image
        if let url = URL(string: user.avatarURL) {
            ImageLoader.shared.load(url: url) { [weak self] image in
                self?.avatarImageView.image = image
            }
        }
    }

    // MARK: - Reuse

    override func prepareForReuse() {
        super.prepareForReuse()
        avatarImageView.image = nil
        nameLabel.text = nil
        emailLabel.text = nil
    }
}

Reusable Component

import UIKit

final class PrimaryButton: UIButton {
    // MARK: - Properties

    private var originalBackgroundColor: UIColor?

    override var isEnabled: Bool {
        didSet {
            alpha = isEnabled ? 1.0 : 0.5
        }
    }

    override var isHighlighted: Bool {
        didSet {
            UIView.animate(withDuration: 0.1) {
                self.transform = self.isHighlighted ? CGAffineTransform(scaleX: 0.95, y: 0.95) : .identity
            }
        }
    }

    // MARK: - Initialization

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupUI()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupUI()
    }

    convenience init(title: String) {
        self.init(frame: .zero)
        setTitle(title, for: .normal)
    }

    // MARK: - Setup

    private func setupUI() {
        backgroundColor = .systemBlue
        setTitleColor(.white, for: .normal)
        titleLabel?.font = .preferredFont(forTextStyle: .headline)

        layer.cornerRadius = 12

        contentEdgeInsets = UIEdgeInsets(top: 14, left: 24, bottom: 14, right: 24)

        originalBackgroundColor = backgroundColor
    }

    // MARK: - Loading State

    private var activityIndicator: UIActivityIndicatorView?
    private var savedTitle: String?

    func showLoading() {
        savedTitle = title(for: .normal)
        setTitle(nil, for: .normal)
        isEnabled = false

        let indicator = UIActivityIndicatorView(style: .medium)
        indicator.color = .white
        indicator.translatesAutoresizingMaskIntoConstraints = false
        addSubview(indicator)

        NSLayoutConstraint.activate([
            indicator.centerXAnchor.constraint(equalTo: centerXAnchor),
            indicator.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])

        indicator.startAnimating()
        activityIndicator = indicator
    }

    func hideLoading() {
        activityIndicator?.stopAnimating()
        activityIndicator?.removeFromSuperview()
        activityIndicator = nil

        setTitle(savedTitle, for: .normal)
        isEnabled = true
    }
}

Collection Views

Modern Collection View with Diffable Data Source

import UIKit

final class PhotoGridViewController: UIViewController {
    // MARK: - Types

    enum Section {
        case main
    }

    typealias DataSource = UICollectionViewDiffableDataSource<Section, Photo>
    typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Photo>

    // MARK: - Properties

    private var collectionView: UICollectionView!
    private var dataSource: DataSource!
    private let viewModel: PhotoGridViewModel

    // MARK: - Initialization

    init(viewModel: PhotoGridViewModel = PhotoGridViewModel()) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupCollectionView()
        setupDataSource()
        setupBindings()
        viewModel.loadPhotos()
    }

    // MARK: - Setup

    private func setupCollectionView() {
        let layout = createLayout()
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.delegate = self

        view.addSubview(collectionView)
    }

    private func createLayout() -> UICollectionViewLayout {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalWidth(1/3)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = NSDirectionalEdgeInsets(top: 2, leading: 2, bottom: 2, trailing: 2)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalWidth(1/3)
        )
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)

        return UICollectionViewCompositionalLayout(section: section)
    }

    private func setupDataSource() {
        let cellRegistration = UICollectionView.CellRegistration<PhotoCell, Photo> { cell, indexPath, photo in
            cell.configure(with: photo)
        }

        dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, photo in
            return collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: photo
            )
        }
    }

    private func setupBindings() {
        viewModel.onPhotosLoaded = { [weak self] photos in
            self?.applySnapshot(photos: photos)
        }
    }

    private func applySnapshot(photos: [Photo], animating: Bool = true) {
        var snapshot = Snapshot()
        snapshot.appendSections([.main])
        snapshot.appendItems(photos)
        dataSource.apply(snapshot, animatingDifferences: animating)
    }
}

// MARK: - UICollectionViewDelegate

extension PhotoGridViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let photo = dataSource.itemIdentifier(for: indexPath) else { return }
        // Handle selection
    }
}

Compositional Layout with Multiple Sections

import UIKit

final class HomeViewController: UIViewController {
    // MARK: - Types

    enum Section: Int, CaseIterable {
        case featured
        case categories
        case recent

        var title: String {
            switch self {
            case .featured: return "Featured"
            case .categories: return "Categories"
            case .recent: return "Recent"
            }
        }
    }

    // MARK: - Layout

    private func createLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in
            guard let section = Section(rawValue: sectionIndex) else { return nil }

            switch section {
            case .featured:
                return self?.createFeaturedSection()
            case .categories:
                return self?.createCategoriesSection()
            case .recent:
                return self?.createRecentSection()
            }
        }
    }

    private func createFeaturedSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(0.9),
            heightDimension: .absolute(250)
        )
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPagingCentered
        section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
        section.interGroupSpacing = 16

        section.boundarySupplementaryItems = [createSectionHeader()]

        return section
    }

    private func createCategoriesSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1.0)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .absolute(100),
            heightDimension: .absolute(100)
        )
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .continuous
        section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 16, trailing: 16)
        section.interGroupSpacing = 12

        section.boundarySupplementaryItems = [createSectionHeader()]

        return section
    }

    private func createRecentSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .estimated(100)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .estimated(100)
        )
        let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])

        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 16, bottom: 16, trailing: 16)
        section.interGroupSpacing = 12

        section.boundarySupplementaryItems = [createSectionHeader()]

        return section
    }

    private func createSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        let headerSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .estimated(44)
        )
        return NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: headerSize,
            elementKind: UICollectionView.elementKindSectionHeader,
            alignment: .top
        )
    }
}

Networking

URL Session Wrapper

import Foundation

protocol APIClientProtocol {
    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T
    func send<T: Decodable, U: Encodable>(_ endpoint: Endpoint, body: U) async throws -> T
}

final class APIClient: APIClientProtocol {
    private let session: URLSession
    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private let baseURL: URL

    init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session

        self.decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601

        self.encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
    }

    func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
        let request = try buildRequest(for: endpoint)
        return try await execute(request)
    }

    func send<T: Decodable, U: Encodable>(_ endpoint: Endpoint, body: U) async throws -> T {
        var request = try buildRequest(for: endpoint)
        request.httpBody = try encoder.encode(body)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        return try await execute(request)
    }

    private func buildRequest(for endpoint: Endpoint) throws -> URLRequest {
        guard let url = URL(string: endpoint.path, relativeTo: baseURL) else {
            throw APIError.invalidURL
        }

        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.timeoutInterval = 30

        // Add auth token if available
        if let token = AuthManager.shared.accessToken {
            request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        }

        return request
    }

    private func execute<T: Decodable>(_ request: URLRequest) async throws -> T {
        let (data, response) = try await session.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw APIError.httpError(statusCode: httpResponse.statusCode)
        }

        do {
            return try decoder.decode(T.self, from: data)
        } catch {
            throw APIError.decodingError(error)
        }
    }
}

Testing

View Controller Testing

import XCTest
@testable import MyApp

final class UserListViewControllerTests: XCTestCase {
    var sut: UserListViewController!
    var mockViewModel: MockUserListViewModel!
    var mockCoordinator: MockUserCoordinator!

    override func setUp() {
        super.setUp()
        mockViewModel = MockUserListViewModel()
        mockCoordinator = MockUserCoordinator()
        sut = UserListViewController(viewModel: mockViewModel)
        sut.coordinator = mockCoordinator
        sut.loadViewIfNeeded()
    }

    override func tearDown() {
        sut = nil
        mockViewModel = nil
        mockCoordinator = nil
        super.tearDown()
    }

    func test_viewDidLoad_loadsUsers() {
        // When
        sut.viewDidLoad()

        // Then
        XCTAssertTrue(mockViewModel.loadUsersCalled)
    }

    func test_tableView_numberOfRows_matchesUsersCount() {
        // Given
        mockViewModel.users = [User.stub(), User.stub()]

        // When
        let rowCount = sut.tableView(sut.tableView, numberOfRowsInSection: 0)

        // Then
        XCTAssertEqual(rowCount, 2)
    }

    func test_didSelectRow_navigatesToUserDetail() {
        // Given
        let user = User.stub()
        mockViewModel.users = [user]
        let indexPath = IndexPath(row: 0, section: 0)

        // When
        sut.tableView(sut.tableView, didSelectRowAt: indexPath)

        // Then
        XCTAssertEqual(mockCoordinator.shownUser?.id, user.id)
    }
}

// Mocks
final class MockUserListViewModel: UserListViewModel {
    var loadUsersCalled = false

    override func loadUsers() {
        loadUsersCalled = true
    }
}

final class MockUserCoordinator: UserCoordinator {
    var shownUser: User?

    override func showUserDetail(_ user: User) {
        shownUser = user
    }
}

Best Practices

Memory Management

  • ✓ Use [weak self] in closures
  • ✓ Break retain cycles with weak delegates
  • ✓ Cancel network tasks in deinit
  • ✓ Use Instruments to detect leaks

Architecture

  • ✓ Use MVVM + Coordinator pattern
  • ✓ Keep view controllers thin
  • ✓ Extract reusable views
  • ✓ Use dependency injection

Performance

  • ✓ Reuse cells properly
  • ✓ Load images asynchronously
  • ✓ Use diffable data sources
  • ✓ Profile with Instruments

References