/Ricemill

🌾 ♻️ 🍚 Unidirectional Input / Output framework with Combine. Supports both of SwiftUI and UIKit.

Primary LanguageSwiftMIT LicenseMIT

Ricemill

🌾 ♻️ 🍚 Unidirectional Input / Output framework with Combine.

SwiftUI Playground UIKit Playground
SwiftUI UIKit

About Ricemill

Ricemill represents unidirectional data flow with these components.

Input

The rule of Input is having Subject properties that are defined internal scope.

struct Input: InputType {
    let increment = PassthroughSubject<Void, Never>()
    let isOn = PassthroughSubject<Bool, Never>()
}

Properties of Input are defined internal scope. But these return SubjectProxy via dynamicMemberLookup if Input is wrapped with InputProxy.

let input: InputProxy<Input>
let increment: SubjectProxy<Void> = input.increment
increment.send()
let isOn: SubjectProxy<Bool> = input.isOn
isOn.send(true)

Output

The rule of Output is having Publisher or @Published properties that are defined internal scope.

class Output: OutputType {
    let count: AnyPublisher<String?, Never>
    @Published var isIncrementEnabled: Bool
}

Store

The rule of Store is having inner states.

class Store: StoreType {
    @Published var count = 0
    @Published var isIncrementEnabled: Bool = false
}

Extra

The rule of Extra is having other dependencies.

Resolver

The rule of Resolver is generating Output from Input, Store and Extra. It generates Output to call static func polish(input:store:extra:). static func polish(input:store:extra:) is called once when Machine is initialized.

enum Resolver: ResolverType {
    typealias Input = ViewModel.Input
    typealias Output = ViewModel.Output
    typealias Store = ViewModel.Store
    typealias Extra = ViewModel.Extra

    static func polish(input: Publishing<Input>, store: Store, extra: Extra) -> Polished<Output> {
        ...                         
    }
}

Here is a exmaple of implementation of static func polish(input:store:extra:).

extension Resolver {

    static func polish(input: Publishing<Input>,
                       store: Store,
                       extra: Extra) -> Polished<Output> {

         var cancellables: [AnyCancellable] = []

         let increment = input.increment
             .flatMap { _ in Just(store.count) }
             .map { $0 + 1 }

         increment.merge(with: decrement)
             .assign(to: \.count, on: store)
             .store(in: &cancellables)

         let count = store.$count
             .map(String.init)
             .map(Optional.some)
             .eraseToAnyPublisher()

         return Polished(output: Output(count: count),
                         cancellables: cancellables)
      }
}

Machine

Machine represents ViewModels of MVVM (it can also be used as Models). It has input: InputProxy<Input> and output: OutputProxy<Output>. It automatically generates input: InputProxy<Input> and output: OutputProxy<Output> from instances of Input, Store, Extra and Resolver.

final class ViewModel: Machine<ViewModel> {

    final class Input: InputType {
        let increment = PassthroughSubject<Void, Never>()
        let decrement = PassthroughSubject<Void, Never>()
    }

    final class Store: StoredOutputType {
        @Published var count: Int = 0
    }

    final class Output: OutputType {
        let count: AnyPublisher<String?, Never>
    }

    struct Extra: ExtraType {}

    static func polish(
        input: Publishing<Input>,
        store: Store,
        extra: Extra
    ) -> Polished<Store> {
        var cancellables: [AnyCancellable] = []

        let increment = input.increment
            .flatMap { _ in Just(store.count) }
            .map { $0 + 1 }

        let decrement = input.decrement
            .flatMap { _ in Just(store.count) }
            .map { $0 - 1 }

        increment.merge(with: decrement)
            .assign(to: \.count, on: store)
            .store(in: &cancellables)

        let count = store.$count
            .map(String.init)
            .map(Optional.some)
            .eraseToAnyPublisher()

        return Polished(output: Output(count: count),
                        cancellables: cancellables)
    }
}

SwiftUI Usage

If Input implements BindableInputType, can access value as Binding<Value> from outside. In addition, if Output equals Store and implements StoredOutputType, can access primitive value and Publisher from outside. Sample implementaion is here.

final class ViewModel: Machine<ViewModel> {
    typealias Output = Store

    final class Input: BindableInputType {
        let increment = PassthroughSubject<Void, Never>()
        let decrement = PassthroughSubject<Void, Never>()
    }

    final class Store: StoredOutputType {
        @Published var count: Int = 0
    }

    struct Extra: ExtraType {}

    static func polish(
        input: Publishing<Input>,
        store: Store,
        extra: Extra
    ) -> Polished<Store> {
        var cancellables: [AnyCancellable] = []

        let increment = input.increment
            .flatMap { _ in Just(store.count) }
            .map { $0 + 1 }

        let decrement = input.decrement
            .flatMap { _ in Just(store.count) }
            .map { $0 - 1 }

         increment.merge(with: decrement)
            .assign(to: \.count, on: store)
            .store(in: &cancellables)

        return Polished(cancellables: cancellables)
    }
}

let viewModel: ViewModel = ...
viewModel.input.isOn    // This is `Binding<Bool>` instance.
viewModel.output.count  // This is `Int` instance.
viewModel.output.$count // This is `Published<Int>.Publisher` instance.

Requirement

  • Xcode 12
  • macOS 10.15
  • iOS 13.0
  • tvOS 13.0
  • watchOS 6.0

Other links

screenshot

License

Ricemill is released under the MIT License.