From e7a6a7375936a2b14ba0d1e8f79bfaaf2181ba21 Mon Sep 17 00:00:00 2001 From: Itay Date: Thu, 26 Sep 2024 17:02:58 -0300 Subject: [PATCH 1/9] Load PreviewModifier before rendering preview --- .../View+PreviewModifier.swift | 21 ++++++++++ .../RenderingStrategy.swift | 12 ++++++ .../SnapshotPreviewsCore.swift | 40 +++++++++++++++++- .../SwiftUIRenderingStrategy.swift | 41 ++++++++++--------- .../UIKitRenderingStrategy.swift | 19 +++++---- 5 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift diff --git a/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift b/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift new file mode 100644 index 00000000..e58472ca --- /dev/null +++ b/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift @@ -0,0 +1,21 @@ +// +// View+PreviewModifier.swift +// SnapshotPreviews +// +// Created by Itay Brenner on 25/9/24. +// + +import SwiftUI +import PreviewsSupport + +extension View { + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) + func applyPreviewModifiers(_ modifiers: [any PreviewModifier]) -> some View { + var currentView: AnyView = AnyView(self) + for modifier in modifiers { + let viewModifier = PreviewModifierViewModifierHelper.getViewModifier(modifier: modifier) + currentView = AnyView(currentView.modifier(viewModifier)) + } + return currentView + } +} diff --git a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift index 05f29fa3..dc06b2c7 100644 --- a/Sources/SnapshotPreviewsCore/RenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/RenderingStrategy.swift @@ -61,6 +61,10 @@ public protocol RenderingStrategy { @MainActor func render( preview: SnapshotPreviewsCore.Preview, completion: @escaping (SnapshotResult) -> Void) + + @MainActor func preparePreview( + preview: SnapshotPreviewsCore.Preview + ) async } private let testHandler: NSObject.Type? = NSClassFromString("EMGTestHandler") as? NSObject.Type @@ -69,5 +73,13 @@ extension RenderingStrategy { static func setup() { testHandler?.perform(NSSelectorFromString("setup")) } + + @MainActor public func preparePreview( + preview: SnapshotPreviewsCore.Preview + ) async { + if #available(iOS 18.0, *) { + await preview.loadPreviewModifiers() + } + } } diff --git a/Sources/SnapshotPreviewsCore/SnapshotPreviewsCore.swift b/Sources/SnapshotPreviewsCore/SnapshotPreviewsCore.swift index dba656ef..8ea3a384 100644 --- a/Sources/SnapshotPreviewsCore/SnapshotPreviewsCore.swift +++ b/Sources/SnapshotPreviewsCore/SnapshotPreviewsCore.swift @@ -14,6 +14,7 @@ public struct Preview: Identifiable { P.previews } } + previewModifiers = [] } #if compiler(>=5.9) @@ -39,6 +40,16 @@ public struct Preview: Identifiable { } } } + let previewModifiersArrays = traits.compactMap({ trait in + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *), + let value = Mirror(reflecting: trait).descendant("value"), + let value = value as? [(any PreviewModifier)] { + return value + } + return nil + }).flatMap { $0 } + self.previewModifiers = previewModifiersArrays + self.orientation = orientation self.layout = layout displayName = preview.descendant("displayName") as? String @@ -77,9 +88,34 @@ public struct Preview: Identifiable { public let index: Int public let device: PreviewDevice? public let layout: PreviewLayout + public let previewModifiers: [Any] private let _view: @MainActor () -> any View - @MainActor public func view() -> any View { - _view() + @MainActor + public func view() -> any View { + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) { + return _view().applyPreviewModifiers(previewModifiers as! [any PreviewModifier]) + } + return _view() + } + + @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) + @MainActor + func loadPreviewModifiers() async { + for previewModifier in previewModifiers { + guard let modifier = previewModifier as? (any PreviewModifier) else { + continue + } + + let type = type(of: modifier) + let hash = String(describing: type) + + guard PreviewModifierContextCache.contextCache[hash] == nil else { + continue + } + + let context = try! await type.makeSharedContext() + PreviewModifierContextCache.contextCache[hash] = context + } } } diff --git a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift index 185be759..0cb1e6e7 100644 --- a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift @@ -23,25 +23,28 @@ public class SwiftUIRenderingStrategy: RenderingStrategy { preview: SnapshotPreviewsCore.Preview, completion: @escaping (SnapshotResult) -> Void) { - Self.setup() - var view = preview.view() - colorScheme = nil - view = PreferredColorSchemeWrapper { - AnyView(view) - } colorSchemeUpdater: { [weak self] scheme in - self?.colorScheme = scheme - } - let wrappedView = EmergeModifierView(wrapped: view) - let renderer = ImageRenderer(content: wrappedView) - #if canImport(UIKit) - let image = renderer.uiImage - #else - let image = renderer.nsImage - #endif - if let image { - completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) - } else { - completion(SnapshotResult(image: .failure(SwiftUIRenderingError.renderingError), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) + Task { @MainActor in + Self.setup() + await preparePreview(preview: preview) + var view = preview.view() + colorScheme = nil + view = PreferredColorSchemeWrapper { + AnyView(view) + } colorSchemeUpdater: { [weak self] scheme in + self?.colorScheme = scheme + } + let wrappedView = EmergeModifierView(wrapped: view) + let renderer = ImageRenderer(content: wrappedView) +#if canImport(UIKit) + let image = renderer.uiImage +#else + let image = renderer.nsImage +#endif + if let image { + completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) + } else { + completion(SnapshotResult(image: .failure(SwiftUIRenderingError.renderingError), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) + } } } } diff --git a/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift index 35bc42a6..e8fed92d 100644 --- a/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift @@ -37,23 +37,26 @@ public class UIKitRenderingStrategy: RenderingStrategy { preview: SnapshotPreviewsCore.Preview, completion: @escaping (SnapshotResult) -> Void ) { + Task { @MainActor in Self.setup() geometryUpdateError = nil let targetOrientation = preview.orientation.toInterfaceOrientation() + await preparePreview(preview: preview) guard #available(iOS 16.0, *), windowScene!.interfaceOrientation != targetOrientation else { - performRender(preview: preview, completion: completion) - return + performRender(preview: preview, completion: completion) + return } - + windowScene!.requestGeometryUpdate(.iOS(interfaceOrientations: targetOrientation.toInterfaceOrientationMask())) { error in - NSLog("Rotation error handler: \(error) \(self.windowScene!.interfaceOrientation)") - DispatchQueue.main.async { - self.geometryUpdateError = error - } + NSLog("Rotation error handler: \(error) \(self.windowScene!.interfaceOrientation)") + DispatchQueue.main.async { + self.geometryUpdateError = error + } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.waitForOrientationChange(targetOrientation: targetOrientation, preview: preview, attempts: 50, completion: completion) + self?.waitForOrientationChange(targetOrientation: targetOrientation, preview: preview, attempts: 50, completion: completion) } + } } @MainActor private func waitForOrientationChange( From 49ec24224caceb3c99da2d98ff3b5e156fc86e60 Mon Sep 17 00:00:00 2001 From: Itay Date: Thu, 26 Sep 2024 18:49:41 -0300 Subject: [PATCH 2/9] Add AnyPreviewModifier to SnapshotsPreview --- .../PreviewModifier/AnyPreviewModifier.swift | 31 +++++++++++++++++++ .../PreviewModifierContextCache.swift | 13 ++++++++ .../View+PreviewModifier.swift | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 Sources/SnapshotPreviewsCore/PreviewModifier/AnyPreviewModifier.swift create mode 100644 Sources/SnapshotPreviewsCore/PreviewModifier/PreviewModifierContextCache.swift diff --git a/Sources/SnapshotPreviewsCore/PreviewModifier/AnyPreviewModifier.swift b/Sources/SnapshotPreviewsCore/PreviewModifier/AnyPreviewModifier.swift new file mode 100644 index 00000000..a8d4e7f3 --- /dev/null +++ b/Sources/SnapshotPreviewsCore/PreviewModifier/AnyPreviewModifier.swift @@ -0,0 +1,31 @@ +// +// AnyPreviewModifier.swift +// SnapshotPreviews +// +// Created by Itay Brenner on 26/9/24. +// + +import SwiftUI + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +struct AnyPreviewModifier: PreviewModifier { + + private let _body: (PreviewModifier.Content) -> AnyView + + init(_ modifier: M) { + let type = type(of: modifier) + let hash = String(describing: type) + + _body = { content in + let cachedContext = PreviewModifierContextCache.contextCache[hash] + guard let typedContext = cachedContext as? M.Context else { + fatalError("Context type mismatch, expected: \(String(describing: M.Context.self)), got: \(String(describing: cachedContext.self))") + } + return AnyView(modifier.body(content: content, context: typedContext)) + } + } + + func body(content: PreviewModifier.Content, context: Void) -> AnyView { + return _body(content) + } +} diff --git a/Sources/SnapshotPreviewsCore/PreviewModifier/PreviewModifierContextCache.swift b/Sources/SnapshotPreviewsCore/PreviewModifier/PreviewModifierContextCache.swift new file mode 100644 index 00000000..3287543d --- /dev/null +++ b/Sources/SnapshotPreviewsCore/PreviewModifier/PreviewModifierContextCache.swift @@ -0,0 +1,13 @@ +// +// PreviewModifierContextCache.swift +// SnapshotPreviews +// +// Created by Itay Brenner on 26/9/24. +// + +import SwiftUI + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, *) +struct PreviewModifierContextCache { + static var contextCache: [String: Any] = [:] +} diff --git a/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift b/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift index e58472ca..7a03284a 100644 --- a/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift +++ b/Sources/SnapshotPreviewsCore/PreviewModifier/View+PreviewModifier.swift @@ -13,7 +13,7 @@ extension View { func applyPreviewModifiers(_ modifiers: [any PreviewModifier]) -> some View { var currentView: AnyView = AnyView(self) for modifier in modifiers { - let viewModifier = PreviewModifierViewModifierHelper.getViewModifier(modifier: modifier) + let viewModifier = PreviewModifierSupport.toViewModifier(modifier: AnyPreviewModifier(modifier)) currentView = AnyView(currentView.modifier(viewModifier)) } return currentView From da7b0647a3e478cc97f2d4999fa693ee2bb821bc Mon Sep 17 00:00:00 2001 From: Trevor Elkins Date: Mon, 30 Sep 2024 17:30:37 -0400 Subject: [PATCH 3/9] Add AppStoreScreenshot modifier (#204) * Add AppStoreScreenshot modifier * fix * fix * fix * fix * Fix * fix * screenshot -> snapshot * Add to SnapshotResult * Fix macOS --- .../AppStoreSnapshotPreference.swift | 48 +++++++++++++++++++ .../EmergeModifierFinder.swift | 4 ++ .../AppKitRenderingStrategy.swift | 9 ++-- .../ExpandingViewController.swift | 4 +- .../SnapshotPreviewsCore/ModifierFinder.swift | 4 ++ .../RenderingStrategy.swift | 5 +- .../SwiftUIRenderingStrategy.swift | 4 +- .../UIKitRenderingStrategy.swift | 4 +- .../SnapshotPreviewsCore/View+Snapshot.swift | 10 ++-- 9 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 Sources/SnapshotPreferences/AppStoreSnapshotPreference.swift diff --git a/Sources/SnapshotPreferences/AppStoreSnapshotPreference.swift b/Sources/SnapshotPreferences/AppStoreSnapshotPreference.swift new file mode 100644 index 00000000..599b49c2 --- /dev/null +++ b/Sources/SnapshotPreferences/AppStoreSnapshotPreference.swift @@ -0,0 +1,48 @@ +// +// AppStoreSnapshotPreference.swift +// +// +// Created by Trevor Elkins on 09/30/24. +// + +import Foundation +import SwiftUI + +struct AppStoreSnapshotPreferenceKey: PreferenceKey { + static func reduce(value: inout Bool?, nextValue: () -> Bool?) { + if value == nil { + value = nextValue() + } + } + + static var defaultValue: Bool? = nil +} + +extension View { + /// Marks a snapshot for use with our App Store screenshot editing tool. This should ideally be used with a + /// full-size preview matching one of our supported devices. + /// + /// - Note: This method is only available on iOS. It is unavailable on macOS, watchOS, visionOS, and tvOS. + /// + /// - Parameter enabled: A Boolean value that determines whether the snapshot is for an App Store screenshot. + /// If `nil`, the effect will default to `false`. + /// + /// - Returns: A view with the app store snapshot preference applied. + /// + /// # Example + /// ```swift + /// struct ContentView: View { + /// var body: some View { + /// Text("My App Store listing!") + /// .emergeAppStoreSnapshot(true) + /// } + /// } + /// ``` + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + @available(tvOS, unavailable) + public func emergeAppStoreSnapshot(_ enabled: Bool?) -> some View { + preference(key: AppStoreSnapshotPreferenceKey.self, value: enabled) + } +} diff --git a/Sources/SnapshotPreferences/EmergeModifierFinder.swift b/Sources/SnapshotPreferences/EmergeModifierFinder.swift index 3e6ea8d7..0fa533cf 100644 --- a/Sources/SnapshotPreferences/EmergeModifierFinder.swift +++ b/Sources/SnapshotPreferences/EmergeModifierFinder.swift @@ -30,6 +30,7 @@ class EmergeModifierState: NSObject { var renderingMode: EmergeRenderingMode.RawValue? var precision: Float? var accessibilityEnabled: Bool? + var appStoreSnapshot: Bool? } @objc(EmergeModifierFinder) @@ -49,5 +50,8 @@ class EmergeModifierFinder: NSObject { .onPreferenceChange(AccessibilityPreferenceKey.self, perform: { value in EmergeModifierState.shared.accessibilityEnabled = value }) + .onPreferenceChange(AppStoreSnapshotPreferenceKey.self, perform: { value in + EmergeModifierState.shared.appStoreSnapshot = value + }) } } diff --git a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift index 6c11aafc..4267490b 100644 --- a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift @@ -47,7 +47,7 @@ public class AppKitRenderingStrategy: RenderingStrategy { window.contentViewController = NSViewController() window.setContentSize(AppKitContainer.defaultSize) window.contentViewController = vc - vc.rendered = { [weak vc] mode, precision, accessibilityEnabled in + vc.rendered = { [weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot in DispatchQueue.main.async { let image = vc?.view.snapshot() completion( @@ -56,7 +56,8 @@ public class AppKitRenderingStrategy: RenderingStrategy { precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, - colorScheme: _colorScheme)) + colorScheme: _colorScheme, + appStoreSnapshot: appStoreSnapshot)) } } } @@ -70,7 +71,7 @@ final class AppKitContainer: NSHostingController, ScrollExpa var heightAnchor: NSLayoutConstraint? var previousHeight: CGFloat? - public var rendered: ((EmergeRenderingMode?, Float?, Bool?) -> Void)? { + public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?) -> Void)? { didSet { didCall = false } } @@ -120,7 +121,7 @@ final class AppKitContainer: NSHostingController, ScrollExpa guard !didCall else { return } didCall = true - rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled) + rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot) } override func updateViewConstraints() { diff --git a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift index c0e613a4..40c7e93e 100644 --- a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift +++ b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift @@ -31,7 +31,7 @@ public final class ExpandingViewController: UIHostingController Void)? { + public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, Error?) -> Void)? { didSet { didCall = false } } @@ -78,7 +78,7 @@ public final class ExpandingViewController: UIHostingController @@ -55,6 +57,7 @@ public struct SnapshotResult { public let accessibilityEnabled: Bool? public let accessibilityMarkers: [AccessibilityMark]? public let colorScheme: ColorScheme? + public let appStoreSnapshot: Bool? } public protocol RenderingStrategy { diff --git a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift index 185be759..01123ba6 100644 --- a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift @@ -39,9 +39,9 @@ public class SwiftUIRenderingStrategy: RenderingStrategy { let image = renderer.nsImage #endif if let image { - completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) + completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot)) } else { - completion(SnapshotResult(image: .failure(SwiftUIRenderingError.renderingError), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme)) + completion(SnapshotResult(image: .failure(SwiftUIRenderingError.renderingError), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot)) } } } diff --git a/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift index 35bc42a6..9f509a9a 100644 --- a/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/UIKitRenderingStrategy.swift @@ -63,12 +63,12 @@ public class UIKitRenderingStrategy: RenderingStrategy { completion: @escaping (SnapshotResult) -> Void ) { if let geometryUpdateError { - completion(SnapshotResult(image: .failure(geometryUpdateError), precision: nil, accessibilityEnabled: nil, accessibilityMarkers: nil, colorScheme: nil)) + completion(SnapshotResult(image: .failure(geometryUpdateError), precision: nil, accessibilityEnabled: nil, accessibilityMarkers: nil, colorScheme: nil, appStoreSnapshot: nil)) return } guard attempts > 0 else { let timeoutError = NSError(domain: "OrientationChangeTimeout", code: 0, userInfo: [NSLocalizedDescriptionKey: "Orientation change timed out"]) - completion(SnapshotResult(image: .failure(timeoutError), precision: nil, accessibilityEnabled: nil, accessibilityMarkers: nil, colorScheme: nil)) + completion(SnapshotResult(image: .failure(timeoutError), precision: nil, accessibilityEnabled: nil, accessibilityMarkers: nil, colorScheme: nil, appStoreSnapshot: nil)) return } diff --git a/Sources/SnapshotPreviewsCore/View+Snapshot.swift b/Sources/SnapshotPreviewsCore/View+Snapshot.swift index bb4892af..cfbe2d5f 100644 --- a/Sources/SnapshotPreviewsCore/View+Snapshot.swift +++ b/Sources/SnapshotPreviewsCore/View+Snapshot.swift @@ -58,14 +58,14 @@ extension View { async: Bool, completion: @escaping (SnapshotResult) -> Void) { - controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, error in + controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, appStoreSnapshot, error in guard let controller, let window, let containerVC = controller.parent else { return } if let error { DispatchQueue.main.async { - completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme)) + completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot)) } return } @@ -73,7 +73,7 @@ extension View { if async { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, rootVC: containerVC, targetView: controller.view) - completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme)) + completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot)) } } else { DispatchQueue.main.async { @@ -99,10 +99,10 @@ extension View { a11yView.sizeToFit() let result = Self.takeSnapshot(layout: .sizeThatFits, renderingMode: renderingMode, rootVC: containerVC, targetView: a11yView) a11yView.removeFromSuperview() - completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: elements, colorScheme: _colorScheme)) + completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: elements, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot)) } else { let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, rootVC: containerVC, targetView: controller.view) - completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme)) + completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot)) } } } From 8f26ff83b056af6b2eda060f5b97f18328baa830 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Tue, 1 Oct 2024 10:51:25 -0400 Subject: [PATCH 4/9] Fix names (#205) --- .../SnapshotPreviewsCore/ConformanceLookup.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SnapshotPreviewsCore/ConformanceLookup.swift b/Sources/SnapshotPreviewsCore/ConformanceLookup.swift index 1d13d2dd..bc07c647 100644 --- a/Sources/SnapshotPreviewsCore/ConformanceLookup.swift +++ b/Sources/SnapshotPreviewsCore/ConformanceLookup.swift @@ -11,6 +11,13 @@ import MachO private func getTypeName(descriptor: UnsafePointer) -> String? { let flags = descriptor.pointee.flags var parentName: String? = nil + if descriptor.pointee.parent != 0 { + let parent = UnsafeRawPointer(descriptor).advanced(by: MemoryLayout.offset(of: \.parent)!).advanced(by: Int(descriptor.pointee.parent)) + if abs(descriptor.pointee.parent) % 2 == 1 { + return nil + } + parentName = getTypeName(descriptor: parent.assumingMemoryBound(to: TargetModuleContextDescriptor.self)) + } switch flags.kind { case .Module, .Enum, .Struct, .Class: let name = UnsafeRawPointer(descriptor) @@ -18,19 +25,12 @@ private func getTypeName(descriptor: UnsafePointer.offset(of: \.parent)!).advanced(by: Int(descriptor.pointee.parent)) - if abs(descriptor.pointee.parent) % 2 == 1 { - return nil - } - parentName = getTypeName(descriptor: parent.assumingMemoryBound(to: TargetModuleContextDescriptor.self)) - } if let parentName = parentName { return "\(parentName).\(typeName)" } return typeName default: - return nil + return parentName } } From 94e16f96b2183f06673025cd8815fac5d8fbf131 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Tue, 1 Oct 2024 10:55:53 -0400 Subject: [PATCH 5/9] Update macOS runner and Xcode version (#202) * Update xcode * Bump macOS version * Replace SwiftUICore.View with SwiftUI.View * Use macos-14 to everything aside from xrOS * Use Xcode 15.4 where we don't need Xcode 16 * Use `macos-15` with `Xcode 15.4` * Remove whitespace * Only use macos 15 and xcode 16 for testing visionOS * Restore whitespace --------- Co-authored-by: Itay --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0bd51a0e..c0871856 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,13 +35,13 @@ jobs: - name: Build SnapshottingTests run: xcodebuild build -scheme SnapshottingTests -sdk appletvsimulator -destination 'generic/platform=tvOS Simulator' build-visionos: - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout code uses: actions/checkout@v2 - name: Xcode select - run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer' + run: sudo xcode-select -s '/Applications/Xcode_16.0.app/Contents/Developer' - name: Build TestApp run: cd Examples && xcodebuild build -scheme DemoApp -sdk xrsimulator -destination 'generic/platform=visionOS Simulator' -project DemoApp/DemoApp.xcodeproj - name: Build Snapshotting @@ -57,7 +57,7 @@ jobs: - name: Xcode select run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer' - name: Build Test Watch App - run: cd Examples && xcodebuild build -scheme 'Demo Watch App' -sdk watchsimulator -destination 'platform=watchOS Simulator,name=Apple Watch Series 9 (41mm)' -project DemoApp/DemoApp.xcodeproj CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + run: cd Examples && xcodebuild build -scheme 'Demo Watch App' -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' -project DemoApp/DemoApp.xcodeproj CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO - name: Build Snapshotting run: xcodebuild build -scheme Snapshotting -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' - name: Build SnapshottingTests From fc7a10d6307076565c5a780ca36dee6cb31fcccd Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Wed, 2 Oct 2024 10:59:18 -0400 Subject: [PATCH 6/9] Use one instance of rendering strategy (#206) --- Sources/SnapshottingTests/SnapshotTest.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 401efdeb..f87d9f80 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -49,7 +49,7 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { } #endif } - private let renderingStrategy = getRenderingStrategy() + private static let renderingStrategy = getRenderingStrategy() static private var previews: [SnapshotPreviewsCore.PreviewType] = [] @@ -79,7 +79,7 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { var result: SnapshotResult? = nil let expectation = XCTestExpectation() - renderingStrategy.render(preview: preview) { snapshotResult in + Self.renderingStrategy.render(preview: preview) { snapshotResult in result = snapshotResult expectation.fulfill() } From d0e145794067763fb957ec85bb21e6496426622b Mon Sep 17 00:00:00 2001 From: Itay Brenner Date: Wed, 20 Nov 2024 17:51:22 -0300 Subject: [PATCH 7/9] Update readme (#209) * Update readme * Update readme and script to upload PreviewGallery --- .github/workflows/release.yaml | 5 ++++- Package.swift | 1 + README.md | 18 ++++++++++-------- build.sh | 8 ++++++++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 04567fe1..0549022b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,8 +16,10 @@ jobs: run: sudo xcode-select -s '/Applications/Xcode_15.4.app/Contents/Developer' - name: Build xcframework run: sh build.sh - - name: Zip xcframework + - name: Zip SnapshottingTests xcframework run: zip -r SnapshottingTests.xcframework.zip SnapshottingTests.xcframework + - name: Zip PreviewGallery xcframework + run: zip -r PreviewGallery.xcframework.zip PreviewGallery.xcframework - name: Zip preivews support run: (cd PreviewsSupport && zip -r PreviewsSupport.xcframework.zip PreviewsSupport.xcframework) - name: Upload Artifact @@ -25,6 +27,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') with: files: | + PreviewGallery.xcframework.zip SnapshottingTests.xcframework.zip PreviewsSupport/PreviewsSupport.xcframework.zip body: diff --git a/Package.swift b/Package.swift index a0509b26..26f5cbbe 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "PreviewGallery", + type: .static, // Replace this to build dynamic targets: ["PreviewGallery"]), // Test library to import in your XCTest target. // This is the only library that depends on XCTest.framework diff --git a/README.md b/README.md index 03b69054..22b094e5 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # 📸 SnapshotPreviews -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews-iOS%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews-iOS) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews-iOS%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews-iOS) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FEmergeTools%2FSnapshotPreviews%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/EmergeTools/SnapshotPreviews) +[![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.emergetools.com%2Fapi%2Fv2%2Fpublic_new_build%3FexampleId%3Dsnapshotpreviews-ios.PreviewGallery%26platform%3Dios%26badgeOption%3Dversion_and_max_install_size%26buildType%3Drelease&query=$.badgeMetadata&label=PreviewGallery&logo=apple)](https://www.emergetools.com/app/example/ios/snapshotpreviews-ios.PreviewGallery/release?utm_campaign=badge-data) +[![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fwww.emergetools.com%2Fapi%2Fv2%2Fpublic_new_build%3FexampleId%3Dsnapshotpreviews-ios.SnapshottingTests%26platform%3Dios%26badgeOption%3Dversion_and_max_install_size%26buildType%3Drelease&query=$.badgeMetadata&label=SnapshottingTests&logo=apple)](https://www.emergetools.com/app/example/ios/snapshotpreviews-ios.SnapshottingTests/release?utm_campaign=badge-data) An all-in-one snapshot testing solution built on Xcode previews. Automatic browsable gallery of previews, and no-code snapshot generation with XCTest. Supports SwiftUI and UIKit previews using `PreviewProvider` or `#Preview` and works on all Apple platforms (iOS/macOS/watchOS/tvOS/visionOS). @@ -16,7 +18,7 @@ An all-in-one snapshot testing solution built on Xcode previews. Automatic brows `PreviewGallery` is an interactive UI built on top of snapshot extraction. It turns your Xcode previews into a gallery of components and features you can access from your application, for example in an internal settings screen. **Xcode is not required to view the previews.** You can use it to preview individual components (buttons/rows/icons/etc) or even entire interactive features.

- +

The public API of PreviewGallery is a single SwiftUI `View` named `PreviewGallery`. Displaying this view gives you access to the full gallery. For example, you could add a button to open the gallery like this: @@ -65,7 +67,7 @@ Note that there are no test functions; they are automatically added at runtime b > [!NOTE] > When you use Preview macros (`#Preview("Display Name")`) the name of the snapshot uses the file path and the name, for example: "MyModule/MyFile.swift:Display Name" -![Screenshot of Xcode test output](https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews-iOS/master/images/testOutput.png) +![Screenshot of Xcode test output](https://raw.githubusercontent.com/EmergeTools/SnapshotPreviews/master/images/testOutput.png) The [EmergeTools snapshot testing service](https://docs.emergetools.com/docs/snapshot-testing) generates snapshots and diffs them in the cloud to control for sources of flakiness, store images outside of git, and optimize test performance. `SnapshotTest` is for locally debugging these snapshot tests. You can also use `PreviewTest` to get code coverage of all previews in your unit test without generating PNGs. This will validate that previews do not crash (such as a missing @EnvironmentObject) but runs faster because it does not render the views to images. @@ -101,10 +103,10 @@ See the demo app for a full example. # Installation -Add the package dependency to your Xcode project using the URL of this repository (https://github.com/EmergeTools/SnapshotPreviews-iOS). +Add the package dependency to your Xcode project using the URL of this repository (https://github.com/EmergeTools/SnapshotPreviews).

- +

Link your app to `PreviewGallery` and (optionally) to `SnapshotPreferences` to customize the behavior of snapshot generation. @@ -150,7 +152,7 @@ Check `ProcessInfo.isRunningPeviews` to disable behavior you don’t want in pre > [!TIP] > Using PreviewVariants greatly simplifies snapshot testing, by ensuring a consistent set of variants and that every view is provided a name. -Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews-iOS/blob/main/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this: +Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews/blob/main/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this: ```swift struct MyView_Previews: PreviewProvider { @@ -172,7 +174,7 @@ struct MyView_Previews: PreviewProvider { # Star History -[![Star History Chart](https://api.star-history.com/svg?repos=EmergeTools/SnapshotPreviews-iOS&type=Date)](https://star-history.com/#EmergeTools/SnapshotPreviews-iOS&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=EmergeTools/SnapshotPreviews&type=Date)](https://star-history.com/#EmergeTools/SnapshotPreviews&Date) # Related Reading - [How to use VariadicView, SwiftUI's Private View API](https://www.emergetools.com/blog/posts/how-to-use-variadic-view): VariadicView is a core part of how multiple images are rendered for one PreviewProvider. diff --git a/build.sh b/build.sh index b4adb90b..a657ac81 100755 --- a/build.sh +++ b/build.sh @@ -45,10 +45,18 @@ build_framework() { # Update the Package.swift to build the library as dynamic instead of static sed -i '' '/Replace this/ s/.*/type: .dynamic,/' Package.swift +# Build SnapshottingTests build_framework "iphonesimulator" "generic/platform=iOS Simulator" "SnapshottingTests" build_framework "iphoneos" "generic/platform=iOS" "SnapshottingTests" +# Build PreviewGallery +build_framework "iphonesimulator" "generic/platform=iOS Simulator" "PreviewGallery" +build_framework "iphoneos" "generic/platform=iOS" "PreviewGallery" + echo "Builds completed successfully." rm -rf "SnapshottingTests.xcframework" xcodebuild -create-xcframework -framework SnapshottingTests-iphonesimulator.xcarchive/Products/Library/Frameworks/SnapshottingTests.framework -framework SnapshottingTests-iphoneos.xcarchive/Products/Library/Frameworks/SnapshottingTests.framework -output SnapshottingTests.xcframework + +rm -rf "PreviewGallery.xcframework" +xcodebuild -create-xcframework -framework PreviewGallery-iphonesimulator.xcarchive/Products/Library/Frameworks/PreviewGallery.framework -framework PreviewGallery-iphoneos.xcarchive/Products/Library/Frameworks/PreviewGallery.framework -output PreviewGallery.xcframework \ No newline at end of file From cdbf9cc5cb63a88f1329b958428f9706a6af557e Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Thu, 21 Nov 2024 14:20:19 -0800 Subject: [PATCH 8/9] Make SwiftUI Logic Also use RenderingError enum (#210) * try this out * just use .zero in SwiftUIRenderingStrategy --- .../AppKitRenderingStrategy.swift | 2 +- .../SwiftUIRenderingStrategy.swift | 6 +----- Sources/SnapshotPreviewsCore/View+Snapshot.swift | 14 ++++++++------ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift index 4267490b..6444b434 100644 --- a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift @@ -52,7 +52,7 @@ public class AppKitRenderingStrategy: RenderingStrategy { let image = vc?.view.snapshot() completion( SnapshotResult( - image: image != nil ? .success(image!) : .failure(SwiftUIRenderingError.renderingError), + image: image != nil ? .success(image!) : .failure(RenderingError.failedRendering(vc?.view.bounds.size ?? .zero)), precision: precision, accessibilityEnabled: accessibilityEnabled, accessibilityMarkers: nil, diff --git a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift index 01123ba6..a45ed0f1 100644 --- a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift @@ -8,10 +8,6 @@ import Foundation import SwiftUI -enum SwiftUIRenderingError: Error { - case renderingError -} - @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) public class SwiftUIRenderingStrategy: RenderingStrategy { @@ -41,7 +37,7 @@ public class SwiftUIRenderingStrategy: RenderingStrategy { if let image { completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot)) } else { - completion(SnapshotResult(image: .failure(SwiftUIRenderingError.renderingError), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot)) + completion(SnapshotResult(image: .failure(RenderingError.failedRendering(.zero)), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, accessibilityMarkers: [], colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot)) } } } diff --git a/Sources/SnapshotPreviewsCore/View+Snapshot.swift b/Sources/SnapshotPreviewsCore/View+Snapshot.swift index cfbe2d5f..e9d07ef3 100644 --- a/Sources/SnapshotPreviewsCore/View+Snapshot.swift +++ b/Sources/SnapshotPreviewsCore/View+Snapshot.swift @@ -5,12 +5,7 @@ // Created by Noah Martin on 12/22/22. // -#if canImport(UIKit) && !os(visionOS) && !os(watchOS) && !os(tvOS) -import Foundation -import SwiftUI -import UIKit -import AccessibilitySnapshotCore -import SnapshotSharedModels +import CoreFoundation public enum RenderingError: Error { case failedRendering(CGSize) @@ -18,6 +13,13 @@ public enum RenderingError: Error { case expandingViewTimeout(CGSize) } +#if canImport(UIKit) && !os(visionOS) && !os(watchOS) && !os(tvOS) +import Foundation +import SwiftUI +import UIKit +import AccessibilitySnapshotCore +import SnapshotSharedModels + extension AccessibilityMarker: AccessibilityMark { public var accessibilityShape: MarkerShape { switch shape { From 7318868992ce6c9e9e833c820a4a13c237568176 Mon Sep 17 00:00:00 2001 From: Noah Martin Date: Wed, 4 Dec 2024 10:12:55 -1000 Subject: [PATCH 9/9] Update README.md (#211) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 22b094e5..c363c5bb 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Check `ProcessInfo.isRunningPeviews` to disable behavior you don’t want in pre > [!TIP] > Using PreviewVariants greatly simplifies snapshot testing, by ensuring a consistent set of variants and that every view is provided a name. -Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews/blob/main/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this: +Using multiple variants of the same view can ensure test coverage of all the ways users interact with your UI. Most are provided by SwiftUI, eg: `.dynamicTypeSize(.xxxLarge)`. There is one built into the package: `.emergeAccessibility(true)`. This function adds a visualization of voice over elements to your snapshot. You can automatically add variants using the [`PreviewVariants` View](https://github.com/EmergeTools/SnapshotPreviews/blob/main/Examples/DemoApp/DemoApp/TestViews/PreviewVariants.swift) that is demonstrated in the example app. It adds RTL, landscape, accessibility, dark mode and large text variants. You can use it like this: ```swift struct MyView_Previews: PreviewProvider {