diff --git a/Example/OpenSwiftUIUITests/Render/GeometryEffect/ScaleEffectUITests.swift b/Example/OpenSwiftUIUITests/Render/GeometryEffect/ScaleEffectUITests.swift new file mode 100644 index 000000000..11282b24d --- /dev/null +++ b/Example/OpenSwiftUIUITests/Render/GeometryEffect/ScaleEffectUITests.swift @@ -0,0 +1,68 @@ +// +// ScaleEffectUITests.swift +// OpenSwiftUIUITests + +import Testing +import SnapshotTesting + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct ScaleEffectUITests { + @Test + func scaleWithFrame() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .scaleEffect(0.5) + .background { Color.red } + .overlay { + Color.green + .frame(width: 40, height: 30) + .scaleEffect(1.5) + } + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } + + @Test + func scaleWithAnchor() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .scaleEffect(0.5, anchor: .topLeading) + .background { Color.red } + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } + + @Test + func scaleWithXY() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .scaleEffect(x: 0.5, y: 1.5) + .background { Color.red } + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } + + @Test + func scaleToZero() { + struct ContentView: View { + var body: some View { + Color.blue + .frame(width: 80, height: 60) + .scaleEffect(0) + .background { Color.red } + } + } + openSwiftUIAssertSnapshot(of: ContentView()) + } +} + diff --git a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift index 0a05946b5..483a02439 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift @@ -167,7 +167,6 @@ public struct ContentTransition: Equatable, Sendable { } } - @_spi(Private) public struct EffectType: Equatable, Sendable { package enum Arg: Equatable, Sendable { diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/ScaleEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/ScaleEffect.swift new file mode 100644 index 000000000..7d8578e09 --- /dev/null +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/ScaleEffect.swift @@ -0,0 +1,241 @@ +// +// ScaleEffect.swift +// OpenSwiftUICore +// +// Audited for 6.5.4 +// Status: Complete +// ID: 8AD2EA4DF9F96B2E7AB78754CF15EB14 (SwiftUICore) + +public import OpenCoreGraphicsShims + +private let leastNonzeroScaleFactor = (2 * CGFloat.leastNormalMagnitude).squareRoot() + +// MARK: - ScaleEffect + +@available(OpenSwiftUI_v1_0, *) +@frozen +public struct _ScaleEffect: GeometryEffect, Equatable { + + public var scale: CGSize + + public var anchor: UnitPoint + + @inlinable + public init(scale: CGSize, anchor: UnitPoint = .center) { + self.scale = scale + self.anchor = anchor + } + + public func effectValue(size: CGSize) -> ProjectionTransform { + var effectScale = scale + if scale.width == 0 { + effectScale.width = leastNonzeroScaleFactor + } + if scale.height == 0 { + effectScale.height = leastNonzeroScaleFactor + } + let position = anchor.in(size) + let negatePosition = -anchor.in(size) + let transform = CGAffineTransform(translationX: negatePosition.x, y: negatePosition.y) + .concatenating(.init(scaleX: effectScale.width, y: effectScale.height)) + .concatenating(.init(translationX: position.x, y: position.y)) + return ProjectionTransform(transform) + } + + public typealias AnimatableData = AnimatablePair + + public var animatableData: AnimatableData { + get { + .init(scale.animatableData, anchor.animatableData) + } + set { + scale.animatableData = newValue.first + anchor.animatableData = newValue.second + } + } + + nonisolated public static func _makeView( + modifier: _GraphValue<_ScaleEffect>, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + makeGeometryEffect(modifier: modifier, inputs: inputs, body: body) + } +} + +// MARK: - View + scaleEffect + +@available(OpenSwiftUI_v1_0, *) +extension View { + + /// Scales this view's rendered output by the given vertical and horizontal + /// size amounts, relative to an anchor point. + /// + /// Use `scaleEffect(_:anchor:)` to scale a view by applying a scaling + /// transform of a specific size, specified by `scale`. + /// + /// Image(systemName: "envelope.badge.fill") + /// .resizable() + /// .frame(width: 100, height: 100, alignment: .center) + /// .foregroundColor(Color.red) + /// .scaleEffect(CGSize(x: 0.9, y: 1.3), anchor: .leading) + /// .border(Color.gray) + /// + /// ![A screenshot showing a red envelope scaled to a size of 90x130 + /// pixels.](OpenSwiftUI-View-scaleEffect.png) + /// + /// - Parameters: + /// - scale: A [CGSize](https://developer.apple.com/documentation/coregraphics/cgsize) that + /// represents the horizontal and vertical amount to scale the view. + /// - anchor: The point with a default of ``UnitPoint/center`` that + /// defines the location within the view from which to apply the + /// transformation. + @inlinable + nonisolated public func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some View { + return modifier(_ScaleEffect(scale: scale, anchor: anchor)) + } + + /// Scales this view's rendered output by the given amount in both the + /// horizontal and vertical directions, relative to an anchor point. + /// + /// Use `scaleEffect(_:anchor:)` to apply a horizontally and vertically + /// scaling transform to a view. + /// + /// Image(systemName: "envelope.badge.fill") + /// .resizable() + /// .frame(width: 100, height: 100, alignment: .center) + /// .foregroundColor(Color.red) + /// .scaleEffect(2, anchor: .leading) + /// .border(Color.gray) + /// + /// ![A screenshot showing a 100x100 pixel red envelope scaled up to 2x the + /// size of its view.](OpenSwiftUI-View-scaleEffect-cgfloat.png) + /// + /// - Parameters: + /// - s: The amount to scale the view in the view in both the horizontal + /// and vertical directions. + /// - anchor: The anchor point with a default of ``UnitPoint/center`` that + /// indicates the starting position for the scale operation. + @inlinable + nonisolated public func scaleEffect(_ s: CGFloat, anchor: UnitPoint = .center) -> some View { + return scaleEffect(CGSize(width: s, height: s), anchor: anchor) + } + + /// Scales this view's rendered output by the given horizontal and vertical + /// amounts, relative to an anchor point. + /// + /// Use `scaleEffect(x:y:anchor:)` to apply a scaling transform to a view by + /// a specific horizontal and vertical amount. + /// + /// Image(systemName: "envelope.badge.fill") + /// .resizable() + /// .frame(width: 100, height: 100, alignment: .center) + /// .foregroundColor(Color.red) + /// .scaleEffect(x: 0.5, y: 0.5, anchor: .bottomTrailing) + /// .border(Color.gray) + /// + /// ![A screenshot showing a 100x100 pixel red envelope scaled down 50% in + /// both the x and y axes.](OpenSwiftUI-View-scaleEffect-xy.png) + /// + /// - Parameters: + /// - x: An amount that represents the horizontal amount to scale the + /// view. The default value is `1.0`. + /// - y: An amount that represents the vertical amount to scale the view. + /// The default value is `1.0`. + /// - anchor: The anchor point that indicates the starting position for + /// the scale operation. + @inlinable + nonisolated public func scaleEffect(x: CGFloat = 1.0, y: CGFloat = 1.0, anchor: UnitPoint = .center) -> some View { + return scaleEffect(CGSize(width: x, height: y), anchor: anchor) + } +} + +// MARK: - AnyTransition + ScaleTransition + +@available(OpenSwiftUI_v1_0, *) +extension AnyTransition { + + /// Returns a transition that scales the view. + public static var scale: AnyTransition { + AnyTransition(ScaleTransition(1e-5)) + } + + /// Returns a transition that scales the view by the specified amount. + public static func scale(scale: CGFloat, anchor: UnitPoint = .center) -> AnyTransition { + AnyTransition(ScaleTransition(Double(scale), anchor: anchor)) + } +} + +// MARK: - Transition + ScaleTransition + +@available(OpenSwiftUI_v5_0, *) +extension Transition where Self == ScaleTransition { + + /// Returns a transition that scales the view. + @_alwaysEmitIntoClient + public static var scale: ScaleTransition { + get { Self(1e-5) } + } + + /// Returns a transition that scales the view by the specified amount. + @_alwaysEmitIntoClient + public static func scale(_ scale: Double, anchor: UnitPoint = .center) -> Self { + Self(scale, anchor: anchor) + } +} + +// MARK: - ScaleTransition + +/// Returns a transition that scales the view. +@available(OpenSwiftUI_v5_0, *) +public struct ScaleTransition: Transition { + + /// The amount to scale the view by. + public var scale: Double + + /// The anchor point to scale the view around. + public var anchor: UnitPoint + + /// Creates a transition that scales the view by the specified amount. + public init(_ scale: Double, anchor: UnitPoint = .center) { + self.scale = scale + self.anchor = anchor + } + + public func body(content: ScaleTransition.Content, phase: TransitionPhase) -> some View { + content.scaleEffect(phase.isIdentity ? 1.0 : scale, anchor: anchor) + } + + public func _makeContentTransition(transition: inout _Transition_ContentTransition) { + guard case .effects = transition.operation else { + transition.result = .bool(true) + return + } + let effect = ContentTransition.Effect(.scale(scale)) + transition.result = .effects([effect]) + } +} + +@available(*, unavailable) +extension ScaleTransition: Sendable {} + +// MARK: _ ScaleEffect + ProtobufMessage + +extension _ScaleEffect: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + try encoder.messageField(1, scale, defaultValue: CGSize(width: 1, height: 1)) + try encoder.messageField(2, anchor, defaultValue: .center) + } + + package init(from decoder: inout ProtobufDecoder) throws { + var effect = _ScaleEffect(scale: CGSize(width: 1, height: 1), anchor: .center) + while let field = try decoder.nextField() { + switch field.tag { + case 1: effect.scale = try decoder.messageField(field) + case 2: effect.anchor = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } + self = effect + } +}