diff --git a/Sources/OpenSwiftUI/View/EquatableView.swift b/Sources/OpenSwiftUI/View/EquatableView.swift new file mode 100644 index 000000000..0f3182f63 --- /dev/null +++ b/Sources/OpenSwiftUI/View/EquatableView.swift @@ -0,0 +1,115 @@ +// +// EquatableView.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 93C51C71D9D4CBAB391E78A2AAC640D6 (SwiftUI) + +import OpenGraphShims +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore + +// MARK: - EquatableView + +/// A view type that compares itself against its previous value and prevents its +/// child updating if its new value is the same as its old value. +@available(OpenSwiftUI_v1_0, *) +@frozen +public struct EquatableView: View, UnaryView, PrimitiveView where Content: Equatable, Content: View { + public var content: Content + + @inlinable + public init(content: Content) { + self.content = content + } + + nonisolated public static func _makeView( + view: _GraphValue, + inputs: _ViewInputs + ) -> _ViewOutputs { + let child = Child(view: view.value) + return Content.makeDebuggableView( + view: _GraphValue(child), + inputs: inputs + ) + } + + private struct Child: Rule, AsyncAttribute { + @Attribute var view: EquatableView + + typealias Value = Content + + var value: Value { + view.content + } + + static var comparisonMode: ComparisonMode { + .equatableAlways + } + } +} + +@available(*, unavailable) +extension EquatableView: Sendable {} + +extension View where Self: Equatable { + /// Prevents the view from updating its child view when its new value is the + /// same as its old value. + @inlinable + nonisolated public func equatable() -> EquatableView { + EquatableView(content: self) + } +} + +// MARK: - EquatableProxyView + +package struct EquatableProxyView: View, UnaryView, PrimitiveView where Content: View, Token: Equatable { + package var content: Content + + package var token: Token + + package init(content: Content, token: Token) { + self.content = content + self.token = token + } + + nonisolated public static func _makeView( + view: _GraphValue, + inputs: _ViewInputs + ) -> _ViewOutputs { + let child = Child(view: view.value, lastToken: nil) + return Content.makeDebuggableView( + view: _GraphValue(child), + inputs: inputs + ) + } + + private struct Child: StatefulRule, AsyncAttribute { + @Attribute var view: EquatableProxyView + var lastToken: Token? + + init(view: Attribute, lastToken: Token?) { + self._view = view + self.lastToken = lastToken + } + + typealias Value = Content + + mutating func updateValue() { + guard hasValue && lastToken == view.token else { + value = view.content + lastToken = view.token + return + } + } + } +} + +extension View { + /// Prevents the view from updating its child view when its new value is the + /// same as its old value. + nonisolated package func equatableProxy(_ token: Token) -> EquatableProxyView where Token: Equatable { + EquatableProxyView(content: self, token: token) + } +} diff --git a/Sources/OpenSwiftUITestsSupport/Integration/PlatformHostingControllerHelper.swift b/Sources/OpenSwiftUITestsSupport/Integration/PlatformHostingControllerHelper.swift index f16b2d822..f16126298 100644 --- a/Sources/OpenSwiftUITestsSupport/Integration/PlatformHostingControllerHelper.swift +++ b/Sources/OpenSwiftUITestsSupport/Integration/PlatformHostingControllerHelper.swift @@ -15,7 +15,7 @@ extension PlatformHostingController { let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) window.rootViewController = self window.makeKeyAndVisible() - view.layoutSubviews() + view.layoutIfNeeded() #else let window = NSWindow( contentRect: CGRect(x: 0, y: 0, width: 100, height: 100), @@ -25,7 +25,7 @@ extension PlatformHostingController { ) window.contentViewController = self window.makeKeyAndOrderFront(nil) - view.layout() + view.layoutSubtreeIfNeeded() #endif } } diff --git a/Tests/OpenSwiftUICompatibilityTests/View/EquatableViewTests.swift b/Tests/OpenSwiftUICompatibilityTests/View/EquatableViewTests.swift new file mode 100644 index 000000000..f55f4db58 --- /dev/null +++ b/Tests/OpenSwiftUICompatibilityTests/View/EquatableViewTests.swift @@ -0,0 +1,191 @@ +// +// EquatableViewTests.swift +// OpenSwiftUITests + +import Foundation +import OpenSwiftUITestsSupport +import Testing + +// MARK: - EquatableViewTests + +#if canImport(Darwin) + +struct EquatableViewTests { + // Inspired by https://swiftui-lab.com/equatableview/ + // NOTES: + // 1. Even we implement Equatable and use EquatableView, the body will still call 2 times. + // Explain: For 2nd call, the value is actually equal but the implementation is not called due to Layout is not ready + // 2. If we implement Equatable but not use EquatableView, it still gets the same effect as EquatableView for POD types. + // The difference of using EquatableView is only required for non-POD types. (Change the Rule's default comparison mode from .equatableUnlessPOD to .alwaysEquatable) + // FIXME: 1. ?: Changing NumberView to non-POD types seems not working here + // FIXME: 2. the count is not accurate on Unit Test target + + struct NonEquatableNumberView: View { + var number: Int + + var confirmation: Confirmation + + var body: some View { + confirmation() + #if os(iOS) + return Color(uiColor: number.isEven ? .red : .blue) + #elseif os(macOS) + return Color(nsColor: number.isEven ? .red : .blue) + #endif + } + } + + struct NonEquatableNumberViewWrapper: View { + @State private var count = 0 + + var confirmation: Confirmation + var continuation: UnsafeContinuation + + var body: some View { + NonEquatableNumberView(number: count, confirmation: confirmation) + .onAppear { + DispatchQueue.main.async { + count += 2 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + count += 2 + continuation.resume() + } + } + } + } + } + + struct EquatableNumberView: View, Equatable { + var number: Int + + var confirmation: Confirmation + + var body: some View { + confirmation() + #if os(iOS) + return Color(uiColor: number.isEven ? .red : .blue) + #elseif os(macOS) + return Color(nsColor: number.isEven ? .red : .blue) + #endif + } + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.number.isEven == rhs.number.isEven + } + } + + struct EquatableNumberViewWrapper: View { + @State private var count = 0 + + var confirmation: Confirmation + var continuation: UnsafeContinuation + + var body: some View { + EquatableNumberView(number: count, confirmation: confirmation) + .equatable() + .onAppear { + DispatchQueue.main.async { + count += 2 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + count += 2 + continuation.resume() + } + } + } + } + } + + @Test + func nonEquatable() async throws { + #if os(iOS) + let expectedCount = 1 // FIXME: Not expected, probably due to triggerLayout implementation + #elseif os(macOS) + let expectedCount = 2 ... 3 // FIXME: Not expected, local 3 while CI 2 :( + #endif + await confirmation(expectedCount: expectedCount) { @MainActor confirmation in + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + let vc = PlatformHostingController( + rootView: NonEquatableNumberViewWrapper( + confirmation: confirmation, + continuation: continuation + ) + ) + vc.triggerLayout() + workaroundIssue87(vc) + } + } + } + + @Test + func equatable() async throws { + #if os(iOS) + let expectedCount = 1 // FIXME: Not expected, probably due to triggerLayout implementation + #elseif os(macOS) + let expectedCount = 2 + #endif + await confirmation(expectedCount: expectedCount) { @MainActor confirmation in + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + let vc = PlatformHostingController( + rootView: EquatableNumberViewWrapper( + confirmation: confirmation, + continuation: continuation + ) + ) + vc.triggerLayout() + workaroundIssue87(vc) + } + } + } + + #if !OPENSWIFTUI_COMPATIBILITY_TEST + struct Number: Equatable { + var value: Int + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value.isEven == rhs.value.isEven + } + } + + struct EquatableProxyNumberViewWrapper: View { + @State private var count = 0 + + var confirmation: Confirmation + var continuation: UnsafeContinuation + + var body: some View { + NonEquatableNumberView(number: count, confirmation: confirmation) + .equatableProxy(Number(value: count)) + .onAppear { + DispatchQueue.main.async { + count += 2 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + count += 2 + continuation.resume() + } + } + } + } + } + + @Test + func equatableProxy() async throws { + await confirmation(expectedCount: 1) { @MainActor confirmation in + await withUnsafeContinuation { (continuation: UnsafeContinuation) in + let vc = PlatformHostingController( + rootView: EquatableProxyNumberViewWrapper( + confirmation: confirmation, + continuation: continuation + ) + ) + vc.triggerLayout() + workaroundIssue87(vc) + } + } + } + #endif +} + +extension Int { + fileprivate var isEven: Bool { self % 2 == 0 } +} +#endif