Skip to content

Commit

Permalink
paste in WorkflowSwiftUI types
Browse files Browse the repository at this point in the history
  • Loading branch information
square-tomb committed Jun 22, 2023
1 parent 95ddd1e commit c7d51b6
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 1 deletion.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ let package = Package(
),
.target(
name: "WorkflowSwiftUI",
dependencies: ["Workflow"],
dependencies: ["Workflow", "WorkflowUI"],
path: "WorkflowSwiftUI/Sources"
),

Expand Down
1 change: 1 addition & 0 deletions WorkflowSwiftUI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Pod::Spec.new do |s|
s.source_files = 'WorkflowSwiftUI/Sources/*.swift'

s.dependency 'Workflow', "#{s.version}"
s.dependency 'WorkflowUI', "#{s.version}"

s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' }

Expand Down
29 changes: 29 additions & 0 deletions WorkflowSwiftUI/Sources/EnvironmentValues+ViewEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2023 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI
import WorkflowUI

private struct ViewEnvironmentKey: EnvironmentKey {
static let defaultValue: ViewEnvironment = .empty
}

public extension EnvironmentValues {
var viewEnvironment: ViewEnvironment {
get { self[ViewEnvironmentKey.self] }
set { self[ViewEnvironmentKey.self] = newValue }
}
}
48 changes: 48 additions & 0 deletions WorkflowSwiftUI/Sources/ObservableValue+Binding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2023 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if canImport(UIKit)

import SwiftUI

public extension ObservableValue {
func binding<T>(
get: @escaping (Value) -> T,
set: @escaping (Value) -> (T) -> Void
) -> Binding<T> {
// This convoluted way of creating a `Binding`, relative to `Binding.init(get:set:)`, is
// a workaround borrowed from TCA for a SwiftUI issue:
// https://github.com/pointfreeco/swift-composable-architecture/pull/770
ObservedObject(wrappedValue: self)
.projectedValue[get: .init(rawValue: get), set: .init(rawValue: set)]
}

private subscript<T>(
get get: HashableWrapper<(Value) -> T>,
set set: HashableWrapper<(Value) -> (T) -> Void>
) -> T {
get { get.rawValue(value) }
set { set.rawValue(value)(newValue) }
}

private struct HashableWrapper<Value>: Hashable {
let rawValue: Value
static func == (lhs: Self, rhs: Self) -> Bool { false }
func hash(into hasher: inout Hasher) {}
}
}

#endif
106 changes: 106 additions & 0 deletions WorkflowSwiftUI/Sources/ObservableValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2023 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Combine
import Workflow

@dynamicMemberLookup
public final class ObservableValue<Value>: ObservableObject {
private var internalValue: Value
private let subject = PassthroughSubject<Value, Never>()
private var cancellable: AnyCancellable?
private var isDuplicate: ((Value, Value) -> Bool)?
public private(set) var value: Value {
get {
return internalValue
}
set {
subject.send(newValue)
}
}

public private(set) lazy var objectWillChange = ObservableObjectPublisher()
private var parentCancellable: AnyCancellable?

public static func makeObservableValue(
_ value: Value,
isDuplicate: ((Value, Value) -> Bool)? = nil
) -> (ObservableValue, Sink<Value>) {
let observableValue = ObservableValue(value: value, isDuplicate: isDuplicate)
let sink = Sink { newValue in
observableValue.value = newValue
}

return (observableValue, sink)
}

private init(value: Value, isDuplicate: ((Value, Value) -> Bool)?) {
self.internalValue = value
self.isDuplicate = isDuplicate
self.cancellable = valuePublisher()
.dropFirst()
.sink { [weak self] newValue in
guard let self = self else { return }
self.objectWillChange.send()
self.internalValue = newValue
}
// Allows removeDuplicates operator to have the initial value.
subject.send(value)
}

//// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure while allowing to optionally remove duplicates.
/// - Parameters:
/// - toLocalValue: A closure that takes a Value and returns a LocalValue.
/// - isDuplicate: An optional closure that checks to see if a LocalValue is a duplicate.
/// - Returns: a scoped ObservableValue of LocalValue.
public func scope<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue, isDuplicate: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue<LocalValue> {
return scopeToLocalValue(toLocalValue, isDuplicate: isDuplicate)
}

/// Scopes the ObservableValue to a subset of Value to LocalValue given the supplied closure and removes duplicate values using Equatable.
/// - Parameter toLocalValue: A closure that takes a Value and returns a LocalValue.
/// - Returns: a scoped ObservableValue of LocalValue.
public func scope<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue) -> ObservableValue<LocalValue> where LocalValue: Equatable {
return scopeToLocalValue(toLocalValue, isDuplicate: { $0 == $1 })
}

/// Returns the value at the given keypath of ``Value``.
///
/// In combination with `@dynamicMemberLookup`, this allows us to write `model.myProperty` instead of
/// `model.value.myProperty` where `model` has type `ObservableValue<T>`.
public subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
internalValue[keyPath: keyPath]
}

private func scopeToLocalValue<LocalValue>(_ toLocalValue: @escaping (Value) -> LocalValue, isDuplicate: ((LocalValue, LocalValue) -> Bool)? = nil) -> ObservableValue<LocalValue> {
let localObservableValue = ObservableValue<LocalValue>(
value: toLocalValue(internalValue),
isDuplicate: isDuplicate
)
localObservableValue.parentCancellable = valuePublisher().sink(receiveValue: { newValue in
localObservableValue.value = toLocalValue(newValue)
})
return localObservableValue
}

private func valuePublisher() -> AnyPublisher<Value, Never> {
guard let isDuplicate = isDuplicate else {
return subject.eraseToAnyPublisher()
}

return subject.removeDuplicates(by: isDuplicate).eraseToAnyPublisher()
}
}
93 changes: 93 additions & 0 deletions WorkflowSwiftUI/Sources/SwiftUIScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2023 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#if canImport(UIKit)

import SwiftUI
import Workflow
import WorkflowUI

public protocol SwiftUIScreen: Screen {
associatedtype Content: View

@ViewBuilder
static func makeView(model: ObservableValue<Self>) -> Content

static var isDuplicate: ((Self, Self) -> Bool)? { get }
}

public extension SwiftUIScreen {
static var isDuplicate: ((Self, Self) -> Bool)? { return nil }
}

public extension SwiftUIScreen where Self: Equatable {
static var isDuplicate: ((Self, Self) -> Bool)? { { $0 == $1 } }
}

public extension SwiftUIScreen {
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
ViewControllerDescription(
type: ModeledHostingController<Self, WithModel<Self, EnvironmentInjectingView<Content>>>.self,
environment: environment,
build: {
let (model, modelSink) = ObservableValue.makeObservableValue(self, isDuplicate: Self.isDuplicate)
let (viewEnvironment, envSink) = ObservableValue.makeObservableValue(environment)
return ModeledHostingController(
modelSink: modelSink,
viewEnvironmentSink: envSink,
rootView: WithModel(model, content: { model in
EnvironmentInjectingView(
viewEnvironment: viewEnvironment,
content: Self.makeView(model: model)
)
})
)
},
update: {
$0.modelSink.send(self)
$0.viewEnvironmentSink.send(environment)
}
)
}
}

private struct EnvironmentInjectingView<Content: View>: View {
@ObservedObject var viewEnvironment: ObservableValue<ViewEnvironment>
let content: Content

var body: some View {
content
.environment(\.viewEnvironment, viewEnvironment.value)
}
}

private final class ModeledHostingController<Model, Content: View>: UIHostingController<Content> {
let modelSink: Sink<Model>
let viewEnvironmentSink: Sink<ViewEnvironment>

init(modelSink: Sink<Model>, viewEnvironmentSink: Sink<ViewEnvironment>, rootView: Content) {
self.modelSink = modelSink
self.viewEnvironmentSink = viewEnvironmentSink

super.init(rootView: rootView)
}

required init?(coder aDecoder: NSCoder) {
fatalError("not implemented")
}
}

#endif
34 changes: 34 additions & 0 deletions WorkflowSwiftUI/Sources/WithModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2023 Square Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import SwiftUI

struct WithModel<Model, Content: View>: View {
@ObservedObject private var model: ObservableValue<Model>
private let content: (ObservableValue<Model>) -> Content

init(
_ model: ObservableValue<Model>,
@ViewBuilder content: @escaping (ObservableValue<Model>) -> Content
) {
self.model = model
self.content = content
}

var body: Content {
content(model)
}
}

0 comments on commit c7d51b6

Please sign in to comment.