diff --git a/Example/OpenSwiftUIUITests/Graphic/Color/ColorAnimationExampleUITests.swift b/Example/OpenSwiftUIUITests/Graphic/Color/ColorAnimationExampleUITests.swift deleted file mode 100644 index 2d4b3889b..000000000 --- a/Example/OpenSwiftUIUITests/Graphic/Color/ColorAnimationExampleUITests.swift +++ /dev/null @@ -1,33 +0,0 @@ - // - // ColorAnimationExampleUITests.swift - // OpenSwiftUIUITests - - import Testing - import SnapshotTesting - - @MainActor - @Suite(.snapshots(record: .never, diffTool: diffTool)) - struct ColorAnimationExampleUITests { - @Test - func colorAnimationExample() async { - struct ContentView: View { - @State private var showRed = false - var body: some View { - VStack { - Color(platformColor: showRed ? .red : .blue) - .frame(width: showRed ? 200 : 400, height: showRed ? 200 : 400) - } - .animation(.easeInOut(duration: 2), value: showRed) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation { - - showRed.toggle() - } - } - } - } - } - openSwiftUIAssertSnapshot(of: ContentView()) - } - } diff --git a/Example/OpenSwiftUIUITests/Graphic/Color/ColorUITests.swift b/Example/OpenSwiftUIUITests/Graphic/Color/ColorUITests.swift index 3e1a9033f..c2acbcd04 100644 --- a/Example/OpenSwiftUIUITests/Graphic/Color/ColorUITests.swift +++ b/Example/OpenSwiftUIUITests/Graphic/Color/ColorUITests.swift @@ -57,4 +57,67 @@ struct ColorUITests { testName: "hsb_\(name)" ) } + + @Test + func frameAnimation() { + struct ContentView: AnimationTestView { + nonisolated static var model: AnimationTestModel { + AnimationTestModel(duration: 1, count: 4) + } + + @State private var smaller = false + var body: some View { + Color.red + .frame(width: smaller ? 50 : 100, height: smaller ? 50 : 100) + .animation(.linear(duration: Self.model.duration), value: smaller) + .onAppear { + smaller.toggle() + } + } + } + openSwiftUIAssertAnimationSnapshot(of: ContentView()) + } + + // FIXME + @Test(.disabled("Color interpolation is not aligned with SwiftUI yet")) + func colorAnimation() { + struct ContentView: AnimationTestView { + nonisolated static var model: AnimationTestModel { + AnimationTestModel(duration: 1, count: 4) + } + + @State private var showRed = false + var body: some View { + Color(platformColor: showRed ? .red : .blue) + .animation(.linear(duration: Self.model.duration), value: showRed) + .onAppear { + showRed.toggle() + } + } + } + openSwiftUIAssertAnimationSnapshot(of: ContentView()) + } + + // FIXME + @Test(.disabled("Color interpolation is not aligned with SwiftUI yet")) + func frameColorAnimation() { + struct ContentView: AnimationTestView { + nonisolated static var model: AnimationTestModel { + AnimationTestModel(duration: 1, count: 4) + } + + @State private var showRed = false + var body: some View { + Color(platformColor: showRed ? .red : .blue) + .frame(width: showRed ? 50 : 100, height: showRed ? 50 : 100) + .animation(.linear(duration: Self.model.duration), value: showRed) + .onAppear { + showRed.toggle() + } + } + } + openSwiftUIAssertAnimationSnapshot( + of: ContentView() + ) + } } diff --git a/Example/OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan b/Example/OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan index b13a8e513..a756e13ef 100644 --- a/Example/OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan +++ b/Example/OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan @@ -10,6 +10,11 @@ ], "defaultOptions" : { "environmentVariableEntries" : [ + { + "enabled" : false, + "key" : "SWIFTUI_PRINT_TREE", + "value" : "1" + }, { "key" : "SNAPSHOT_REFERENCE_DIR", "value" : "$(PROJECT_DIR)\/ReferenceImages" @@ -25,7 +30,7 @@ { "target" : { "containerPath" : "container:Example.xcodeproj", - "identifier" : "275751F42DEE1456003E467C", + "identifier" : "279283B82DFF11CE00234D64", "name" : "OpenSwiftUIUITests" } } diff --git a/Example/OpenSwiftUIUITests/UITests/AnimationDebugController.swift b/Example/OpenSwiftUIUITests/UITests/AnimationDebugController.swift new file mode 100644 index 000000000..f4c7e1263 --- /dev/null +++ b/Example/OpenSwiftUIUITests/UITests/AnimationDebugController.swift @@ -0,0 +1,119 @@ +// +// AnimationDebugController.swift +// OpenSwiftUIUITests + +import Foundation +import OpenSwiftUI_SPI + +struct AnimationTestModel: Hashable { + var intervals: [Double] + + init(intervals: [Double]) { + self.intervals = intervals + } + + init(times: [Double]) { + intervals = zip(times.dropFirst(), times).map { $0 - $1 } + } + + init(duration: Double, count: Int) { + intervals = Array(repeating: duration / Double(count), count: count) + } + + var duration: Double { + intervals.reduce(0, +) + } +} + +protocol AnimationTestView: View { + static var model: AnimationTestModel { get } +} + +final class AnimationDebugController: PlatformHostingController where V: View { + init(_ view: V) { + super.init(rootView: view) + } + + @MainActor + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var host: PlatformHostingView { + view as! PlatformHostingView + } + + func advance(interval: Double) { + host._renderForTest(interval: interval) + } + + func advanceAsync(interval: Double) -> Bool { + host._renderAsyncForTest(interval: interval) + } + + override func viewDidLoad() { + super.viewDidLoad() + #if os(iOS) || os(visionOS) + Self.hookLayoutSubviews(type(of: host)) + Self.hookDisplayLinkTimer() + #endif + } + + #if os(iOS) || os(visionOS) + var disableLayoutSubview = false + + // Fix swift-snapshot framework snapshot will trigger uncessary _UIHostingView.layoutSubview issue + static func hookLayoutSubviews(_ cls: AnyClass?) { + let originalSelector = #selector(PlatformView.layoutSubviews) + let swizzledSelector = #selector(PlatformView.swizzled_layoutSubviews) + + guard let targetClass = cls, + let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + static func hookDisplayLinkTimer() { + #if OPENSWIFTUI + let cls: AnyClass? = NSClassFromString("OpenSwiftUI.DisplayLink") + #else + let cls: AnyClass? = NSClassFromString("SwiftUI.DisplayLink") + #endif + let sel = NSSelectorFromString("displayLinkTimer:") + guard let targetClass = cls, + let originalMethod = class_getInstanceMethod(targetClass, sel), + let swizzledMethod = class_getInstanceMethod(self, #selector(swizzled_displayLinkTimerWithLink(_:))) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + } + + @objc + func swizzled_displayLinkTimerWithLink(_ sender: CADisplayLink) { + sender.isPaused = true + } + #endif +} + +#if os(iOS) || os(visionOS) +// Avoid generic parameter casting +private protocol AnimationDebuggableController: PlatformViewController { + var disableLayoutSubview: Bool { get set } +} + +extension AnimationDebugController: AnimationDebuggableController {} + +extension PlatformView { + @objc func swizzled_layoutSubviews() { + guard let vc = _viewControllerForAncestor as? AnimationDebuggableController else { + swizzled_layoutSubviews() + return + } + guard !vc.disableLayoutSubview else { + return + } + swizzled_layoutSubviews() + vc.disableLayoutSubview = true + } +} +#endif diff --git a/Example/OpenSwiftUIUITests/Shared/SnapshotTesting+Testing.swift b/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift similarity index 68% rename from Example/OpenSwiftUIUITests/Shared/SnapshotTesting+Testing.swift rename to Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift index 65c0741d7..6df87b1b7 100644 --- a/Example/OpenSwiftUIUITests/Shared/SnapshotTesting+Testing.swift +++ b/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift @@ -9,6 +9,7 @@ import Foundation #if canImport(AppKit) import AppKit typealias PlatformHostingController = NSHostingController +typealias PlatformHostingView = NSHostingView typealias PlatformViewController = NSViewController typealias PlatformView = NSView typealias PlatformImage = NSImage @@ -21,6 +22,7 @@ extension Color { #else import UIKit typealias PlatformHostingController = UIHostingController +typealias PlatformHostingView = _UIHostingView typealias PlatformViewController = UIViewController typealias PlatformView = UIView typealias PlatformImage = UIImage @@ -35,7 +37,7 @@ extension Color { let defaultSize = CGSize(width: 200, height: 200) func openSwiftUIAssertSnapshot( - of value: @autoclosure () throws -> V, + of value: @autoclosure () -> V, perceptualPrecision: Float = 1, size: CGSize = defaultSize, named name: String? = nil, @@ -48,7 +50,7 @@ func openSwiftUIAssertSnapshot( column: UInt = #column ) { openSwiftUIAssertSnapshot( - of: PlatformHostingController(rootView: try value()), + of: PlatformHostingController(rootView: value()), as: .image(perceptualPrecision: perceptualPrecision, size: size), named: (name.map { ".\($0)" } ?? "") + "\(Int(size.width))x\(Int(size.height))", record: recording, @@ -62,7 +64,7 @@ func openSwiftUIAssertSnapshot( } func openSwiftUIAssertSnapshot( - of value: @autoclosure () throws -> V, + of value: @autoclosure () -> V, as snapshotting: Snapshotting, named name: String? = nil, record recording: Bool? = shouldRecord, @@ -74,7 +76,7 @@ func openSwiftUIAssertSnapshot( column: UInt = #column ) { openSwiftUIAssertSnapshot( - of: PlatformHostingController(rootView: try value()), + of: PlatformHostingController(rootView: value()), as: snapshotting, named: name, record: recording, @@ -88,7 +90,7 @@ func openSwiftUIAssertSnapshot( } func openSwiftUIAssertSnapshot( - of value: @autoclosure () throws -> V, + of value: @autoclosure () -> V, as snapshotting: Snapshotting, named name: String? = nil, record recording: Bool? = shouldRecord, @@ -100,7 +102,7 @@ func openSwiftUIAssertSnapshot( column: UInt = #column ) { openSwiftUIAssertSnapshot( - of: PlatformHostingController(rootView: try value()), + of: PlatformHostingController(rootView: value()), as: snapshotting, named: name, record: recording, @@ -114,7 +116,7 @@ func openSwiftUIAssertSnapshot( } private func openSwiftUIAssertSnapshot( - of value: @autoclosure () throws -> Value, + of value: @autoclosure () -> Value, as snapshotting: Snapshotting, named name: String? = nil, record recording: Bool? = shouldRecord, @@ -131,7 +133,7 @@ private func openSwiftUIAssertSnapshot( let os = "iOS_Simulator" #endif let snapshotDirectory = ProcessInfo.processInfo.environment["SNAPSHOT_REFERENCE_DIR"]! + "/\(os)/" + fileID.description - let failure = try verifySnapshot( + let failure = verifySnapshot( of: value(), as: snapshotting, named: name, @@ -155,3 +157,49 @@ private func openSwiftUIAssertSnapshot( ) ) } + +// MARK: - Animation + +func openSwiftUIAssertAnimationSnapshot( + of value: @autoclosure () -> V, + precision: Float = 1, + perceptualPrecision: Float = 1, + size: CGSize = defaultSize, + record recording: Bool? = shouldRecord, + timeout: TimeInterval = 5, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + testName: String = #function, + line: UInt = #line, + column: UInt = #column +) { + let vc = AnimationDebugController(value()) + let model = V.model + var intervals = model.intervals + intervals.insert(.zero, at: 0) + intervals.enumerated().forEach { (index, interval) in + switch index { + case 0: + break + case 1: + vc.advance(interval: .zero) + vc.advance(interval: .zero) + vc.advance(interval: .zero) + vc.advance(interval: interval) + default: + vc.advance(interval: interval) + } + openSwiftUIAssertSnapshot( + of: vc, + as: .image(precision: precision, perceptualPrecision: perceptualPrecision, size: size), + named: "\(index)_\(model.intervals.count).\(Int(size.width))x\(Int(size.height))", + record: recording, + timeout: timeout, + fileID: fileID, + file: filePath, + testName: testName, + line: line, + column: column + ) + } +} diff --git a/Example/OpenSwiftUIUITests/Shared/SnapshotTesting+XCTest.swift b/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+XCTest.swift similarity index 100% rename from Example/OpenSwiftUIUITests/Shared/SnapshotTesting+XCTest.swift rename to Example/OpenSwiftUIUITests/UITests/SnapshotTesting+XCTest.swift diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift index dfacbfa4a..f3cbe027c 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift @@ -416,12 +416,36 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont @available(visionOS, unavailable) extension NSHostingView { public func _renderForTest(interval: Double) { - // TODO + // FIXME: Copy from iOS version + _openSwiftUIUnimplementedWarning() + + func shouldContinue() -> Bool { + if propertiesNeedingUpdate == [], !CoreTesting.needsRender { + false + } else { + times >= 0 + } + } + advanceTimeForTest(interval: interval) + canAdvanceTimeAutomatically = false + var times = 16 + repeat { + times -= 1 + CoreTesting.needsRender = false + updateGraph { host in + host.flushTransactions() + } + RunLoop.flushObservers() + render(targetTimestamp: nil) + CATransaction.flush() + } while shouldContinue() + CoreTesting.needsRender = false + canAdvanceTimeAutomatically = true } public func _renderAsyncForTest(interval: Double) -> Bool { - // TODO - false + _openSwiftUIUnimplementedWarning() + return false } }