Update the template screen to use the new(ish) Observation framework. (#4077)
* Update the template screen to use the new(ish) Observation framework. * Add a variant of deferFulfillment that supports observables. * Update snapshot fulfilment to work with either a publisher or a stream.
This commit is contained in:
@@ -1055,6 +1055,7 @@
|
||||
CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; };
|
||||
CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; };
|
||||
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; };
|
||||
CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */; };
|
||||
CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; };
|
||||
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; };
|
||||
D02DEB36D32A72A1B365E452 /* SessionVerificationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */; };
|
||||
@@ -1275,6 +1276,7 @@
|
||||
FCD3F2B82CAB29A07887A127 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; };
|
||||
FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; };
|
||||
FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.swift */; };
|
||||
FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; };
|
||||
FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; };
|
||||
FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; };
|
||||
FDC67E8C0EDCB00ABC66C859 /* landscape_test_video.mov in Resources */ = {isa = PBXBuildFile; fileRef = 78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */; };
|
||||
@@ -1783,6 +1785,7 @@
|
||||
529513218340CC8419273165 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = "<group>"; };
|
||||
52F5EE5DE3B55D59299DB5BC /* AppLockSetupBiometricsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupBiometricsScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||
5327E3B3C58BEB0E65F4CF98 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
|
||||
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = "<group>"; };
|
||||
5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = "<group>"; };
|
||||
536C0E2178949B290776EA4E /* QRCodeLoginServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginServiceProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -1941,6 +1944,7 @@
|
||||
7893780A1FD6E3F38B3E9049 /* UserIndicatorControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerMock.swift; sourceTree = "<group>"; };
|
||||
78BBDF7A05CF53B5CDC13682 /* landscape_test_video.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = landscape_test_video.mov; sourceTree = "<group>"; };
|
||||
796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModelV2.swift; sourceTree = "<group>"; };
|
||||
7A03E073077D92AA19C43DCF /* IdentityConfirmationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
7AAD8C633AA57948B34EDCF7 /* RoomChangeRolesScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangeRolesScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientBuilderHook.swift; sourceTree = "<group>"; };
|
||||
@@ -2868,6 +2872,7 @@
|
||||
children = (
|
||||
6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */,
|
||||
6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */,
|
||||
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */,
|
||||
);
|
||||
path = ViewModel;
|
||||
sourceTree = "<group>";
|
||||
@@ -3681,6 +3686,7 @@
|
||||
C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */,
|
||||
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */,
|
||||
95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */,
|
||||
5327E3B3C58BEB0E65F4CF98 /* Observable.swift */,
|
||||
62B07B296D7A9D2F09120853 /* OrderedSet.swift */,
|
||||
D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */,
|
||||
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */,
|
||||
@@ -7366,6 +7372,7 @@
|
||||
523C6800ED85D5810CF18C19 /* OIDCAccountSettingsPresenter.swift in Sources */,
|
||||
9A4E3D5AA44B041DAC3A0D81 /* OIDCAuthenticationPresenter.swift in Sources */,
|
||||
E362924A42934C9F0F97A956 /* OIDCConfigurationProxy.swift in Sources */,
|
||||
FD573B5D665824EB79EABF06 /* Observable.swift in Sources */,
|
||||
11A6B8E3CBDBF0A4107FF4CE /* OnboardingFlowCoordinator.swift in Sources */,
|
||||
3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */,
|
||||
AA5924D3B67F7ACD98BBEFDC /* OrientationManagerProtocol.swift in Sources */,
|
||||
@@ -7654,6 +7661,7 @@
|
||||
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */,
|
||||
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */,
|
||||
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */,
|
||||
CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */,
|
||||
B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */,
|
||||
1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */,
|
||||
DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */,
|
||||
|
||||
@@ -9,4 +9,19 @@ extension AsyncSequence {
|
||||
func first() async rethrows -> Self.Element? {
|
||||
try await first { _ in true }
|
||||
}
|
||||
|
||||
/// Type-erases the sequence into a newly constructed asynchronous stream. This is useful until
|
||||
/// we drop support for iOS 17, at which point we can replace this with `any AsyncSequence`.
|
||||
@available(iOS, deprecated: 18.0, message: "Use `any AsyncSequence` instead.")
|
||||
func eraseToStream() -> AsyncStream<Element> {
|
||||
var asyncIterator = makeAsyncIterator()
|
||||
return AsyncStream<Element> {
|
||||
do {
|
||||
return try await asyncIterator.next()
|
||||
} catch {
|
||||
MXLog.warning("Stopping stream: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
ElementX/Sources/Other/Extensions/Observable.swift
Normal file
37
ElementX/Sources/Other/Extensions/Observable.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// Copyright 2025 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Observable {
|
||||
/// Creates an async stream for the specified property on this object. We probably won't need this once SE-0475 is available:
|
||||
/// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0475-observed.md
|
||||
///
|
||||
/// - Parameter property: The key path to the property you would like to observe.
|
||||
func observe<Value>(_ property: KeyPath<Self, Value>) -> AsyncStream<Value> {
|
||||
AsyncStream { continuation in
|
||||
var isActive = true
|
||||
|
||||
@Sendable func observe() {
|
||||
let value = withObservationTracking {
|
||||
self[keyPath: property]
|
||||
} onChange: {
|
||||
// Dispatch the update as this is willSet not didSet.
|
||||
DispatchQueue.main.async {
|
||||
guard isActive else { return }
|
||||
observe()
|
||||
}
|
||||
}
|
||||
continuation.yield(value)
|
||||
}
|
||||
|
||||
continuation.onTermination = { _ in isActive = false }
|
||||
|
||||
observe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,26 @@ struct SnapshotPerceptualPrecisionPreferenceKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
struct FulfillmentPublisherEquatableWrapper: Equatable {
|
||||
let publisher: AnyPublisher<Bool, Never>?
|
||||
|
||||
// Publisher equatability complicates things but, luckily, we're only interesting in them changing from nil
|
||||
static func == (lhs: FulfillmentPublisherEquatableWrapper, rhs: FulfillmentPublisherEquatableWrapper) -> Bool {
|
||||
lhs.publisher != nil && rhs.publisher != nil
|
||||
}
|
||||
}
|
||||
struct SnapshotFulfillmentPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: Wrapper?
|
||||
|
||||
struct SnapshotFulfillmentPublisherPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: FulfillmentPublisherEquatableWrapper?
|
||||
|
||||
static func reduce(value: inout FulfillmentPublisherEquatableWrapper?, nextValue: () -> FulfillmentPublisherEquatableWrapper?) {
|
||||
static func reduce(value: inout Wrapper?, nextValue: () -> Wrapper?) {
|
||||
value = nextValue()
|
||||
}
|
||||
|
||||
enum Source {
|
||||
case publisher(AnyPublisher<Bool, Never>)
|
||||
case stream(AsyncStream<Bool>)
|
||||
}
|
||||
|
||||
struct Wrapper: Equatable {
|
||||
let id = UUID()
|
||||
let source: Source
|
||||
|
||||
static func == (lhs: Wrapper, rhs: Wrapper) -> Bool {
|
||||
lhs.id == rhs.id // Not ideal, but it's good enough for snapshots.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SwiftUI.View {
|
||||
@@ -47,7 +52,7 @@ extension SwiftUI.View {
|
||||
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - delay: The delay time in seconds that you want to set as a preference to the View.
|
||||
/// - expect: A publisher that indicates when the preview is ready for snapshotting.
|
||||
/// - precision: The percentage of pixels that must match.
|
||||
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye.
|
||||
func snapshotPreferences(expect fulfillmentPublisher: (any Publisher<Bool, Never>)? = nil,
|
||||
@@ -55,6 +60,22 @@ extension SwiftUI.View {
|
||||
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
|
||||
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
|
||||
.preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision)
|
||||
.preference(key: SnapshotFulfillmentPublisherPreferenceKey.self, value: FulfillmentPublisherEquatableWrapper(publisher: fulfillmentPublisher?.eraseToAnyPublisher()))
|
||||
.preference(key: SnapshotFulfillmentPreferenceKey.self, value: fulfillmentPublisher.map { SnapshotFulfillmentPreferenceKey.Wrapper(source: .publisher($0.eraseToAnyPublisher())) })
|
||||
}
|
||||
|
||||
/// Use this modifier when you want to apply snapshot-specific preferences,
|
||||
/// like delay and precision, to the view.
|
||||
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - expect: A stream that indicates when the preview is ready for snapshotting.
|
||||
/// - precision: The percentage of pixels that must match.
|
||||
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye.
|
||||
func snapshotPreferences(expect fulfillmentStream: AsyncStream<Bool>? = nil,
|
||||
precision: Float = 1.0,
|
||||
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
|
||||
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
|
||||
.preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision)
|
||||
.preference(key: SnapshotFulfillmentPreferenceKey.self, value: fulfillmentStream.map { SnapshotFulfillmentPreferenceKey.Wrapper(source: .stream($0)) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,39 @@ extension XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// XCTest utility that assists in observing an async stream, deferring the fulfilment and results until some condition has been met.
|
||||
/// - Parameters:
|
||||
/// - asyncStream: The stream to wait on.
|
||||
/// - timeout: A timeout after which we give up.
|
||||
/// - message: An optional custom expectation message
|
||||
/// - until: callback that evaluates outputs until some condition is reached
|
||||
/// - Returns: The deferred fulfilment to be executed after some actions and that returns the result of the publisher.
|
||||
func deferFulfillment<Value>(_ asyncStream: AsyncStream<Value>,
|
||||
timeout: TimeInterval = 10,
|
||||
message: String? = nil,
|
||||
until condition: @escaping (Value) -> Bool) -> DeferredFulfillment<Value> {
|
||||
var result: Result<Value, Error>?
|
||||
let expectation = expectation(description: message ?? "Awaiting stream")
|
||||
var hasFulfilled = false
|
||||
|
||||
let task = Task {
|
||||
for await value in asyncStream {
|
||||
if condition(value), !hasFulfilled {
|
||||
result = .success(value)
|
||||
expectation.fulfill()
|
||||
hasFulfilled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DeferredFulfillment<Value> {
|
||||
await self.fulfillment(of: [expectation], timeout: timeout)
|
||||
task.cancel()
|
||||
let unwrappedResult = try XCTUnwrap(result, "Awaited stream did not produce any output")
|
||||
return try unwrappedResult.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// XCTest utility that assists in subscribing to a publisher and deferring the fulfilment and results until some other actions have been performed.
|
||||
/// - Parameters:
|
||||
/// - publisher: The publisher to wait on.
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A common ViewModel implementation for handling of `State` and `ViewAction`s
|
||||
/// A common ViewModel implementation for handling of `State` and `ViewAction`s. This original version
|
||||
/// is implemented using SwiftUI's original `ObservableObject` and `@Published` pattern.
|
||||
///
|
||||
/// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to)
|
||||
/// a specific portion of state that can be safely bound to.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// A common ViewModel implementation for handling of `State` and `ViewAction`s. Version 2 of this state
|
||||
/// store has been re-written to use Swift's new Observation framework.
|
||||
///
|
||||
///
|
||||
/// Generic type State is constrained to the BindableState protocol in that it may contain (but doesn't have to)
|
||||
/// a specific portion of state that can be safely bound to.
|
||||
/// If we decide to add more features to our state management (like doing state processing off the main thread)
|
||||
/// we can do it in this centralised place.
|
||||
@MainActor
|
||||
class StateStoreViewModelV2<State: BindableState, ViewAction> {
|
||||
/// For storing subscription references.
|
||||
///
|
||||
/// Left as public for `ViewModel` implementations convenience.
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Constrained interface for passing to Views.
|
||||
var context: Context
|
||||
|
||||
var state: State {
|
||||
get { context.viewState }
|
||||
set { context.viewState = newValue }
|
||||
}
|
||||
|
||||
init(initialViewState: State, mediaProvider: MediaProviderProtocol? = nil) {
|
||||
context = Context(initialViewState: initialViewState, mediaProvider: mediaProvider)
|
||||
context.viewModel = self
|
||||
}
|
||||
|
||||
/// Override to handles incoming `ViewAction`s from the `ViewModel`.
|
||||
/// - Parameter viewAction: The `ViewAction` to be processed in `ViewModel` implementation.
|
||||
func process(viewAction: ViewAction) {
|
||||
// Default implementation, -no-op
|
||||
}
|
||||
|
||||
// MARK: - Context
|
||||
|
||||
/// A constrained and concise interface for interacting with the ViewModel.
|
||||
///
|
||||
/// This class is closely bound to`StateStoreViewModel`. It provides the exact interface the view should need to interact
|
||||
/// ViewModel (as modelled on our previous template architecture with the addition of two-way binding):
|
||||
/// - The ability read/observe view state
|
||||
/// - The ability to send view events
|
||||
/// - The ability to bind state to a specific portion of the view state safely.
|
||||
/// This class was brought about a little bit by necessity. The most idiomatic way of interacting with SwiftUI is via `@Published`
|
||||
/// properties which which are property wrappers and therefore can't be defined within protocols.
|
||||
/// A similar approach is taken in libraries like [CombineFeedback](https://github.com/sergdort/CombineFeedback).
|
||||
/// It provides a nice layer of consistency and also safety. As we are not passing the `ViewModel` to the view directly, shortcuts/hacks
|
||||
/// can't be made into the `ViewModel`.
|
||||
@dynamicMemberLookup
|
||||
@MainActor
|
||||
@Observable final class Context {
|
||||
fileprivate weak var viewModel: StateStoreViewModelV2?
|
||||
|
||||
/// Get-able property for the `ViewState`
|
||||
fileprivate(set) var viewState: State
|
||||
|
||||
/// An optional image loading service so that views can manage themselves
|
||||
/// Intentionally non-generic so that it doesn't grow uncontrollably
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
|
||||
/// Set-able access to the bindable state.
|
||||
subscript<T>(dynamicMember keyPath: WritableKeyPath<State.BindStateType, T>) -> T {
|
||||
get { viewState.bindings[keyPath: keyPath] }
|
||||
set { viewState.bindings[keyPath: keyPath] = newValue }
|
||||
}
|
||||
|
||||
/// Send a `ViewAction` to the `ViewModel` for processing.
|
||||
/// - Parameter viewAction: The `ViewAction` to send to the `ViewModel`.
|
||||
func send(viewAction: ViewAction) {
|
||||
viewModel?.process(viewAction: viewAction)
|
||||
}
|
||||
|
||||
fileprivate init(initialViewState: State, mediaProvider: MediaProviderProtocol?) {
|
||||
self.viewState = initialViewState
|
||||
self.mediaProvider = mediaProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,15 +57,21 @@ class PreviewTests: XCTestCase {
|
||||
let preferenceReadingView = preview.content
|
||||
.onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 }
|
||||
.onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $0 }
|
||||
.onPreferenceChange(SnapshotFulfillmentPublisherPreferenceKey.self) { preferences.fulfillmentPublisher = $0?.publisher }
|
||||
.onPreferenceChange(SnapshotFulfillmentPreferenceKey.self) { preferences.fulfillmentSource = $0?.source }
|
||||
|
||||
// Render an image of the view in order to trigger the preference updates to occur.
|
||||
let imageRenderer = ImageRenderer(content: preferenceReadingView)
|
||||
_ = imageRenderer.uiImage
|
||||
|
||||
if let fulfillmentPublisher = preferences.fulfillmentPublisher {
|
||||
let deferred = deferFulfillment(fulfillmentPublisher) { $0 == true }
|
||||
switch preferences.fulfillmentSource {
|
||||
case .publisher(let publisher):
|
||||
let deferred = deferFulfillment(publisher) { $0 == true }
|
||||
try await deferred.fulfill()
|
||||
case .stream(let stream):
|
||||
let deferred = deferFulfillment(stream) { $0 == true }
|
||||
try await deferred.fulfill()
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
var sanitizedSuiteName = String(testName.suffix(testName.count - "test".count).dropLast(2))
|
||||
@@ -152,7 +158,7 @@ class PreviewTests: XCTestCase {
|
||||
private class SnapshotPreferences: @unchecked Sendable {
|
||||
var precision: Float = 1
|
||||
var perceptualPrecision: Float = 1
|
||||
var fulfillmentPublisher: AnyPublisher<Bool, Never>?
|
||||
var fulfillmentSource: SnapshotFulfillmentPreferenceKey.Source?
|
||||
}
|
||||
|
||||
// MARK: - SnapshotTesting + Extensions
|
||||
|
||||
@@ -16,6 +16,8 @@ enum TemplateScreenViewModelAction {
|
||||
struct TemplateScreenViewState: BindableState {
|
||||
var title: String
|
||||
var placeholder: String
|
||||
var counter = 0
|
||||
|
||||
var bindings: TemplateScreenViewStateBindings
|
||||
}
|
||||
|
||||
@@ -27,5 +29,8 @@ enum TemplateScreenViewAction {
|
||||
case done
|
||||
case textChanged
|
||||
|
||||
case incrementCounter
|
||||
case decrementCounter
|
||||
|
||||
// Consider adding CustomStringConvertible conformance if the actions contain PII
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias TemplateScreenViewModelType = StateStoreViewModel<TemplateScreenViewState, TemplateScreenViewAction>
|
||||
typealias TemplateScreenViewModelType = StateStoreViewModelV2<TemplateScreenViewState, TemplateScreenViewAction>
|
||||
|
||||
class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewModelProtocol {
|
||||
private let actionsSubject: PassthroughSubject<TemplateScreenViewModelAction, Never> = .init()
|
||||
@@ -32,6 +32,16 @@ class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewMo
|
||||
actionsSubject.send(.done)
|
||||
case .textChanged:
|
||||
MXLog.info("View model: composer text changed to: \(state.bindings.composerText)")
|
||||
case .incrementCounter:
|
||||
Task {
|
||||
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
|
||||
state.counter += 1
|
||||
}
|
||||
case .decrementCounter:
|
||||
Task {
|
||||
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
|
||||
state.counter -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct TemplateScreen: View {
|
||||
@ObservedObject var context: TemplateScreenViewModel.Context
|
||||
@Bindable var context: TemplateScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -18,9 +18,19 @@ struct TemplateScreen: View {
|
||||
kind: .textField(text: $context.composerText))
|
||||
|
||||
ListRow(label: .centeredAction(title: L10n.actionDone,
|
||||
systemIcon: .doorLeftHandClosed),
|
||||
icon: \.leave),
|
||||
kind: .button { context.send(viewAction: .done) })
|
||||
}
|
||||
|
||||
Section {
|
||||
ListRow(label: .default(title: "Counter", icon: \.chart),
|
||||
details: .counter(context.viewState.counter),
|
||||
kind: .label)
|
||||
ListRow(label: .default(title: "Increment", icon: \.plus),
|
||||
kind: .button { context.send(viewAction: .incrementCounter) })
|
||||
ListRow(label: .default(title: "Decrement", icon: \.minus),
|
||||
kind: .button { context.send(viewAction: .decrementCounter) })
|
||||
}
|
||||
}
|
||||
.compoundList()
|
||||
.navigationTitle(context.viewState.title)
|
||||
@@ -33,10 +43,29 @@ struct TemplateScreen: View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct TemplateScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TemplateScreenViewModel()
|
||||
static let viewModel = makeViewModel()
|
||||
static let incrementedViewModel = makeViewModel(counterValue: 1)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
TemplateScreen(context: viewModel.context)
|
||||
}
|
||||
.previewDisplayName("Initial")
|
||||
|
||||
NavigationStack {
|
||||
TemplateScreen(context: incrementedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Incremented")
|
||||
.snapshotPreferences(expect: incrementedViewModel.context.observe(\.viewState.counter).map { $0 == 1 }.eraseToStream())
|
||||
}
|
||||
|
||||
static func makeViewModel(counterValue: Int = 0) -> TemplateScreenViewModel {
|
||||
let viewModel = TemplateScreenViewModel()
|
||||
|
||||
for _ in 0..<counterValue {
|
||||
viewModel.context.send(viewAction: .incrementCounter)
|
||||
}
|
||||
|
||||
return viewModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,31 @@ class TemplateScreenViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testInitialState() {
|
||||
XCTAssertFalse(context.viewState.placeholder.isEmpty)
|
||||
XCTAssertFalse(context.composerText.isEmpty)
|
||||
XCTAssertEqual(context.viewState.counter, 0)
|
||||
}
|
||||
|
||||
func testCounter() async throws {
|
||||
func testTextField() async throws {
|
||||
context.composerText = "123"
|
||||
context.send(viewAction: .textChanged)
|
||||
XCTAssertEqual(context.composerText, "123")
|
||||
}
|
||||
|
||||
func testCounter() async throws {
|
||||
var deferred = deferFulfillment(context.observe(\.viewState.counter)) { $0 == 1 }
|
||||
context.send(viewAction: .incrementCounter)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertEqual(context.viewState.counter, 1)
|
||||
|
||||
deferred = deferFulfillment(context.observe(\.viewState.counter)) { $0 == 3 }
|
||||
context.send(viewAction: .incrementCounter)
|
||||
context.send(viewAction: .incrementCounter)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertEqual(context.viewState.counter, 3)
|
||||
|
||||
deferred = deferFulfillment(context.observe(\.viewState.counter)) { $0 == 2 }
|
||||
context.send(viewAction: .decrementCounter)
|
||||
try await deferred.fulfill()
|
||||
XCTAssertEqual(context.viewState.counter, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user