wikipediabrown/napkin
napkin is a fork of Uber's [RIBs](https://github.com/uber/RIBs) with RxSwift replaced by Combine. It structures iOS and macOS applications as a tree of modular units using the Router-Interactor-Builder pattern.
Table of Contents
- Builder - Component & Dependency - Interactor - Router - Presenter (Optional) - ViewControllable
Supported Platforms
- iOS 13.0+
- macOS 10.15+
Installation
Add napkin via Swift Package Manager:
- In Xcode, navigate to File > Add Package Dependencies...
- Paste the repository URL:
https://github.com/WikipediaBrown/napkin.git - Click Add Package.
Architecture Overview
napkin structures your app as a tree of units called "napkins." Each napkin encapsulates a feature and consists of:
flowchart LR
subgraph napkin[" "]
direction LR
B([Builder]):::builder --> R([Router]):::core
B --> I([Interactor]):::core
B -.-> P([Presenter]):::optional
R --> I
R --> C([Child Routers]):::children
P -.-> V([View]):::optional
end
classDef core fill:#4a90d9,stroke:#2c5aa0,color:#fff
classDef builder fill:#50c878,stroke:#3a9a5c,color:#fff
classDef optional fill:#f5f5f5,stroke:#999,color:#666,stroke-dasharray: 5 5
classDef children fill:#ffb347,stroke:#cc8a2e,color:#fff| Component | Required | Role | |-----------|----------|------| | Builder | Yes | Constructs the napkin, wires dependencies | | Component | Yes | Provides dependencies to this napkin and its children | | Interactor | Yes | Business logic, state management, lifecycle | | Router | Yes | Manages the napkin tree (attach/detach children) | | Presenter | No | Transforms business data into view-friendly formats | | View | No | UIKit view controller or SwiftUI hosting controller |
Data flows down the tree. Events flow up via listener protocols.
Concurrency Model
napkin is built with Swift 6 strict concurrency. Business logic runs off the main thread. Only view controllers are @MainActor.
| Layer | Isolation | Sendable | |-------|-----------|----------| | Interactor | Non-isolated | @unchecked Sendable (lock-protected) | | Router | Non-isolated | @unchecked Sendable (lock-protected) | | Builder | Non-isolated | — | | Component | Non-isolated | — (lock-protected shared()) | | Presenter | Non-isolated | — | | ViewControllable | @MainActor | — |
ViewControllable is the enforcement boundary. The compiler requires @MainActor context to access any view controller. Everything else — interactors, routers, builders, components — runs on whatever thread the caller is on.
On ViewableRouter and Presenter, the viewController property is @MainActor-isolated. To access it from a non-isolated context, use Task { @MainActor in }:
// Inside a router method (non-isolated)
func routeToDetails() {
guard detailsRouter == nil else { return }
Task { @MainActor in
let router = detailsBuilder.build(withListener: interactor)
detailsRouter = router
attachChild(router)
viewController.uiviewController.present(
router.viewControllable.uiviewController,
animated: true
)
}
}Core Components
### Builder
The **Builder** constructs a napkin and wires its dependencies. It receives a `Dependency` from its parent and returns a `Router`.
When the napkin has a view, mark `build()` as `@MainActor` because `UIViewController` initialization requires the main thread:
```swift
protocol HomeDependency: Dependency {
var userService: UserServiceProtocol { get }
}
protocol HomeBuildable: Buildable {
@MainActor func build(withListener listener: HomeListener) -> HomeRouting
}
final class HomeBuilder: Builder<HomeDependency>, HomeBuildable {
@MainActor func build(withListener listener: HomeListener) -> HomeRouting {
let component = HomeComponent(dependency: dependency)
let viewController = HomeViewController()
let interactor = HomeInteractor(
presenter: viewController,
userService: component.userService
)
interactor.listener = listener
return HomeRouter(interactor: interactor, viewController: viewController)
}
}
```
For napkins without views, `build()` does not need `@MainActor`:
```swift
protocol AnalyticsBuildable: Buildable {
func build(withListener listener: AnalyticsListener) -> AnalyticsRouting
}
```
### Component & Dependency
A **Dependency** protocol declares what a napkin requires from its parent. A **Component** provides those dependencies and can create new ones for its children.
Use `shared {}` to create a single instance per component scope. Without `shared`, a new instance is created on each access. The `shared()` method is thread-safe.
```swift
protocol HomeDependency: Dependency {
var analyticsService: AnalyticsServiceProtocol { get }
var userSession: UserSession { get }
}
final class HomeComponent: Component<HomeDependency> {
// Passed through from parent
var analyticsService: AnalyticsServiceProtocol {
dependency.analyticsService
}
// Created once, shared within this scope
var userService: UserServiceProtocol {
shared { UserService(session: dependency.userSession) }
}
// New instance each time
var viewModel: HomeViewModel {
HomeViewModel(service: userService)
}
}
```
The root napkin uses `EmptyDependency`:
```swift
final class AppComponent: Component<EmptyDependency>, HomeDependency {
var analyticsService: AnalyticsServiceProtocol {
shared { AnalyticsService() }
}
var userSession: UserSession {
shared { UserSession() }
}
}
```
### Interactor
The **Interactor** contains all business logic. It has a lifecycle driven by its parent router: `didBecomeActive()` when attached, `willResignActive()` when detached.
Interactors communicate:
- **Up** to parent napkins via `weak var listener` (a protocol the parent implements)
- **Down** to navigation via `weak var router` (a routing protocol the router implements)
```swift
protocol HomeListener: AnyObject {
func homeDidRequestLogout()
}
protocol HomeInteractable: Interactable {
var router: HomeRouting? { get set }
var listener: HomeListener? { get set }
}
final class HomeInteractor: PresentableInteractor<HomePresentable>,
HomeInteractable,
HomePresentableListener {
weak var router: HomeRouting?
weak var listener: HomeListener?
private let userService: UserServiceProtocol
private var cancellables = Set<AnyCancellable>()
init(presenter: HomePresentable, userService: UserServiceProtocol) {
self.userService = userService
super.init(presenter: presenter)
}
override func didBecomeActive() {
super.didBecomeActive()
userService.currentUser
.sink { [weak self] user in
self?.presenter.presentUser(user)
}
.store(in: &cancellables)
}
override func willResignActive() {
super.willResignActive()
cancellables.removeAll()
}
// MARK: - HomePresentableListener
func didTapProfile() {
router?.routeToProfile()
}
func didTapLogout() {
listener?.homeDidRequestLogout()
}
}
```
Use `PresentableInteractor<T>` when the interactor communicates with a view through a presentable protocol. Use plain `Interactor` for napkins without views.
### Router
The **Router** manages the napkin tree. It owns the interactor, maintains a list of children, and coordinates navigation.
- `attachChild(_:)` — adds a child router, activates its interactor, and loads it
- `detachChild(_:)` — deactivates the child's interactor and removes it
- `didLoad()` — called once when the router is first loaded; attach permanent children here
Use `Router<InteractorType>` for napkins without views. Use `ViewableRouter<InteractorType, ViewControllerType>` when the napkin has a view controller.
```swift
protocol HomeRouting: ViewableRouting {
func routeToProfile()
func routeBackFromProfile()
}
final class HomeRouter: ViewableRouter<HomeInteractable, HomeViewControllable>,
HomeRouting {
private let profileBuilder: ProfileBuildable
private var profileRouter: ProfileRouting?
init(interactor: HomeInteractable,
viewController: HomeViewControllable,
profileBuilder: ProfileBuildable) {
self.profileBuilder = profileBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
func routeToProfile() {
guard profileRouter == nil else { return }
Task { @MainActor in
let router = profileBuilder.build(withListener: interactor)
profileRouter = router
attachChild(router)
viewController.uiviewController.present(
router.viewControllable.uiviewController,
animated: true
)
}
}
func routeBackFromProfile() {
guard let router = profileRouter else { return }
profileRouter = nil
Task { @MainActor in
viewController.uiviewController.dismiss(animated: true)
}
detachChild(router)
}
}
```
The `Task { @MainActor in }` block is the boundary crossing: `attachChild` and `detachChild` are thread-safe and non-isolated, but accessing `viewController` or `viewControllable` requires `@MainActor`. The pattern is:
1. Build the child (`@MainActor` if it creates a view controller)
2. Attach — activates the interactor, loads the router
3. Present — manipulates the view hierarchy on `@MainActor`
For detaching, the order is reversed: dismiss the view, then detach the child.
### Presenter (Optional)
The **Presenter** transforms business data into view-friendly formats. It sits between the interactor and the view controller.
The `Presenter<ViewControllerType>` class stores the view controller, but its `viewController` property is `@MainActor`. Use `Task { @MainActor in }` to update the view:
```swift
protocol HomePresentable: Presentable {
func presentUser(_ user: User)
}
final class HomePresenter: Presenter<HomeViewControllable>, HomePresentable {
func presentUser(_ user: User) {
let displayName = "\(user.firstName) \(user.lastName)"
Task { @MainActor in
viewController.displayUserName(displayName)
}
}
}
```
In many cases you won't need a separate `Presenter` class. The simpler pattern — used by the included templates — is to make the view controller conform to the `Presentable` protocol directly. The interactor uses `PresentableInteractor<MyPresentable>`, where the view controller is the presentable. The interactor calls methods on `presenter` to send data, and the view controller forwards user events back to the interactor via a `PresentableListener` protocol.
### ViewControllable
`ViewControllable` is the only `@MainActor`-isolated protocol in napkin. It provides access to the underlying platform view controller:
```swift
// UIKit — UIViewController subclasses conform automatically
final class HomeViewController: UIViewController, HomeViewControllable {
// uiviewController returns self via default implementation
}
// SwiftUI — use a UIHostingController
final class HomeHostingController: UIHostingController<HomeView>, HomeViewControllable {
init() {
super.init(rootView: HomeView())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
```
Define a feature-specific protocol extending `ViewControllable` for methods the router or presenter needs:
```swift
@MainActor protocol HomeViewControllable: ViewControllable {
func displayUserName(_ name: String)
}
```Launching the App
Use LaunchRouter as the root of the napkin tree. Its launch(from:) method sets the root view controller on the window and activates the tree:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var launchRouter: LaunchRouting?
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
let component = AppComponent()
let builder = RootBuilder(dependency: component)
let router = builder.build()
self.launchRouter = router
router.launch(from: window)
}
}The root router subclasses LaunchRouter:
final class RootRouter: LaunchRouter<RootInteractable, RootViewControllable>,
RootRouting {
private let homeBuilder: HomeBuildable
init(interactor: RootInteractable,
viewController: RootViewControllable,
homeBuilder: HomeBuildable) {
self.homeBuilder = homeBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
override func didLoad() {
super.didLoad()
routeToHome()
}
func routeToHome() {
Task { @MainActor in
let router = homeBuilder.build(withListener: interactor)
attachChild(router)
}
}
}SwiftUI Integration
Wrap SwiftUI views in a UIHostingController that conforms to ViewControllable:
struct HomeView: View {
@ObservedObject var viewModel: HomeViewModel
var body: some View {
Text(viewModel.userName)
}
}
@MainActor protocol HomeViewControllable: ViewControllable {}
final class HomeViewController: UIHostingController<HomeView>,
HomeViewControllable {
init(viewModel: HomeViewModel) {
super.init(rootView: HomeView(viewModel: viewModel))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}To pass data from the interactor to the SwiftUI view, share an ObservableObject view model. The builder creates it and passes it to both the view controller and the interactor:
@MainActor func build(withListener listener: HomeListener) -> HomeRouting {
let component = HomeComponent(dependency: dependency)
let viewModel = HomeViewModel()
let viewController = HomeViewController(viewModel: viewModel)
let interactor = HomeInteractor(viewModel: viewModel, userService: component.userService)
interactor.listener = listener
return HomeRouter(interactor: interactor, viewController: viewController)
}To forward user actions from SwiftUI back to the interactor, use a listener protocol on the view:
protocol HomePresentableListener: AnyObject {
func didTapProfile()
}
struct HomeView: View {
@ObservedObject var viewModel: HomeViewModel
weak var listener: HomePresentableListener?
var body: some View {
Button("Profile") {
listener?.didTapProfile()
}
}
}Testing
napkin's non-isolated design makes testing straightforward. No @MainActor annotations are needed on test classes or mocks for interactors and routers:
import Testing
@testable import YourApp
struct HomeInteractorTests {
@Test func didTapLogout_notifiesListener() {
let listener = MockHomeListener()
let presenter = MockHomePresentable()
let interactor = HomeInteractor(presenter: presenter, userService: MockUserService())
interactor.listener = listener
interactor.activate()
interactor.didTapLogout()
#expect(listener.logoutCalled)
}
}
final class MockHomeListener: HomeListener {
var logoutCalled = false
func homeDidRequestLogout() { logoutCalled = true }
}
final class MockHomePresentable: HomePresentable {
var listener: HomePresentableListener?
var lastUser: User?
func presentUser(_ user: User) { lastUser = user }
}Run tests with Command+U in Xcode, or via fastlane:
cd napkin
bundle install
bundle exec fastlane unit_testTooling
Xcode Templates
napkin includes Xcode templates for creating napkin components from the File > New File... menu.
Install
git clone https://github.com/WikipediaBrown/napkin.git
bash napkin/Tools/InstallXcodeTemplates.shAvailable Templates
| Template | Description | |----------|-------------| | napkin | Builder, Interactor, Router (+ optional ViewController) | | Launch napkin | Root napkin for app launch | | napkin Unit Tests | Interactor and Router test files | | Component Extension | Component extension for child dependencies | | Service Manager | Service manager pattern |
Versioning
napkin releases new versions on GitHub automatically when a pull request is merged from develop to main.
Contributing
Send a pull request or create an issue. Commits must be signed:
git config commit.gpgsign trueLicense
napkin is available under the Apache 2.0 license. See the LICENSE file for more info.
<p align="center">Made with cascadian love</p>
Package Metadata
Repository: wikipediabrown/napkin
Default branch: main
README: README.md