A SwiftUI Architecture that merges Redux event handling and state management with functional programming.
The Granite architecture provides:
- Testability and Isolation. Routing, business, view logic, independant classes/protocols to be inherited. Each component is positioned to be decoupled and independant from eachother.
- Allows for a more structured approach in development.
- Building in a way, that makes context-switching less stressful when coming back to old code.
- View Library. Granite comes with a comprehensive view library (GraniteUI) that can speed up development, offering thought out templates and other UX solutions.
- Rapid prototyping, when bootstrapping ideas. Simultaneously making them testable and production ready.
- Neatia as seen below took 2 weeks from the ground-up, to build. Given Third-Party Packages were used in the process.
This is my prior architecture, that has an in-depth look into my thought process, eventually leading to this re-write: https://github.com/pexavc/GraniteUI
- GraniteComponent
- GraniteCommand
- GraniteCenter
- GraniteState
- GraniteReducer
- GraniteCenter
- GraniteCommand
- GraniteRelay
- GraniteService
- GraniteCenter
- GraniteState
- GraniteReducer
- GraniteCenter
- GraniteService
This doc will use my open-sourced macOS app, 100% built with Granite/SwiftUI, as a working example.
The architecture itself is still a WIP, but currently I have moved onto seeing its production viability and it has passed certain benchmarks that made me comfortable to begin sharing documentation and reasons behind use.
-
Live Production Apps that use
Granite
- Bullish
- An iOS/iPadOS virtual portfolio for Stocks. With on-device training
- Big data management and storage, with Stock/time-series data dating older than decade or more.
- High throughput within
GraniteComponents
when training data for the Generative Forecasting or processing updates when syncing Stocks and displaying changes in a portfolio.
- An iOS/iPadOS virtual portfolio for Stocks. With on-device training
- Loom
- A iOS/macOS client for the federated link aggregation service, Lemmy.
- Network interfacing
- High throughput between
GraniteRelays
for account/config/content interaction
- A iOS/macOS client for the federated link aggregation service, Lemmy.
- Bullish
-
Apps in Development that WILL use
Granite
- Marble This was initially built with an earlier version of the Granite design pattern.
- An Audio/Video music visualizer that uses Metal to render textures at 60FPS+. With multiple instance in 1 page running at 30FPS+.
- High throughput texture management, video encoding, and audio processing.
- And eventually, Vision Pro compatibility for Mixed Reality experiences.
- An Audio/Video music visualizer that uses Metal to render textures at 60FPS+. With multiple instance in 1 page running at 30FPS+.
- Marble This was initially built with an earlier version of the Granite design pattern.
- XCTemplates
- Guide
- GraniteComponent //Views
- GraniteReducer //Business logic
- GraniteRelay //Services
- WIP
Located in /Resources/Templates
Move XCTemplate files to this location: ~/Library/Developer/Xcode/Templates/Modules
They will appear as modules within XCode for easy Component/Relay and Reducer creation when creating a new file.
/ComponentName
- ComponentName.swift
- ComponentName+View.swift
- ComponentName+Center.swift //The "State" sits here
/Reducers
- ComponentName.ExampleReducer.swift
Initialized the SwiftUI Granite Component, with it's "GraniteCenter/GraniteState" and relevant services ("Relays") required.
import Granite
import SandKit
import SwiftUI
struct Mount: GraniteComponent {
@Command var center: Center
@Relay var environment: EnvironmentService
@Relay var account: AccountService
@Relay var sand: SandService
@Relay var config: ConfigService
@SharedObject(SessionManager.id) var session: SessionManager
}
Granite does not use var body: some View
to initialize a view, but rather var view: some View
. In the backend, var body
is still used however, but different lifecycles are monitored prior to the component appearing.
import Granite
import GraniteUI
import SwiftUI
import SandKit
extension Mount: View {
{ ... }
public var view: some View { //Granite's "var body: some View {}"
ZStack {
backgroundView
mainView
toolbarViews
if session.isLocked {
lockedView
}
}
}
}
The "Center" houses the State. And works similarly to a @State
property wrapper. This view heavily relies on the state changes of its services rather than its own. Which is why there isn't much to see here.
import Granite
import SwiftUI
extension Mount {
struct Center: GraniteCenter {
struct State: GraniteState {
var somethingToUpdate: Bool = false
}
@Store public var state: State
}
}
Below is an example of a component firing its own reducer and how a state would update triggering view changes.
Code changed vs. links' for clarity.
public var view: some View {
VStack(spacing: 0) {
ZStack(alignment: .top) {
if session.isLocked {
{ ... }
} else {
MacEditorTextView(
text: query.center.$state.binding.value,//<----- state's string is bound (1)
{ ... }
onEditingChanged: {
center.$state.binding.wrappedValue.isEditing = true
},
onCommit: {
guard environment.center.state.isCommandActive == false else {
return
}
center.ask.send()//<----- fire reducer (2)
},
{ ... }
}
}
Comment (2)
triggers this.
extension Query {
struct Center: GraniteCenter {
struct State: GraniteState {
var query: String = ""
var isEditing: Bool = false
var isCommandMenuActive: Bool = false
}
@Event var ask: Query.Ask.Reducer// <----- primed reducer
@Store public var state: State
}
}
Reducers are isolated files, that house core business logic.
import Granite
import SandKit
extension Query {
struct Ask: GraniteReducer {
typealias Center = Query.Center
func reduce(state: inout Center.State) {
state.query//<---- do something with the state
state.query = "newValue"
state.isEditing = false
}
}
}
A Granite Reducer can have 2 patterns.
Pretty straightforward, but what if we want to fire another reducer from here? Or maybe add a Payload?
import Granite
extension EnvironmentService {
struct Boot: GraniteReducer {
typealias Center = EnvironmentService.Center
func reduce(state: inout Center.State) {
}
}
}
Creating a struct that inherits from GranitePayload
allows it to be linked to a reducers send
function. As we saw earlier in center.ask.send()
. Now we can also do something like env.boot.send(Boot.Meta(data: someData)))
Other reducers can be nested in reducers to fire. But, for state changes to persist as expected they MUST have an appropriate forwarding type applied.
CAREFUL about circular dependency. If a reducer in a service references another service, in which the callee service also references the caller. A recursive block will occur. And it doesn't have to be in the samed called reducer. It can be in any within.
import Granite
extension EnvironmentService {
struct PreviousReducer: GraniteReducer {
typealias Center = EnvironmentService.Center
func reduce(state: inout Center.State) {
//Do something
}
}
struct Boot: GraniteReducer {
typealias Center = EnvironmentService.Center
struct Meta: GranitePayload {
var data: Data
}
@Payload var meta: Meta?//the `optional` is important
@Event(.before) var next: NextReducer.Reducer//Another reducer, forwarded BEFORE the current reducer completes.
@Event(.after) var next: NextReducer.Reducer//Forwarded AFTER the current reducer completes.
func reduce(state: inout Center.State) {
//Do something with the BEFORE reducer's state changes
}
}
struct NextReducer: GraniteReducer {
typealias Center = EnvironmentService.Center
func reduce(state: inout Center.State) {
//Do something with the state updated with the caller's changes
}
}
}
Issue #1 Recent changes have made async nested reducers affect the order AND reliability of how a state updates. This will be fixed soon, with this comment being removed after.
You can also define a new typealias to skip the @Payload
property wrapper.
import Granite
extension EnvironmentService {
struct Boot: GraniteReducer {
typealias Center = EnvironmentService.Center
typealias Payload = Boot.Meta
struct Meta: GranitePayload {
var data: Data
}
func reduce(state: inout Center.State, payload: Payload)//New Parameter required, or this will never fire
{
//Do something.
}
}
}
Dependency injection is tedious when wanting context through an App's lifecycle. In Granite you can simply reference the service again in the reducer, uptodate, and invoke its' reducers.
import Granite
extension EnvironmentService {
struct Boot: GraniteReducer {
typealias Center = EnvironmentService.Center
@Relay var service: AnotherService
func reduce(state: inout Center.State)
{
service.preload()//Services load their states async. Force a preload if it's required now.
}
}
}
You can set a lifecycle property to a reducer's @Event
property wrapper to have it called automatically.
import Granite
import SwiftUI
extension AnotherComponent {
struct Center: GraniteCenter {
struct State: GraniteState {
}
//Property wrapper accepts a lifecycle param
@Event(.onAppear) var onAppear: OnAppear.Reducer
@Store public var state: State
}
}
/RelayName
- RelayName
- RelayName+Center.swift //The "State" sits here
/Reducers
- RelayName.ExampleReducer.swift
A GraniteRelay is setup similarly to a GraniteComponent
. Except there's no var view
and @Command
.
The online parameter of the property wrapper @Service
allows the changes of this service to be broadcasted to all views that have it declared. Changing in option in 1 view, will update ALL the views that reference the same stateful variable to render something.
import Granite
struct ConfigService: GraniteService {
@Service(.online) var center: Center
}
A GraniteRelay
's State can have multiple different params declared to change its behavior across the app.
persist:
sets a filename, storing the State(Codable) into the applications document directory. Restoring it upon initialization.autoSave:
allows the saving operation to occur whenever the State observes changes.preload:
preloads the state, upon initialization, synchronously. Allowing the service's data to be immeediately available in the context declared.
import Granite
import SwiftUI
import LaunchAtLogin
import SandKit
extension ConfigService {
struct Center: GraniteCenter {
struct State: GraniteState {
}
@Store(persist: "save.0001",
autoSave: true,
preload: true) public var state: State
}
WIP