diff --git a/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift b/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift index 6df87b1b7..744566979 100644 --- a/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift +++ b/Example/OpenSwiftUIUITests/UITests/SnapshotTesting+Testing.swift @@ -115,6 +115,34 @@ func openSwiftUIAssertSnapshot( ) } +// FIXME: Should remove controller in name +func openSwiftUIControllerAssertSnapshot( + of value: @autoclosure () -> V, + as snapshotting: Snapshotting, + named name: String? = nil, + record recording: Bool? = shouldRecord, + timeout: TimeInterval = 5, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + testName: String = #function, + line: UInt = #line, + column: UInt = #column +) { + openSwiftUIAssertSnapshot( + of: value(), + as: snapshotting, + named: name, + record: recording, + timeout: timeout, + fileID: fileID, + file: filePath, + testName: testName, + line: line, + column: column + ) +} + +// FIXME: Should be internal, private due to conflict infer private func openSwiftUIAssertSnapshot( of value: @autoclosure () -> Value, as snapshotting: Snapshotting, diff --git a/Example/OpenSwiftUIUITests/View/Image/AsyncImageUITests.swift b/Example/OpenSwiftUIUITests/View/Image/AsyncImageUITests.swift new file mode 100644 index 000000000..e53b36626 --- /dev/null +++ b/Example/OpenSwiftUIUITests/View/Image/AsyncImageUITests.swift @@ -0,0 +1,45 @@ +// +// AsyncImageUITests.swift +// OpenSwiftUIUITests + +import Foundation +import SnapshotTesting +import Testing + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct AsyncImageUITests { + @Test + func localLogoImage() async throws { + struct ContentView: View { + var body: some View { + AsyncImage(url: Bundle.main.url(forResource: "logo", withExtension: "png")) { phase in + switch phase { + case .empty: + Color.red + case .success(let image): + image + .resizable() + .frame(width: 100, height: 100) + case .failure: + Color.yellow + @unknown default: + Color.yellow + } + } + } + } + // FIXME: + // 1. SUI can screenshot without manully set frame for placeholder state while OSUI need it + // let controller = PlatformHostingController(rootView: ContentView()) + // openSwiftUIControllerAssertSnapshot(of: controller, as: .image, named: "placeholder") + // openSwiftUIControllerAssertSnapshot(of: controller, as: .wait(for: 5, on: .image), named: "logo") + // 2. OSUI can screenshot correctly using .wait(for: 1, on: .image) while SUI need to use try await Task.sleep + // Those mismatch behavior is PlatformViewController issue + let controller = PlatformHostingController(rootView: ContentView()) + controller.view.frame = CGRect(origin: .zero, size: CGSize(width: 200, height: 200)) + openSwiftUIControllerAssertSnapshot(of: controller, as: .image, named: "placeholder") + try await Task.sleep(for: .seconds(1)) + openSwiftUIControllerAssertSnapshot(of: controller, as: .image, named: "logo") + } +} diff --git a/Example/TestingHost/images/logo.png b/Example/TestingHost/images/logo.png new file mode 100644 index 000000000..597077362 Binary files /dev/null and b/Example/TestingHost/images/logo.png differ diff --git a/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.h b/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.h index c60e424c0..c65d75530 100644 --- a/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.h +++ b/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.h @@ -13,6 +13,7 @@ #if OPENSWIFTUI_TARGET_OS_OSX #import +#import OPENSWIFTUI_ASSUME_NONNULL_BEGIN @@ -24,6 +25,8 @@ OPENSWIFTUI_ASSUME_NONNULL_BEGIN @end +void _SetLayerViewDelegate(CALayer *layer, id view); + OPENSWIFTUI_ASSUME_NONNULL_END #endif /* OPENSWIFTUI_TARGET_OS_OSX */ diff --git a/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.m b/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.m index 608f98273..a2979f893 100644 --- a/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.m +++ b/Sources/COpenSwiftUI/Overlay/AppKit/OpenSwiftUI+NSView.m @@ -9,4 +9,8 @@ #if OPENSWIFTUI_TARGET_OS_OSX +void _SetLayerViewDelegate(CALayer *layer, id view) { + layer.delegate = view; +} + #endif diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.h b/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.h new file mode 100644 index 000000000..8fe08df4f --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.h @@ -0,0 +1,23 @@ +// +// OpenSwiftUI+UIView.h +// COpenSwiftUI + +#ifndef OpenSwiftUI_UIView_h +#define OpenSwiftUI_UIView_h + +#include "OpenSwiftUIBase.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +#import + +OPENSWIFTUI_ASSUME_NONNULL_BEGIN + +UIView * _UIKitCreateCustomView(Class class, CALayer *layer); + +OPENSWIFTUI_ASSUME_NONNULL_END + +#endif /* OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION */ + +#endif /* OpenSwiftUI_UIView_h */ + diff --git a/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.m b/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.m new file mode 100644 index 000000000..2d83818b8 --- /dev/null +++ b/Sources/COpenSwiftUI/Overlay/UIKit/OpenSwiftUI+UIView.m @@ -0,0 +1,16 @@ +// +// OpenSwiftUI+UIView.m +// COpenSwiftUI + +#import "OpenSwiftUI+UIView.h" + +#if OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION + +#include "Shims/UIKit/UIKit_Private.h" + +UIView * _UIKitCreateCustomView(Class class, CALayer *layer) { + return [[class alloc] _initWithLayer:layer]; +} + +#endif /* OPENSWIFTUI_TARGET_OS_IOS || OPENSWIFTUI_TARGET_OS_VISION */ + diff --git a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h index 9cd1da1c1..0a7140e5a 100644 --- a/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/AppKit/AppKit_Private.h @@ -29,6 +29,8 @@ typedef OPENSWIFTUI_ENUM(NSInteger, NSViewVibrantBlendingStyle) { @interface NSView () @property (getter=isOpaque) BOOL opaque; - (void)_updateLayerGeometryFromView; +- (void)_updateLayerShadowFromView; +- (void)_updateLayerShadowColorFromView; @end @interface NSAppearance (OpenSwiftUI_SPI) diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h index 54f8d22f9..8f5380698 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.h @@ -91,8 +91,6 @@ bool UIViewIgnoresTouchEvents(UIView *view); OPENSWIFTUI_EXPORT float UIAnimationDragCoefficient(void); -UIView * _UIKitCreateCustomView(Class class, CALayer *layer); - // MARK: - UIUpdate related private API from UIKitCore OPENSWIFTUI_EXPORT diff --git a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m index 2c50ec437..e6f050bb1 100644 --- a/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m +++ b/Sources/COpenSwiftUI/Shims/UIKit/UIKit_Private.m @@ -110,8 +110,4 @@ - (NSObject *)_environmentWrapper_openswiftui_safe_wrapper { } @end -UIView * _UIKitCreateCustomView(Class class, CALayer *layer) { - return [[class alloc] _initWithLayer:layer]; -} - #endif /* __has_include() */ diff --git a/Sources/OpenSwiftUI/Render/DisplayList/AppKitDisplayList.swift b/Sources/OpenSwiftUI/Render/DisplayList/AppKitDisplayList.swift index 79b96df2c..465f69534 100644 --- a/Sources/OpenSwiftUI/Render/DisplayList/AppKitDisplayList.swift +++ b/Sources/OpenSwiftUI/Render/DisplayList/AppKitDisplayList.swift @@ -60,8 +60,22 @@ final class NSViewPlatformViewDefinition: PlatformViewDefinition, @unchecked Sen } } + // Audited for 6.5.4 override static func makeLayerView(type: CALayer.Type, kind: PlatformViewDefinition.ViewKind) -> AnyObject { - _openSwiftUIUnimplementedFailure() + let cls: NSView.Type + if kind == .shape { + cls = _NSShapeHitTestingView.self + } else if kind == .platformLayer { + cls = _NSPlatformLayerView.self + } else { + cls = kind.isContainer ? _NSInheritedView.self : _NSGraphicsView.self + } + let view = cls.init() + let layer = type.init() + _SetLayerViewDelegate(layer, view) + view.layer = layer + initView(view, kind: kind) + return view } override class func makePlatformView(view: AnyObject, kind: PlatformViewDefinition.ViewKind) { @@ -142,4 +156,49 @@ private class _NSProjectionView: _NSInheritedView { layer?.transform = .init(projectionTransform) } } + +// MARK: - _NSShapeHitTestingView [WIP] + +@objc +private class _NSShapeHitTestingView: _NSGraphicsView { + var path: Path + + override init(frame frameRect: NSRect) { + path = .init() + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + path = .init() + super.init(coder: coder) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + // path.contains(, eoFill: false) + _openSwiftUIUnimplementedWarning() + return nil + } +} + +// MARK: - _NSPlatformLayerView + +@objc +private class _NSPlatformLayerView: _NSGraphicsView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func _updateLayerShadowFromView() { + _openSwiftUIEmptyStub() + } + + override func _updateLayerShadowColorFromView() { + _openSwiftUIEmptyStub() + } +} + #endif diff --git a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift index e9bb03d5c..0a05946b5 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift @@ -1,27 +1,46 @@ // // ContentTransition.swift // OpenSwiftUICore - -// TODO +// +// Audited for 6.5.4 +// Status: WIP public import OpenCoreGraphicsShims package import OpenRenderBoxShims +// MARK: - ContentTransition [WIP] + +/// A kind of transition that applies to the content within a single view, +/// rather than to the insertion or removal of a view. +/// +/// Set the behavior of content transitions within a view with the +/// ``View/contentTransition(_:)`` modifier, passing in one of the defined +/// transitions, such as ``opacity`` or ``interpolate`` as the parameter. +/// +/// > Tip: Content transitions only take effect within transactions that apply +/// an ``Animation`` to the views inside the ``View/contentTransition(_:)`` +/// modifier. +/// +/// Content transitions only take effect within the context of an +/// ``Animation`` block. @available(OpenSwiftUI_v4_0, *) public struct ContentTransition: Equatable, Sendable { + + // MARK: - ContentTransition.Storage + package enum Storage: Equatable, @unchecked Sendable { case named(ContentTransition.NamedTransition) // case custom(ContentTransition.CustomTransition) -// case symbolReplace(_SymbolEffect.ReplaceConfiguration) + case symbolReplace(_SymbolEffect.ReplaceConfiguration) } + // MARK: - ContentTransition.Style + @_spi(Private) public struct Style: Hashable, Sendable/*, Codable*/ { package enum Storage: Hashable, Sendable { case `default` - case sessionWidget - case animatedWidget } @@ -44,9 +63,12 @@ public struct ContentTransition: Equatable, Sendable { package var isReplaceable: Bool package init(storage: ContentTransition.Storage) { - _openSwiftUIUnimplementedFailure() + self.storage = storage + self.isReplaceable = false } + // MARK: - ContentTransition.NamedTransition + package struct NamedTransition: Hashable, Sendable { package enum Name: Hashable { case `default` @@ -55,7 +77,7 @@ public struct ContentTransition: Equatable, Sendable { case diff case fadeIfDifferent case text(different: Bool) - // case numericText(ContentTransition.NumericTextConfiguration) + case numericText(ContentTransition.NumericTextConfiguration) } package var name: ContentTransition.NamedTransition.Name @@ -73,7 +95,78 @@ public struct ContentTransition: Equatable, Sendable { } } - // TODO: NumericTextConfiguration + // MARK: - ContentTransition.NumericTextConfiguration [WIP] + + @_spi(Private) + @available(OpenSwiftUI_v5_0, *) + public struct NumericTextConfiguration: Hashable, Sendable { + package enum Direction: Hashable { + case fixed(downwards: Bool) + case automatic(value: Float) + } + + package struct Options: OptionSet, Hashable { + package let rawValue: UInt8 + + package init(rawValue: UInt8) { + self.rawValue = rawValue + } + + package static let reversed: ContentTransition.NumericTextConfiguration.Options = .init(rawValue: 1 << 0) + + package static let relativeBlur: ContentTransition.NumericTextConfiguration.Options = .init(rawValue: 1 << 1) + } + + package var direction: ContentTransition.NumericTextConfiguration.Direction + package var axis: Axis? + package var options: ContentTransition.NumericTextConfiguration.Options + private var _delay: UInt8 = 18 + private var _scale: UInt8 = 51 + private var _blur: UInt8 = 32 + private var _offset: UInt8 = 19 + + @_spi(Private) + package init( + direction: ContentTransition.NumericTextConfiguration.Direction = .fixed(downwards: false), + axis: Axis? = nil, + options: ContentTransition.NumericTextConfiguration.Options = .relativeBlur + ) { + self.direction = direction + self.axis = axis + self.options = options + } + + @_spi(Private) + package var delay: Float { + get { _openSwiftUIUnimplementedFailure() } + set { _openSwiftUIUnimplementedFailure() } + } + + package var maxDurationMultiple: Float { + _openSwiftUIUnimplementedFailure() + } + + package var scale: Float { + get { _openSwiftUIUnimplementedFailure() } + set { _openSwiftUIUnimplementedFailure() } + } + + package var blur: Float { + get { _openSwiftUIUnimplementedFailure() } + set { _openSwiftUIUnimplementedFailure() } + } + + package var relativeBlur: Float { + get { _openSwiftUIUnimplementedFailure() } + set { _openSwiftUIUnimplementedFailure() } + } + + package var offset: Float { + get { _openSwiftUIUnimplementedFailure() } + set { _openSwiftUIUnimplementedFailure() } + } + } + @_spi(Private) public struct EffectType: Equatable, Sendable { @@ -223,8 +316,193 @@ public struct ContentTransition: Equatable, Sendable { } } - // TODO - package struct State {} + @_spi(Private) + public static let `default`: ContentTransition = .init(storage: .named(.init())) // FIXME + + /// The identity content transition, which indicates that content changes + /// shouldn't animate. + /// + /// You can pass this value to a ``View/contentTransition(_:)`` + /// modifier to selectively disable animations that would otherwise + /// be applied by a ``withAnimation(_:_:)`` block. + public static let identity: ContentTransition = .init(storage: .named(.init())) // FIXME + + /// A content transition that indicates content fades from transparent + /// to opaque on insertion, and from opaque to transparent on removal. + public static let opacity: ContentTransition = .init(storage: .named(.init())) // FIXME + + /// A content transition that indicates the views attempt to interpolate + /// their contents during transitions, where appropriate. + /// + /// Text views can interpolate transitions when the text views have + /// identical strings. Matching glyph pairs can animate changes to their + /// color, position, size, and any variable properties. Interpolation can + /// apply within a ``Font/Design`` case, but not between cases, or between + /// entirely different fonts. For example, you can interpolate a change + /// between ``Font/Weight/thin`` and ``Font/Weight/black`` variations of a + /// font, since these are both cases of ``Font/Weight``. However, you can't + /// interpolate between the default design of a font and its Italic version, + /// because these are different fonts. Any changes that can't show an + /// interpolated animation use an opacity animation instead. + /// + /// Symbol images created with the ``Image/init(systemName:)`` initializer + /// work the same way as text: changes within the same symbol attempt to + /// interpolate the symbol's paths. When interpolation is unavailable, the + /// system uses an opacity transition instead. + public static let interpolate: ContentTransition = .init(storage: .named(.init())) // FIXME + + // MARK: - ContentTransition.Options + + @_spi(Private) + @frozen + public struct Options: OptionSet { + public let rawValue: UInt32 + + @inlinable + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let addsDrawingGroup: ContentTransition.Options = .init(rawValue: 1 << 0) + + public static let animatesDifferentContent: ContentTransition.Options = .init(rawValue: 1 << 1) + + package static let formsGroup: ContentTransition.Options = .init(rawValue: 1 << 2) + + package static let implicitGroup: ContentTransition.Options = .init(rawValue: 1 << 3) + + package static let inherited: ContentTransition.Options = .addsDrawingGroup + } + + // MARK: - ContentTransition.State + + package struct State: Equatable, EnvironmentKey { + package static let defaultValue: ContentTransition.State = .init() + + package var transition: ContentTransition + package var style: ContentTransition.Style + package var animation: Animation? + package var options: ContentTransition.Options + + package init( + transition: ContentTransition = .default, + style: ContentTransition.Style = .default, + animation: Animation? = nil, + options: ContentTransition.Options = .init() + ) { + self.transition = transition + self.style = style + self.animation = animation + self.options = options + } + + package var rasterizationOptions: RasterizationOptions { + var rasterizationOptions = RasterizationOptions() + rasterizationOptions.flags.subtract(.requiresLayer) + rasterizationOptions.flags.formUnion(options.contains(.addsDrawingGroup) ? .isAccelerated : []) + return rasterizationOptions + } + + package mutating func applyDynamicTextAnimation( + in transaction: Transaction + ) { + guard animation == nil, + !transaction.disablesAnimations, + style != .default + else { return } + animation = .default + } + } + + package mutating func applyEnvironmentValues( + style: ContentTransition.Style, + layoutDirection: LayoutDirection + ) { + _openSwiftUIUnimplementedFailure() + } +} + +// MARK: - EnvironmentValues + ContentTransition + +@available(OpenSwiftUI_v4_0, *) +extension EnvironmentValues { + package var contentTransitionState: ContentTransition.State { + get { self[ContentTransition.State.self] } + set { self[ContentTransition.State.self] = newValue } + } + + /// The current method of animating the contents of views. + public var contentTransition: ContentTransition { + get { contentTransitionState.transition } + set { contentTransitionState.transition = newValue } + } + + @_spi(Private) + public var contentTransitionStyle: ContentTransition.Style { + get { contentTransitionState.style } + set { contentTransitionState.style = newValue } + } + + @_spi(Private) + public var contentTransitionAnimation: Animation? { + get { contentTransitionState.animation } + set { contentTransitionState.animation = newValue } + } + + /// A Boolean value that controls whether views that render content + /// transitions use GPU-accelerated rendering. + /// + /// Setting this value to `true` causes SwiftUI to wrap content transitions + /// with a ``View/drawingGroup(opaque:colorMode:)`` modifier. + public var contentTransitionAddsDrawingGroup: Bool { + get { contentTransitionState.options.contains(.addsDrawingGroup) } + set { contentTransitionState.options.setValue(newValue, for: .addsDrawingGroup) } + } + + package var contentTransitionGroupEffect: ContentTransitionEffect { + ContentTransitionEffect(state: contentTransitionState) + } +} + +// MARK: - _ContentTransitionGroup [WIP] + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +@frozen +public struct _ContentTransitionGroup: MultiViewModifier, PrimitiveViewModifier { + init() { + _openSwiftUIEmptyStub() + } + + nonisolated public static func _makeView( + modifier: _GraphValue<_ContentTransitionGroup>, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + _openSwiftUIUnimplementedFailure() + } +} + +// MARK: - ContentTransitionEffect [WIP] + +package struct ContentTransitionEffect: _RendererEffect { + package var state: ContentTransition.State + + package init(state: ContentTransition.State) { + self.state = state + } + + package func effectValue(size: CGSize) -> DisplayList.Effect { + .contentTransition(state) + } + + nonisolated package static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + _openSwiftUIUnimplementedFailure() + } } // FIXME: ORB @@ -271,3 +549,9 @@ package enum ORBTransitionEffectType: UInt32, Equatable { case translationScale = 15 case relativeBlur = 16 } + +// MARK: - DisablesContentTransitionsKey + +package struct DisablesContentTransitionsKey: EnvironmentKey { + package static var defaultValue: Bool { false } +} diff --git a/Sources/OpenSwiftUICore/Layout/Dynamic/DynamicLayoutView.swift b/Sources/OpenSwiftUICore/Layout/Dynamic/DynamicLayoutView.swift index 0f0547a3d..003728023 100644 --- a/Sources/OpenSwiftUICore/Layout/Dynamic/DynamicLayoutView.swift +++ b/Sources/OpenSwiftUICore/Layout/Dynamic/DynamicLayoutView.swift @@ -301,9 +301,6 @@ struct DynamicLayoutScrollable {} // TODO: - ViewListContentTransition -// FIXME -struct ContentTransitionEffect {} - private struct ViewListContentTransition: StatefulRule, AsyncAttribute where T: Transition { var helper: TransitionHelper @Attribute var size: ViewSize diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index b7c2cf11b..7f38f358d 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -530,6 +530,21 @@ extension DisplayList { init() { // _openSwiftUIUnimplementedFailure() } + + func reset() { + _openSwiftUIEmptyStub() + } + + func rewriteDisplayList( + _ list: inout DisplayList, + time: Attribute