# UIKit Views How a Tiledown-style native editor writes its UIKit renderer: `UIViewController` and `UIView` subclasses that are purely presentational over the framework-free model (see `pre-ui-layer.md`). The renderer reads Surface values, forwards every user action as `perform(_ intent:)`, and re-renders on the model's change signal. It holds ZERO business logic and owns ZERO state that is not view-local. > **Naming.** In a codebase that uses the cross-framework layer, the platform type names in the examples below are written as their `AppUI*` aliases and the module imported is `AppUIKit`; see `appuikit.md` (MANDATORY). ## Core rules 1. The renderer renders Surface state only: no business logic, no API calls, no domain decisions. 2. Every user action becomes a `perform(_ intent:)` call; the renderer reports, it never decides. 3. Observe the model's `Set` change signal and re-pull the dirty areas; UIKit is not reactive, so drive updates from the signal, never from ad hoc mutation. 4. All layout in code (Auto Layout or manual `layoutSubviews`); no Storyboards, no XIBs. 5. Use a diffable data source for any list or grid, keyed by stable Surface identifiers. 6. Every interactive element has an `accessibilityLabel` (and a hint where the action is non-obvious). 7. The one place appearance happens is a single `color(for: token)` map; never hard-code a `UIColor` inline. 8. Reuse cells; never stash per-row state on a cell, it will be recycled. ## Decision tree: code belongs in the UIKit renderer? ``` Visual presentation (views, layout, animation) → YES View-local UI state (in-flight gesture, scroll offset) → YES Reading a Surface to populate views → YES Translating a tap/swipe/edit into an Intent → YES (perform(intent)) Data transformation → NO (domain service, in the model) Business logic, validation, enable/disable decision → NO (engine; reflect the surface flag) Navigation decision → NO (engine sets a destination surface; the controller reflects it) Network / persistence → NO (data layer, behind a seam) ``` ## Patterns ### Binding the model and re-pulling on the signal ```swift final class TileListViewController: UIViewController { private let model: TileListRendererModel // pull slices + perform(_:) + change signal private lazy var dataSource = makeDataSource() init(model: TileListRendererModel) { self.model = model super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("code-only UI") } override func viewDidLoad() { super.viewDidLoad() configureHierarchy() // all layout in code model.onChange = { [weak self] dirty in self?.apply(dirty) // re-pull only the dirty surface areas } model.perform(.appWillAppear) // lifecycle is an intent, not controller logic } private func apply(_ dirty: Set) { if dirty.contains(.list) { applyListSnapshot(model.listSurface) } if dirty.contains(.status) { statusLabel.text = model.statusSurface.message } } } ``` ### User action to Intent (never a decision in the controller) ```swift // CORRECT: report the intent, the re-pull happens via the change signal @objc private func didTapReload() { model.perform(.run(.reload)) } func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let id = dataSource.itemIdentifier(for: indexPath) else { return } model.perform(.select(id)) } // INCORRECT: business logic in the renderer @objc private func badReload() { Task { try? await RenderService.shared.reload() } // wrong layer, wrong everything } ``` ### Lists via a diffable data source, keyed by Surface IDs ```swift private func makeDataSource() -> UICollectionViewDiffableDataSource { let cell = UICollectionView.CellRegistration { [model] cell, _, id in cell.render(model.tileSurface(id)) // cell reflects a surface, owns no state } return .init(collectionView: collectionView) { cv, indexPath, id in cv.dequeueConfiguredReusableCell(using: cell, for: indexPath, item: id) } } private func applyListSnapshot(_ surface: ListSurface) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(surface.orderedIDs, toSection: .main) dataSource.apply(snapshot, animatingDifferences: true) } ``` ### Enable/disable reflects a surface flag (the engine decides) ```swift // The engine computes whether the action is allowed; the renderer only reflects it. reloadButton.isEnabled = model.toolbarSurface.isReloadEnabled ``` ### Navigation reflects a destination surface ```swift private func apply(_ dirty: Set) { guard dirty.contains(.navigation) else { return } switch model.navigationSurface.destination { case .none: if presentedViewController != nil { dismiss(animated: true) } case .detail(let detail): present(TileDetailViewController(model: detail), animated: true) } } ``` ### Accessibility The full rule is [accessibility.md](../accessibility.md): text styles and Dynamic Type, names that say what a control does, 28 point targets, an alternative for every gesture, contrast, Reduce Motion, and the audit that fails the build. What follows is the minimum for one element. ```swift reloadButton.accessibilityLabel = "Reload tiles" reloadButton.accessibilityHint = "Fetches the latest tiles" ``` ## Observing the change signal UIKit has no built-in reactivity, so the renderer is driven by the model's `Notify` signal, never by mutating views from wherever an event lands: - **Re-pull loop (default).** The model calls back with a `Set`; the controller re-pulls only those areas and updates the corresponding views. Coalesce rapid signals to the next runloop tick to avoid redundant layout. - **Observation bridge (optional).** If the model exposes `@Observable` surfaces, wrap reads in `withObservationTracking`, re-registering after each callback, and funnel the callback into `setNeedsUpdate`-style invalidation. It is still a re-pull; Observation only schedules it. Mutation flows one way: intent in, surface out, re-pull. Never mutate a view from a place that also mutates the model. ## Identity and reuse (the UIKit trap) Cells and reusable views are recycled; treat each as a pure function of the surface it renders. - A cell MUST fully reconfigure from its surface in `render(_:)`; never assume residual state from a prior row. - Cancel any in-flight async (an image load) in `prepareForReuse`. - Drive list identity from stable `SurfaceID`s in the diffable snapshot, never from index paths. - Keep view-local values (a half-finished gesture) on the view, never in the model, and never let them survive reuse. ## App setup (no storyboard, no XIB) UI is created in code, always. A code-only UIKit app removes the storyboard scaffolding the template ships with: - Delete the **Main storyboard** entry: clear "Main storyboard file base name" (`UIMainStoryboardFile`) and, under the scene manifest, the `UISceneStoryboardFile` reference in Info.plist; remove `Main.storyboard` and any `.xib`. - Create the window and root view controller in code, in the scene delegate (`scene(_:willConnectTo:)` builds the `UIWindow`, sets `rootViewController`, and calls `makeKeyAndVisible()`), or in the app delegate for a single-scene app. - Set `UILaunchScreen` as an empty dictionary in Info.plist (a code-only launch screen) rather than pointing at a launch storyboard. ## Validation checklist - [ ] Zero business logic; every decision reflects a surface the engine set - [ ] Every user action forwarded as `perform(_ intent:)` - [ ] Updates driven by the `Set` change signal, not ad hoc mutation - [ ] All layout in code; no Storyboards or XIBs - [ ] Lists use a diffable data source keyed by stable Surface IDs - [ ] Cells reconfigure fully on reuse; async cancelled in `prepareForReuse` - [ ] Accessibility labels on every interactive element - [ ] Appearance only through the `color(for:)` map - [ ] This is one of several renderers over the same model (see `pre-ui-layer.md`) ## Companion rules - `pre-ui-layer.md`: the framework-free model this renderer adapts; renderers are the only UI import, gated mechanically. - `swiftui-views.md`, `appkit-views.md`: the sibling renderers over the same model; build all three and compare (see `../domain-first.md`). - `view-models.md`: responsibilities of the presentation layer the renderer binds to. - `colors.md`, `fonts.md`: the `color(for:)` and font sources the renderer reflects.