diff --git a/Sources/SnapshotTesting/Common/View.swift b/Sources/SnapshotTesting/Common/View.swift index 75315374..368c5553 100644 --- a/Sources/SnapshotTesting/Common/View.swift +++ b/Sources/SnapshotTesting/Common/View.swift @@ -10,6 +10,19 @@ #if os(iOS) || os(macOS) import WebKit #endif + #if canImport(SwiftUI) + import SwiftUI + + /// The rendering mode for SwiftUI snapshots. + public enum SwiftUIRenderingMode { + /// Automatically detect SwiftUI content and use the most accurate rendering method when possible. + case auto + /// Always use drawHierarchy rendering (requires host application). + case enabled + /// Always use layer rendering (works in framework tests). + case disabled + } + #endif #if os(iOS) || os(tvOS) public struct ViewImageConfig: Sendable { @@ -926,6 +939,45 @@ } } + func prepareView( + config: ViewImageConfig, + renderingMode: SwiftUIRenderingMode, + traits: UITraitCollection, + view: UIView, + viewController: UIViewController + ) -> () -> Void { + let size = config.size ?? viewController.view.frame.size + view.frame.size = size + if view != viewController.view { + viewController.view.bounds = view.bounds + viewController.view.addSubview(view) + } + let traits = UITraitCollection(traitsFrom: [config.traits, traits]) + let window: UIWindow + if shouldUseDrawHierarchy(renderingMode: renderingMode, viewController: viewController) { + guard let keyWindow = getKeyWindow() else { + fatalError("'renderingMode' requires tests to be run in a host application") + } + window = keyWindow + window.frame.size = size + } else { + window = Window( + config: .init(safeArea: config.safeArea, size: config.size ?? size, traits: traits), + viewController: viewController + ) + } + let dispose = add(traits: traits, viewController: viewController, to: window) + + if size.width == 0 || size.height == 0 { + // Try to call sizeToFit() if the view still has invalid size + view.sizeToFit() + view.setNeedsLayout() + view.layoutIfNeeded() + } + + return dispose + } + func prepareView( config: ViewImageConfig, drawHierarchyInKeyWindow: Bool, @@ -965,6 +1017,26 @@ return dispose } + func snapshotView( + config: ViewImageConfig, + renderingMode: SwiftUIRenderingMode, + traits: UITraitCollection, + view: UIView, + viewController: UIViewController + ) + -> Async + { + // Convert to legacy call for UIKit compatibility + let drawHierarchy = shouldUseDrawHierarchy(renderingMode: renderingMode, viewController: viewController) + return snapshotView( + config: config, + drawHierarchyInKeyWindow: drawHierarchy, + traits: traits, + view: view, + viewController: viewController + ) + } + func snapshotView( config: ViewImageConfig, drawHierarchyInKeyWindow: Bool, @@ -1019,6 +1091,31 @@ return renderer } + private func shouldUseDrawHierarchy( + renderingMode: SwiftUIRenderingMode, viewController: UIViewController + ) -> Bool { + #if canImport(SwiftUI) + switch renderingMode { + case .enabled: + return true + case .disabled: + return false + case .auto: + if #available(iOS 13.0, tvOS 13.0, *) { + // Auto-detect SwiftUI content and prefer drawHierarchy for better rendering accuracy + // Only when a key window is available to avoid breaking framework tests + let typeName = String(describing: type(of: viewController)) + if typeName.contains("UIHostingController") && getKeyWindow() != nil { + return true + } + } + return false + } + #else + return false + #endif + } + private func add( traits: UITraitCollection, viewController: UIViewController, to window: UIWindow ) -> () -> Void { diff --git a/Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md b/Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md index 7a698fd7..bf7e5c55 100644 --- a/Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md +++ b/Sources/SnapshotTesting/Documentation.docc/Articles/CustomStrategies.md @@ -91,6 +91,48 @@ extension Snapshotting where Value == WKWebView, Format == UIImage { } ``` +## SwiftUI Considerations + +When working with SwiftUI views, you can control the rendering method using the `renderingMode` +parameter to ensure proper snapshot consistency across different iOS versions and complex shapes. + +### Rendering Modes + +SwiftUI views with complex shapes (such as `RoundedRectangle` with `.continuous` style) may render +differently when using Core Graphics layer rendering versus the system's native drawing hierarchy. +The `renderingMode` parameter gives you explicit control over this behavior: + +``` swift +// Default: Automatic detection (recommended) +assertSnapshot(of: mySwiftUIView, as: .image) +assertSnapshot(of: mySwiftUIView, as: .image(renderingMode: .auto)) + +// Always use high-accuracy rendering (requires host application) +assertSnapshot(of: mySwiftUIView, as: .image(renderingMode: .enabled)) + +// Always use layer rendering (works in framework tests) +assertSnapshot(of: mySwiftUIView, as: .image(renderingMode: .disabled)) +``` + +### Automatic Mode (`.auto`) + +The default `.auto` mode intelligently detects SwiftUI content and uses `drawHierarchy` rendering +for better accuracy when a key window is available, gracefully falling back to layer rendering +in framework test environments. + +### Framework Test Compatibility + +When using `.auto` mode, the library automatically adapts to framework test environments where +no key window is available. For complex SwiftUI views that may have minor rendering differences +in framework tests, consider using perceptual precision: + +``` swift +assertSnapshot(of: myView, as: .image( + renderingMode: .auto, + perceptualPrecision: 0.98 // Allow minor rendering differences +)) +``` + ## Diffing The ``SnapshotTesting/Diffing`` type represents the ability to compare `Value`s and convert them to diff --git a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift index 8d85e1f0..0322e54f 100644 --- a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift +++ b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift @@ -26,9 +26,8 @@ /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. /// /// - Parameters: - /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render - /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your - /// tests and will _not_ work for framework test targets. + /// - renderingMode: The rendering mode to use. Defaults to `.auto` which automatically + /// detects SwiftUI content and uses the most accurate rendering method when possible. /// - precision: The percentage of pixels that must match. /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a /// match. 98-99% mimics @@ -37,7 +36,7 @@ /// - layout: A view layout override. /// - traits: A trait collection override. public static func image( - drawHierarchyInKeyWindow: Bool = false, + renderingMode: SwiftUIRenderingMode = .auto, precision: Float = 1, perceptualPrecision: Float = 1, layout: SwiftUISnapshotLayout = .sizeThatFits, @@ -81,13 +80,45 @@ return snapshotView( config: config, - drawHierarchyInKeyWindow: drawHierarchyInKeyWindow, + renderingMode: renderingMode, traits: traits, view: controller.view, viewController: controller ) } } + + /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. + /// + /// - Parameters: + /// - drawHierarchyInKeyWindow: Utilize the simulator's key window in order to render + /// `UIAppearance` and `UIVisualEffect`s. This option requires a host application for your + /// tests and will _not_ work for framework test targets. + /// - precision: The percentage of pixels that must match. + /// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a + /// match. 98-99% mimics + /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the + /// human eye. + /// - layout: A view layout override. + /// - traits: A trait collection override. + @available(*, deprecated, message: "Use renderingMode parameter instead") + public static func image( + drawHierarchyInKeyWindow: Bool, + precision: Float = 1, + perceptualPrecision: Float = 1, + layout: SwiftUISnapshotLayout = .sizeThatFits, + traits: UITraitCollection = .init() + ) + -> Snapshotting + { + return .image( + renderingMode: drawHierarchyInKeyWindow ? .enabled : .disabled, + precision: precision, + perceptualPrecision: perceptualPrecision, + layout: layout, + traits: traits + ) + } } #endif #endif