Skip to content

Commit 9327570

Browse files
feat(core): Freeze UIActivityIndicatorView spinners during snapshots (#279)
* feat(core): Freeze UIActivityIndicatorView spinners during snapshots Disabling UIView and SwiftUI animations does not stop UIActivityIndicatorView, which drives its own layer animation. Indeterminate spinners, including the default SwiftUI ProgressView, were captured mid-rotation and produced flaky diffs. Swizzle startAnimating to a no-op from RenderingStrategy.setup so every rendering path skips spinners. Because hidesWhenStopped defaults to true, the indicator is hidden rather than frozen at a fixed frame. This matches the behavior the hosted Emerge runner previously injected outside the library. * feat(core): Add opt-out for spinner freezing and exclude watchOS Set EMERGE_DISABLE_FREEZE_SPINNERS=1 to keep UIActivityIndicatorView animating, mirroring the existing EMERGE_DISABLE_FIX_TIME opt-out for the clock hook. watchOS ships UIKit without UIActivityIndicatorView, so guard the swizzle the same way other UIKit-only code in this target does. Mark the replacement method dynamic to follow the standard swizzling idiom. * test(demo): Add loading indicator preview to MultiModuleDemo Add a ModuleA preview containing an indeterminate ProgressView so the CI snapshot upload renders a spinner case. With spinner freezing in place the image contains only the label, and any regression shows up as a diff. * ref(core): Use snapshot-specific spinner control names Replace company-branded names in the new spinner opt-out and swizzled selector. Keep the Objective-C hook product-specific to avoid collisions, and update the environment-variable test and documentation together.
1 parent 856a1c1 commit 9327570

6 files changed

Lines changed: 75 additions & 1 deletion

File tree

‎Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ public struct ModuleALabel: View {
2929
}
3030
}
3131

32+
public struct ModuleALoadingIndicator: View {
33+
public init() {}
34+
35+
public var body: some View {
36+
VStack(spacing: 12) {
37+
ProgressView()
38+
Text("Loading")
39+
.font(.subheadline)
40+
}
41+
.padding()
42+
}
43+
}
44+
3245
#Preview("ModuleA Button") {
3346
ModuleAButton()
3447
}
@@ -42,3 +55,7 @@ public struct ModuleALabel: View {
4255
ModuleALabel()
4356
.preferredColorScheme(.dark)
4457
}
58+
59+
#Preview("ModuleA Loading Indicator") {
60+
ModuleALoadingIndicator()
61+
}

‎Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ final class ModuleFilterAssertionTests: XCTestCase {
1414
excludedModules: nil
1515
)
1616

17-
XCTAssertEqual(previews.count, 8)
17+
XCTAssertEqual(previews.count, 9)
1818
XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleA.") })
1919
XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleB.") })
2020
XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleC.") })

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ Display names should be unique within each `PreviewProvider`, or within a file w
220220

221221
Snapshot previews should be deterministic. Avoid live network calls, timers, animations that do not settle, locale-dependent data, and dates generated from the current clock. Prefer fixed fixtures and mocked dependencies so the same preview renders the same pixels in Xcode, local test runs, and CI.
222222

223+
During snapshot rendering, `UIActivityIndicatorView.startAnimating()` is a no-op, so indeterminate spinners (including SwiftUI's default `ProgressView`) never start and are hidden instead of being captured mid-rotation. Set `SNAPSHOTS_DISABLE_FREEZE_SPINNERS=1` in the test environment to opt out.
224+
223225
### Detecting the snapshot environment
224226

225227
Set `SNAPSHOTS_RUNNING_FOR_PREVIEWS=1` in your unit test scheme to mirror the variable Xcode sets when rendering live previews. You can then disable preview-unfriendly behavior (logging, analytics, network calls) with a single check:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#if canImport(UIKit) && !os(watchOS)
2+
import UIKit
3+
import ObjectiveC
4+
5+
enum ActivityIndicatorFreezing {
6+
private static let installed: Void = {
7+
guard
8+
let original = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.startAnimating)),
9+
let replacement = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.snapshotPreviews_frozenStartAnimating))
10+
else { return }
11+
method_exchangeImplementations(original, replacement)
12+
}()
13+
14+
static func isEnabled(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool {
15+
environment["SNAPSHOTS_DISABLE_FREEZE_SPINNERS"] != "1"
16+
}
17+
18+
static func install() {
19+
guard isEnabled() else { return }
20+
_ = installed
21+
}
22+
}
23+
24+
private extension UIActivityIndicatorView {
25+
@objc dynamic func snapshotPreviews_frozenStartAnimating() {}
26+
}
27+
#endif

‎Sources/SnapshotPreviewsCore/RenderingStrategy.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as
6868

6969
extension RenderingStrategy {
7070
static func setup() {
71+
#if canImport(UIKit) && !os(watchOS)
72+
ActivityIndicatorFreezing.install()
73+
#endif
7174
testHandler?.perform(NSSelectorFromString("setup"))
7275
}
7376
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#if canImport(UIKit) && !os(watchOS)
2+
import XCTest
3+
import UIKit
4+
@testable import SnapshotPreviewsCore
5+
6+
final class ActivityIndicatorFreezingTests: XCTestCase {
7+
@MainActor
8+
func testStartAnimatingIsNoOpAfterRenderingSetup() {
9+
UIKitRenderingStrategy.setup()
10+
11+
let indicator = UIActivityIndicatorView(style: .medium)
12+
indicator.startAnimating()
13+
14+
XCTAssertFalse(indicator.isAnimating)
15+
}
16+
17+
func testFreezingIsEnabledByDefault() {
18+
XCTAssertTrue(ActivityIndicatorFreezing.isEnabled(environment: [:]))
19+
}
20+
21+
func testFreezingIsDisabledByEnvironmentVariable() {
22+
XCTAssertFalse(ActivityIndicatorFreezing.isEnabled(environment: ["SNAPSHOTS_DISABLE_FREEZE_SPINNERS": "1"]))
23+
}
24+
}
25+
#endif

0 commit comments

Comments
 (0)