Skip to content

Commit ada3b7e

Browse files
authored
Add WorkflowSwiftUIExperimental (#252)
* move sources from WorkflowSwiftUIExperimental repo * rename `isDuplicate` to `isEquivalent` * update podspecs
1 parent 4399ddc commit ada3b7e

8 files changed

+348
-0
lines changed

Diff for: Development.podspec

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Pod::Spec.new do |s|
1717
s.dependency 'WorkflowRxSwift'
1818
s.dependency 'WorkflowCombine'
1919
s.dependency 'WorkflowConcurrency'
20+
s.dependency 'WorkflowSwiftUIExperimental'
2021
s.dependency 'ViewEnvironment'
2122
s.dependency 'ViewEnvironmentUI'
2223

Diff for: WorkflowSwiftUIExperimental.podspec

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
require_relative('version')
2+
3+
Pod::Spec.new do |s|
4+
s.name = 'WorkflowSwiftUIExperimental'
5+
s.version = '0.1'
6+
s.summary = 'Infrastructure for Workflow-powered SwiftUI'
7+
s.homepage = 'https://www.github.com/square/workflow-swift'
8+
s.license = 'Apache License, Version 2.0'
9+
s.author = 'Square'
10+
s.source = { :git => 'https://github.com/square/workflow-swift.git', :tag => "swiftui-experimental/v#{s.version}" }
11+
12+
# 1.7 is needed for `swift_versions` support
13+
s.cocoapods_version = '>= 1.7.0'
14+
15+
s.swift_versions = [WORKFLOW_SWIFT_VERSION]
16+
s.ios.deployment_target = WORKFLOW_IOS_DEPLOYMENT_TARGET
17+
s.osx.deployment_target = WORKFLOW_MACOS_DEPLOYMENT_TARGET
18+
19+
s.source_files = 'WorkflowSwiftUIExperimental/Sources/*.swift'
20+
21+
s.dependency 'Workflow', WORKFLOW_VERSION
22+
s.dependency 'WorkflowUI', WORKFLOW_VERSION
23+
24+
s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' }
25+
end

Diff for: WorkflowSwiftUIExperimental/README.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# WorkflowSwiftUIExperimental
2+
3+
Experimental extensions to Workflow for writing Screens in SwiftUI.
4+
5+
## Versioning
6+
7+
Because this module is experimental, it is versioned separately from other modules in Workflow. You should bump its version as part of any pull request that changes it, and need not bump its version in PRs that change only other modules.
8+
9+
Per semantic versioning, its major version remains at `0`, and only its minor version is incremented. Any increase in the minor version may come with breaking changes.
10+
11+
To bump the minor version, update `s.version` in `WorkflowSwiftUIExperimental.podspec`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2023 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import SwiftUI
18+
import WorkflowUI
19+
20+
private struct ViewEnvironmentKey: EnvironmentKey {
21+
static let defaultValue: ViewEnvironment = .empty
22+
}
23+
24+
public extension EnvironmentValues {
25+
var viewEnvironment: ViewEnvironment {
26+
get { self[ViewEnvironmentKey.self] }
27+
set { self[ViewEnvironmentKey.self] = newValue }
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2023 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if canImport(UIKit)
18+
19+
import SwiftUI
20+
21+
public extension ObservableValue {
22+
func binding<T>(
23+
get: @escaping (Value) -> T,
24+
set: @escaping (Value) -> (T) -> Void
25+
) -> Binding<T> {
26+
// This convoluted way of creating a `Binding`, relative to `Binding.init(get:set:)`, is
27+
// a workaround borrowed from TCA for a SwiftUI issue:
28+
// https://github.com/pointfreeco/swift-composable-architecture/pull/770
29+
ObservedObject(wrappedValue: self)
30+
.projectedValue[get: .init(rawValue: get), set: .init(rawValue: set)]
31+
}
32+
33+
private subscript<T>(
34+
get get: HashableWrapper<(Value) -> T>,
35+
set set: HashableWrapper<(Value) -> (T) -> Void>
36+
) -> T {
37+
get { get.rawValue(value) }
38+
set { set.rawValue(value)(newValue) }
39+
}
40+
41+
private struct HashableWrapper<Value>: Hashable {
42+
let rawValue: Value
43+
static func == (lhs: Self, rhs: Self) -> Bool { false }
44+
func hash(into hasher: inout Hasher) {}
45+
}
46+
}
47+
48+
#endif
+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright 2023 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Combine
18+
import Workflow
19+
20+
@dynamicMemberLookup
21+
public final class ObservableValue<Value>: ObservableObject {
22+
private var internalValue: Value
23+
private let subject = PassthroughSubject<Value, Never>()
24+
private var cancellable: AnyCancellable?
25+
private let isEquivalent: ((Value, Value) -> Bool)?
26+
27+
public private(set) var value: Value {
28+
get {
29+
return internalValue
30+
}
31+
set {
32+
subject.send(newValue)
33+
}
34+
}
35+
36+
public private(set) lazy var objectWillChange = ObservableObjectPublisher()
37+
private var parentCancellable: AnyCancellable?
38+
39+
public static func makeObservableValue(
40+
_ value: Value,
41+
isEquivalent: ((Value, Value) -> Bool)? = nil
42+
) -> (ObservableValue, Sink<Value>) {
43+
let observableValue = ObservableValue(value: value, isEquivalent: isEquivalent)
44+
let sink = Sink { newValue in
45+
observableValue.value = newValue
46+
}
47+
48+
return (observableValue, sink)
49+
}
50+
51+
private init(value: Value, isEquivalent: ((Value, Value) -> Bool)?) {
52+
self.internalValue = value
53+
self.isEquivalent = isEquivalent
54+
self.cancellable = valuePublisher()
55+
.dropFirst()
56+
.sink { [weak self] newValue in
57+
guard let self = self else { return }
58+
self.objectWillChange.send()
59+
self.internalValue = newValue
60+
}
61+
// Allows removeDuplicates operator to have the initial value.
62+
subject.send(value)
63+
}
64+
65+
//// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure while allowing to optionally remove duplicates.
66+
/// - Parameters:
67+
/// - toLocalValue: A closure that takes a Value and returns a LocalValue.
68+
/// - isEquivalent: An optional closure that checks to see if a LocalValue is equivalent.
69+
/// - Returns: a scoped ObservableValue of LocalValue.
70+
public func scope<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue, isEquivalent: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue<LocalValue> {
71+
return scopeToLocalValue(toLocalValue, isEquivalent: isEquivalent)
72+
}
73+
74+
/// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure and removes duplicate values using Equatable.
75+
/// - Parameter toLocalValue: A closure that takes a Value and returns a LocalValue.
76+
/// - Returns: a scoped ObservableValue of LocalValue.
77+
public func scope<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue) -> ObservableValue<LocalValue> where LocalValue: Equatable {
78+
return scopeToLocalValue(toLocalValue, isEquivalent: { $0 == $1 })
79+
}
80+
81+
/// Returns the value at the given keypath of ``Value``.
82+
///
83+
/// In combination with `@dynamicMemberLookup`, this allows us to write `model.myProperty` instead of
84+
/// `model.value.myProperty` where `model` has type `ObservableValue<T>`.
85+
public subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
86+
internalValue[keyPath: keyPath]
87+
}
88+
89+
private func scopeToLocalValue<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue, isEquivalent: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue<LocalValue> {
90+
let localObservableValue = ObservableValue<LocalValue>(
91+
value: toLocalValue(internalValue),
92+
isEquivalent: isEquivalent
93+
)
94+
localObservableValue.parentCancellable = valuePublisher().sink(receiveValue: { newValue in
95+
localObservableValue.value = toLocalValue(newValue)
96+
})
97+
return localObservableValue
98+
}
99+
100+
private func valuePublisher() -> AnyPublisher<Value, Never> {
101+
guard let isEquivalent = isEquivalent else {
102+
return subject.eraseToAnyPublisher()
103+
}
104+
105+
return subject.removeDuplicates(by: isEquivalent).eraseToAnyPublisher()
106+
}
107+
}
+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2023 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if canImport(UIKit)
18+
19+
import SwiftUI
20+
import Workflow
21+
import WorkflowUI
22+
23+
public protocol SwiftUIScreen: Screen {
24+
associatedtype Content: View
25+
26+
@ViewBuilder
27+
static func makeView(model: ObservableValue<Self>) -> Content
28+
29+
static var isEquivalent: ((Self, Self) -> Bool)? { get }
30+
}
31+
32+
public extension SwiftUIScreen {
33+
static var isEquivalent: ((Self, Self) -> Bool)? { return nil }
34+
}
35+
36+
public extension SwiftUIScreen where Self: Equatable {
37+
static var isEquivalent: ((Self, Self) -> Bool)? { { $0 == $1 } }
38+
}
39+
40+
public extension SwiftUIScreen {
41+
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
42+
ViewControllerDescription(
43+
type: ModeledHostingController<Self, WithModel<Self, EnvironmentInjectingView<Content>>>.self,
44+
environment: environment,
45+
build: {
46+
let (model, modelSink) = ObservableValue.makeObservableValue(self, isEquivalent: Self.isEquivalent)
47+
let (viewEnvironment, envSink) = ObservableValue.makeObservableValue(environment)
48+
return ModeledHostingController(
49+
modelSink: modelSink,
50+
viewEnvironmentSink: envSink,
51+
rootView: WithModel(model, content: { model in
52+
EnvironmentInjectingView(
53+
viewEnvironment: viewEnvironment,
54+
content: Self.makeView(model: model)
55+
)
56+
})
57+
)
58+
},
59+
update: {
60+
$0.modelSink.send(self)
61+
$0.viewEnvironmentSink.send(environment)
62+
}
63+
)
64+
}
65+
}
66+
67+
private struct EnvironmentInjectingView<Content: View>: View {
68+
@ObservedObject var viewEnvironment: ObservableValue<ViewEnvironment>
69+
let content: Content
70+
71+
var body: some View {
72+
content
73+
.environment(\.viewEnvironment, viewEnvironment.value)
74+
}
75+
}
76+
77+
private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content> {
78+
let modelSink: Sink<Model>
79+
let viewEnvironmentSink: Sink<ViewEnvironment>
80+
81+
init(modelSink: Sink<Model>, viewEnvironmentSink: Sink<ViewEnvironment>, rootView: Content) {
82+
self.modelSink = modelSink
83+
self.viewEnvironmentSink = viewEnvironmentSink
84+
85+
super.init(rootView: rootView)
86+
}
87+
88+
required init?(coder aDecoder: NSCoder) {
89+
fatalError("not implemented")
90+
}
91+
}
92+
93+
#endif

Diff for: WorkflowSwiftUIExperimental/Sources/WithModel.swift

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2023 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import SwiftUI
18+
19+
struct WithModel<Model, Content: View>: View {
20+
@ObservedObject private var model: ObservableValue<Model>
21+
private let content: (ObservableValue<Model>) -> Content
22+
23+
init(
24+
_ model: ObservableValue<Model>,
25+
@ViewBuilder content: @escaping (ObservableValue<Model>) -> Content
26+
) {
27+
self.model = model
28+
self.content = content
29+
}
30+
31+
var body: Content {
32+
content(model)
33+
}
34+
}

0 commit comments

Comments
 (0)