doordash-oss / swiftui-preview-snapshots

Apache License 2.0
154 stars 7 forks source link

Add basic macOS support #3

Closed jflan-dd closed 11 months ago

jflan-dd commented 11 months ago

Add macOS support to Package.swift and loosen hard UIImage requirement to allow any snapshotting strategy that takes AnyView/ModifiedView as input.

jflan-dd commented 11 months ago

@mackoj This should be a more flexible solution to #1

mackoj commented 11 months ago

Thanks for making this.

andrewtheis commented 6 months ago

This doesn't appear to work, this code:

func test_snapshots() {
    MacOSContentView_Previews.snapshots.assertSnapshots(as: .image)
}

Results in compiler error:

Generic parameter 'Format' could not be inferred

Am I missing something?

@jflan-dd @mackoj

jflan-dd commented 6 months ago

@andrewtheis The main swift-snapshot-testing library doesn't ship with an NSImage version of .image out of the box. I defined one locally in tests within this project, but it felt weird to include it as part of the package since the this library is otherwise strategy agnostic.

You could try including this strategy in your project.

extension Snapshotting where Value: SwiftUI.View, Format == NSImage {
    static var image: Self {
        Snapshotting<NSView, NSImage>.image(size: .init(width: 400, height: 400)).pullback { view in
            let view = NSHostingView(rootView: view)
            view.wantsLayer = true
            view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
            return view
        }
    }
}
andrewtheis commented 6 months ago

@jflan-dd makes sense. This is code I used, which also correctly scales the image based on the backing scale factor - necessary if you're using Xcode Cloud, which runs at a 1px = 1pt resolution. Hope this helps anyone else that stumbles upon this!

#if os(macOS)
import AppKit
import Cocoa

extension Snapshotting where Value == NSView, Format == NSImage {

    public static func scaleFactorAdjustedImage(
        precision: Float = 1,
        perceptualPrecision: Float = 1,
        size: CGSize
    ) -> Snapshotting {
        return SimplySnapshotting.image(
            precision: precision, perceptualPrecision: perceptualPrecision
        ).asyncPullback { view in
            Async { callback in
                view.frame.size = size
                guard view.frame.width > 0, view.frame.height > 0 else {
                    fatalError("View not renderable to image at size \(view.frame.size)")
                }

                let bitmapRep = view.bitmapImageRepForCachingDisplay(in: view.bounds)!
                view.cacheDisplay(in: view.bounds, to: bitmapRep)

                // Use the scaling function to get the scaled image
                let image = adjustForBackingScaleFactor(bitmapRep, in: view)
                callback(image)
            }
        }
    }

    // Function to scale the image based on the window's scale factor
    static func adjustForBackingScaleFactor(_ bitmapRep: NSBitmapImageRep, in view: NSView) -> NSImage {
        let scaleFactor = 1 / (NSScreen.main?.backingScaleFactor ?? view.window?.backingScaleFactor ?? 1)
        let scaledSize = NSSize(width: bitmapRep.size.width * scaleFactor, height: bitmapRep.size.height * scaleFactor)

        guard scaleFactor < 1, let scaledBitmapRep = bitmapRep.copy() as? NSBitmapImageRep else {
            return NSImage(size: bitmapRep.size, flipped: false) { rect in
                bitmapRep.draw(in: rect)
            }
        }

        scaledBitmapRep.size = scaledSize
        return NSImage(size: scaledSize, flipped: false) { rect in
            scaledBitmapRep.draw(in: rect)
        }
    }
}

#endif

You'll need some additional code to wrap it in a NSHostingController and get the sizeThatFits

jflan-dd commented 6 months ago

@andrewtheis Thanks for sharing this! As a small nit-pick, is it possible to use plain .pullback instead of .asyncPullback since I don't see any asynchronous work happening via a callback?