Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions Sources/OpenSwiftUI/View/EquatableView.swift
Original file line number Diff line number Diff line change
@@ -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<Content>: 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<Self>,
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<Self> {
EquatableView(content: self)
}
}

// MARK: - EquatableProxyView

package struct EquatableProxyView<Content, Token>: 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<Self>,
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<EquatableProxyView>, 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: Token) -> EquatableProxyView<Self, Token> where Token: Equatable {
EquatableProxyView(content: self, token: token)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -25,7 +25,7 @@ extension PlatformHostingController {
)
window.contentViewController = self
window.makeKeyAndOrderFront(nil)
view.layout()
view.layoutSubtreeIfNeeded()
#endif
}
}
Expand Down
191 changes: 191 additions & 0 deletions Tests/OpenSwiftUICompatibilityTests/View/EquatableViewTests.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>

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<Void, Never>

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<Void, Never>) 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<Void, Never>) 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<Void, Never>

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<Void, Never>) 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