§ · Cookbook
Recipes.
Short, named patterns for common napkin shapes. Copy, paste, rename.
Attach a child napkin
The parent's router builds the child, attaches it (which activates its lifecycle), and adds the child's view to the parent's hierarchy.
func routeToProfile() async {
let router = await profileBuilder.build(withListener: interactor)
self.profileRouter = router
await attachChild(router)
viewController.embed(router.viewControllable)
}
Inject a service through the dependency tree
The feature declares what it needs; the parent component satisfies it. Compile-checked, no runtime container.
protocol HomeDependency: Dependency {
var authService: AuthService { get }
}
final class HomeComponent: Component<HomeDependency>, @unchecked Sendable {
var authService: AuthService { dependency.authService }
}
// The parent's component conforms to HomeDependency:
extension AppComponent: HomeDependency {}
Swap two child napkins on tap
Two children, only one attached at a time. Each attachX tears down the other first.
func attachA() async {
await detachBIfNeeded()
guard aRouter == nil else { return }
let router = await aBuilder.build(withListener: interactor)
aRouter = router
await attachChild(router)
viewController.embed(router.viewControllable)
}
private func detachBIfNeeded() async {
guard let router = bRouter else { return }
bRouter = nil
viewController.detach(router.viewControllable)
await detachChild(router)
}
Headless parent (orchestrator with no view)
For napkins that route between children without rendering their own UI. The view controller is a plain UIViewController with embed/detach for the active child.
@MainActor
final class ParentViewController: UIViewController, ParentViewControllable {
func embed(_ child: ViewControllable) {
let vc = child.uiviewController
addChild(vc)
vc.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(vc.view)
NSLayoutConstraint.activate([
vc.view.topAnchor.constraint(equalTo: view.topAnchor),
vc.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
vc.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
vc.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
vc.didMove(toParent: self)
}
func detach(_ child: ViewControllable) {
let vc = child.uiviewController
vc.willMove(toParent: nil)
vc.view.removeFromSuperview()
vc.removeFromParent()
}
}
Forward a tap from SwiftUI to the interactor
Use the dispatch helper, not inline Task { } — it captures the listener weakly and handles cancellation.
struct HomeView: View {
weak var listener: HomePresentableListener?
var body: some View {
Button("Refresh") {
dispatch { [listener] in await listener?.didTapRefresh() }
}
}
}
Send an intent up the tree
Children never import their parent. They call a method on a <Self>NapkinListener the parent's interactor conforms to.
protocol CounterNapkinListener: AnyObject, Sendable {
func counterDidFinish() async
}
final actor CounterInteractor: ..., CounterPresentableListener {
weak var listener: CounterNapkinListener?
func didTapDone() async {
await listener?.counterDidFinish()
}
}
Fetch data when the napkin becomes active
Lifecycle hooks are just async methods on the actor. Cancellation is automatic when the napkin deactivates.
func didBecomeActive() async {
do {
let profile = try await profileService.fetchProfile()
await presenter.update(profile)
} catch is CancellationError {
// Napkin was deactivated mid-fetch — bail.
} catch {
await presenter.update(.error(error))
}
}
Test an interactor
Mock the protocols, call methods directly. No simulator, no XCTestExpectation.
@MainActor
final class CartInteractorTests: XCTestCase {
func testAddItem_updatesPresenter() async {
let presenter = MockCartPresentable()
let sut = CartInteractor(presenter: presenter)
await sut.didTapAddItem(.brisket)
XCTAssertEqual(presenter.updateCalls.count, 1)
XCTAssertEqual(presenter.updateCalls.last?.items, [.brisket])
}
}
Bootstrap the root napkin from SceneDelegate
Build the app component, build the launch router, call launch(from:). Three lines of meaningful code.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: ...) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
Task { @MainActor in
let builder = LaunchNapkinBuilder(dependency: AppComponent())
let router = await builder.build(withListener: AppListener())
self.launchRouter = router
await router.launch(from: window)
}
}
Pass data through to a child router
Builder takes data as a parameter on build(...). The router holds it as let user: User; the interactor and view receive it via the builder.
protocol LoggedInBuildable: Buildable {
@MainActor func build(
withListener listener: LoggedInListener,
user: User
) async -> LoggedInRouting
}
@MainActor
func build(withListener listener: LoggedInListener, user: User) async -> LoggedInRouting {
let viewController = LoggedInViewController(user: user)
let interactor = LoggedInInteractor(presenter: viewController, user: user)
await interactor.set(listener: listener)
let router = LoggedInRouter(interactor: interactor, viewController: viewController, user: user)
await interactor.set(router: router)
return router
}