Skip to content

Commit 49404fc

Browse files
authored
Merge pull request #11 from ReactiveCocoa/anders/swiftui
@LoopBinding and @EnvironmentLoop for SwiftUI.
2 parents a013754 + 2a29ae7 commit 49404fc

14 files changed

+413
-1
lines changed

Example/Root/RootView.swift

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ struct RootView: View {
2020
CardNavigationLink(label: "Unified Store + UIKit", color: .blue) {
2121
UnifiedStoreUIKitHomeView()
2222
}
23+
24+
CardNavigationLink(label: "SwiftUI: Basic Binding", color: .orange) {
25+
SwiftUIBasicBindingHomeView()
26+
}
2327
}
2428
.navigationBarTitle("Loop Examples")
2529
.navigationBarHidden(true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import SwiftUI
2+
import Loop
3+
4+
struct EnvironmentLoopExampleView: View {
5+
let loop: Loop<Int, Int>
6+
7+
init(loop: Loop<Int, Int>) {
8+
self.loop = loop
9+
}
10+
11+
var body: some View {
12+
EnvironmentLoopContentView()
13+
.environmentLoop(self.loop)
14+
.navigationBarTitle("@EnvironmentLoop")
15+
}
16+
}
17+
18+
private struct EnvironmentLoopContentView: View {
19+
@EnvironmentLoop<Int, Int> var state: Int
20+
21+
var body: some View {
22+
SimpleCounterView(binding: $state)
23+
}
24+
}
25+
26+
struct EnvironmentLoopExampleView_Previews: PreviewProvider {
27+
static var previews: some View {
28+
NavigationView {
29+
EnvironmentLoopExampleView(loop: simpleCounterStore)
30+
}
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import SwiftUI
2+
import Loop
3+
4+
struct LoopBindingExampleView: View {
5+
@LoopBinding<Int, Int> var state: Int
6+
7+
init(state: LoopBinding<Int, Int>) {
8+
_state = state
9+
}
10+
11+
var body: some View {
12+
SimpleCounterView(binding: $state)
13+
.navigationBarTitle("@LoopBinding")
14+
}
15+
}
16+
17+
struct LoopBindingExampleView_Previews: PreviewProvider {
18+
static var previews: some View {
19+
NavigationView {
20+
LoopBindingExampleView(state: simpleCounterStore.binding)
21+
}
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Loop
2+
3+
let simpleCounterStore = Loop(initial: 0, reducer: { state, event in state += event }, feedbacks: [])
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import SwiftUI
2+
import Loop
3+
4+
struct SimpleCounterView: View {
5+
@LoopBinding<Int, Int> var state: Int
6+
7+
init(binding: LoopBinding<Int, Int>) {
8+
_state = binding
9+
}
10+
11+
var body: some View {
12+
VStack {
13+
Spacer()
14+
.layoutPriority(1.0)
15+
16+
Button(
17+
action: { self.$state.send(-1) },
18+
label: { Image(systemName: "minus.circle") }
19+
)
20+
.padding()
21+
22+
Text("\(self.state)")
23+
.font(.system(.largeTitle, design: .monospaced))
24+
25+
Button(
26+
action: { self.$state.send(1) },
27+
label: { Image(systemName: "plus.circle") }
28+
)
29+
.padding()
30+
31+
Spacer()
32+
.layoutPriority(1.0)
33+
}
34+
}
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import SwiftUI
2+
import Loop
3+
4+
struct SwiftUIBasicBindingHomeView: View {
5+
var body: some View {
6+
ScrollView {
7+
CardNavigationLink(label: "@LoopBinding", color: .orange) {
8+
LoopBindingExampleView(state: simpleCounterStore.binding)
9+
}
10+
11+
CardNavigationLink(label: "@EnvironmentLoop", color: .orange) {
12+
EnvironmentLoopExampleView(loop: simpleCounterStore)
13+
}
14+
}
15+
}
16+
}

Loop.xcodeproj/project.pbxproj

+76
Large diffs are not rendered by default.

Loop/LoopBox.swift

+13
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ internal class ScopedLoopBox<RootState, RootEvent, ScopedState, ScopedEvent>: Lo
99
root.lifetime
1010
}
1111

12+
/// Loop Internal SPI
13+
override var _current: ScopedState {
14+
root._current[keyPath: value]
15+
}
16+
1217
private let root: LoopBoxBase<RootState, RootEvent>
1318
private let value: KeyPath<RootState, ScopedState>
1419
private let eventTransform: (ScopedEvent) -> RootEvent
@@ -44,6 +49,11 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
4449
_lifetime
4550
}
4651

52+
/// Loop Internal SPI
53+
override var _current: State {
54+
floodgate.withValue { state, _ in state }
55+
}
56+
4757
let floodgate: Floodgate<State, Event>
4858
private let _lifetime: Lifetime
4959
private let token: Lifetime.Token
@@ -89,6 +99,9 @@ internal class RootLoopBox<State, Event>: LoopBoxBase<State, Event> {
8999
}
90100

91101
internal class LoopBoxBase<State, Event> {
102+
/// Loop Internal SPI
103+
var _current: State { subclassMustImplement() }
104+
92105
var lifetime: Lifetime { subclassMustImplement() }
93106
var producer: SignalProducer<State, Never> { subclassMustImplement() }
94107

Loop/Public/Loop.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ public final class Loop<State, Event> {
1919
}
2020
}
2121

22-
private let box: LoopBoxBase<State, Event>
22+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
23+
public var binding: LoopBinding<State, Event> {
24+
LoopBinding(self)
25+
}
26+
27+
internal let box: LoopBoxBase<State, Event>
2328

2429
private init(box: LoopBoxBase<State, Event>) {
2530
self.box = box
+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#if canImport(SwiftUI) && canImport(Combine)
2+
3+
import SwiftUI
4+
import Combine
5+
import ReactiveSwift
6+
7+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
8+
@propertyWrapper
9+
public struct EnvironmentLoop<State, Event>: DynamicProperty {
10+
@Environment(\.loops[LoopType(Loop<State, Event>.self)])
11+
var erasedLoop: AnyObject?
12+
13+
@ObservedObject
14+
private var subscription: SwiftUIHotSwappableSubscription<State, Event>
15+
16+
@inlinable
17+
public var wrappedValue: State {
18+
acknowledgedState
19+
}
20+
21+
public var projectedValue: LoopBinding<State, Event> {
22+
guard let loop = erasedLoop as! Loop<State, Event>? else {
23+
fatalError("Scoped bindings can only be created inside the view body.")
24+
}
25+
26+
return LoopBinding(loop)
27+
}
28+
29+
@usableFromInline
30+
internal var acknowledgedState: State!
31+
32+
public init() {
33+
self.subscription = SwiftUIHotSwappableSubscription()
34+
}
35+
36+
public mutating func update() {
37+
guard let loop = erasedLoop as! Loop<State, Event>? else {
38+
fatalError("Expect parent view to inject a `Loop<\(State.self), \(Event.self)>` through `View.environmentLoop(_:)`. Found none.")
39+
}
40+
41+
acknowledgedState = subscription.currentState(in: loop)
42+
}
43+
}
44+
45+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#if canImport(SwiftUI)
2+
3+
import SwiftUI
4+
5+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
6+
extension View {
7+
@inlinable
8+
public func environmentLoop<State, Event>(_ loop: Loop<State, Event>) -> some View {
9+
let typeId = LoopType(type(of: loop))
10+
11+
return transformEnvironment(\.loops) { loops in
12+
loops[typeId] = loop
13+
}
14+
}
15+
}
16+
17+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
18+
extension EnvironmentValues {
19+
@usableFromInline
20+
internal var loops: [LoopType: AnyObject] {
21+
get { self[LoopEnvironmentKey.self] }
22+
set { self[LoopEnvironmentKey.self] = newValue }
23+
}
24+
}
25+
26+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
27+
internal enum LoopEnvironmentKey: EnvironmentKey {
28+
static var defaultValue: [LoopType: AnyObject] {
29+
return [:]
30+
}
31+
}
32+
33+
@usableFromInline
34+
struct LoopType: Hashable {
35+
let id: ObjectIdentifier
36+
37+
@usableFromInline
38+
init(_ type: Any.Type) {
39+
id = ObjectIdentifier(type)
40+
}
41+
}
42+
43+
#endif

Loop/Public/SwiftUI/LoopBinding.swift

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#if canImport(SwiftUI) && canImport(Combine)
2+
3+
import SwiftUI
4+
import Combine
5+
import ReactiveSwift
6+
7+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
8+
@propertyWrapper
9+
public struct LoopBinding<State, Event>: DynamicProperty {
10+
@ObservedObject
11+
private var subscription: SwiftUISubscription<State, Event>
12+
13+
private let loop: Loop<State, Event>
14+
15+
@inlinable
16+
public var wrappedValue: State {
17+
acknowledgedState
18+
}
19+
20+
public var projectedValue: LoopBinding<State, Event> {
21+
self
22+
}
23+
24+
@usableFromInline
25+
internal var acknowledgedState: State
26+
27+
public init(_ loop: Loop<State, Event>) {
28+
// The subscription can be copied without restrictions.
29+
let subscription = SwiftUISubscription(loop: loop)
30+
31+
self.subscription = subscription
32+
self.acknowledgedState = subscription.latestValue
33+
self.loop = loop
34+
}
35+
36+
public mutating func update() {
37+
// Move latest value from the subscription only when SwiftUI has requested an update.
38+
acknowledgedState = subscription.latestValue
39+
}
40+
41+
public func scoped<ScopedState, ScopedEvent>(
42+
to value: KeyPath<State, ScopedState>,
43+
event: @escaping (ScopedEvent) -> Event
44+
) -> LoopBinding<ScopedState, ScopedEvent> {
45+
LoopBinding<ScopedState, ScopedEvent>(loop.scoped(to: value, event: event))
46+
}
47+
48+
public func send(_ event: Event) {
49+
loop.send(event)
50+
}
51+
}
52+
53+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#if canImport(Combine)
2+
3+
import Combine
4+
import ReactiveSwift
5+
6+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
7+
internal final class SwiftUIHotSwappableSubscription<State, Event>: ObservableObject {
8+
9+
@Published private var latestValue: State!
10+
private weak var attachedLoop: Loop<State, Event>?
11+
private var disposable: Disposable?
12+
13+
init() {}
14+
15+
deinit {
16+
disposable?.dispose()
17+
}
18+
19+
func currentState(in loop: Loop<State, Event>) -> State {
20+
if attachedLoop !== loop {
21+
disposable?.dispose()
22+
23+
latestValue = loop.box._current
24+
25+
disposable = loop.producer
26+
.observe(on: UIScheduler())
27+
.startWithValues { [weak self] state in
28+
guard let self = self else { return }
29+
self.latestValue = state
30+
}
31+
}
32+
33+
return latestValue
34+
}
35+
}
36+
37+
#endif
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#if canImport(Combine)
2+
3+
import Combine
4+
import ReactiveSwift
5+
6+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
7+
internal final class SwiftUISubscription<State, Event>: ObservableObject {
8+
9+
@Published var latestValue: State
10+
private var disposable: Disposable?
11+
12+
init(loop: Loop<State, Event>) {
13+
latestValue = loop.box._current
14+
disposable = loop.producer
15+
.observe(on: UIScheduler())
16+
.startWithValues { [weak self] state in
17+
guard let self = self else { return }
18+
self.latestValue = state
19+
}
20+
}
21+
22+
deinit {
23+
disposable?.dispose()
24+
}
25+
}
26+
27+
#endif

0 commit comments

Comments
 (0)