Puddles Logo
Puddles

A Native SwiftUI Architecture

View on GitHub

Native
Powered by what SwiftUI has to offer, extending only what's necessary.
Modular
A project structure that encourages you to build reusable components inside a very flexible app.
Composable
Naturally nest components to build increasingly complex apps, just like SwiftUI intends.
Mockable
A setup that makes mocking data easy, unleashing the power of previews and more.
Adoptable
Designed to work in every project, partially or fully. No huge commitment, easy to opt out.
Lightweight
Small Swift package companion, building on native mechanisms that SwiftUI provides.

The Architecture

Puddles logically divides your project into 4 layers

Modules

The Structure of the App

Apps in Puddles are made up of Modules, which generally can be thought of as individual screens - for example, Home is a Module responsible for showing the home screen while NumbersExample is responsible for a screen showing facts about random numbers. Modules are SwiftUI views, so they can be composed together in a natural and familiar way to form the overall structure of the app.

/// The Root Module - the entry point of a simple example app.
struct Root: View {
/// A global router instance that centralizes the app's navigational states for performant and convenient access across the app.
@ObservedObject var rootRouter = Router.shared.root
var body: some View {
Home()
.sheet(isPresented: $rootRouter.isShowingLogin) {
Login()
}
.sheet(isPresented: $rootRouter.isShowingNumbersExample) {
NumbersExample()
}
}
}

Composing the User Interface

Modules define the screens and behavior of the app by composing simple, generic components together. They have access to the environment where they can get access to a controlled, abstract interface that drives the app's interaction with external data and other frameworks.

/// A Module rendering a screen where you can fetch and display facts about random numbers.
struct NumbersExample: View {
/// A Provider granting access to external data and other business logic around number facts.
@EnvironmentObject var numberFactProvider: NumberFactProvider
/// A local state managing the list of already fetched number facts.
@State private var numberFacts: [NumberFact] = []
// The Module's body, composing the UI and UX from various generic view components.
var body: some View {
NavigationStack {
List {
Button("Add Random Number Fact") { addRandomFact() }
Section {
ForEach(numberFacts) { fact in
NumberFactView(numberFact: fact)
}
}
}
.navigationTitle("Number Facts")
}
}
private func addRandomFact() {
Task {
let number = Int.random(in: 0...100)
try await numberFacts.append(.init(number: number, content: numberFactProvider.factAboutNumber(number)))
}
}
}

Modules are not Components

Modules describe the overall structure of the app, so they are not reusable. They have a fixed and predetermined position in the app and can therefore hardwire specific behavioral and navigational actions inside them. You can define multiple Modules in different places of the view hierarchy, that use the same underlying components, but apply different behaviors to them.

/// A (slightly contrived) example of a Module similar to NumbersExample, rendering a screen where you can shuffle all the number facts provided by a parent module.
struct ShuffleNumbersExample: View {
/// A list of number facts that can be passed in
@Binding var numberFacts: [NumberFact] = []
// The Module's body, composing the UI and UX from various generic view components.
var body: some View {
NavigationStack {
List {
Button("Shuffle Everything") { shuffleFacts() }
Section {
ForEach(numberFacts) { fact in
NumberFactView(numberFact: fact)
}
}
}
.navigationTitle("Shuffle Your Facts")
}
}
private func shuffleFacts() {
numberFacts = numberFacts.shuffled()
}
}

Components

Generic SwiftUI views

The Components layer is made up of many small, generic SwiftUI views that, put together, form the UI of your app. They don't own any data or have access to external business logic. Their only purpose is to take pieces of information and describe how they should be displayed.

/// A simple component that displays a number fact.
struct NumberFactView: View {
var numberFact: NumberFact // Data model
var body: some View {
if let content = numberFact.content {
VStack(alignment: .leading) {
Text("Number: \(numberFact.number)")
.font(.caption)
.fixedSize()
Text(content)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}
}

They Are Building Blocks

View components are the fundamental building blocks that naturally cause a powerful modularity by allowing you to combine them in different ways, creating a vast range of possible user interfaces and experiences in the Modules.

They Don't Make Assumptions

View components don't make any assumptions about the context in which they are used. Ideally, they are built in a way that makes them reusable in any context, by letting their parent views supply the data and interpretation of user interactions.

Build Interactive Previews

Puddles comes with a set of tools that make it easy to add fully interactive previews to your view components.

private struct PreviewState {
var numberFact: NumberFact = .init(number: 5, content: Mock.factAboutNumber(5))
}
struct NumberFactView_Previews: PreviewProvider {
static var previews: some View {
StateHosting(PreviewState()) { $state in // Binding to the preview state
List {
NumberFactView(numberFact: state.numberFact)
Section {/* Debug Controls ... */}
}
}
}
}

Providers

Control Data Access and Interaction

The Providers drive the app's interaction with external data and other frameworks by exposing a controlled and stable interface to the Modules. This fully hides any implementation details and logic specific to the nature and origin of the provided data, allowing you to swap dependencies without ever touching the Modules relying on them.

/// Provides access to facts about numbers.
@MainActor final class NumberFactProvider: ObservableObject {
struct Dependencies {
var factAboutNumber: (_ number: Int) async throws -> String
}
private let dependencies: Dependencies
init(dependencies: Dependencies) {/* ... */}
// The views only ever use the public interface and know nothing about the dependencies
func factAboutNumber(_ number: Int) async throws -> String {
try await dependencies.factAboutNumber(number)
}
}

Inject Dependencies during Initialization

Providers use dependency injection to enable full control over what data the Provider is distributing to the app. You can define variants using real data for the live app and mocked data for testing and previewing purposes.

extension NumberFactProvider {
static var mock: NumberFactProvider = {/* Provide mocked data */}()
static var live: NumberFactProvider = {
let numbers = Numbers() // From the Core Swift package
return .init(
dependencies: .init(factAboutNumber: { number in
try await numbers.factAboutNumber(number)
})
)
}()
}

Distribute through the SwiftUI Environment

Providers are distributed through the SwiftUI environment, allowing you to inject them at any point in the view hierarchy and even override parts of it with mocked variants.

struct YourApp: App {
var body: some Scene {
WindowGroup {
Root()
.environmentObject(NumberFactProvider.live)
}
}
}


struct Root: View {
var body: some View {
List {
SectionA() // SectionA will interact with real data
SectionB()
.environmentObject(NumberFactProvider.mock) // SectionB will interact with mocked data
}
}
}

Unleash the Power of Previews

This way of working with business logic and external data access allows you to build fully interactive and functional SwiftUI Previews with ease, for every single view in your app, by simply injecting mocked data into the previews provider.

struct Root_Previews: PreviewProvider {
static var previews: some View {
Root().withMockProviders()
}
}

The Core

Isolate Business Logic

The Core layer forms the backbone of Puddles. It is implemented as a local Swift package that contains the app's entire business logic in the form of (mostly) isolated components, divided into individual targets. Everything that is not directly related to the UI belongs in here, encouraging building modular types that are easily and independently modifiable and replaceable.

let package = Package(
name: "Core",
dependencies: [/* ... */],
products: [/* ... */],
targets: [
.target(name: "Models"), // App Models
.target(name: "Extensions"), // Useful extensions and helpers
.target(name: "MockData"), // Mock data
.target(name: "BackendConnector", dependencies: ["Models"]), // Connects to a backend
.target(name: "LocalStore", dependencies: ["Models"]), // Manages a local database
.target(name: "CultureMinds", dependencies: ["MockData"]), // Data Provider for Iain Banks's Culture book universe
.target(name: "NumbersAPI", dependencies: ["MockData", "Get"]) // API connector for numbersAPI.com
]
)

Connect External Dependencies

Build targets that connect to your backend, local database or any external framework dependency and provide an interface for the app to connect to them.

import Get // https://github.com/kean/Get
/// Fetches random facts about numbers from https://numbersapi.com
public final class Numbers {
private let client: APIClient
public init() {/* ... */}
public func factAboutNumber(_ number: Int) async throws -> String {
let request = Request<String>(path: "/\(number)")
return try await client.send(request).value
}
}

Define App Models

The app's data models are also defined inside this package, so that each feature component can use and expose them, instead of leaking implementation details in the form of DTO objects or something similar.

public struct NumberFact: Identifiable, Equatable {
public var id: Int { number }
public var number: Int
public var content: String?
public init(number: Int, content: String? = nil) {
self.number = number
self.content = content
}
}

Navigation

While not stricly part of the fundamental architecture, the Router pattern works really well with Puddles.

The Router

Globally Accessible Router

Since Modules are anchored in a fixed and predetermined location of the app, navigation can be hardwired into them. Therefore, a globally accessible Router singleton makes it easy to jump from one place in the app to any other, with a simple call.

/// The home Module.
struct Home: View {
var body: some View {
List {
Button("Login") {
Router.shared.showLogin()
}
Button("Numbers Example") {
Router.shared.navigate(to: .numbersExample)
}
}
}
}

Centralized Navigation State

The Router class is a singleton that is responsible for managing the entire navigation state for every part of the app. That allows it to navigate to any point in the view hierarchy and expose simple and convenient methods for the Modules to do so.

/// An object that holds the entire navigational state of the app.
@MainActor final class Router {
static let shared: Router = .init()
/// An observable object holding all the navigational state of the root Module.
var root: RootRouter = .init()
var home: HomeRouter = .init()
/// An enum that represents all the possible destinations in the app.
enum Destination: Hashable {
case root
case numbersExample
}
/// Navigates to a destination.
func navigate(to destination: Destination) {
switch destination {
case .root:
root.reset()
home.reset()
case .numbersExample: // Shows the numbers example after resetting the app's navigation state.
root.reset()
home.reset()
showNumbersExample()
}
}
/// Presents the login modally over the current context.
func showLogin() {
root.isShowingLogin = true
}
/// Dismisses the login.
func dismissLogin() {
root.isShowingLogin = false
}
/// Presents the numbers example modally over the current context.
func showNumbersExample() {
root.isShowingNumbersExample = true
}
/// Dismisses the numbers example.
func dismissNumbersExample() {
root.isShowingNumbersExample = false
}
}

Observed Only Where Needed

The only place a Router is marked as @ObservedObject is inside the Modules that implement the view modifiers driven by the Router's published state. This way, changing the navigation state will only ever update the Modules that are actually affected by the change.

/// The Root Module - the entry point of a simple example app.
struct Root: View {
/// A global router instance that centralizes the app's navigational states for performant and convenient access across the app.
@ObservedObject var rootRouter = Router.shared.root
var body: some View {
Home()
.sheet(isPresented: $rootRouter.isShowingLogin) {
Login()
}
.sheet(isPresented: $rootRouter.isShowingNumbersExample) {
NumbersExample()
}
}
}

Find Out More

Example Project

Apple's Scrumdinger tutorial app, built with Puddles

View on GitHub

Scrumdinger App Screenshot 2
Scrumdinger App Screenshot 2
Scrumdinger App Screenshot 2