Skill: UIKit View Layer
Overview
This codebase's screen convention splits view responsibilities in two: the UIViewController owns lifecycle, presenter wiring, and navigation triggers, while a separate UIView subclass owns every subview and every constraint. The controller never builds constraints — it installs the view and forwards lifecycle events to the presenter. This keeps controllers small by construction, since layout code has nowhere to accumulate inside them, and it makes the view independently previewable and reusable: it can be instantiated, laid out, and inspected (in a snapshot test or an Xcode preview) with no presenter, no controller, and no navigation stack involved.
Use Cases
- Any new programmatic UIKit screen built on the four-file MVP convention.
- Splitting layout out of an existing view controller as the first structural step before further decomposition.
- Screens that need a snapshot test of layout alone, independent of presenter state.
- Reusing a screen's visual layer (e.g. embedding it in a container view controller) without dragging its presenter or navigation logic along.
Best Practices
- Name the view type
<Screen>View(for exampleArticleListView) and give it a single public root: the controller adds exactly one view as a subview ofself.view, or installs it directly vialoadView(). - Build every constraint once, in
init(frame:)or asetUpConstraints()called frominit(frame:)— never inlayoutSubviews(), and never in the controller. - Set
translatesAutoresizingMaskIntoConstraints = falseon every subview before activating constraints against it. - Expose configuration through plain methods or properties (
func configure(with:)), not by handing the controller direct access to internal subviews it should not touch. - Register table and collection view cells by type, never by string identifier.
- Prefer
UITableViewDiffableDataSource/UICollectionViewDiffableDataSource(both available from iOS 13) over manualreloadData()so list updates are diffed and animated instead of re-rendering the whole list on every change. - Give interactive controls an
accessibilityLabeland Dynamic Type support viaUIFont.preferredFont(forTextStyle:), per the accessibility rules in../../../standards/uikit_standards.md.
Anti-Patterns
- ❌ Building or re-activating constraints inside
layoutSubviews()instead of once atinit(frame:)time. - ❌ Adding subviews inside
viewDidLayoutSubviews()on the controller instead of in the view's own initializer. - ❌ Dequeuing cells with a string literal identifier instead of registering and dequeuing by type.
- ❌ Calling
reloadData()on every keystroke or state change instead of diffing with a diffable data source or targeted row/section updates. - ❌ Storing per-row state on the cell itself (a cached image, a timer, a selection flag) that outlives reuse and leaks into the next row unless
prepareForReuse()clears it.
Checklist
- [ ] Layout and subview construction live entirely in the
<Screen>Viewtype, never in the view controller. - [ ] Constraints are built once, in
init(frame:), not inlayoutSubviews(). - [ ] Every added subview sets
translatesAutoresizingMaskIntoConstraints = false. - [ ] Cells are registered and dequeued by type, not by string identifier.
- [ ] Lists use a diffable data source rather than unconditional
reloadData(). - [ ] Cells implement
prepareForReuse()if they hold any mutable or in-flight state. - [ ] The view type has no reference to the presenter and no navigation logic.
See also ../../../standards/uikit_standards.md and ../../../checklists/uikit_review.md.
Swift Examples
A view that owns all of its own layout, built with UIKit's own layout anchors:
// ArticleListView.swift
final class ArticleListView: UIView {
let tableView = UITableView(frame: .zero, style: .plain)
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
}The controller installs that view via loadView() instead of building it inside viewDidLoad(), so the view exists before any lifecycle method that might reference it runs:
// ArticleListViewController.swift
final class ArticleListViewController: UIViewController {
private let articleListView = ArticleListView(frame: .zero)
var presenter: ArticleListPresenterProtocol!
override func loadView() {
view = articleListView
}
override func viewDidLoad() {
super.viewDidLoad()
presenter.onViewDidLoad()
}
}A diffable data source (iOS 13+) keeps the table in sync with presenter state without manual reloadData() calls. Key the snapshot on Article.ID, not Article itself: diffable data sources require their identifier type to be Hashable, and this toolkit's Article entity (templates/ios/clean_architecture_feature/Domain.swift) declares only Equatable, Identifiable, Sendable — it is deliberately not Hashable. Article is already Identifiable, so Article.ID (String) is available and is Hashable. Because the snapshot now holds only identifiers, the cell provider needs a way to resolve an identifier back to the Article it should display; the example below takes that lookup as a closure supplied by the caller, which already holds the current [Article]:
// ArticleListView+DataSource.swift
extension ArticleListView {
enum Section { case main }
func makeDataSource(
articleForID: @escaping (Article.ID) -> Article?
) -> UITableViewDiffableDataSource<Section, Article.ID> {
tableView.register(ArticleCell.self, forCellReuseIdentifier: ArticleCell.reuseID)
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, articleID in
let cell = tableView.dequeueReusableCell(
withIdentifier: ArticleCell.reuseID,
for: indexPath
) as! ArticleCell
if let article = articleForID(articleID) {
cell.configure(with: article)
}
return cell
}
}
func apply(_ articles: [Article], to dataSource: UITableViewDiffableDataSource<Section, Article.ID>) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Article.ID>()
snapshot.appendSections([.main])
snapshot.appendItems(articles.map(\.id), toSection: .main)
dataSource.apply(snapshot, animatingDifferences: true)
}
}A host app that uses a layout DSL (such as SnapKit) applies the same structure — one UIView subclass owning all subviews and constraints, built once in init(frame:) — with only the constraint-building syntax differing from the anchor calls shown above.
Common Interview Questions
- Why does the view own layout instead of the view controller?
- What is the difference between building constraints in
init(frame:)versuslayoutSubviews(), and why does it matter for performance and correctness? - Why prefer a diffable data source over
reloadData()? - What goes wrong if a cell doesn't implement
prepareForReuse()? - How would you snapshot-test this view without a presenter or a running app?
AI Implementation Notes
- When creating a new UIKit screen, always generate a
<Screen>View: UIViewsubclass alongside the controller — never inline subview creation or constraints into the controller. - Default to
init(frame:)for constraint construction; only introduce a separatesetUpConstraints()method when the view has enough subviews that inlining everything ininitwould hurt readability. - Register cells by type and prefer a diffable data source for any list-backed view unless an existing screen's convention already uses a different, established pattern.
- Do not introduce a third-party layout library into generated examples; use
NSLayoutConstraint.activateand anchor properties only. - Related:
massive_view_controller.md,../../architecture/ios/mvp.md,../../../standards/uikit_standards.md,../../../checklists/uikit_review.md.