Skip to content

fix: default to drawHierarchy rendering when available #996

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Sources/SnapshotTesting/Common/View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -965,6 +1017,26 @@
return dispose
}

func snapshotView(
config: ViewImageConfig,
renderingMode: SwiftUIRenderingMode,
traits: UITraitCollection,
view: UIView,
viewController: UIViewController
)
-> Async<UIImage>
{
// 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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 36 additions & 5 deletions Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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