Skip to content

Commit b77ec9e

Browse files
authored
Add AnimationTest support (#628)
1 parent 490deff commit b77ec9e

File tree

7 files changed

+271
-45
lines changed

7 files changed

+271
-45
lines changed

Example/OpenSwiftUIUITests/Graphic/Color/ColorAnimationExampleUITests.swift

Lines changed: 0 additions & 33 deletions
This file was deleted.

Example/OpenSwiftUIUITests/Graphic/Color/ColorUITests.swift

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,67 @@ struct ColorUITests {
5757
testName: "hsb_\(name)"
5858
)
5959
}
60+
61+
@Test
62+
func frameAnimation() {
63+
struct ContentView: AnimationTestView {
64+
nonisolated static var model: AnimationTestModel {
65+
AnimationTestModel(duration: 1, count: 4)
66+
}
67+
68+
@State private var smaller = false
69+
var body: some View {
70+
Color.red
71+
.frame(width: smaller ? 50 : 100, height: smaller ? 50 : 100)
72+
.animation(.linear(duration: Self.model.duration), value: smaller)
73+
.onAppear {
74+
smaller.toggle()
75+
}
76+
}
77+
}
78+
openSwiftUIAssertAnimationSnapshot(of: ContentView())
79+
}
80+
81+
// FIXME
82+
@Test(.disabled("Color interpolation is not aligned with SwiftUI yet"))
83+
func colorAnimation() {
84+
struct ContentView: AnimationTestView {
85+
nonisolated static var model: AnimationTestModel {
86+
AnimationTestModel(duration: 1, count: 4)
87+
}
88+
89+
@State private var showRed = false
90+
var body: some View {
91+
Color(platformColor: showRed ? .red : .blue)
92+
.animation(.linear(duration: Self.model.duration), value: showRed)
93+
.onAppear {
94+
showRed.toggle()
95+
}
96+
}
97+
}
98+
openSwiftUIAssertAnimationSnapshot(of: ContentView())
99+
}
100+
101+
// FIXME
102+
@Test(.disabled("Color interpolation is not aligned with SwiftUI yet"))
103+
func frameColorAnimation() {
104+
struct ContentView: AnimationTestView {
105+
nonisolated static var model: AnimationTestModel {
106+
AnimationTestModel(duration: 1, count: 4)
107+
}
108+
109+
@State private var showRed = false
110+
var body: some View {
111+
Color(platformColor: showRed ? .red : .blue)
112+
.frame(width: showRed ? 50 : 100, height: showRed ? 50 : 100)
113+
.animation(.linear(duration: Self.model.duration), value: showRed)
114+
.onAppear {
115+
showRed.toggle()
116+
}
117+
}
118+
}
119+
openSwiftUIAssertAnimationSnapshot(
120+
of: ContentView()
121+
)
122+
}
60123
}

Example/OpenSwiftUIUITests/OpenSwiftUIUITests.xctestplan

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
],
1111
"defaultOptions" : {
1212
"environmentVariableEntries" : [
13+
{
14+
"enabled" : false,
15+
"key" : "SWIFTUI_PRINT_TREE",
16+
"value" : "1"
17+
},
1318
{
1419
"key" : "SNAPSHOT_REFERENCE_DIR",
1520
"value" : "$(PROJECT_DIR)\/ReferenceImages"
@@ -25,7 +30,7 @@
2530
{
2631
"target" : {
2732
"containerPath" : "container:Example.xcodeproj",
28-
"identifier" : "275751F42DEE1456003E467C",
33+
"identifier" : "279283B82DFF11CE00234D64",
2934
"name" : "OpenSwiftUIUITests"
3035
}
3136
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//
2+
// AnimationDebugController.swift
3+
// OpenSwiftUIUITests
4+
5+
import Foundation
6+
import OpenSwiftUI_SPI
7+
8+
struct AnimationTestModel: Hashable {
9+
var intervals: [Double]
10+
11+
init(intervals: [Double]) {
12+
self.intervals = intervals
13+
}
14+
15+
init(times: [Double]) {
16+
intervals = zip(times.dropFirst(), times).map { $0 - $1 }
17+
}
18+
19+
init(duration: Double, count: Int) {
20+
intervals = Array(repeating: duration / Double(count), count: count)
21+
}
22+
23+
var duration: Double {
24+
intervals.reduce(0, +)
25+
}
26+
}
27+
28+
protocol AnimationTestView: View {
29+
static var model: AnimationTestModel { get }
30+
}
31+
32+
final class AnimationDebugController<V>: PlatformHostingController<V> where V: View {
33+
init(_ view: V) {
34+
super.init(rootView: view)
35+
}
36+
37+
@MainActor
38+
required init?(coder: NSCoder) {
39+
fatalError("init(coder:) has not been implemented")
40+
}
41+
42+
private var host: PlatformHostingView<V> {
43+
view as! PlatformHostingView<V>
44+
}
45+
46+
func advance(interval: Double) {
47+
host._renderForTest(interval: interval)
48+
}
49+
50+
func advanceAsync(interval: Double) -> Bool {
51+
host._renderAsyncForTest(interval: interval)
52+
}
53+
54+
override func viewDidLoad() {
55+
super.viewDidLoad()
56+
#if os(iOS) || os(visionOS)
57+
Self.hookLayoutSubviews(type(of: host))
58+
Self.hookDisplayLinkTimer()
59+
#endif
60+
}
61+
62+
#if os(iOS) || os(visionOS)
63+
var disableLayoutSubview = false
64+
65+
// Fix swift-snapshot framework snapshot will trigger uncessary _UIHostingView.layoutSubview issue
66+
static func hookLayoutSubviews(_ cls: AnyClass?) {
67+
let originalSelector = #selector(PlatformView.layoutSubviews)
68+
let swizzledSelector = #selector(PlatformView.swizzled_layoutSubviews)
69+
70+
guard let targetClass = cls,
71+
let originalMethod = class_getInstanceMethod(targetClass, originalSelector),
72+
let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector)
73+
else { return }
74+
method_exchangeImplementations(originalMethod, swizzledMethod)
75+
}
76+
77+
static func hookDisplayLinkTimer() {
78+
#if OPENSWIFTUI
79+
let cls: AnyClass? = NSClassFromString("OpenSwiftUI.DisplayLink")
80+
#else
81+
let cls: AnyClass? = NSClassFromString("SwiftUI.DisplayLink")
82+
#endif
83+
let sel = NSSelectorFromString("displayLinkTimer:")
84+
guard let targetClass = cls,
85+
let originalMethod = class_getInstanceMethod(targetClass, sel),
86+
let swizzledMethod = class_getInstanceMethod(self, #selector(swizzled_displayLinkTimerWithLink(_:)))
87+
else { return }
88+
method_exchangeImplementations(originalMethod, swizzledMethod)
89+
}
90+
91+
@objc
92+
func swizzled_displayLinkTimerWithLink(_ sender: CADisplayLink) {
93+
sender.isPaused = true
94+
}
95+
#endif
96+
}
97+
98+
#if os(iOS) || os(visionOS)
99+
// Avoid generic parameter casting
100+
private protocol AnimationDebuggableController: PlatformViewController {
101+
var disableLayoutSubview: Bool { get set }
102+
}
103+
104+
extension AnimationDebugController: AnimationDebuggableController {}
105+
106+
extension PlatformView {
107+
@objc func swizzled_layoutSubviews() {
108+
guard let vc = _viewControllerForAncestor as? AnimationDebuggableController else {
109+
swizzled_layoutSubviews()
110+
return
111+
}
112+
guard !vc.disableLayoutSubview else {
113+
return
114+
}
115+
swizzled_layoutSubviews()
116+
vc.disableLayoutSubview = true
117+
}
118+
}
119+
#endif

Example/OpenSwiftUIUITests/Shared/SnapshotTesting+Testing.swift renamed to Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Foundation
99
#if canImport(AppKit)
1010
import AppKit
1111
typealias PlatformHostingController = NSHostingController
12+
typealias PlatformHostingView = NSHostingView
1213
typealias PlatformViewController = NSViewController
1314
typealias PlatformView = NSView
1415
typealias PlatformImage = NSImage
@@ -21,6 +22,7 @@ extension Color {
2122
#else
2223
import UIKit
2324
typealias PlatformHostingController = UIHostingController
25+
typealias PlatformHostingView = _UIHostingView
2426
typealias PlatformViewController = UIViewController
2527
typealias PlatformView = UIView
2628
typealias PlatformImage = UIImage
@@ -35,7 +37,7 @@ extension Color {
3537
let defaultSize = CGSize(width: 200, height: 200)
3638

3739
func openSwiftUIAssertSnapshot<V: View>(
38-
of value: @autoclosure () throws -> V,
40+
of value: @autoclosure () -> V,
3941
perceptualPrecision: Float = 1,
4042
size: CGSize = defaultSize,
4143
named name: String? = nil,
@@ -48,7 +50,7 @@ func openSwiftUIAssertSnapshot<V: View>(
4850
column: UInt = #column
4951
) {
5052
openSwiftUIAssertSnapshot(
51-
of: PlatformHostingController(rootView: try value()),
53+
of: PlatformHostingController(rootView: value()),
5254
as: .image(perceptualPrecision: perceptualPrecision, size: size),
5355
named: (name.map { ".\($0)" } ?? "") + "\(Int(size.width))x\(Int(size.height))",
5456
record: recording,
@@ -62,7 +64,7 @@ func openSwiftUIAssertSnapshot<V: View>(
6264
}
6365

6466
func openSwiftUIAssertSnapshot<V: View>(
65-
of value: @autoclosure () throws -> V,
67+
of value: @autoclosure () -> V,
6668
as snapshotting: Snapshotting<PlatformViewController, PlatformImage>,
6769
named name: String? = nil,
6870
record recording: Bool? = shouldRecord,
@@ -74,7 +76,7 @@ func openSwiftUIAssertSnapshot<V: View>(
7476
column: UInt = #column
7577
) {
7678
openSwiftUIAssertSnapshot(
77-
of: PlatformHostingController(rootView: try value()),
79+
of: PlatformHostingController(rootView: value()),
7880
as: snapshotting,
7981
named: name,
8082
record: recording,
@@ -88,7 +90,7 @@ func openSwiftUIAssertSnapshot<V: View>(
8890
}
8991

9092
func openSwiftUIAssertSnapshot<V: View, Format>(
91-
of value: @autoclosure () throws -> V,
93+
of value: @autoclosure () -> V,
9294
as snapshotting: Snapshotting<PlatformViewController, Format>,
9395
named name: String? = nil,
9496
record recording: Bool? = shouldRecord,
@@ -100,7 +102,7 @@ func openSwiftUIAssertSnapshot<V: View, Format>(
100102
column: UInt = #column
101103
) {
102104
openSwiftUIAssertSnapshot(
103-
of: PlatformHostingController(rootView: try value()),
105+
of: PlatformHostingController(rootView: value()),
104106
as: snapshotting,
105107
named: name,
106108
record: recording,
@@ -114,7 +116,7 @@ func openSwiftUIAssertSnapshot<V: View, Format>(
114116
}
115117

116118
private func openSwiftUIAssertSnapshot<Value, Format>(
117-
of value: @autoclosure () throws -> Value,
119+
of value: @autoclosure () -> Value,
118120
as snapshotting: Snapshotting<Value, Format>,
119121
named name: String? = nil,
120122
record recording: Bool? = shouldRecord,
@@ -131,7 +133,7 @@ private func openSwiftUIAssertSnapshot<Value, Format>(
131133
let os = "iOS_Simulator"
132134
#endif
133135
let snapshotDirectory = ProcessInfo.processInfo.environment["SNAPSHOT_REFERENCE_DIR"]! + "/\(os)/" + fileID.description
134-
let failure = try verifySnapshot(
136+
let failure = verifySnapshot(
135137
of: value(),
136138
as: snapshotting,
137139
named: name,
@@ -155,3 +157,49 @@ private func openSwiftUIAssertSnapshot<Value, Format>(
155157
)
156158
)
157159
}
160+
161+
// MARK: - Animation
162+
163+
func openSwiftUIAssertAnimationSnapshot<V: AnimationTestView>(
164+
of value: @autoclosure () -> V,
165+
precision: Float = 1,
166+
perceptualPrecision: Float = 1,
167+
size: CGSize = defaultSize,
168+
record recording: Bool? = shouldRecord,
169+
timeout: TimeInterval = 5,
170+
fileID: StaticString = #fileID,
171+
file filePath: StaticString = #filePath,
172+
testName: String = #function,
173+
line: UInt = #line,
174+
column: UInt = #column
175+
) {
176+
let vc = AnimationDebugController(value())
177+
let model = V.model
178+
var intervals = model.intervals
179+
intervals.insert(.zero, at: 0)
180+
intervals.enumerated().forEach { (index, interval) in
181+
switch index {
182+
case 0:
183+
break
184+
case 1:
185+
vc.advance(interval: .zero)
186+
vc.advance(interval: .zero)
187+
vc.advance(interval: .zero)
188+
vc.advance(interval: interval)
189+
default:
190+
vc.advance(interval: interval)
191+
}
192+
openSwiftUIAssertSnapshot(
193+
of: vc,
194+
as: .image(precision: precision, perceptualPrecision: perceptualPrecision, size: size),
195+
named: "\(index)_\(model.intervals.count).\(Int(size.width))x\(Int(size.height))",
196+
record: recording,
197+
timeout: timeout,
198+
fileID: fileID,
199+
file: filePath,
200+
testName: testName,
201+
line: line,
202+
column: column
203+
)
204+
}
205+
}

0 commit comments

Comments
 (0)