GithubHelp home page GithubHelp logo

pexavc / granite Goto Github PK

View Code? Open in Web Editor NEW
4.0 1.0 1.0 3.36 MB

A SwiftUI Architecture that merges Redux to the functional world of Swift

License: Apache License 2.0

Swift 100.00%
clean-architecture design-patterns redux swift swiftui

granite's Introduction

Granite - Alpha

iOS & macOS compatible

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.

High Level Overview

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

Inheritance

  • GraniteComponent
    • GraniteCommand
      • GraniteCenter
        • GraniteState
      • GraniteReducer
  • GraniteRelay
    • GraniteService
      • GraniteCenter
        • GraniteState
      • GraniteReducer

This doc will use my open-sourced macOS app, 100% built with Granite/SwiftUI, as a working example.

Disclaimer

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.
    • Loom
      • A iOS/macOS client for the federated link aggregation service, Lemmy.
        • Network interfacing
        • High throughput between GraniteRelays for account/config/content interaction
  • 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.

Table of Contents

XCTemplates

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.

Guide

Directory Structure

  • /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.

Pattern 1

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.

Pattern 2

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.
        }
    }
}

Services in Reducers

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.
        }
    }
}

Lifecycle Reducers

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
    }
}

Directory Structure

  • /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.

  1. persist: sets a filename, storing the State(Codable) into the applications document directory. Restoring it upon initialization.
  2. autoSave: allows the saving operation to occur whenever the State observes changes.
  3. 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

granite's People

Contributors

pexavc avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar

Forkers

cryptomacedonia

granite's Issues

Non-Main thread Reducer Side Effect causes State inconsistency

2023-05-15

struct AskResponse: GraniteReducer {
    typealias Center = SandService.Center
    
    @Payload var response: SandGPT.Response?
    
    @Event var processDocument: AskDocumentResponse.Reducer
    
    func reduce(state: inout Center.State) {
        guard response?.isComplete == false else {
            print("[SandService] AskIsComplete")
            
            if response?.isStream == false {
                if let payload = response {
                    processDocument.send(payload) // <-------- Becomes priority stateful update (1)
                } else {
                    state.response = response?.data ?? ""
                }
            }
            
            state.isResponding = false // <---------- This won't be final (2)
            return
        }
        print("[SandService] test")
        
        state.response = response?.data ?? ""
    }
}

struct AskDocumentResponse: GraniteReducer {
    typealias Center = SandService.Center
    
    @Relay var environment: EnvironmentService
    
    @Payload var response: SandGPT.Response?
    
    func reduce(state: inout Center.State) {
        guard let response = response?.data else { return }
        let doc = Document(parsing: response, source: nil)
        let lineCount = Int(doc.range?.upperBound.line ?? 0)
        
        environment
            .center
            .responseWindowSize
            .send(
                EnvironmentService
                    .ResponseWindowSizeUpdated
                    .Meta(lineCount: lineCount))
        
        state.response = response
        state.isResponding = false // <------------- this is final (3)
    }
    
    var thread: DispatchQueue? {
        .global(qos: .utility)
    }
}

Comments have marked order of operations. Second reducer is using utility thread.

state.isResponding was not set to false in comment (3) then it would persist true even if (2) marked it false.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.