Skip to content

@LoopBinding and @EnvironmentLoop for SwiftUI. #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Example/Root/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ struct RootView: View {
CardNavigationLink(label: "Unified Store + UIKit", color: .blue) {
UnifiedStoreUIKitHomeView()
}

CardNavigationLink(label: "SwiftUI: Basic Binding", color: .orange) {
SwiftUIBasicBindingHomeView()
}
}
.navigationBarTitle("Loop Examples")
.navigationBarHidden(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import SwiftUI
import Loop

struct EnvironmentLoopExampleView: View {
let loop: Loop<Int, Int>

init(loop: Loop<Int, Int>) {
self.loop = loop
}

var body: some View {
EnvironmentLoopContentView()
.environmentLoop(self.loop)
.navigationBarTitle("@EnvironmentLoop")
}
}

private struct EnvironmentLoopContentView: View {
@EnvironmentLoop<Int, Int> var state: Int

var body: some View {
SimpleCounterView(binding: $state)
}
}

struct EnvironmentLoopExampleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
EnvironmentLoopExampleView(loop: simpleCounterStore)
}
}
}
23 changes: 23 additions & 0 deletions Example/SwiftUIBasicBindingExample/LoopBindingExampleView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import SwiftUI
import Loop

struct LoopBindingExampleView: View {
@LoopBinding<Int, Int> var state: Int

init(state: LoopBinding<Int, Int>) {
_state = state
}

var body: some View {
SimpleCounterView(binding: $state)
.navigationBarTitle("@LoopBinding")
}
}

struct LoopBindingExampleView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LoopBindingExampleView(state: simpleCounterStore.binding)
}
}
}
3 changes: 3 additions & 0 deletions Example/SwiftUIBasicBindingExample/SimpleCounterStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Loop

let simpleCounterStore = Loop(initial: 0, reducer: { state, event in state += event }, feedbacks: [])
35 changes: 35 additions & 0 deletions Example/SwiftUIBasicBindingExample/SimpleCounterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import SwiftUI
import Loop

struct SimpleCounterView: View {
@LoopBinding<Int, Int> var state: Int

init(binding: LoopBinding<Int, Int>) {
_state = binding
}

var body: some View {
VStack {
Spacer()
.layoutPriority(1.0)

Button(
action: { self.$state.send(-1) },
label: { Image(systemName: "minus.circle") }
)
.padding()

Text("\(self.state)")
.font(.system(.largeTitle, design: .monospaced))

Button(
action: { self.$state.send(1) },
label: { Image(systemName: "plus.circle") }
)
.padding()

Spacer()
.layoutPriority(1.0)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import SwiftUI
import Loop

struct SwiftUIBasicBindingHomeView: View {
var body: some View {
ScrollView {
CardNavigationLink(label: "@LoopBinding", color: .orange) {
LoopBindingExampleView(state: simpleCounterStore.binding)
}

CardNavigationLink(label: "@EnvironmentLoop", color: .orange) {
EnvironmentLoopExampleView(loop: simpleCounterStore)
}
}
}
}
76 changes: 76 additions & 0 deletions Loop.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions Loop/LoopBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ internal class ScopedLoopBox<RootState, RootEvent, ScopedState, ScopedEvent>: Lo
root.lifetime
}

/// Loop Internal SPI
override var _current: ScopedState {
root._current[keyPath: value]
}

private let root: LoopBoxBase<RootState, RootEvent>
private let value: KeyPath<RootState, ScopedState>
private let eventTransform: (ScopedEvent) -> RootEvent
Expand Down Expand Up @@ -44,6 +49,11 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
_lifetime
}

/// Loop Internal SPI
override var _current: State {
floodgate.withValue { state, _ in state }
}

let floodgate: Floodgate<State, Event>
private let _lifetime: Lifetime
private let token: Lifetime.Token
Expand Down Expand Up @@ -89,6 +99,9 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
}

internal class LoopBoxBase<State, Event> {
/// Loop Internal SPI
var _current: State { subclassMustImplement() }

var lifetime: Lifetime { subclassMustImplement() }
var producer: SignalProducer<State, Never> { subclassMustImplement() }

Expand Down
7 changes: 6 additions & 1 deletion Loop/Public/Loop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ public final class Loop<State, Event> {
}
}

private let box: LoopBoxBase<State, Event>
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public var binding: LoopBinding<State, Event> {
LoopBinding(self)
}

internal let box: LoopBoxBase<State, Event>

private init(box: LoopBoxBase<State, Event>) {
self.box = box
Expand Down
45 changes: 45 additions & 0 deletions Loop/Public/SwiftUI/EnvironmentLoop.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#if canImport(SwiftUI) && canImport(Combine)

import SwiftUI
import Combine
import ReactiveSwift

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper
public struct EnvironmentLoop<State, Event>: DynamicProperty {
@Environment(\.loops[LoopType(Loop<State, Event>.self)])
var erasedLoop: AnyObject?

@ObservedObject
private var subscription: SwiftUIHotSwappableSubscription<State, Event>

@inlinable
public var wrappedValue: State {
acknowledgedState
}

public var projectedValue: LoopBinding<State, Event> {
guard let loop = erasedLoop as! Loop<State, Event>? else {
fatalError("Scoped bindings can only be created inside the view body.")
}

return LoopBinding(loop)
}

@usableFromInline
internal var acknowledgedState: State!

public init() {
self.subscription = SwiftUIHotSwappableSubscription()
}

public mutating func update() {
guard let loop = erasedLoop as! Loop<State, Event>? else {
fatalError("Expect parent view to inject a `Loop<\(State.self), \(Event.self)>` through `View.environmentLoop(_:)`. Found none.")
}

acknowledgedState = subscription.currentState(in: loop)
}
}

#endif
43 changes: 43 additions & 0 deletions Loop/Public/SwiftUI/EnvironmentValues.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#if canImport(SwiftUI)

import SwiftUI

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension View {
@inlinable
public func environmentLoop<State, Event>(_ loop: Loop<State, Event>) -> some View {
let typeId = LoopType(type(of: loop))

return transformEnvironment(\.loops) { loops in
loops[typeId] = loop
}
}
}

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
extension EnvironmentValues {
@usableFromInline
internal var loops: [LoopType: AnyObject] {
get { self[LoopEnvironmentKey.self] }
set { self[LoopEnvironmentKey.self] = newValue }
}
}

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
internal enum LoopEnvironmentKey: EnvironmentKey {
static var defaultValue: [LoopType: AnyObject] {
return [:]
}
}

@usableFromInline
struct LoopType: Hashable {
let id: ObjectIdentifier

@usableFromInline
init(_ type: Any.Type) {
id = ObjectIdentifier(type)
}
}

#endif
53 changes: 53 additions & 0 deletions Loop/Public/SwiftUI/LoopBinding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#if canImport(SwiftUI) && canImport(Combine)

import SwiftUI
import Combine
import ReactiveSwift

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper
public struct LoopBinding<State, Event>: DynamicProperty {
@ObservedObject
private var subscription: SwiftUISubscription<State, Event>

private let loop: Loop<State, Event>

@inlinable
public var wrappedValue: State {
acknowledgedState
}

public var projectedValue: LoopBinding<State, Event> {
self
}

@usableFromInline
internal var acknowledgedState: State

public init(_ loop: Loop<State, Event>) {
// The subscription can be copied without restrictions.
let subscription = SwiftUISubscription(loop: loop)

self.subscription = subscription
self.acknowledgedState = subscription.latestValue
self.loop = loop
}

public mutating func update() {
// Move latest value from the subscription only when SwiftUI has requested an update.
acknowledgedState = subscription.latestValue
}

public func scoped<ScopedState, ScopedEvent>(
to value: KeyPath<State, ScopedState>,
event: @escaping (ScopedEvent) -> Event
) -> LoopBinding<ScopedState, ScopedEvent> {
LoopBinding<ScopedState, ScopedEvent>(loop.scoped(to: value, event: event))
}

public func send(_ event: Event) {
loop.send(event)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we extend this to also have binding(for: KeyPath<State, V>, event: Event) -> Binding<V>?

Copy link
Member Author

@andersio andersio May 28, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an additive functionality that needs more thoughts, esp conversion/relationship of the KeyPaths and Event.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well the common use case I guess is showing the sheet which requires an optional binding that drives presentation of the sheet

}

#endif
37 changes: 37 additions & 0 deletions Loop/Public/SwiftUI/SwiftUIHotSwappableSubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#if canImport(Combine)

import Combine
import ReactiveSwift

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
internal final class SwiftUIHotSwappableSubscription<State, Event>: ObservableObject {

@Published private var latestValue: State!
private weak var attachedLoop: Loop<State, Event>?
private var disposable: Disposable?

init() {}

deinit {
disposable?.dispose()
}

func currentState(in loop: Loop<State, Event>) -> State {
if attachedLoop !== loop {
disposable?.dispose()

latestValue = loop.box._current

disposable = loop.producer
.observe(on: UIScheduler())
.startWithValues { [weak self] state in
guard let self = self else { return }
self.latestValue = state
}
}

return latestValue
}
}

#endif
27 changes: 27 additions & 0 deletions Loop/Public/SwiftUI/SwiftUISubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#if canImport(Combine)

import Combine
import ReactiveSwift

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
internal final class SwiftUISubscription<State, Event>: ObservableObject {

@Published var latestValue: State
private var disposable: Disposable?

init(loop: Loop<State, Event>) {
latestValue = loop.box._current
disposable = loop.producer
.observe(on: UIScheduler())
.startWithValues { [weak self] state in
guard let self = self else { return }
self.latestValue = state
}
}

deinit {
disposable?.dispose()
}
}

#endif