From b6e614325e449c490cf5564def4b8ee834c7e949 Mon Sep 17 00:00:00 2001 From: lumiasaki Date: Tue, 27 Jul 2021 18:55:14 +0800 Subject: [PATCH] Introduce key path to shared state extension --- .../xcshareddata/xcschemes/SceneBox.xcscheme | 92 +++++++++++ README.md | 12 +- .../Extension/Core/SharedStateExtension.swift | 155 ++++++++++++++++-- Tests/SceneBoxTests/SceneBoxTests.swift | 90 +++++++++- Tests/SceneBoxTests/Support/Car.swift | 18 ++ .../SceneBoxTestSceneViewController.swift | 7 +- .../Support/SharedStateTestKey.swift | 36 ++++ 7 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SceneBox.xcscheme create mode 100644 Tests/SceneBoxTests/Support/Car.swift create mode 100644 Tests/SceneBoxTests/Support/SharedStateTestKey.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SceneBox.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SceneBox.xcscheme new file mode 100644 index 0000000..c40b97b --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SceneBox.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index ea968e3..d0492ab 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Based on these two pain points, I conceived the framework to enable us to develo To integrate using Apple's SPM, add following as a dependency to your Target. -`.package(url: "https://github.com/lumiasaki/SceneBox.git", .upToNextMajor(from: "0.2.4"))` +`.package(url: "https://github.com/lumiasaki/SceneBox.git", .upToNextMajor(from: "0.3.0"))` ## How to use @@ -107,11 +107,11 @@ class MyViewController: UIViewController, Scene { var sceneIdentifier: UUID! func saveValue() { - sbx.putSharedState(by: "Color", sharedState: UIColor.red) + sbx.putSharedState(by: \.color, sharedState: UIColor.red) } func fetchValue() { - let color: UIColor? = sbx.getSharedState(by: "Color") + let color: UIColor? = sbx.getSharedState(by: \.color) } func pushToNext() { @@ -131,7 +131,7 @@ class MyViewController: UIViewController, Scene { var sceneIdentifier: UUID! - @SceneBoxSharedStateInjected(key: "Color") + @SharedStateInjected(\.timestamp) private var color: UIColor? init() { @@ -157,7 +157,9 @@ In general, after initializing a `SceneBox`, it can be held manually by the call ### Scene -The `Scene` represents a page in the `SceneBox`, which is currently limited in the `UIViewController` class, and the limitation may be removed in the future. The fact that `Scene` is a protocol means that using `SceneBox` does not need to change the inheritance of your existing code, making it relatively easy to transform an existing `UIViewController` into a class that can be used in `SceneBox`. `Scene` provides a number of capabilities that can be used in `SceneBox`, such as `getSharedState(by:)`, `putSharedState(state:key:)` and so on. +The `Scene` represents a page in the `SceneBox`, which is currently limited in the `UIViewController` class, and the limitation may be removed in the future. The fact that `Scene` is a protocol means that using `SceneBox` does not need to change the inheritance of your existing code, making it relatively easy to transform an existing `UIViewController` into a class that can be used in `SceneBox`. `Scene` provides a number of capabilities that can be used in `SceneBox`, such as `getSharedState(by:)`, `putSharedState(state:keyPath:)` and so on. + +> For more details about shared state extension with key path, check this: https://github.com/lumiasaki/SceneBox/issues/10 Once a `UIViewController` is marked as conforming to the `Scene` protocol, you can access a number of capabilities under `sbx` namespace of your view controller. Even more, you can extend your own capabilities to the `Scene` under the namespace easily by extend `SceneCapabilityWrapper`, you can follow the guide to extend it. diff --git a/Sources/SceneBox/Extension/Core/SharedStateExtension.swift b/Sources/SceneBox/Extension/Core/SharedStateExtension.swift index 6ee3aa5..ce8273a 100644 --- a/Sources/SceneBox/Extension/Core/SharedStateExtension.swift +++ b/Sources/SceneBox/Extension/Core/SharedStateExtension.swift @@ -8,6 +8,7 @@ import Foundation +@available(*, deprecated, message: "Use `SharedStateInjected` instead.") @propertyWrapper public class SceneBoxSharedStateInjected { @@ -41,6 +42,80 @@ public class SceneBoxSharedStateInjected { } } +/// Property wrapper for helping developers to simplify state sharing among different `Scene`s, the principle of it is similar to `Environment` in SwiftUI, all `Scene`s share same source of truth through the `SharedStateExtension`, use this property wrapper will make developer to manipulate variables as normal properties, the only difference is that this one is backed by `SharedStateExtension` in static-type ( key path ) way. +/// Notice: Because the property wrapper needs an instance of `Scene` to get capability from it, since that, you need to configure it before using it by calling `configure(scene:)` in a proper place. +@propertyWrapper +public struct SharedStateInjected { + + private var scene: Scene? + + private let keyPath: WritableKeyPath + + public mutating func configure(scene: Scene?) { + self.scene = scene + } + + public var wrappedValue: T? { + get { + guard let scene = scene else { + fatalError("configure scene firstly") + } + + return scene.sbx.getSharedState(by: keyPath) + } + + set { + guard let scene = scene else { + fatalError("configure scene firstly") + } + + scene.sbx.putSharedState(by: keyPath, sharedState: newValue) + } + } + + public init(_ keyPath: WritableKeyPath) { + self.keyPath = keyPath + } +} + +/// This is an entry point for your custom keys. +/// A case about how to create a custom key for your logic: +/// ```swift +/// struct TimestampKey: SharedStateKey { +/// +/// // this will give compiler a hint about concrete type. +/// static var currentValue: TimeInterval? +/// } +/// ``` +public protocol SharedStateKey { + + associatedtype ValueType + + static var currentValue: ValueType? { get set } +} + +/// This is an entry point for extending your values. +/// A case about how to create a key path for your value as below: +/// ```swift +/// extension SharedStateValues { +/// +/// // this will generate a key path for `timestamp` variable. +/// var timestamp: TimeInterval? { +/// get { Self[TimestampKey.self] } +/// set { Self[TimestampKey.self] = newValue } +/// } +/// } +/// ``` +public struct SharedStateValues { + + public static var current = SharedStateValues() + + public static subscript(key: K.Type) -> K.ValueType? { + get { key.currentValue } + set { key.currentValue = newValue } + } +} + /// Message data structure for shared state extension to exchange information with other extensions in the SceneBox. public struct SharedStateMessage { @@ -77,27 +152,30 @@ public final class SharedStateExtension: Extension { // MARK: - Private private let workerQueue = DispatchQueue(label: "com.scenebox.shared-state.queue", attributes: .concurrent) + + @available(*, deprecated, message: "Use `stateValue` instead.") private var state: [AnyHashable : Any] = Dictionary() - fileprivate func querySharedState(by key: AnyHashable) -> Any? { - var result: Any? - - workerQueue.sync { - result = state[key] - } - - return result - } + private var stateValue: SharedStateValues = SharedStateValues() - fileprivate func setSharedState(_ sharedState: Any?, on key: AnyHashable) { + fileprivate func setSharedState(_ sharedState: T?, on keyPath: WritableKeyPath) { workerQueue.async(flags: .barrier) { - self.state[key] = sharedState + self.stateValue[keyPath: keyPath] = sharedState - self.sceneBox?.dispatch(event: EventBus.EventName.sharedStateChanges, message: SharedStateMessage(key: key, state: sharedState)) - self.logger(content: "shared state changes: key: \(key), state: \(String(describing: sharedState))") + self.sceneBox?.dispatch(event: EventBus.EventName.sharedStateChanges, message: SharedStateMessage(key: keyPath, state: sharedState)) + self.logger(content: "shared state changes: key: \(keyPath), state: \(String(describing: sharedState))") } } + fileprivate func querySharedState(by keyPath: WritableKeyPath) -> T? { + var result: T? + + workerQueue.sync { + result = stateValue[keyPath: keyPath] + } + + return result + } } extension SceneCapabilityWrapper { @@ -105,6 +183,7 @@ extension SceneCapabilityWrapper { /// Receiving a shared state from the shared store if it exists. /// - Parameter key: The key of the data you want to fetch from the store. /// - Returns: The shared state stored in the extension. Nil if the data not exists with the provided key. + @available(*, deprecated, message: "Use `getSharedState(by keyPath: WritableKeyPath) -> T?` instead.") public func getSharedState(by key: AnyHashable) -> Any? { guard let ext = try? _getExtension(by: SharedStateExtension.self) else { return nil @@ -113,10 +192,22 @@ extension SceneCapabilityWrapper { return ext.querySharedState(by: key) } + /// Receiving a shared state from the shared store if it exists. + /// - Parameter keyPath: Key path to the value by extending `SharedStateValues`. + /// - Returns: The shared state stored in the extension. Nil if the data not exists with the provided key path. + public func getSharedState(by keyPath: WritableKeyPath) -> T? { + guard let ext = try? _getExtension(by: SharedStateExtension.self) else { + return nil + } + + return ext.querySharedState(by: keyPath) + } + /// Putting a shared state to the shared store. /// - Parameters: /// - key: The key of the data you want to put into the store. /// - sharedState: The shared state you want to save. Nil if you want to remove the data from the store. + @available(*, deprecated, message: "Use `putSharedState(by keyPath: WritableKeyPath, sharedState: T?)` instead.") public func putSharedState(by key: AnyHashable, sharedState: Any?) { guard let ext = try? _getExtension(by: SharedStateExtension.self) else { return @@ -124,4 +215,42 @@ extension SceneCapabilityWrapper { ext.setSharedState(sharedState, on: key) } + + /// Putting a shared state to the shared store. + /// - Parameters: + /// - keyPath: Key path to the value by extending `SharedStateValues`. + /// - sharedState: The shared state you want to save. Nil if you want to remove the data from the store. + public func putSharedState(by keyPath: WritableKeyPath, sharedState: T?) { + guard let ext = try? _getExtension(by: SharedStateExtension.self) else { + return + } + + ext.setSharedState(sharedState, on: keyPath) + } +} + +// MARK: - Deprecated + +extension SharedStateExtension { + + @available(*, deprecated, message: "Use `querySharedState(by keyPath: WritableKeyPath) -> T?` instead.") + fileprivate func querySharedState(by key: AnyHashable) -> Any? { + var result: Any? + + workerQueue.sync { + result = state[key] + } + + return result + } + + @available(*, deprecated, message: "Use `setSharedState(_ sharedState: T?, on keyPath: WritableKeyPath)` instead.") + fileprivate func setSharedState(_ sharedState: Any?, on key: AnyHashable) { + workerQueue.async(flags: .barrier) { + self.state[key] = sharedState + + self.sceneBox?.dispatch(event: EventBus.EventName.sharedStateChanges, message: SharedStateMessage(key: key, state: sharedState)) + self.logger(content: "shared state changes: key: \(key), state: \(String(describing: sharedState))") + } + } } diff --git a/Tests/SceneBoxTests/SceneBoxTests.swift b/Tests/SceneBoxTests/SceneBoxTests.swift index b71acb5..154c4a9 100644 --- a/Tests/SceneBoxTests/SceneBoxTests.swift +++ b/Tests/SceneBoxTests/SceneBoxTests.swift @@ -222,6 +222,7 @@ final class SceneBoxTests: XCTestCase { wait(for: [expectation], timeout: 10) } + @available(*, deprecated, message: "Use `testSharedStateExtensionWithKeyPathApproach` instead.") func testSharedStateExtension() { sceneBox.lazyAdd(identifier: sceneIdentifier1, sceneBuilder: self.scene1) sceneBox.lazyAdd(identifier: sceneIdentifier2, sceneBuilder: self.scene2) @@ -241,11 +242,98 @@ final class SceneBoxTests: XCTestCase { XCTAssertTrue(scene2.sbx.getSharedState(by: key) as? Int == value) } + + func testSharedStateExtensionWithKeyPathApproach() { + sceneBox.lazyAdd(identifier: sceneIdentifier1, sceneBuilder: self.scene1) + sceneBox.lazyAdd(identifier: sceneIdentifier2, sceneBuilder: self.scene2) + + XCTAssertNoThrow(try Executor.shared.execute(box: sceneBox)) + + // value type + do { + let timestamp = Date().timeIntervalSince1970 + + scene1.sbx.putSharedState(by: \.timestamp, sharedState: timestamp) + + XCTAssertTrue(scene1.sbx.getSharedState(by: \.timestamp) == timestamp) + + scene1.sbx.transit(to: SceneState.page2.rawValue) + + XCTAssertTrue(unitTestExtension.getSceneStates()?.last == SceneState.page2.rawValue) + + XCTAssertTrue(scene2.sbx.getSharedState(by: \.timestamp) == timestamp) + + let newTimestamp = Date().timeIntervalSince1970 + + scene2.sbx.putSharedState(by: \.timestamp, sharedState: newTimestamp) + + XCTAssertTrue(scene2.sbx.getSharedState(by: \.timestamp) == newTimestamp) + XCTAssertTrue(scene1.sbx.getSharedState(by: \.timestamp) == newTimestamp) + + scene1.sbx.putSharedState(by: \.timestamp, sharedState: nil) + XCTAssertNil(scene1.sbx.getSharedState(by: \.timestamp)) + XCTAssertNil(scene2.sbx.getSharedState(by: \.timestamp)) + } + + // reference type + do { + let myCar = Car(name: "benz") + + scene1.sbx.putSharedState(by: \.car, sharedState: myCar) + + XCTAssertTrue(scene1.sbx.getSharedState(by: \.car) === myCar) + XCTAssertTrue(scene1.sbx.getSharedState(by: \.car)?.name == "benz") + + XCTAssertTrue(scene2.sbx.getSharedState(by: \.car) === myCar) + XCTAssertTrue(scene2.sbx.getSharedState(by: \.car)?.name == "benz") + + myCar.name = "bmw" + + XCTAssertTrue(scene1.sbx.getSharedState(by: \.car) === myCar) + XCTAssertTrue(scene1.sbx.getSharedState(by: \.car)?.name == "bmw") + + XCTAssertTrue(scene2.sbx.getSharedState(by: \.car) === myCar) + XCTAssertTrue(scene2.sbx.getSharedState(by: \.car)?.name == "bmw") + + scene1.sbx.putSharedState(by: \.car, sharedState: nil) + XCTAssertNil(scene1.sbx.getSharedState(by: \.car)) + XCTAssertNil(scene2.sbx.getSharedState(by: \.car)) + } + + // injection property wrapper + do { + let myCar = Car(name: "mini") + + XCTAssertNil(scene1.car) + XCTAssertNil(scene2.car) + + scene1.car = myCar + + XCTAssertTrue(scene1.car === myCar) + XCTAssertTrue(scene1.car?.name == "mini") + + XCTAssertTrue(scene2.car === myCar) + XCTAssertTrue(scene2.car?.name == "mini") + + scene2.car?.name = "benz" + + XCTAssertTrue(scene1.car === myCar) + XCTAssertTrue(scene1.car?.name == "benz") + + XCTAssertTrue(scene2.car === myCar) + XCTAssertTrue(scene2.car?.name == "benz") + + scene2.car = nil + + XCTAssertNil(scene1.car) + XCTAssertNil(scene2.car) + } + } static var allTests = [ ("testBasicLifeCyclesOfSceneBox", testBasicLifeCyclesOfSceneBox), ("testBasicLifeCycleOfScene", testBasicLifeCycleOfScene), ("testNavigationExtension", testNavigationExtension), - ("testSharedStateExtension", testSharedStateExtension) + ("testSharedStateExtensionWithKeyPathApproach", testSharedStateExtensionWithKeyPathApproach) ] } diff --git a/Tests/SceneBoxTests/Support/Car.swift b/Tests/SceneBoxTests/Support/Car.swift new file mode 100644 index 0000000..25d6628 --- /dev/null +++ b/Tests/SceneBoxTests/Support/Car.swift @@ -0,0 +1,18 @@ +// +// Car.swift +// SceneBox +// +// Created by Lumia_Saki on 2021/7/27. +// Copyright © 2021年 tianren.zhu. All rights reserved. +// + +import Foundation + +class Car { + + var name: String + + init(name: String) { + self.name = name + } +} diff --git a/Tests/SceneBoxTests/Support/SceneBoxTestSceneViewController.swift b/Tests/SceneBoxTests/Support/SceneBoxTestSceneViewController.swift index 8829bee..7c62429 100644 --- a/Tests/SceneBoxTests/Support/SceneBoxTestSceneViewController.swift +++ b/Tests/SceneBoxTests/Support/SceneBoxTestSceneViewController.swift @@ -14,13 +14,18 @@ public final class SceneBoxTestSceneViewController: UIViewController, Scene { public var sceneIdentifier: UUID! + @SharedStateInjected(\.car) + var car: Car? + var isActiveScene: Bool { sbx.currentIsActiveScene() } var sceneDidLoadedBlock: (() -> Void)? var sceneWillUnloadedBlock: (() -> Void)? - var sceneBoxWillTerminateBlock: (() -> Void)? + var sceneBoxWillTerminateBlock: (() -> Void)? public func sceneDidLoaded() { sceneDidLoadedBlock?() + + _car.configure(scene: self) } public func sceneWillUnload() { diff --git a/Tests/SceneBoxTests/Support/SharedStateTestKey.swift b/Tests/SceneBoxTests/Support/SharedStateTestKey.swift new file mode 100644 index 0000000..ba6d95b --- /dev/null +++ b/Tests/SceneBoxTests/Support/SharedStateTestKey.swift @@ -0,0 +1,36 @@ +// +// SharedStateTestKey.swift +// SceneBox +// +// Created by Lumia_Saki on 2021/7/27. +// Copyright © 2021年 tianren.zhu. All rights reserved. +// + +import Foundation +import SceneBox + +struct TimestampKey: SharedStateKey { + + static var currentValue: TimeInterval? +} + +extension SharedStateValues { + + var timestamp: TimeInterval? { + get { Self[TimestampKey.self] } + set { Self[TimestampKey.self] = newValue } + } +} + +struct CarKey: SharedStateKey { + + static var currentValue: Car? +} + +extension SharedStateValues { + + var car: Car? { + get { Self[CarKey.self] } + set { Self[CarKey.self] = newValue } + } +}