Skip to content

Commit

Permalink
Merge pull request #11 from ReactiveCocoa/anders/swiftui
Browse files Browse the repository at this point in the history
@LoopBinding and @EnvironmentLoop for SwiftUI.
  • Loading branch information
andersio authored May 30, 2020
2 parents a013754 + 2a29ae7 commit 49404fc
Show file tree
Hide file tree
Showing 14 changed files with 413 additions and 1 deletion.
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)
}
}

#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

0 comments on commit 49404fc

Please sign in to comment.