195 lines
8.6 KiB
Markdown
195 lines
8.6 KiB
Markdown
# 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<SurfaceArea>` 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<SurfaceArea>) {
|
|
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<Section, SurfaceID> {
|
|
let cell = UICollectionView.CellRegistration<TileCell, SurfaceID> { [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<Section, SurfaceID>()
|
|
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<SurfaceArea>) {
|
|
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<SurfaceArea>`; 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<SurfaceArea>` 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.
|