diff --git a/Example/Root/RootView.swift b/Example/Root/RootView.swift index a9c0061..75c1586 100644 --- a/Example/Root/RootView.swift +++ b/Example/Root/RootView.swift @@ -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) diff --git a/Example/SwiftUIBasicBindingExample/EnvironmentLoopExampleView.swift b/Example/SwiftUIBasicBindingExample/EnvironmentLoopExampleView.swift new file mode 100644 index 0000000..d913f6f --- /dev/null +++ b/Example/SwiftUIBasicBindingExample/EnvironmentLoopExampleView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Loop + +struct EnvironmentLoopExampleView: View { + let loop: Loop + + init(loop: Loop) { + self.loop = loop + } + + var body: some View { + EnvironmentLoopContentView() + .environmentLoop(self.loop) + .navigationBarTitle("@EnvironmentLoop") + } +} + +private struct EnvironmentLoopContentView: View { + @EnvironmentLoop var state: Int + + var body: some View { + SimpleCounterView(binding: $state) + } +} + +struct EnvironmentLoopExampleView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + EnvironmentLoopExampleView(loop: simpleCounterStore) + } + } +} diff --git a/Example/SwiftUIBasicBindingExample/LoopBindingExampleView.swift b/Example/SwiftUIBasicBindingExample/LoopBindingExampleView.swift new file mode 100644 index 0000000..cd77c5c --- /dev/null +++ b/Example/SwiftUIBasicBindingExample/LoopBindingExampleView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import Loop + +struct LoopBindingExampleView: View { + @LoopBinding var state: Int + + init(state: LoopBinding) { + _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) + } + } +} diff --git a/Example/SwiftUIBasicBindingExample/SimpleCounterStore.swift b/Example/SwiftUIBasicBindingExample/SimpleCounterStore.swift new file mode 100644 index 0000000..0be0694 --- /dev/null +++ b/Example/SwiftUIBasicBindingExample/SimpleCounterStore.swift @@ -0,0 +1,3 @@ +import Loop + +let simpleCounterStore = Loop(initial: 0, reducer: { state, event in state += event }, feedbacks: []) diff --git a/Example/SwiftUIBasicBindingExample/SimpleCounterView.swift b/Example/SwiftUIBasicBindingExample/SimpleCounterView.swift new file mode 100644 index 0000000..2144bef --- /dev/null +++ b/Example/SwiftUIBasicBindingExample/SimpleCounterView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import Loop + +struct SimpleCounterView: View { + @LoopBinding var state: Int + + init(binding: LoopBinding) { + _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) + } + } +} diff --git a/Example/SwiftUIBasicBindingExample/SwiftUIBasicBindingHomeView.swift b/Example/SwiftUIBasicBindingExample/SwiftUIBasicBindingHomeView.swift new file mode 100644 index 0000000..3f5d875 --- /dev/null +++ b/Example/SwiftUIBasicBindingExample/SwiftUIBasicBindingHomeView.swift @@ -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) + } + } + } +} diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 21e320b..cfa4fa1 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -38,6 +38,18 @@ 5B017D2C246F33DE00400BFE /* ArrayCollectionViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B017D2B246F33DE00400BFE /* ArrayCollectionViewAdapter.swift */; }; 5B017D30246F344500400BFE /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B017D2F246F344500400BFE /* CardView.swift */; }; 5B017D32246F466600400BFE /* RACHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B017D31246F466600400BFE /* RACHeaderView.swift */; }; + 5B8F920C24731F2800C1C90E /* LoopBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920B24731F2800C1C90E /* LoopBinding.swift */; }; + 5B8F920D24731F2800C1C90E /* LoopBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920B24731F2800C1C90E /* LoopBinding.swift */; }; + 5B8F920E24731F2800C1C90E /* LoopBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920B24731F2800C1C90E /* LoopBinding.swift */; }; + 5B8F92102473242900C1C90E /* SwiftUISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920F2473242900C1C90E /* SwiftUISubscription.swift */; }; + 5B8F92112473242900C1C90E /* SwiftUISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920F2473242900C1C90E /* SwiftUISubscription.swift */; }; + 5B8F92122473242900C1C90E /* SwiftUISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F920F2473242900C1C90E /* SwiftUISubscription.swift */; }; + 5B8F922124732C4600C1C90E /* SwiftUIBasicBindingHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F922024732C4600C1C90E /* SwiftUIBasicBindingHomeView.swift */; }; + 5B8F922324732C9500C1C90E /* LoopBindingExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F922224732C9500C1C90E /* LoopBindingExampleView.swift */; }; + 5B8F922724732E1700C1C90E /* SimpleCounterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F922624732E1700C1C90E /* SimpleCounterView.swift */; }; + 5BAB9750247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BAB974F247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift */; }; + 5BAB9751247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BAB974F247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift */; }; + 5BAB9752247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BAB974F247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift */; }; 5BC88F842469CBA300394C63 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BC88F832469CBA300394C63 /* Nimble.framework */; }; 5BC88F862469CBAB00394C63 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BC88F852469CBAB00394C63 /* Nimble.framework */; }; 5BC88F88246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */; }; @@ -55,6 +67,14 @@ 5BC88F9C246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F9B246B1CDE00394C63 /* SignalProducer+Loop.swift */; }; 5BC88F9D246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F9B246B1CDE00394C63 /* SignalProducer+Loop.swift */; }; 5BC88F9E246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC88F9B246B1CDE00394C63 /* SignalProducer+Loop.swift */; }; + 5BDEDA3B2473357A00A13013 /* EnvironmentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F92132473250E00C1C90E /* EnvironmentLoop.swift */; }; + 5BDEDA3C2473357A00A13013 /* EnvironmentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F92132473250E00C1C90E /* EnvironmentLoop.swift */; }; + 5BDEDA3D2473357B00A13013 /* EnvironmentLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F92132473250E00C1C90E /* EnvironmentLoop.swift */; }; + 5BDEDA3E2473359C00A13013 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F921B247325C300C1C90E /* EnvironmentValues.swift */; }; + 5BDEDA3F2473359D00A13013 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F921B247325C300C1C90E /* EnvironmentValues.swift */; }; + 5BDEDA402473359D00A13013 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F921B247325C300C1C90E /* EnvironmentValues.swift */; }; + 5BDEDA41247335C700A13013 /* EnvironmentLoopExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8F922424732CA000C1C90E /* EnvironmentLoopExampleView.swift */; }; + 5BDEDA432473377A00A13013 /* SimpleCounterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BDEDA422473377A00A13013 /* SimpleCounterStore.swift */; }; 656A9C9323D0813500EFB2F8 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */; }; 656A9C9423D0813500EFB2F8 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */; }; 656A9C9523D0813500EFB2F8 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */; }; @@ -203,6 +223,15 @@ 5B017D2B246F33DE00400BFE /* ArrayCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayCollectionViewAdapter.swift; sourceTree = ""; }; 5B017D2F246F344500400BFE /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; 5B017D31246F466600400BFE /* RACHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RACHeaderView.swift; sourceTree = ""; }; + 5B8F920B24731F2800C1C90E /* LoopBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopBinding.swift; sourceTree = ""; }; + 5B8F920F2473242900C1C90E /* SwiftUISubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUISubscription.swift; sourceTree = ""; }; + 5B8F92132473250E00C1C90E /* EnvironmentLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentLoop.swift; sourceTree = ""; }; + 5B8F921B247325C300C1C90E /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; + 5B8F922024732C4600C1C90E /* SwiftUIBasicBindingHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIBasicBindingHomeView.swift; sourceTree = ""; }; + 5B8F922224732C9500C1C90E /* LoopBindingExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopBindingExampleView.swift; sourceTree = ""; }; + 5B8F922424732CA000C1C90E /* EnvironmentLoopExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentLoopExampleView.swift; sourceTree = ""; }; + 5B8F922624732E1700C1C90E /* SimpleCounterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleCounterView.swift; sourceTree = ""; }; + 5BAB974F247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHotSwappableSubscription.swift; sourceTree = ""; }; 5BC88F832469CBA300394C63 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5BC88F852469CBAB00394C63 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5BC88F87246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReactiveSwift+EnqueueTo.swift"; sourceTree = ""; }; @@ -210,6 +239,7 @@ 5BC88F91246B17B200394C63 /* Context.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = ""; }; 5BC88F97246B191200394C63 /* Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loop.swift; sourceTree = ""; }; 5BC88F9B246B1CDE00394C63 /* SignalProducer+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SignalProducer+Loop.swift"; sourceTree = ""; }; + 5BDEDA422473377A00A13013 /* SimpleCounterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleCounterStore.swift; sourceTree = ""; }; 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackLoop.swift; sourceTree = ""; }; 656A9C9623D0826100EFB2F8 /* FeedbackEventConsumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackEventConsumer.swift; sourceTree = ""; }; 65761B2523CF20EF004D5506 /* Floodgate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Floodgate.swift; sourceTree = ""; }; @@ -341,6 +371,7 @@ 25E1D21F1F5493D000D90192 /* Example */ = { isa = PBXGroup; children = ( + 5B8F921F24732C0300C1C90E /* SwiftUIBasicBindingExample */, 585CD886239E904E004BE9CC /* UnifiedStoreUIKitExample */, 5B017D2D246F340900400BFE /* Root */, 5B017D21246F2CBF00400BFE /* Misc */, @@ -434,9 +465,34 @@ path = SwiftUI; sourceTree = ""; }; + 5B8F920A24731F1800C1C90E /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 5B8F920B24731F2800C1C90E /* LoopBinding.swift */, + 5B8F92132473250E00C1C90E /* EnvironmentLoop.swift */, + 5B8F921B247325C300C1C90E /* EnvironmentValues.swift */, + 5B8F920F2473242900C1C90E /* SwiftUISubscription.swift */, + 5BAB974F247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; + 5B8F921F24732C0300C1C90E /* SwiftUIBasicBindingExample */ = { + isa = PBXGroup; + children = ( + 5B8F922024732C4600C1C90E /* SwiftUIBasicBindingHomeView.swift */, + 5B8F922224732C9500C1C90E /* LoopBindingExampleView.swift */, + 5B8F922424732CA000C1C90E /* EnvironmentLoopExampleView.swift */, + 5B8F922624732E1700C1C90E /* SimpleCounterView.swift */, + 5BDEDA422473377A00A13013 /* SimpleCounterStore.swift */, + ); + path = SwiftUIBasicBindingExample; + sourceTree = ""; + }; 5BC88F95246B188300394C63 /* Public */ = { isa = PBXGroup; children = ( + 5B8F920A24731F1800C1C90E /* SwiftUI */, 5BC88F97246B191200394C63 /* Loop.swift */, 5BC88F9B246B1CDE00394C63 /* SignalProducer+Loop.swift */, 656A9C9223D0813500EFB2F8 /* FeedbackLoop.swift */, @@ -759,18 +815,23 @@ buildActionMask = 2147483647; files = ( A9509BE4551098F4A5503820 /* Feedback.swift in Sources */, + 5B8F920C24731F2800C1C90E /* LoopBinding.swift in Sources */, A950943401765BB90FA846B2 /* SignalProducer+System.swift in Sources */, 65761B2E23CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, 5BC88F9C246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */, 5BC88F92246B17B200394C63 /* Context.swift in Sources */, + 5BAB9750247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */, 585CD87B239E6A39004BE9CC /* Reducer.swift in Sources */, 9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */, 5BC88F88246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */, 656A9C9323D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, + 5B8F92102473242900C1C90E /* SwiftUISubscription.swift in Sources */, + 5BDEDA3E2473359C00A13013 /* EnvironmentValues.swift in Sources */, 656A9C9723D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, 5BC88F8E246B11DE00394C63 /* LoopBox.swift in Sources */, 65761B2623CF20EF004D5506 /* Floodgate.swift in Sources */, 5BC88F98246B191200394C63 /* Loop.swift in Sources */, + 5BDEDA3B2473357A00A13013 /* EnvironmentLoop.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -781,6 +842,9 @@ 5B017D27246F2EDB00400BFE /* UnifiedStoreUIKitHomeView.swift in Sources */, 5B017D29246F2F7E00400BFE /* ContentViewController.swift in Sources */, 585CD88A239E9077004BE9CC /* Counter.swift in Sources */, + 5BDEDA432473377A00A13013 /* SimpleCounterStore.swift in Sources */, + 5B8F922324732C9500C1C90E /* LoopBindingExampleView.swift in Sources */, + 5BDEDA41247335C700A13013 /* EnvironmentLoopExampleView.swift in Sources */, 5810F03A239EBEA100708F62 /* UnifiedStore.swift in Sources */, 5810F038239EB5A700708F62 /* MovieCell.swift in Sources */, 5B017D23246F2CD600400BFE /* RootView.swift in Sources */, @@ -793,6 +857,8 @@ 5810F03F239FAB0200708F62 /* ColorPickerView.swift in Sources */, 5810F03D239FA9F200708F62 /* ColorPicker.swift in Sources */, 25E1D2211F5493D000D90192 /* AppDelegate.swift in Sources */, + 5B8F922124732C4600C1C90E /* SwiftUIBasicBindingHomeView.swift in Sources */, + 5B8F922724732E1700C1C90E /* SimpleCounterView.swift in Sources */, 585CD88C239E90EB004BE9CC /* CounterView.swift in Sources */, 5B017D30246F344500400BFE /* CardView.swift in Sources */, 5B017D2C246F33DE00400BFE /* ArrayCollectionViewAdapter.swift in Sources */, @@ -804,18 +870,23 @@ buildActionMask = 2147483647; files = ( 65F8C260218371A800924657 /* Feedback.swift in Sources */, + 5B8F920D24731F2800C1C90E /* LoopBinding.swift in Sources */, 65F8C261218371A800924657 /* SignalProducer+System.swift in Sources */, 65761B2F23CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, 5BC88F9D246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */, 5BC88F93246B17B200394C63 /* Context.swift in Sources */, + 5BAB9751247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */, 585CD87C239E6A3E004BE9CC /* Reducer.swift in Sources */, 65F8C262218371A800924657 /* Property+System.swift in Sources */, 5BC88F89246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */, 656A9C9423D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, + 5B8F92112473242900C1C90E /* SwiftUISubscription.swift in Sources */, + 5BDEDA3F2473359D00A13013 /* EnvironmentValues.swift in Sources */, 656A9C9823D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, 5BC88F8F246B11DE00394C63 /* LoopBox.swift in Sources */, 65761B2723CF20EF004D5506 /* Floodgate.swift in Sources */, 5BC88F99246B191200394C63 /* Loop.swift in Sources */, + 5BDEDA3C2473357A00A13013 /* EnvironmentLoop.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -824,18 +895,23 @@ buildActionMask = 2147483647; files = ( 65F8C26F218371AC00924657 /* Feedback.swift in Sources */, + 5B8F920E24731F2800C1C90E /* LoopBinding.swift in Sources */, 65F8C270218371AC00924657 /* SignalProducer+System.swift in Sources */, 65761B3023CF4CA2004D5506 /* NSLock+Extensions.swift in Sources */, 5BC88F9E246B1CDE00394C63 /* SignalProducer+Loop.swift in Sources */, 5BC88F94246B17B200394C63 /* Context.swift in Sources */, + 5BAB9752247FFBC10079B532 /* SwiftUIHotSwappableSubscription.swift in Sources */, 585CD87D239E6A3E004BE9CC /* Reducer.swift in Sources */, 65F8C271218371AC00924657 /* Property+System.swift in Sources */, 5BC88F8A246AFC5900394C63 /* ReactiveSwift+EnqueueTo.swift in Sources */, 656A9C9523D0813500EFB2F8 /* FeedbackLoop.swift in Sources */, + 5B8F92122473242900C1C90E /* SwiftUISubscription.swift in Sources */, + 5BDEDA402473359D00A13013 /* EnvironmentValues.swift in Sources */, 656A9C9923D0826100EFB2F8 /* FeedbackEventConsumer.swift in Sources */, 5BC88F90246B11DE00394C63 /* LoopBox.swift in Sources */, 65761B2823CF20EF004D5506 /* Floodgate.swift in Sources */, 5BC88F9A246B191200394C63 /* Loop.swift in Sources */, + 5BDEDA3D2473357B00A13013 /* EnvironmentLoop.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/LoopBox.swift b/Loop/LoopBox.swift index 59e1e39..469cdcd 100644 --- a/Loop/LoopBox.swift +++ b/Loop/LoopBox.swift @@ -9,6 +9,11 @@ internal class ScopedLoopBox: Lo root.lifetime } + /// Loop Internal SPI + override var _current: ScopedState { + root._current[keyPath: value] + } + private let root: LoopBoxBase private let value: KeyPath private let eventTransform: (ScopedEvent) -> RootEvent @@ -44,6 +49,11 @@ internal class RootLoopBox: LoopBoxBase { _lifetime } + /// Loop Internal SPI + override var _current: State { + floodgate.withValue { state, _ in state } + } + let floodgate: Floodgate private let _lifetime: Lifetime private let token: Lifetime.Token @@ -89,6 +99,9 @@ internal class RootLoopBox: LoopBoxBase { } internal class LoopBoxBase { + /// Loop Internal SPI + var _current: State { subclassMustImplement() } + var lifetime: Lifetime { subclassMustImplement() } var producer: SignalProducer { subclassMustImplement() } diff --git a/Loop/Public/Loop.swift b/Loop/Public/Loop.swift index 7852227..d7a562b 100644 --- a/Loop/Public/Loop.swift +++ b/Loop/Public/Loop.swift @@ -19,7 +19,12 @@ public final class Loop { } } - private let box: LoopBoxBase + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + public var binding: LoopBinding { + LoopBinding(self) + } + + internal let box: LoopBoxBase private init(box: LoopBoxBase) { self.box = box diff --git a/Loop/Public/SwiftUI/EnvironmentLoop.swift b/Loop/Public/SwiftUI/EnvironmentLoop.swift new file mode 100644 index 0000000..301f58f --- /dev/null +++ b/Loop/Public/SwiftUI/EnvironmentLoop.swift @@ -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: DynamicProperty { + @Environment(\.loops[LoopType(Loop.self)]) + var erasedLoop: AnyObject? + + @ObservedObject + private var subscription: SwiftUIHotSwappableSubscription + + @inlinable + public var wrappedValue: State { + acknowledgedState + } + + public var projectedValue: LoopBinding { + guard let loop = erasedLoop as! Loop? 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? else { + fatalError("Expect parent view to inject a `Loop<\(State.self), \(Event.self)>` through `View.environmentLoop(_:)`. Found none.") + } + + acknowledgedState = subscription.currentState(in: loop) + } +} + +#endif diff --git a/Loop/Public/SwiftUI/EnvironmentValues.swift b/Loop/Public/SwiftUI/EnvironmentValues.swift new file mode 100644 index 0000000..2a0e1f8 --- /dev/null +++ b/Loop/Public/SwiftUI/EnvironmentValues.swift @@ -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(_ loop: Loop) -> 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 diff --git a/Loop/Public/SwiftUI/LoopBinding.swift b/Loop/Public/SwiftUI/LoopBinding.swift new file mode 100644 index 0000000..a15126e --- /dev/null +++ b/Loop/Public/SwiftUI/LoopBinding.swift @@ -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: DynamicProperty { + @ObservedObject + private var subscription: SwiftUISubscription + + private let loop: Loop + + @inlinable + public var wrappedValue: State { + acknowledgedState + } + + public var projectedValue: LoopBinding { + self + } + + @usableFromInline + internal var acknowledgedState: State + + public init(_ loop: Loop) { + // 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( + to value: KeyPath, + event: @escaping (ScopedEvent) -> Event + ) -> LoopBinding { + LoopBinding(loop.scoped(to: value, event: event)) + } + + public func send(_ event: Event) { + loop.send(event) + } +} + +#endif diff --git a/Loop/Public/SwiftUI/SwiftUIHotSwappableSubscription.swift b/Loop/Public/SwiftUI/SwiftUIHotSwappableSubscription.swift new file mode 100644 index 0000000..2ee90c6 --- /dev/null +++ b/Loop/Public/SwiftUI/SwiftUIHotSwappableSubscription.swift @@ -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: ObservableObject { + + @Published private var latestValue: State! + private weak var attachedLoop: Loop? + private var disposable: Disposable? + + init() {} + + deinit { + disposable?.dispose() + } + + func currentState(in loop: Loop) -> 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 diff --git a/Loop/Public/SwiftUI/SwiftUISubscription.swift b/Loop/Public/SwiftUI/SwiftUISubscription.swift new file mode 100644 index 0000000..c8a40cf --- /dev/null +++ b/Loop/Public/SwiftUI/SwiftUISubscription.swift @@ -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: ObservableObject { + + @Published var latestValue: State + private var disposable: Disposable? + + init(loop: Loop) { + 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