Introducing Puddles - A Native SwiftUI App Architecture
Puddles is an app architecture that lets you build highly modular and flexible SwiftUI apps.
View on GitHub
Puddles logically divides your project into 4 layers
The Structure of the App
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
/// 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
/// 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() }}
Generic SwiftUI views
/// 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
They Don't Make Assumptions
Build Interactive Previews
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 ... */} } } }}
Control Data Access and Interaction
/// 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
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
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
struct Root_Previews: PreviewProvider { static var previews: some View { Root().withMockProviders() }}
Isolate Business Logic
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
import Get // https://github.com/kean/Get/// Fetches random facts about numbers from https://numbersapi.compublic 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
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 }}
While not stricly part of the fundamental architecture, the Router
pattern works really well with Puddles.
Globally Accessible Router
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
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
@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() } }}
Apple's Scrumdinger tutorial app, built with Puddles
View on GitHub