diff --git a/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift b/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift index e07d1c76..c302e802 100644 --- a/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift +++ b/Examples/MultiModuleDemo/ModuleA/ModuleAViews.swift @@ -29,6 +29,19 @@ public struct ModuleALabel: View { } } +public struct ModuleALoadingIndicator: View { + public init() {} + + public var body: some View { + VStack(spacing: 12) { + ProgressView() + Text("Loading") + .font(.subheadline) + } + .padding() + } +} + #Preview("ModuleA Button") { ModuleAButton() } @@ -42,3 +55,7 @@ public struct ModuleALabel: View { ModuleALabel() .preferredColorScheme(.dark) } + +#Preview("ModuleA Loading Indicator") { + ModuleALoadingIndicator() +} diff --git a/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift b/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift index e534a7f6..30f82807 100644 --- a/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift +++ b/Examples/MultiModuleDemo/MultiModuleDemoTests/ModuleFilterAssertionTests.swift @@ -14,7 +14,7 @@ final class ModuleFilterAssertionTests: XCTestCase { excludedModules: nil ) - XCTAssertEqual(previews.count, 8) + XCTAssertEqual(previews.count, 9) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleA.") }) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleB.") }) XCTAssertTrue(previews.contains { $0.typeName.hasPrefix("ModuleC.") }) diff --git a/README.md b/README.md index 2906eab8..9ce6b866 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,8 @@ Display names should be unique within each `PreviewProvider`, or within a file w 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. +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. + ### Detecting the snapshot environment 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: diff --git a/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift new file mode 100644 index 00000000..2dfd136c --- /dev/null +++ b/Sources/SnapshotPreviewsCore/ActivityIndicatorFreezing.swift @@ -0,0 +1,27 @@ +#if canImport(UIKit) && !os(watchOS) +import UIKit +import ObjectiveC + +enum ActivityIndicatorFreezing { + private static let installed: Void = { + guard + let original = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.startAnimating)), + let replacement = class_getInstanceMethod(UIActivityIndicatorView.self, #selector(UIActivityIndicatorView.snapshotPreviews_frozenStartAnimating)) + else { return } + method_exchangeImplementations(original, replacement) + }() + + static func isEnabled(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["SNAPSHOTS_DISABLE_FREEZE_SPINNERS"] != "1" + } + + static func install() { + guard isEnabled() else { return } + _ = installed + } +} + +private extension UIActivityIndicatorView { + @objc dynamic func snapshotPreviews_frozenStartAnimating() {} +} +#endif diff --git a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift index 15704733..0a5c9816 100644 --- a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift @@ -68,6 +68,9 @@ private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as extension RenderingStrategy { static func setup() { + #if canImport(UIKit) && !os(watchOS) + ActivityIndicatorFreezing.install() + #endif testHandler?.perform(NSSelectorFromString("setup")) } } diff --git a/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift new file mode 100644 index 00000000..290f9500 --- /dev/null +++ b/Tests/SnapshotPreviewsTests/ActivityIndicatorFreezingTests.swift @@ -0,0 +1,25 @@ +#if canImport(UIKit) && !os(watchOS) +import XCTest +import UIKit +@testable import SnapshotPreviewsCore + +final class ActivityIndicatorFreezingTests: XCTestCase { + @MainActor + func testStartAnimatingIsNoOpAfterRenderingSetup() { + UIKitRenderingStrategy.setup() + + let indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + + XCTAssertFalse(indicator.isAnimating) + } + + func testFreezingIsEnabledByDefault() { + XCTAssertTrue(ActivityIndicatorFreezing.isEnabled(environment: [:])) + } + + func testFreezingIsDisabledByEnvironmentVariable() { + XCTAssertFalse(ActivityIndicatorFreezing.isEnabled(environment: ["SNAPSHOTS_DISABLE_FREEZE_SPINNERS": "1"])) + } +} +#endif