mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
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 */; };
|
CEB8FB1269DE20536608B957 /* LoginMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B41FABA2B0AEF4389986495 /* LoginMode.swift */; };
|
||||||
CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; };
|
CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; };
|
||||||
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.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 */; };
|
CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; };
|
||||||
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; };
|
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; };
|
||||||
D02DEB36D32A72A1B365E452 /* SessionVerificationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 796CBD0C56FA0D3AEDAB255B /* SessionVerificationScreenCoordinator.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 */; };
|
FCD3F2B82CAB29A07887A127 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 2B43F2AF7456567FE37270A7 /* KeychainAccess */; };
|
||||||
FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; };
|
FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; };
|
||||||
FD4C21F8DA1E273DE94FCD1A /* NotificationItemProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B927CF5EF7FCCDA5EDC474B /* NotificationItemProxyProtocol.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 */; };
|
FD762761C5D0C30E6255C3D8 /* ServerConfirmationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABA4CF2F5B4F68D02E412004 /* ServerConfirmationScreenViewModelProtocol.swift */; };
|
||||||
FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientBuilderHook.swift; sourceTree = "<group>"; };
|
||||||
@@ -2868,6 +2872,7 @@
|
|||||||
children = (
|
children = (
|
||||||
6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */,
|
6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */,
|
||||||
6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */,
|
6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */,
|
||||||
|
79A106D76741914F82664959 /* StateStoreViewModelV2.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModel;
|
path = ViewModel;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -3681,6 +3686,7 @@
|
|||||||
C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */,
|
C14D83B2B7CD5501A0089EFC /* LayoutDirection.swift */,
|
||||||
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */,
|
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */,
|
||||||
95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */,
|
95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */,
|
||||||
|
5327E3B3C58BEB0E65F4CF98 /* Observable.swift */,
|
||||||
62B07B296D7A9D2F09120853 /* OrderedSet.swift */,
|
62B07B296D7A9D2F09120853 /* OrderedSet.swift */,
|
||||||
D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */,
|
D26813CCE39221FE30BF22CD /* PlatformViewVersionPredicate.swift */,
|
||||||
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */,
|
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */,
|
||||||
@@ -7366,6 +7372,7 @@
|
|||||||
523C6800ED85D5810CF18C19 /* OIDCAccountSettingsPresenter.swift in Sources */,
|
523C6800ED85D5810CF18C19 /* OIDCAccountSettingsPresenter.swift in Sources */,
|
||||||
9A4E3D5AA44B041DAC3A0D81 /* OIDCAuthenticationPresenter.swift in Sources */,
|
9A4E3D5AA44B041DAC3A0D81 /* OIDCAuthenticationPresenter.swift in Sources */,
|
||||||
E362924A42934C9F0F97A956 /* OIDCConfigurationProxy.swift in Sources */,
|
E362924A42934C9F0F97A956 /* OIDCConfigurationProxy.swift in Sources */,
|
||||||
|
FD573B5D665824EB79EABF06 /* Observable.swift in Sources */,
|
||||||
11A6B8E3CBDBF0A4107FF4CE /* OnboardingFlowCoordinator.swift in Sources */,
|
11A6B8E3CBDBF0A4107FF4CE /* OnboardingFlowCoordinator.swift in Sources */,
|
||||||
3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */,
|
3CE4C5071B6D2576E2473989 /* OrderedSet.swift in Sources */,
|
||||||
AA5924D3B67F7ACD98BBEFDC /* OrientationManagerProtocol.swift in Sources */,
|
AA5924D3B67F7ACD98BBEFDC /* OrientationManagerProtocol.swift in Sources */,
|
||||||
@@ -7654,6 +7661,7 @@
|
|||||||
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */,
|
CB6BCBF28E4B76EA08C2926D /* StateRoomTimelineItem.swift in Sources */,
|
||||||
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */,
|
3B277D9538090766DA6C4566 /* StateRoomTimelineView.swift in Sources */,
|
||||||
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */,
|
B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */,
|
||||||
|
CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */,
|
||||||
B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */,
|
B1069F361E604D5436AE9FFD /* StaticLocationScreen.swift in Sources */,
|
||||||
1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */,
|
1AB3D8563AB12635250A6A6E /* StaticLocationScreenCoordinator.swift in Sources */,
|
||||||
DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */,
|
DFD5AA8688A34C72D48AF3B1 /* StaticLocationScreenViewModel.swift in Sources */,
|
||||||
|
|||||||
@@ -9,4 +9,19 @@ extension AsyncSequence {
|
|||||||
func first() async rethrows -> Self.Element? {
|
func first() async rethrows -> Self.Element? {
|
||||||
try await first { _ in true }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
struct SnapshotFulfillmentPreferenceKey: PreferenceKey {
|
||||||
let publisher: AnyPublisher<Bool, Never>?
|
static var defaultValue: Wrapper?
|
||||||
|
|
||||||
// Publisher equatability complicates things but, luckily, we're only interesting in them changing from nil
|
static func reduce(value: inout Wrapper?, nextValue: () -> Wrapper?) {
|
||||||
static func == (lhs: FulfillmentPublisherEquatableWrapper, rhs: FulfillmentPublisherEquatableWrapper) -> Bool {
|
|
||||||
lhs.publisher != nil && rhs.publisher != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SnapshotFulfillmentPublisherPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: FulfillmentPublisherEquatableWrapper?
|
|
||||||
|
|
||||||
static func reduce(value: inout FulfillmentPublisherEquatableWrapper?, nextValue: () -> FulfillmentPublisherEquatableWrapper?) {
|
|
||||||
value = nextValue()
|
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 {
|
extension SwiftUI.View {
|
||||||
@@ -47,7 +52,7 @@ extension SwiftUI.View {
|
|||||||
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
|
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - 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.
|
/// - 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.
|
/// - 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,
|
func snapshotPreferences(expect fulfillmentPublisher: (any Publisher<Bool, Never>)? = nil,
|
||||||
@@ -55,6 +60,22 @@ extension SwiftUI.View {
|
|||||||
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
|
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
|
||||||
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
|
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
|
||||||
.preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision)
|
.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.
|
/// XCTest utility that assists in subscribing to a publisher and deferring the fulfilment and results until some other actions have been performed.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - publisher: The publisher to wait on.
|
/// - publisher: The publisher to wait on.
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
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)
|
/// 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.
|
/// 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
|
let preferenceReadingView = preview.content
|
||||||
.onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 }
|
.onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 }
|
||||||
.onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $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.
|
// Render an image of the view in order to trigger the preference updates to occur.
|
||||||
let imageRenderer = ImageRenderer(content: preferenceReadingView)
|
let imageRenderer = ImageRenderer(content: preferenceReadingView)
|
||||||
_ = imageRenderer.uiImage
|
_ = imageRenderer.uiImage
|
||||||
|
|
||||||
if let fulfillmentPublisher = preferences.fulfillmentPublisher {
|
switch preferences.fulfillmentSource {
|
||||||
let deferred = deferFulfillment(fulfillmentPublisher) { $0 == true }
|
case .publisher(let publisher):
|
||||||
|
let deferred = deferFulfillment(publisher) { $0 == true }
|
||||||
try await deferred.fulfill()
|
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))
|
var sanitizedSuiteName = String(testName.suffix(testName.count - "test".count).dropLast(2))
|
||||||
@@ -152,7 +158,7 @@ class PreviewTests: XCTestCase {
|
|||||||
private class SnapshotPreferences: @unchecked Sendable {
|
private class SnapshotPreferences: @unchecked Sendable {
|
||||||
var precision: Float = 1
|
var precision: Float = 1
|
||||||
var perceptualPrecision: Float = 1
|
var perceptualPrecision: Float = 1
|
||||||
var fulfillmentPublisher: AnyPublisher<Bool, Never>?
|
var fulfillmentSource: SnapshotFulfillmentPreferenceKey.Source?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SnapshotTesting + Extensions
|
// MARK: - SnapshotTesting + Extensions
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ enum TemplateScreenViewModelAction {
|
|||||||
struct TemplateScreenViewState: BindableState {
|
struct TemplateScreenViewState: BindableState {
|
||||||
var title: String
|
var title: String
|
||||||
var placeholder: String
|
var placeholder: String
|
||||||
|
var counter = 0
|
||||||
|
|
||||||
var bindings: TemplateScreenViewStateBindings
|
var bindings: TemplateScreenViewStateBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,5 +29,8 @@ enum TemplateScreenViewAction {
|
|||||||
case done
|
case done
|
||||||
case textChanged
|
case textChanged
|
||||||
|
|
||||||
|
case incrementCounter
|
||||||
|
case decrementCounter
|
||||||
|
|
||||||
// Consider adding CustomStringConvertible conformance if the actions contain PII
|
// Consider adding CustomStringConvertible conformance if the actions contain PII
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
typealias TemplateScreenViewModelType = StateStoreViewModel<TemplateScreenViewState, TemplateScreenViewAction>
|
typealias TemplateScreenViewModelType = StateStoreViewModelV2<TemplateScreenViewState, TemplateScreenViewAction>
|
||||||
|
|
||||||
class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewModelProtocol {
|
class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewModelProtocol {
|
||||||
private let actionsSubject: PassthroughSubject<TemplateScreenViewModelAction, Never> = .init()
|
private let actionsSubject: PassthroughSubject<TemplateScreenViewModelAction, Never> = .init()
|
||||||
@@ -32,6 +32,16 @@ class TemplateScreenViewModel: TemplateScreenViewModelType, TemplateScreenViewMo
|
|||||||
actionsSubject.send(.done)
|
actionsSubject.send(.done)
|
||||||
case .textChanged:
|
case .textChanged:
|
||||||
MXLog.info("View model: composer text changed to: \(state.bindings.composerText)")
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct TemplateScreen: View {
|
struct TemplateScreen: View {
|
||||||
@ObservedObject var context: TemplateScreenViewModel.Context
|
@Bindable var context: TemplateScreenViewModel.Context
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -18,9 +18,19 @@ struct TemplateScreen: View {
|
|||||||
kind: .textField(text: $context.composerText))
|
kind: .textField(text: $context.composerText))
|
||||||
|
|
||||||
ListRow(label: .centeredAction(title: L10n.actionDone,
|
ListRow(label: .centeredAction(title: L10n.actionDone,
|
||||||
systemIcon: .doorLeftHandClosed),
|
icon: \.leave),
|
||||||
kind: .button { context.send(viewAction: .done) })
|
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()
|
.compoundList()
|
||||||
.navigationTitle(context.viewState.title)
|
.navigationTitle(context.viewState.title)
|
||||||
@@ -33,10 +43,29 @@ struct TemplateScreen: View {
|
|||||||
// MARK: - Previews
|
// MARK: - Previews
|
||||||
|
|
||||||
struct TemplateScreen_Previews: PreviewProvider, TestablePreview {
|
struct TemplateScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = TemplateScreenViewModel()
|
static let viewModel = makeViewModel()
|
||||||
|
static let incrementedViewModel = makeViewModel(counterValue: 1)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TemplateScreen(context: viewModel.context)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -22,13 +22,31 @@ class TemplateScreenViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testInitialState() {
|
func testInitialState() {
|
||||||
XCTAssertFalse(context.viewState.placeholder.isEmpty)
|
|
||||||
XCTAssertFalse(context.composerText.isEmpty)
|
XCTAssertFalse(context.composerText.isEmpty)
|
||||||
|
XCTAssertEqual(context.viewState.counter, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCounter() async throws {
|
func testTextField() async throws {
|
||||||
context.composerText = "123"
|
context.composerText = "123"
|
||||||
context.send(viewAction: .textChanged)
|
context.send(viewAction: .textChanged)
|
||||||
XCTAssertEqual(context.composerText, "123")
|
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