diff --git a/Example/Shared/Animation/Transition/NumericTextTransitionExample.swift b/Example/Shared/Animation/Transition/NumericTextTransitionExample.swift new file mode 100644 index 000000000..d1cbe7746 --- /dev/null +++ b/Example/Shared/Animation/Transition/NumericTextTransitionExample.swift @@ -0,0 +1,36 @@ +// +// NumericTextTransitionExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +// Tap to compare the numeric transition with the default text transition. +// FIXME: OpenSwiftUI currently replaces both values directly. Text does not +// yet attach its interpolator group, and content interpolation is incomplete. +struct NumericTextTransitionExample: View { + @State private var counter = 0 + + var body: some View { + VStack { + Text("\(counter)") + .contentTransition(.numericText()) + .font(.title) + Text("\(counter)") + .font(.title) + } + .padding() + .onTapGesture { + withAnimation(.interactiveSpring) { + counter += 1 + } + } + } +} + +#Preview { + NumericTextTransitionExample() +} diff --git a/Package.resolved b/Package.resolved index b8db4f941..2b56331a3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/OpenSwiftUIProject/DarwinPrivateFrameworks.git", "state" : { "branch" : "main", - "revision" : "3690622baedc4daf3cf9f9e32b38ede607fc7f11" + "revision" : "bc86728a45d2d6dc04bea9ad248971b24c36f019" } }, { @@ -43,7 +43,7 @@ "location" : "https://github.com/OpenSwiftUIProject/OpenRenderBox", "state" : { "branch" : "main", - "revision" : "f9a39649347134aa1801ff6ce973506a5b3a9ee0" + "revision" : "0bb9980ad7aab62231913c659d1909a95e50c5b4" } }, { diff --git a/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffect.swift b/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffect.swift index c4bbd83f2..a3931022c 100644 --- a/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffect.swift +++ b/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffect.swift @@ -792,7 +792,7 @@ extension SymbolEffect { case .effects: let effect = ContentTransition.Effect( type: ContentTransition.EffectType( - type: .symbolAppearDisappear, + type: .appearDisappear, arg0: .int(config.base.transitionFlags.rawValue), arg1: .float(1) ) diff --git a/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffectAPI.swift b/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffectAPI.swift index 662115621..b02d70deb 100644 --- a/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffectAPI.swift +++ b/Sources/OpenSwiftUI/Render/SymbolEffect/SymbolEffectAPI.swift @@ -340,7 +340,7 @@ public struct SymbolEffectTransition: Transition { } let effect = ContentTransition.Effect( type: ContentTransition.EffectType( - type: .symbolAppearDisappear, + type: .appearDisappear, arg0: .int(hidden.transitionFlags.rawValue), arg1: .float(_SymbolEffect.Options(options).speed) ) diff --git a/Sources/OpenSwiftUICore/Animation/Animation/TransactionAnimation.swift b/Sources/OpenSwiftUICore/Animation/Animation/TransactionAnimation.swift index 674d6ea4f..f74c588d1 100644 --- a/Sources/OpenSwiftUICore/Animation/Animation/TransactionAnimation.swift +++ b/Sources/OpenSwiftUICore/Animation/Animation/TransactionAnimation.swift @@ -66,9 +66,9 @@ extension Transaction { } package var disablesContentTransitions: Bool { - get { self[DisablesContentTransactionKey.self] } - set { self[DisablesContentTransactionKey.self] = newValue } - } + get { self[DisablesContentTransitionsKey.self] } + set { self[DisablesContentTransitionsKey.self] = newValue } + } package mutating func disableAnimations() { animation = nil @@ -107,6 +107,6 @@ private struct AnimationReasonKey: TransactionKey { static var defaultValue: UInt32? { nil } } -private struct DisablesContentTransactionKey: TransactionKey { +private struct DisablesContentTransitionsKey: TransactionKey { static var defaultValue: Bool { false } } diff --git a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift index f2c2987a0..d5c3b2932 100644 --- a/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift +++ b/Sources/OpenSwiftUICore/Animation/Transition/ContentTransition.swift @@ -3,12 +3,14 @@ // OpenSwiftUICore // // Audited for 6.5.4 -// Status: WIP +// Status: Complete +// ID: 91D8F0C7DA490950AB41112E50631DCA (SwiftUICore) +package import OpenAttributeGraphShims public import OpenCoreGraphicsShims package import OpenRenderBoxShims -// MARK: - ContentTransition [WIP] +// MARK: - ContentTransition /// A kind of transition that applies to the content within a single view, /// rather than to the insertion or removal of a view. @@ -30,14 +32,14 @@ public struct ContentTransition: Equatable, Sendable { package enum Storage: Equatable, @unchecked Sendable { case named(ContentTransition.NamedTransition) -// case custom(ContentTransition.CustomTransition) + case custom(ContentTransition.CustomTransition) case symbolReplace(_SymbolEffect.ReplaceConfiguration) } // MARK: - ContentTransition.Style @_spi(Private) - public struct Style: Hashable, Sendable/*, Codable*/ { + public struct Style: Hashable, Sendable, Codable { package enum Storage: Hashable, Sendable { case `default` case sessionWidget @@ -95,7 +97,7 @@ public struct ContentTransition: Equatable, Sendable { } } - // MARK: - ContentTransition.NumericTextConfiguration [WIP] + // MARK: - ContentTransition.NumericTextConfiguration @_spi(Private) @available(OpenSwiftUI_v5_0, *) @@ -123,9 +125,8 @@ public struct ContentTransition: Equatable, Sendable { private var _delay: UInt8 = 18 private var _scale: UInt8 = 51 private var _blur: UInt8 = 32 - private var _offset: UInt8 = 19 + private var _offset: Int8 = 19 - @_spi(Private) package init( direction: ContentTransition.NumericTextConfiguration.Direction = .fixed(downwards: false), axis: Axis? = nil, @@ -136,34 +137,61 @@ public struct ContentTransition: Equatable, Sendable { self.options = options } - @_spi(Private) package var delay: Float { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } + get { Float(_delay) * (1 / 120) } + set { _delay = UInt8(clamping: newValue * 120) } } package var maxDurationMultiple: Float { - _openSwiftUIUnimplementedFailure() + 1.25 } package var scale: Float { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } + get { Float(_scale) / 128 } + set { _scale = UInt8(clamping: newValue * 128) } } package var blur: Float { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } + get { Float(_blur) / 4 } + set { + _blur = UInt8(clamping: newValue * 4) + options.remove(.relativeBlur) + } } package var relativeBlur: Float { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } + get { Float(_blur) / 128 } + set { + _blur = UInt8(clamping: newValue * 128) + options.insert(.relativeBlur) + } } package var offset: Float { - get { _openSwiftUIUnimplementedFailure() } - set { _openSwiftUIUnimplementedFailure() } + get { Float(_offset) / 32 } + set { _offset = Int8(clamping: newValue * 32) } + } + } + + package init(_ name: ContentTransition.NamedTransition.Name) { + self.init(storage: .named(.init(name: name))) + } + + // MARK: - ContentTransition.CustomTransition + + package struct CustomTransition: Equatable, Sendable { + package var effects: [ContentTransition.Effect] + package var method: ContentTransition.Method + package var layoutDirection: LayoutDirection? + + package init( + effects: [ContentTransition.Effect], + method: ContentTransition.Method, + layoutDirection: LayoutDirection? = nil + ) { + self.effects = effects + self.method = method + self.layoutDirection = layoutDirection } } @@ -189,12 +217,12 @@ public struct ContentTransition: Equatable, Sendable { } public static var opacity: ContentTransition.EffectType { - self.init(type: .opacity, arg0: .none, arg1: .none) + self.init(type: .alpha, arg0: .none, arg1: .none) } @available(*, deprecated, message: "use opacity variable") public static func opacity(_ opacity: Double = 0) -> ContentTransition.EffectType { - self.init(type: .opacity, arg0: .none, arg1: .none) + self.init(type: .alpha, arg0: .none, arg1: .none) } public static func blur(radius: CGFloat) -> ContentTransition.EffectType { @@ -207,16 +235,16 @@ public struct ContentTransition: Equatable, Sendable { } public static func scale(_ scale: CGFloat = 0) -> ContentTransition.EffectType { - self.init(type: .scale, arg0: .float(Float(scale)), arg1: .none) + self.init(type: .scaleCenter, arg0: .float(Float(scale)), arg1: .none) } public static func translation(_ size: CGSize) -> ContentTransition.EffectType { - self.init(type: .translationSize, arg0: .float(Float(size.width)), arg1: .float(Float(size.height))) + self.init(type: .translation, arg0: .float(Float(size.width)), arg1: .float(Float(size.height))) } @available(OpenSwiftUI_v6_0, *) public static func translation(scale: CGSize) -> ContentTransition.EffectType { - self.init(type: .translationScale, arg0: .float(Float(scale.width)), arg1: .float(Float(scale.height))) + self.init(type: .relativeTranslation, arg0: .float(Float(scale.width)), arg1: .float(Float(scale.height))) } public static var matchMove: ContentTransition.EffectType { @@ -277,12 +305,27 @@ public struct ContentTransition: Equatable, Sendable { appliesOnInsertion: Bool = true, appliesOnRemoval: Bool = true ) -> ContentTransition.Effect { - _openSwiftUIUnimplementedFailure() + let type: ORBTransitionEffectType = switch direction { + case .leading: .sequenceLeft + case .trailing: .sequenceRight + case .up: .sequenceUp + case .down: .sequenceDown + case .forwards: .sequenceForwards + case .backwards: .sequenceBackwards + } + // The target version applies sequence effects to both events. + return .init( + type: .init(type: type), + begin: Float(delay), + duration: 1 / Float(maxAllowedDurationMultiple) + ) } @available(OpenSwiftUI_v6_0, *) public func removeInverts(_ state: Bool) -> ContentTransition.Effect { - _openSwiftUIUnimplementedFailure() + var effect = self + effect.flags.setValue(state, for: .removeInverts) + return effect } } @@ -316,7 +359,12 @@ public struct ContentTransition: Equatable, Sendable { } @_spi(Private) - public static let `default`: ContentTransition = .init(storage: .named(.init())) // FIXME + public init(method: ContentTransition.Method, effects: [ContentTransition.Effect]) { + self.init(storage: .custom(.init(effects: effects, method: method))) + } + + @_spi(Private) + public static let `default`: ContentTransition = .init(.default) /// The identity content transition, which indicates that content changes /// shouldn't animate. @@ -324,11 +372,13 @@ public struct ContentTransition: Equatable, Sendable { /// 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 + public static let identity: ContentTransition = .init(.identity) /// 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 + public static let opacity: ContentTransition = .init(.opacity) + + package static let fadeIfDifferent: ContentTransition = .init(.fadeIfDifferent) /// A content transition that indicates the views attempt to interpolate /// their contents during transitions, where appropriate. @@ -348,7 +398,12 @@ public struct ContentTransition: Equatable, Sendable { /// 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 + public static let interpolate: ContentTransition = .init(.diff) + + @_spi(Private) + public static var text: ContentTransition { .init(.text(different: false)) } + + package static var differentText: ContentTransition { .init(.text(different: true)) } /// Creates a content transition intended to be used with `Text` /// views displaying numeric text. In certain environments changes @@ -363,13 +418,11 @@ public struct ContentTransition: Equatable, Sendable { public static func numericText(countsDown: Bool = false) -> ContentTransition { .init( storage: .named( - .init( + NamedTransition( name: .numericText( - .init( - direction: .fixed( - downwards: countsDown == isDeployedOnOrAfter(.v6) - ) - ) + NumericTextConfiguration(direction: .fixed( + downwards: countsDown == isDeployedOnOrAfter(.v6) + )) ) ) ) @@ -382,6 +435,97 @@ public struct ContentTransition: Equatable, Sendable { .numericText(countsDown: !increasing) } + @available(OpenSwiftUI_v5_0, *) + public static func numericText(value: Double) -> ContentTransition { + .numericText(.automatic(value: value)) + } + + @_spi(Private) + @available(OpenSwiftUI_v5_0, *) + public static func numericText(_ config: ContentTransition.NumericTextConfiguration) -> ContentTransition { + .init(.numericText(config)) + } + + package func isNamed(_ name: ContentTransition.NamedTransition.Name) -> Bool { + guard case let .named(transition) = storage else { return false } + return transition.name == name + } + + @_spi(Private) + public var style: ContentTransition.Style? { + get { + guard case let .named(transition) = storage else { return nil } + return transition.style + } + set { + guard case var .named(transition) = storage else { return } + transition.style = newValue + storage = .named(transition) + } + } + + package var rbTransition: ORBTransition { + switch storage { + case let .named(named): + let key = NamedKey(name: named, isReplaceable: isReplaceable) + if let transition = Self._namedTransitions.wrappedValue[key] { + return transition + } + let transition = named.makeRBTransition() + transition.isReplaceable = isReplaceable + Self._namedTransitions.wrappedValue[key] = transition + return transition + case let .custom(custom): + let transition = ORBTransition() + transition.method = custom.method.method + for effect in custom.effects { + let descriptor = ORBTransitionEffect() + var type = effect.type.type + if custom.layoutDirection == .rightToLeft { + switch type { + case .anchorLeft: type = .anchorRight + case .anchorRight: type = .anchorLeft + case .sequenceLeft: type = .sequenceRight + case .sequenceRight: type = .sequenceLeft + default: break + } + } + descriptor.type = type + for (index, argument) in [effect.type.arg0, effect.type.arg1].enumerated() { + switch argument { + case .none: break + case let .float(value): descriptor.setArgumentValue(value, at: UInt(index)) + case let .int(value): descriptor.setIntegerArgumentValue(value, at: UInt(index)) + } + } + descriptor.events = effect.events + descriptor.flags = effect.flags + descriptor.beginTime = effect.begin + descriptor.duration = effect.duration + transition.addEffect(descriptor) + } + transition.isReplaceable = isReplaceable + return transition + case let .symbolReplace(configuration): + let effect = ORBTransitionEffect() + effect.type = .replace + effect.setIntegerArgumentValue(configuration.transitionFlags.rawValue, at: 0) + effect.setArgumentValue(1, at: 1) + let transition = ORBTransition() + transition.method = .binary + transition.addEffect(effect) + return transition + } + } + + package var numericValue: Float? { + guard case let .named(transition) = storage, + case let .numericText(config) = transition.name, + case let .automatic(value) = config.direction + else { return nil } + return value + } + // MARK: - ContentTransition.Options @_spi(Private) @@ -449,10 +593,156 @@ public struct ContentTransition: Equatable, Sendable { style: ContentTransition.Style, layoutDirection: LayoutDirection ) { - _openSwiftUIUnimplementedFailure() + switch storage { + case var .named(transition): + transition.style = transition.style ?? style + transition.layoutDirection = transition.layoutDirection ?? layoutDirection + storage = .named(transition) + case var .custom(transition): + transition.layoutDirection = transition.layoutDirection ?? layoutDirection + storage = .custom(transition) + case .symbolReplace: + break + } + } + + private struct NamedKey: Hashable { + var name: NamedTransition + var isReplaceable: Bool } + + private static let _namedTransitions = AtomicBox(wrappedValue: [NamedKey: ORBTransition]()) } +// MARK: - ContentTransition.NumericTextConfiguration + Configuration + +@_spi(Private) +@available(OpenSwiftUI_v5_0, *) +extension ContentTransition.NumericTextConfiguration { + public static func fixed(downwards: Bool = false) -> Self { + .init(direction: .fixed(downwards: downwards)) + } + + public static func automatic(value: Double) -> Self { + .init(direction: .automatic(value: Float(value))) + } + + public func axis(_ axis: Axis) -> Self { + var config = self + config.axis = axis + return config + } + + public func delay(_ duration: Double) -> Self { + var config = self + config.delay = Float(duration) + return config + } + + public func scale(_ scale: CGFloat) -> Self { + var config = self + config.scale = Float(scale) + return config + } + + public func blur(radius: CGFloat) -> Self { + var config = self + config.blur = Float(radius) + return config + } + + public func relativeOffset(_ offset: CGFloat) -> Self { + var config = self + config.offset = Float(offset) + return config + } + + public func reversed(_ reversed: Bool) -> Self { + var config = self + config.options.setValue(reversed, for: .reversed) + return config + } +} + +// MARK: - View + ContentTransition + +@available(OpenSwiftUI_v4_0, *) +extension View { + @_alwaysEmitIntoClient + nonisolated public func contentTransition(_ transition: ContentTransition) -> some View { + environment(\.contentTransition, transition) + } + + @_spi(Private) + @inlinable + nonisolated public func contentTransition( + _ transition: ContentTransition, + animation: Animation? = nil, + options: ContentTransition.Options = .init() + ) -> some View { + modifier(_ContentTransitionModifier(transition: transition, animation: animation, options: options)) + } + + @_spi(Private) + @inlinable + nonisolated public func contentTransitionGroup() -> some View { + modifier(_ContentTransitionGroup()) + } + + @_spi(Private) + @available(OpenSwiftUI_v6_0, *) + nonisolated public func contentTransitionPrefersCharacterOrder() -> some View { + preferTextLayoutManager() + } +} + +// MARK: - _ContentTransitionModifier + +@_spi(Private) +@available(OpenSwiftUI_v4_0, *) +@frozen +public struct _ContentTransitionModifier: MultiViewModifier, PrimitiveViewModifier { + @usableFromInline var transition: ContentTransition + @usableFromInline var animation: Animation? + @usableFromInline var options: ContentTransition.Options + + @inlinable + init(transition: ContentTransition, animation: Animation?, options: ContentTransition.Options) { + self.transition = transition + self.animation = animation + self.options = options + } + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + var inputs = inputs + inputs.environment = Attribute(ChildEnvironment(modifier: modifier.value, environment: inputs.environment)) + return body(_Graph(), inputs) + } + + private struct ChildEnvironment: Rule, AsyncAttribute { + @Attribute var modifier: _ContentTransitionModifier + @Attribute var environment: EnvironmentValues + + var value: EnvironmentValues { + var environment = environment + var state = environment.contentTransitionState + state.transition = modifier.transition + state.animation = modifier.animation + state.options = state.options.intersection(.inherited).union(modifier.options) + environment.contentTransitionState = state + return environment + } + } +} + +@_spi(Private) +@available(*, unavailable) +extension _ContentTransitionModifier: Sendable {} + // MARK: - EnvironmentValues + ContentTransition @available(OpenSwiftUI_v4_0, *) @@ -483,7 +773,7 @@ extension EnvironmentValues { /// 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 + /// Setting this value to `true` causes OpenSwiftUI to wrap content transitions /// with a ``View/drawingGroup(opaque:colorMode:)`` modifier. public var contentTransitionAddsDrawingGroup: Bool { get { contentTransitionState.options.contains(.addsDrawingGroup) } @@ -491,16 +781,20 @@ extension EnvironmentValues { } package var contentTransitionGroupEffect: ContentTransitionEffect { - ContentTransitionEffect(state: contentTransitionState) + var state = contentTransitionState + state.options.insert(.formsGroup) + state.transition.applyEnvironmentValues(style: state.style, layoutDirection: layoutDirection) + return ContentTransitionEffect(state: state) } } -// MARK: - _ContentTransitionGroup [WIP] +// MARK: - _ContentTransitionGroup @_spi(Private) @available(OpenSwiftUI_v4_0, *) @frozen public struct _ContentTransitionGroup: MultiViewModifier, PrimitiveViewModifier { + @usableFromInline init() { _openSwiftUIEmptyStub() } @@ -510,11 +804,12 @@ public struct _ContentTransitionGroup: MultiViewModifier, PrimitiveViewModifier inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - _openSwiftUIUnimplementedFailure() + let effect = inputs.base.mapEnvironment(id: .contentTransitionGroupEffect) { $0.contentTransitionGroupEffect } + return ContentTransitionEffect._makeView(modifier: .init(effect), inputs: inputs, body: body) } } -// MARK: - ContentTransitionEffect [WIP] +// MARK: - ContentTransitionEffect package struct ContentTransitionEffect: _RendererEffect { package var state: ContentTransition.State @@ -532,69 +827,473 @@ package struct ContentTransitionEffect: _RendererEffect { inputs: _ViewInputs, body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs ) -> _ViewOutputs { - _openSwiftUIUnimplementedFailure() + _makeRendererEffect(effect: modifier, inputs: inputs, body: body) + } + + struct Init: Rule, AsyncAttribute { + @Attribute var environment: EnvironmentValues + var defaultTransition: ContentTransition + + var value: ContentTransitionEffect { + var state = environment.contentTransitionState + if state.transition.isNamed(.default) { + state.transition = defaultTransition + state.transition.isReplaceable = true + } + state.transition.applyEnvironmentValues( + style: state.style, + layoutDirection: environment.layoutDirection + ) + state.options.insert(.implicitGroup) + return ContentTransitionEffect(state: state) + } } } -// FIXME +extension CachedEnvironment.ID { + static let contentTransitionGroupEffect = CachedEnvironment.ID() +} + +// MARK: - ContentTransition + ProtobufMessage + extension ContentTransition: ProtobufMessage { package func encode(to encoder: inout ProtobufEncoder) throws { - _openSwiftUIUnimplementedFailure() + switch storage { + case let .named(transition): + try encoder.messageField(1, transition) + case let .custom(transition): + try encoder.messageField(2, transition) + case let .symbolReplace(configuration): + try encoder.messageField(4, configuration) + } + encoder.boolField(3, isReplaceable) } package init(from decoder: inout ProtobufDecoder) throws { - _openSwiftUIUnimplementedFailure() + self = .default + while let field = try decoder.nextField() { + switch field.tag { + case 1: storage = .named(try decoder.messageField(field)) + case 2: storage = .custom(try decoder.messageField(field)) + case 3: isReplaceable = try decoder.boolField(field) + case 4: storage = .symbolReplace(try decoder.messageField(field)) + default: try decoder.skipField(field) + } + } } } -// FIXME: ORB - -package enum ORBTransitionMethod: Int { - case empty - case diff - case forwards - case backwards - case prefix - case suffix - case binary - case none - case prefixAndSuffix +// MARK: - ContentTransition.State + ProtobufMessage + +extension ContentTransition.State: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + try encoder.messageField(1, transition, defaultValue: .default) + try encoder.messageField(2, style, defaultValue: .default) + if let animation { + try encoder.messageField(3, CodableAnimation(base: animation)) + } + encoder.uintField(4, UInt(options.rawValue)) + } + + package init(from decoder: inout ProtobufDecoder) throws { + self.init() + while let field = try decoder.nextField() { + switch field.tag { + case 1: transition = try decoder.messageField(field) + case 2: style = try decoder.messageField(field) + case 3: animation = try (decoder.messageField(field) as CodableAnimation).base + case 4: options = .init(rawValue: try decoder.uint32Field(field)) + default: try decoder.skipField(field) + } + } + } } -// ProtobufEnum -package struct ORBTransitionEvents: OptionSet { - package let rawValue: UInt32 +// MARK: - ContentTransition.Style + ProtobufMessage - package init(rawValue: UInt32) { - self.rawValue = rawValue +@_spi(Private) +extension ContentTransition.Style: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch storage { + case .default: break + case .sessionWidget: encoder.emptyField(1) + case .animatedWidget: encoder.emptyField(2) + } } - public static let add: ORBTransitionEvents = .init(rawValue: 1) - public static let remove: ORBTransitionEvents = .init(rawValue: 2) - public static let addRemove: ORBTransitionEvents = .init(rawValue: 3) + package init(from decoder: inout ProtobufDecoder) throws { + self = .default + while let field = try decoder.nextField() { + try decoder.skipField(field) + switch field.tag { + case 1: self = .sessionWidget + case 2: self = .animatedWidget + default: break + } + } + } } -package struct ORBTransitionEffectFlags: OptionSet { - package let rawValue: UInt32 +@_spi(Private) +extension ContentTransition.Style: CodableByProtobuf {} + +// MARK: - ContentTransition.NamedTransition + ProtobufMessage - package init(rawValue: UInt32) { - self.rawValue = rawValue +extension ContentTransition.NamedTransition: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch name { + case .default: break + case .identity: encoder.emptyField(1) + case .opacity: encoder.emptyField(2) + case .diff: encoder.emptyField(3) + case .fadeIfDifferent: encoder.emptyField(4) + case let .text(different): + encoder.messageField(5) { $0.boolField(1, different) } + case let .numericText(configuration): + try encoder.messageField(6, configuration) + } + if let layoutDirection { + encoder.boolField(7, layoutDirection == .rightToLeft) + } + if let style { + try encoder.messageField(8, style, defaultValue: .default) + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + self.init() + while let field = try decoder.nextField() { + switch field.tag { + case 1: + try decoder.skipField(field) + name = .identity + case 2: + try decoder.skipField(field) + name = .opacity + case 3: + try decoder.skipField(field) + name = .diff + case 4: + try decoder.skipField(field) + name = .fadeIfDifferent + case 5: + name = .text(different: try decoder.messageField(field) { decoder in + var different = false + while let field = try decoder.nextField() { + if field.tag == 1 { + different = try decoder.boolField(field) + } else { + try decoder.skipField(field) + } + } + return different + }) + case 6: name = .numericText(try decoder.messageField(field)) + case 7: layoutDirection = try decoder.boolField(field) ? .rightToLeft : .leftToRight + case 8: style = try decoder.messageField(field) + default: try decoder.skipField(field) + } + } } } -package enum ORBTransitionEffectType: UInt32, Equatable { - case opacity = 1 - case scale = 2 - case translationSize = 3 - case blur = 4 - case matchMove = 5 - case translationScale = 15 - case relativeBlur = 16 - case symbolAppearDisappear = 17 +// MARK: - ContentTransition.NumericTextConfiguration + ProtobufMessage + +@_spi(Private) +extension ContentTransition.NumericTextConfiguration: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + switch direction { + case let .fixed(downwards): encoder.boolField(1, !downwards) + case let .automatic(value): encoder.floatField(2, value, defaultValue: nil) + } + if let axis { + encoder.boolField(3, axis == .vertical, defaultValue: nil) + } + encoder.boolField(4, options.contains(.reversed)) + encoder.floatField(5, delay, defaultValue: 0.15) + encoder.floatField(6, scale, defaultValue: 51.0 / 128) + if options.contains(.relativeBlur) { + // The baseline writes relative blur under the absolute-blur tag. + encoder.floatField(7, relativeBlur, defaultValue: 0.25) + } else { + encoder.floatField(7, blur) + } + encoder.floatField(8, offset, defaultValue: 19.0 / 32) + } + + package init(from decoder: inout ProtobufDecoder) throws { + self.init() + while let field = try decoder.nextField() { + switch field.tag { + case 1: direction = .fixed(downwards: try !decoder.boolField(field)) + case 2: direction = .automatic(value: try decoder.floatField(field)) + case 3: axis = try decoder.boolField(field) ? .vertical : .horizontal + case 4: options.setValue(try decoder.boolField(field), for: .reversed) + case 5: delay = try decoder.floatField(field) + case 6: scale = try decoder.floatField(field) + case 7: blur = try decoder.floatField(field) + case 8: offset = try decoder.floatField(field) + case 9: relativeBlur = try decoder.floatField(field) + default: try decoder.skipField(field) + } + } + } +} + +// MARK: - ContentTransition.CustomTransition + ProtobufMessage + +extension ContentTransition.CustomTransition: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.enumField(1, method.method, defaultValue: .empty) + for effect in effects { + try encoder.messageField(2, effect) + } + } + + package init(from decoder: inout ProtobufDecoder) throws { + self.init(effects: [], method: .init(method: .empty)) + while let field = try decoder.nextField() { + switch field.tag { + case 1: method.method = try decoder.enumField(field) ?? .empty + case 2: effects.append(try decoder.messageField(field)) + default: try decoder.skipField(field) + } + } + } } -// MARK: - DisablesContentTransitionsKey +// MARK: - ContentTransition.Effect + ProtobufMessage + +@_spi(Private) +extension ContentTransition.Effect: ProtobufMessage { + package func encode(to encoder: inout ProtobufEncoder) throws { + encoder.enumField(1, type.type, defaultValue: .alpha) + encoder.floatField(4, begin) + encoder.floatField(9, duration, defaultValue: 1) + encoder.enumField(6, events, defaultValue: .addRemove) + switch type.arg0 { + case .none: break + case let .float(value): encoder.floatField(2, value) + case let .int(value): encoder.uintField(7, UInt(value)) + } + switch type.arg1 { + case .none: break + case let .float(value): encoder.floatField(3, value) + case let .int(value): encoder.uintField(8, UInt(value)) + } + encoder.uintField(10, UInt(flags.rawValue)) + } + + package init(from decoder: inout ProtobufDecoder) throws { + self.init(type: .opacity) + while let field = try decoder.nextField() { + switch field.tag { + case 1: type.type = try decoder.enumField(field) ?? .none + case 2: type.arg0 = .float(try decoder.floatField(field)) + case 3: type.arg1 = .float(try decoder.floatField(field)) + case 4: begin = try decoder.floatField(field) + case 5: duration = try decoder.floatField(field) - begin + case 6: events = try decoder.enumField(field) ?? .addRemove + case 7: type.arg0 = .int(try decoder.uint32Field(field)) + // The target decoder skips integer arg1 (tag 8). + case 9: duration = try decoder.floatField(field) + case 10: flags = .init(rawValue: try decoder.uint32Field(field)) + default: try decoder.skipField(field) + } + } + } +} -package struct DisablesContentTransitionsKey: EnvironmentKey { - package static var defaultValue: Bool { false } +// MARK: - RenderBox Transition Types + ProtobufEnum + +extension ORBTransitionMethod: ProtobufEnum { + package init?(protobufValue: UInt) { + guard let value = UInt32(exactly: protobufValue) else { return nil } + self.init(rawValue: value) + } +} + +extension ORBTransitionEffectType: ProtobufEnum { + package init?(protobufValue: UInt) { + guard let value = UInt32(exactly: protobufValue) else { return nil } + self.init(rawValue: value) + } +} + +extension ORBTransitionEvents: ProtobufEnum { + package init?(protobufValue: UInt) { + guard let value = UInt32(exactly: protobufValue) else { return nil } + self.init(rawValue: value) + } +} + +// MARK: - ContentTransition.NamedTransition backend descriptors + +extension ContentTransition.NamedTransition { + package func makeRBTransition() -> ORBTransition { + let transition = ORBTransition() + switch name { + case .identity: + transition.method = .none + case .default, .opacity, .diff, .fadeIfDifferent, .text: + if case .text(different: true) = name { + switch style?.storage { + case .sessionWidget: + makeSessionWidgetTransition(transition) + return transition + case .animatedWidget: + makeAnimatedWidgetTransition(transition) + return transition + default: + break + } + } + switch name { + case .opacity: transition.method = .none + case .diff, .text(different: false): transition.method = .diff + default: transition.method = .binary + } + let effect = ORBTransitionEffect() + effect.duration = 0 + transition.addEffect(effect) + case let .numericText(configuration): + makeNumericTransition(transition, configuration: configuration) + } + return transition + } + + private func makeNumericTransition( + _ transition: ORBTransition, + configuration: ContentTransition.NumericTextConfiguration + ) { + transition.method = .prefixAndSuffix + let reversed = configuration.options.contains(.reversed) + let sequence = ORBTransitionEffect() + if configuration.axis == .vertical { + sequence.type = reversed ? .sequenceDown : .sequenceUp + } else { + sequence.type = reversed != (layoutDirection == .rightToLeft) ? .sequenceRight : .sequenceLeft + } + sequence.beginTime = configuration.delay + sequence.duration = 1 / configuration.maxDurationMultiple + transition.addEffect(sequence) + + let opacity = ORBTransitionEffect() + opacity.animationIndex = 0 + transition.addEffect(opacity) + + let blur = ORBTransitionEffect() + if configuration.options.contains(.relativeBlur) { + blur.type = .relativeBlur + blur.setArgumentValue(configuration.relativeBlur, at: 1) + } else { + blur.type = .blur + blur.setArgumentValue(configuration.blur, at: 0) + } + blur.animationIndex = 0 + transition.addEffect(blur) + + let translation = ORBTransitionEffect() + translation.type = .relativeTranslation + let offset: Float + switch configuration.direction { + case let .fixed(downwards): + offset = downwards ? -configuration.offset : configuration.offset + translation.flags = .removeInverts + case .automatic: + offset = configuration.offset + translation.flags = .init(rawValue: 3) + } + translation.setArgumentValue(configuration.axis == .vertical ? offset : 0, at: 0) + translation.setArgumentValue(configuration.axis == .vertical ? 0 : offset, at: 1) + translation.animationIndex = 1 + transition.addEffect(translation) + + let scale = ORBTransitionEffect() + scale.type = .scaleCenter + scale.setArgumentValue(configuration.scale, at: 0) + scale.animationIndex = 0 + transition.addEffect(scale) + + let animation = ORBAnimation() + animation.addSpringDuration(0.5, mass: 1, stiffness: 344, damping: 37, initialVelocity: 0) + animation.addSpringDuration(0.8, mass: 2, stiffness: 470, damping: 34, initialVelocity: 0) + transition.animation = animation + } + + private func makeSessionWidgetTransition(_ transition: ORBTransition) { + transition.method = .prefixAndSuffix + transition.maxChanges = 10 + let rightToLeft = layoutDirection == .rightToLeft + let sequence = ORBTransitionEffect() + sequence.type = rightToLeft ? .sequenceRight : .sequenceLeft + sequence.events = .init(rawValue: 7) + sequence.flags = .removeInverts + sequence.beginTime = 0.04 + sequence.duration = 1 + transition.addEffect(sequence) + + let opacity = ORBTransitionEffect() + opacity.events = .init(rawValue: 7) + transition.addEffect(opacity) + + let delayedOpacity = ORBTransitionEffect() + delayedOpacity.events = .init(rawValue: 11) + delayedOpacity.beginTime = 0.33 + delayedOpacity.duration = 0.67 + transition.addEffect(delayedOpacity) + + let anchor = ORBTransitionEffect() + anchor.type = rightToLeft ? .anchorRight : .anchorLeft + anchor.events = .init(rawValue: 7) + transition.addEffect(anchor) + + let blur = ORBTransitionEffect() + blur.type = .relativeBlur + blur.setArgumentValue(0.25, at: 1) + blur.beginTime = 0.33 + blur.duration = 0.67 + transition.addEffect(blur) + + let originScale = ORBTransitionEffect() + originScale.type = .scaleOrigin + originScale.setArgumentValue(0.5, at: 0) + originScale.events = .init(rawValue: 7) + transition.addEffect(originScale) + + let scale = ORBTransitionEffect() + scale.type = .scaleCenter + scale.setArgumentValue(0.85, at: 0) + scale.events = .init(rawValue: 11) + scale.beginTime = 0.33 + scale.duration = 0.67 + transition.addEffect(scale) + } + + private func makeAnimatedWidgetTransition(_ transition: ORBTransition) { + transition.method = .binary + let opacity = ORBTransitionEffect() + opacity.insertAnimationIndex = 1 + opacity.removeAnimationIndex = 0 + transition.addEffect(opacity) + + let scale = ORBTransitionEffect() + scale.type = .scaleCenter + scale.setArgumentValue(0.7, at: 0) + scale.insertAnimationIndex = 1 + scale.removeAnimationIndex = 0 + transition.addEffect(scale) + + let blur = ORBTransitionEffect() + blur.type = .relativeBlur + blur.setArgumentValue(0.25, at: 1) + blur.insertAnimationIndex = 1 + blur.removeAnimationIndex = 0 + transition.addEffect(blur) + + let animation = ORBAnimation() + animation.addSpringDuration(0.5, mass: 1, stiffness: 331, damping: 36.4, initialVelocity: 0) + animation.addSpringDuration(0.8, mass: 2, stiffness: 300, damping: 50, initialVelocity: 0) + transition.animation = animation + } } diff --git a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift index 510440d0c..b94e82985 100644 --- a/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift +++ b/Sources/OpenSwiftUICore/Render/DisplayList/DisplayList.swift @@ -1215,5 +1215,3 @@ extension DisplayList.Item { _openSwiftUIUnimplementedWarning() } } - -package struct ORBTransition {} diff --git a/Sources/OpenSwiftUICore/View/Image/Image.swift b/Sources/OpenSwiftUICore/View/Image/Image.swift index a3a2f1eb0..ced861283 100644 --- a/Sources/OpenSwiftUICore/View/Image/Image.swift +++ b/Sources/OpenSwiftUICore/View/Image/Image.swift @@ -177,52 +177,18 @@ package struct ImageResolutionContext { let transaction = Graph.withoutUpdate { transactionAttribute.value } guard !transaction.disablesAnimations else { return false } - // TBA: FIXME when we implement ContentTransition and ORBAnimation let contentTransitionState = environment.contentTransitionState - let contentAnimation = contentTransitionState.animation - let transition = contentTransitionState.transition - let isReplaceable = transition.isReplaceable - - if let contentAnimation { - if case .symbolReplace(let replaceConfig) = transition.storage { - if let oldGlyph, oldGlyph.canBeInterpolated(with: glyph) { - } else { - var options = replaceConfig.animationOptions - options[.timing] = contentAnimation.rbAnimation - symbolAnimator.addAnimation( - .replace, - options: options, - animationListener: transaction.animationListener, - logicalListener: transaction.animationLogicalListener - ) - return false - } - } - // Interpolate animation with content animation timing - var options: [ORBSymbolAnimation.OptionKey: Any] = [ - .timing: contentAnimation.rbAnimation - ] - let isInterpolate = transition.storage == ContentTransition.interpolate.storage - if !isInterpolate || isReplaceable != ContentTransition.interpolate.isReplaceable { - options[.interpolateOptions] = true - } - symbolAnimator.addAnimation( - .interpolate, - options: options, - animationListener: transaction.animationListener, - logicalListener: transaction.animationLogicalListener - ) - return false + var transition = contentTransitionState.transition + if transition.isNamed(.default) { + transition = isLinkedOnOrAfter(.v4) ? .interpolate : .identity } - - // No content animation: dispatch on storage type - switch transition.storage { - case .symbolReplace(let replaceConfig): + let animation = contentTransitionState.animation ?? transaction.animation + if case let .symbolReplace(replaceConfig) = transition.storage, + let oldGlyph = symbolAnimator.glyph, + !oldGlyph.canBeInterpolated(with: glyph) { var options = replaceConfig.animationOptions - if let contentAnimation = environment.contentTransitionState.animation { - options[.timing] = contentAnimation.rbAnimation - } else if let transactionAnimation = transaction.animation { - options[.timing] = transactionAnimation.rbAnimation + if let animation { + options[.timing] = animation.rbAnimation } symbolAnimator.addAnimation( .replace, @@ -231,46 +197,19 @@ package struct ImageResolutionContext { logicalListener: transaction.animationLogicalListener ) return false - - case .named(let named): - if case .default = named.name { - // Resolve .default: >= v4 → .interpolate, else .identity - let resolved = isLinkedOnOrAfter(.v4) ? ContentTransition.interpolate : .identity - if case .symbolReplace(let replaceConfig) = resolved.storage { - var options = replaceConfig.animationOptions - if let contentAnimation = environment.contentTransitionState.animation { - options[.timing] = contentAnimation.rbAnimation - } else if let transactionAnimation = transaction.animation { - options[.timing] = transactionAnimation.rbAnimation - } - symbolAnimator.addAnimation( - .replace, - options: options, - animationListener: transaction.animationListener, - logicalListener: transaction.animationLogicalListener - ) - return false - } - } - // .default (non-replace), .diff, and all other named transitions - guard let transactionAnimation = transaction.animation else { - return false - } - var options: [ORBSymbolAnimation.OptionKey: Any] = [ - .timing: transactionAnimation.rbAnimation - ] - let isInterpolate = ContentTransition.default.storage == ContentTransition.interpolate.storage - if !isInterpolate || isReplaceable != ContentTransition.interpolate.isReplaceable { - options[.interpolateOptions] = true - } - symbolAnimator.addAnimation( - .interpolate, - options: options, - animationListener: transaction.animationListener, - logicalListener: transaction.animationLogicalListener - ) - return false } + guard let animation else { return false } + var options: [ORBSymbolAnimation.OptionKey: Any] = [.timing: animation.rbAnimation] + if transition != .interpolate { + options[.interpolateOptions] = true + } + symbolAnimator.addAnimation( + .interpolate, + options: options, + animationListener: transaction.animationListener, + logicalListener: transaction.animationLogicalListener + ) + return false #else _openSwiftUIUnimplementedWarning() return true diff --git a/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift b/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift index ce404ea67..35a897b3b 100644 --- a/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift +++ b/Sources/OpenSwiftUICore/View/Text/Text/Text+Sizing.swift @@ -136,6 +136,12 @@ extension EnvironmentValues { // MARK: - PreferTextLayoutManagerInput +extension View { + nonisolated func preferTextLayoutManager() -> some View { + modifier(PreferTextLayoutManagerInputModifier()) + } +} + private struct PreferTextLayoutManagerInputModifier: ViewInputsModifier { static func _makeViewInputs( modifier: _GraphValue, diff --git a/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionBackendTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionBackendTests.swift new file mode 100644 index 000000000..eb96404e3 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionBackendTests.swift @@ -0,0 +1,138 @@ +// +// ContentTransitionBackendTests.swift +// OpenSwiftUICoreTests + +import OpenRenderBoxShims +@_spi(Private) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@Suite(.tags(.aigc)) +struct ContentTransitionBackendTests { + @Test(arguments: [ + (ContentTransition.default, ORBTransitionMethod.binary, 1), + (.identity, .none, 0), (.opacity, .none, 1), + (.interpolate, .diff, 1), (.fadeIfDifferent, .binary, 1), + (.text, .diff, 1), (.differentText, .binary, 1), + ]) + func namedTransitions(transition: ContentTransition, method: ORBTransitionMethod, count: Int) { + let backend = transition.rbTransition + #expect(backend.method == method) + #expect(backend.effects.count == count) + if count > 0 { + #expect(backend.effects[0].type == .alpha) + #expect(backend.effects[0].duration == 0) + } + #expect(backend === transition.rbTransition) + var replaceable = transition + replaceable.isReplaceable = true + #expect(replaceable.rbTransition !== backend) + #expect(replaceable.rbTransition.isReplaceable) + #expect(!backend.isReplaceable) + } + + @Test(arguments: [LayoutDirection.leftToRight, .rightToLeft]) + func customEffectsPreserveArgumentsAndResolveDirection(direction: LayoutDirection) { + var transition = ContentTransition(method: .diff, effects: [ + .sequence(direction: .leading, delay: 0.15, maxAllowedDurationMultiple: 2), + .init(type: .init(type: .replace, arg0: .int(0x3f800001), arg1: .float(0.75)), begin: 0.25, duration: 0.5, events: .remove, flags: .removeInverts), + ]) + transition.applyEnvironmentValues(style: .default, layoutDirection: direction) + transition.isReplaceable = true + let backend = transition.rbTransition + #expect(backend !== transition.rbTransition) + #expect(backend.isReplaceable) + #expect(backend.method == .diff) + #expect(backend.effects[0].type == (direction == .leftToRight ? .sequenceLeft : .sequenceRight)) + #expect(backend.effects[0].beginTime == Float(38) * (1 / 255)) + #expect(backend.effects[0].duration == Float(128) * (1 / 255)) + let effect = backend.effects[1] + #expect(effect.integerArgumentValue(at: 0) == 0x3f800001) + #expect(effect.argumentValue(at: 1) == 0.75) + #expect(effect.events == .remove) + #expect(effect.flags == .removeInverts) + } + + @Test + func numericDescriptors() throws { + let backend = ContentTransition.numericText(.fixed(downwards: false)).rbTransition + #expect(backend.method == .prefixAndSuffix) + #expect(backend.effects.map(\.type) == [.sequenceLeft, .alpha, .relativeBlur, .relativeTranslation, .scaleCenter]) + #expect(backend.effects[0].beginTime == Float(38) * (1 / 255)) + #expect(backend.effects[0].duration == Float(204) * (1 / 255)) + #expect(backend.effects[2].argumentValue(at: 0) == 0) + #expect(backend.effects[2].argumentValue(at: 1) == 0.25) + #expect(backend.effects[3].argumentValue(at: 0) == 0) + #expect(backend.effects[3].argumentValue(at: 1) == 19.0 / 32) + #expect(backend.effects[3].flags == .removeInverts) + #expect(backend.effects[3].animationIndex == 1) + #expect(backend.effects[4].argumentValue(at: 0) == 51.0 / 128) + let expected = ORBAnimation() + expected.addSpringDuration(0.5, mass: 1, stiffness: 344, damping: 37, initialVelocity: 0) + expected.addSpringDuration(0.8, mass: 2, stiffness: 470, damping: 34, initialVelocity: 0) + #expect(try #require(backend.animation).isEqual(expected)) + } + + @Test(arguments: [LayoutDirection.leftToRight, .rightToLeft]) + func numericAxisDirectionAndBlur(direction: LayoutDirection) { + var config = ContentTransition.NumericTextConfiguration.fixed(downwards: true) + config.axis = .horizontal + config.options.insert(.reversed) + config.blur = 4 + config.offset = -0.5 + var transition = ContentTransition.numericText(config) + transition.applyEnvironmentValues(style: .default, layoutDirection: direction) + let effects = transition.rbTransition.effects + #expect(effects[0].type == (direction == .leftToRight ? .sequenceRight : .sequenceLeft)) + #expect(effects[2].type == .blur) + #expect(effects[2].argumentValue(at: 0) == 4) + #expect(effects[3].argumentValue(at: 0) == 0) + #expect(effects[3].argumentValue(at: 1) == 0.5) + config.axis = .vertical + config.direction = .automatic(value: -12) + let automatic = ContentTransition.numericText(config).rbTransition.effects + #expect(automatic[0].type == .sequenceDown) + #expect(automatic[3].flags.rawValue == 3) + #expect(automatic[3].argumentValue(at: 0) == -0.5) + #expect(automatic[3].argumentValue(at: 1) == 0) + } + + @Test(arguments: [LayoutDirection.leftToRight, .rightToLeft]) + func widgetStyles(direction: LayoutDirection) throws { + var transition = ContentTransition.differentText + transition.applyEnvironmentValues(style: .sessionWidget, layoutDirection: direction) + let session = transition.rbTransition + #expect(session.method == .prefixAndSuffix) + #expect(session.maxChanges == 10) + #expect(session.effects.map { $0.type.rawValue } == [direction == .leftToRight ? 11 : 12, 1, 1, direction == .leftToRight ? 7 : 8, 16, 6, 2]) + #expect(session.effects.map { $0.events.rawValue } == [7, 7, 11, 7, 3, 7, 11]) + #expect(session.effects[0].flags == .removeInverts) + #expect(session.effects[4].argumentValue(at: 1) == 0.25) + #expect(session.effects[5].argumentValue(at: 0) == 0.5) + #expect(session.effects[6].argumentValue(at: 0) == 0.85) + transition.style = .animatedWidget + let animated = transition.rbTransition + #expect(animated.method == .binary) + #expect(animated.effects.map(\.type) == [.alpha, .scaleCenter, .relativeBlur]) + #expect(animated.effects.allSatisfy { $0.insertAnimationIndex == 1 && $0.removeAnimationIndex == 0 }) + let expected = ORBAnimation() + expected.addSpringDuration(0.5, mass: 1, stiffness: 331, damping: 36.4, initialVelocity: 0) + expected.addSpringDuration(0.8, mass: 2, stiffness: 300, damping: 50, initialVelocity: 0) + #expect(try #require(animated.animation).isEqual(expected)) + } + + @Test(arguments: [true, false]) + func symbolReplacement(layered: Bool) { + var config = _SymbolEffect.ReplaceConfiguration() + config.layered = layered + config.speed = 3 + var transition = ContentTransition(storage: .symbolReplace(config)) + transition.isReplaceable = true + let backend = transition.rbTransition + #expect(backend.method == .binary) + #expect(!backend.isReplaceable) + #expect(backend.effects[0].type == .replace) + #expect(backend.effects[0].integerArgumentValue(at: 0) == (layered ? 0 : 16)) + #expect(backend.effects[0].argumentValue(at: 1) == 1) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionProtobufTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionProtobufTests.swift new file mode 100644 index 000000000..256fcf5a1 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionProtobufTests.swift @@ -0,0 +1,161 @@ +// +// ContentTransitionProtobufTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenRenderBoxShims +@_spi(Private) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@Suite(.tags(.aigc)) +struct ContentTransitionProtobufTests { + static let namedCases: [(ContentTransition, [UInt8])] = [ + (.default, [0x0a, 0]), + (.identity, [0x0a, 2, 0x0a, 0]), + (.opacity, [0x0a, 2, 0x12, 0]), + (.interpolate, [0x0a, 2, 0x1a, 0]), + (.fadeIfDifferent, [0x0a, 2, 0x22, 0]), + (.text, [0x0a, 2, 0x2a, 0]), + (.differentText, [0x0a, 4, 0x2a, 2, 8, 1]), + (.numericText(value: 0), [0x0a, 7, 0x32, 5, 0x15, 0, 0, 0, 0]), + ] + + @Test(arguments: namedCases) + func namedWireValues(transition: ContentTransition, bytes: [UInt8]) throws { + #expect(try ProtobufEncoder.encoding(transition) == Data(bytes)) + #expect(try decode(ContentTransition.self, bytes) == transition) + } + + @Test + func replacementFlagAndLastStorageFieldWin() throws { + var transition = ContentTransition.opacity + transition.isReplaceable = true + #expect(try ProtobufEncoder.encoding(transition) == Data([0x0a, 2, 0x12, 0, 0x18, 1])) + let bytes: [UInt8] = [0x18, 1, 0x0a, 2, 0x0a, 0, 0x78, 42, 0x0a, 2, 0x12, 0] + #expect(try decode(ContentTransition.self, bytes) == transition) + #expect(try decode(ContentTransition.self, []) == .default) + } + + @Test + func optionalNamedEnvironmentFieldsUseWireDefaults() throws { + let explicit = ContentTransition.NamedTransition(name: .opacity, layoutDirection: .leftToRight, style: .default) + #expect(try ProtobufEncoder.encoding(explicit) == Data([0x12, 0])) + #expect(try decode(ContentTransition.NamedTransition.self, [0x12, 0]) == .init(name: .opacity)) + let resolved = ContentTransition.NamedTransition(name: .opacity, layoutDirection: .rightToLeft, style: .sessionWidget) + let bytes: [UInt8] = [0x12, 0, 0x38, 1, 0x42, 2, 0x0a, 0] + #expect(try ProtobufEncoder.encoding(resolved) == Data(bytes)) + #expect(try decode(ContentTransition.NamedTransition.self, bytes) == resolved) + } + + @Test(arguments: [ContentTransition.Style.default, .sessionWidget, .animatedWidget]) + func styleCodableUsesProtobuf(style: ContentTransition.Style) throws { + let json = try JSONEncoder().encode(style) + let wire = try JSONDecoder().decode(Data.self, from: json) + #expect(wire == (try ProtobufEncoder.encoding(style))) + #expect(try JSONDecoder().decode(ContentTransition.Style.self, from: json) == style) + } + + @Test + func numericWireDefaultsAndBlurTags() throws { + typealias Configuration = ContentTransition.NumericTextConfiguration + #expect(try ProtobufEncoder.encoding(Configuration.fixed()) == Data([8, 1])) + #expect(try ProtobufEncoder.encoding(Configuration.fixed(downwards: true)).isEmpty) + #expect(try decode(Configuration.self, []) == .fixed()) + #expect(try decode(Configuration.self, [8, 0]) == .fixed(downwards: true)) + #expect(try ProtobufEncoder.encoding(Configuration.fixed().axis(.horizontal)) == Data([8, 1, 0x18, 0])) + let absolute = Configuration.fixed().blur(radius: 2) + #expect(try ProtobufEncoder.encoding(absolute) == Data([8, 1, 0x3d, 0, 0, 0, 0x40])) + var relative = Configuration.fixed() + relative.relativeBlur = 0.5 + #expect(try ProtobufEncoder.encoding(relative) == Data([8, 1, 0x3d, 0, 0, 0, 0x3f])) + #expect(try decode(Configuration.self, [0x4d, 0, 0, 0, 0x3f]) == relative) + #expect(try decode(Configuration.self, [0x3d, 0, 0, 0, 0x3f]) == .fixed().blur(radius: 0.5)) + } + + @Test + func customTransitionPreservesEffectOrderButOmitsLayoutDirection() throws { + var transition = ContentTransition.CustomTransition(effects: [.init(.opacity), .init(.matchMove)], method: .diff) + transition.layoutDirection = .rightToLeft + let bytes: [UInt8] = [8, 1, 0x12, 0, 0x12, 2, 8, 5] + #expect(try ProtobufEncoder.encoding(transition) == Data(bytes)) + let decoded = try decode(ContentTransition.CustomTransition.self, bytes) + #expect(decoded.effects == transition.effects) + #expect(decoded.method == .diff) + #expect(decoded.layoutDirection == nil) + } + + @Test + func effectWireValueIncludesTimelineAndFlags() throws { + let effect = ContentTransition.Effect( + .translation(CGSize(width: 2, height: 3)), timeline: 0.25 ... 0.75, + appliesOnInsertion: false + ).removeInverts(true) + let bytes: [UInt8] = [ + 8, 3, 0x25, 0, 0, 0x80, 0x3e, 0x4d, 0, 0, 0, 0x3f, 0x30, 2, + 0x15, 0, 0, 0, 0x40, 0x1d, 0, 0, 0x40, 0x40, 0x50, 1, + ] + #expect(try ProtobufEncoder.encoding(effect) == Data(bytes)) + #expect(try decode(ContentTransition.Effect.self, bytes) == effect) + } + + @Test + func legacyEffectEndTimeUsesCurrentBeginTime() throws { + let effect = try decode(ContentTransition.Effect.self, [0x25, 0, 0, 0x80, 0x3e, 0x2d, 0, 0, 0x40, 0x3f]) + #expect(effect.begin == 0.25) + #expect(effect.duration == 0.5) + let reordered = try decode(ContentTransition.Effect.self, [0x2d, 0, 0, 0x40, 0x3f, 0x25, 0, 0, 0x80, 0x3e]) + #expect(reordered.begin == 0.25) + #expect(reordered.duration == 0.75) + } + + @Test + func integerArgumentsKeepBaselineFieldBehavior() throws { + let effect = ContentTransition.Effect(type: .init(type: .matchMove, arg0: .int(4), arg1: .int(5))) + let bytes: [UInt8] = [8, 5, 0x38, 4, 0x40, 5] + #expect(try ProtobufEncoder.encoding(effect) == Data(bytes)) + let decoded = try decode(ContentTransition.Effect.self, bytes) + #expect(decoded.type.arg0 == .int(4)) + #expect(decoded.type.arg1 == .none) + } + + @Test + func unknownBackendValuesAndOverflow() throws { + let unknown = try decode(ContentTransition.Effect.self, [8, 123, 0x30, 0x80, 1, 0x50, 0x80, 2]) + #expect(unknown.type.type.rawValue == 123) + #expect(unknown.events.rawValue == 128) + #expect(unknown.flags.rawValue == 256) + let overflow = try decode(ContentTransition.Effect.self, [8, 0x80, 0x80, 0x80, 0x80, 0x10]) + #expect(overflow.type.type.rawValue == 0) + let events = try decode(ContentTransition.Effect.self, [0x30, 0x80, 0x80, 0x80, 0x80, 0x10]) + #expect(events.events == .addRemove) + } + + @Test + func stateRoundTripIncludesAnimationAndOptions() throws { + let state = ContentTransition.State( + transition: .opacity, style: .animatedWidget, animation: .linear(duration: 0.75), + options: [.formsGroup, .animatesDifferentContent, .addsDrawingGroup] + ) + var decoder = ProtobufDecoder(try ProtobufEncoder.encoding(state)) + #expect(try ContentTransition.State(from: &decoder) == state) + #expect(try ProtobufEncoder.encoding(ContentTransition.State()).isEmpty) + #expect(try decode(ContentTransition.State.self, []) == .init()) + } + + static let malformedMessages: [[UInt8]] = [ + [0], [0x0a, 1], [8, 1], [0x1d, 0, 0, 0, 0], [0x0a, 2, 0x32, 1], + ] + + @Test(arguments: malformedMessages) + func rejectsMalformedMessages(bytes: [UInt8]) { + #expect(throws: ProtobufDecoder.DecodingError.self) { + try decode(ContentTransition.self, bytes) + } + } + + private func decode(_ type: T.Type, _ bytes: [UInt8]) throws -> T { + var decoder = ProtobufDecoder(Data(bytes)) + return try T(from: &decoder) + } +} diff --git a/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionTests.swift new file mode 100644 index 000000000..71f3f5773 --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Transition/ContentTransitionTests.swift @@ -0,0 +1,326 @@ +// +// ContentTransitionTests.swift +// OpenSwiftUICoreTests + +import Foundation +import OpenAttributeGraphShims +@_spi(Private) @_spi(ForOpenSwiftUIOnly) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@MainActor +@Suite(.tags(.aigc)) +struct ContentTransitionTests { + @Test + func publicModifierWritesTransitionEnvironment() throws { + let view = EmptyView().contentTransition(.opacity) + let modified = try #require(view as? ModifiedContent>) + #expect(modified.modifier.keyPath == \EnvironmentValues.contentTransition) + #expect(modified.modifier.value == .opacity) + } + + @Test(arguments: [ + (ContentTransition.default, ContentTransition.identity), + (ContentTransition.default, ContentTransition.opacity), + (ContentTransition.default, ContentTransition.interpolate), + (ContentTransition.identity, ContentTransition.opacity), + (ContentTransition.identity, ContentTransition.interpolate), + (ContentTransition.opacity, ContentTransition.interpolate), + ]) + func namedTransitionsAreDistinct(first: ContentTransition, second: ContentTransition) { + #expect(first != second) + } + + @Test + func replacementFlagParticipatesInEquality() { + var transition = ContentTransition.opacity + transition.isReplaceable = true + #expect(transition != .opacity) + #expect(transition.isNamed(.opacity)) + } + + @Test + func environmentOnlyFillsUnsetValues() { + var transition = ContentTransition.opacity + transition.style = .sessionWidget + transition.isReplaceable = true + transition.applyEnvironmentValues(style: .animatedWidget, layoutDirection: .rightToLeft) + transition.applyEnvironmentValues(style: .default, layoutDirection: .leftToRight) + guard case let .named(named) = transition.storage else { + Issue.record("Expected a named transition") + return + } + #expect(named.style == .sessionWidget) + #expect(named.layoutDirection == .rightToLeft) + #expect(transition.isReplaceable) + transition.style = nil + transition.applyEnvironmentValues(style: .animatedWidget, layoutDirection: .leftToRight) + #expect(transition.style == .animatedWidget) + } + + @Test + func customTransitionPreservesEffectsAndDirection() { + let effects: [ContentTransition.Effect] = [.init(.opacity), .init(.scale(0.5))] + var transition = ContentTransition(method: .prefixAndSuffix, effects: effects) + transition.style = .sessionWidget + transition.applyEnvironmentValues(style: .animatedWidget, layoutDirection: .rightToLeft) + transition.applyEnvironmentValues(style: .default, layoutDirection: .leftToRight) + guard case let .custom(custom) = transition.storage else { + Issue.record("Expected a custom transition") + return + } + #expect(custom.method == .prefixAndSuffix) + #expect(custom.effects == effects) + #expect(custom.layoutDirection == .rightToLeft) + #expect(transition.style == nil) + #expect(transition.numericValue == nil) + #expect(!transition.isNamed(.default)) + } + + @Test(arguments: [ + (Semantics.v5, false, true), + (.v5, true, false), + (.v6, false, false), + (.v6, true, true), + ]) + func fixedNumericDirection(semantics: Semantics, countsDown: Bool, expectedDownwards: Bool) { + semantics.test(as: \.deploymentTarget) { + let transition = ContentTransition.numericText(countsDown: countsDown) + #expect(transition.isNamed(.numericText(.fixed(downwards: expectedDownwards)))) + #expect(transition.numericValue == nil) + } + } + + @Test(arguments: [0, -42.5, 16_777_217, Double.greatestFiniteMagnitude, .infinity, -.infinity]) + func automaticNumericValue(value: Double) { + #expect(ContentTransition.numericText(value: value).numericValue == Float(value)) + } + + @Test + func numericDefaultsAndFluentConfiguration() { + let initial = ContentTransition.NumericTextConfiguration.fixed() + #expect(initial.delay == Float(0.15)) + #expect(initial.scale == 51.0 / 128) + #expect(initial.relativeBlur == 0.25) + #expect(initial.offset == 19.0 / 32) + #expect(initial.maxDurationMultiple == 1.25) + let changed = initial.axis(.horizontal).delay(0.5).scale(0.75) + .blur(radius: 2).relativeOffset(-0.5).reversed(true) + #expect(changed.axis == .horizontal) + #expect(changed.delay == 0.5) + #expect(changed.scale == 0.75) + #expect(changed.blur == 2) + #expect(changed.offset == -0.5) + #expect(changed.options == .reversed) + #expect(changed.reversed(false).options.isEmpty) + #expect(initial.axis == nil) + #expect(initial.options == .relativeBlur) + } + + static let offsetCases: [(Float, Float)] = [ + (-.infinity, -4), + (-0.99, -31.0 / 32), + (0, 0), + (0.99, 31.0 / 32), + (.infinity, 127.0 / 32), + (.nan, -4), + ] + + @Test(arguments: offsetCases) + func numericOffsetClampsAndTruncates(input: Float, expected: Float) { + var config = ContentTransition.NumericTextConfiguration.fixed() + config.offset = input + #expect(config.offset == expected) + } + + static let unsignedInputs: [Float] = [-.infinity, -1, 0, 0.123, 1, 10, .infinity, .nan] + + @Test(arguments: unsignedInputs) + func numericUnsignedFieldsClampAndTruncate(input: Float) { + var config = ContentTransition.NumericTextConfiguration.fixed().reversed(true) + config.delay = input + config.scale = input + config.blur = input + let bounded = input.isNaN ? 0 : max(0, input) + #expect(abs(config.delay - floor(min(255, bounded * 120)) / 120) < 0.000001) + #expect(config.scale == floor(min(255, bounded * 128)) / 128) + #expect(config.blur == floor(min(255, bounded * 4)) / 4) + #expect(config.options == .reversed) + config.relativeBlur = input + #expect(config.relativeBlur == floor(min(255, bounded * 128)) / 128) + #expect(config.options == [.relativeBlur, .reversed]) + } + + @Test(arguments: [ + (ContentTransition.SequenceDirection.leading, UInt32(11)), + (.trailing, 12), (.up, 13), (.down, 14), (.forwards, 19), (.backwards, 20), + ]) + func sequenceConfiguration(direction: ContentTransition.SequenceDirection, rawType: UInt32) { + let effect = ContentTransition.Effect.sequence( + direction: direction, delay: 0.25, maxAllowedDurationMultiple: 2, + appliesOnInsertion: false, appliesOnRemoval: false + ) + #expect(effect.type.type.rawValue == rawType) + #expect(effect.begin == 0.25) + #expect(effect.duration == 0.5) + // The sequence factory applies to both events in the target version. + #expect(effect.events == .addRemove) + #expect(ContentTransition.Effect.sequence(direction: direction, delay: 0).duration == 0) + } + + @Test(arguments: [false, true], [false, true]) + func effectEventsAndRemovalInversion(insertion: Bool, removal: Bool) { + let effect = ContentTransition.Effect( + .opacity, timeline: 0.25 ... 0.75, + appliesOnInsertion: insertion, appliesOnRemoval: removal + ) + #expect(effect.begin == 0.25) + #expect(effect.duration == 0.5) + #expect(effect.events.contains(.add) == insertion) + #expect(effect.events.contains(.remove) == removal) + var flagged = effect + flagged.flags = .init(rawValue: 0x80) + #expect(flagged.removeInverts(true).flags.rawValue == 0x81) + #expect(flagged.removeInverts(true).removeInverts(false) == flagged) + } + + @Test + func groupResolvesEnvironmentWithoutChangingIt() { + var environment = EnvironmentValues() + environment.contentTransition = .opacity + environment.contentTransitionStyle = .sessionWidget + environment.layoutDirection = .rightToLeft + environment.contentTransitionAddsDrawingGroup = true + let state = environment.contentTransitionGroupEffect.state + #expect(state.transition.style == .sessionWidget) + #expect(state.options == [.formsGroup, .addsDrawingGroup]) + #expect(environment.contentTransition.style == nil) + #expect(environment.contentTransitionState.options == .addsDrawingGroup) + } + + @Test(.disabled(if: attributeGraphVendor == .oag)) + func implicitEffectResolvesDefaultsAndTracksEnvironment() { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + var environment = EnvironmentValues() + environment.contentTransition.style = .sessionWidget + environment.contentTransitionStyle = .animatedWidget + environment.contentTransitionAnimation = .linear + environment.contentTransitionAddsDrawingGroup = true + environment.layoutDirection = .rightToLeft + let source = Attribute(value: environment) + let effect = Attribute(ContentTransitionEffect.Init( + environment: source, defaultTransition: .interpolate + )) + let initial = effect.value.state + #expect(initial.transition.isNamed(.diff)) + #expect(initial.transition.isReplaceable) + #expect(initial.transition.style == .animatedWidget) + #expect(initial.style == .animatedWidget) + #expect(initial.animation == .linear) + #expect(initial.options == [.addsDrawingGroup, .implicitGroup]) + if case let .named(named) = initial.transition.storage { + #expect(named.layoutDirection == .rightToLeft) + } else { + Issue.record("Expected a named transition") + } + #expect(source.value.contentTransition.isNamed(.default)) + #expect(source.value.contentTransition.style == .sessionWidget) + + environment.contentTransition = .identity + source.value = environment + #expect(effect.value.state.transition.isNamed(.identity)) + #expect(!effect.value.state.transition.isReplaceable) + + environment.contentTransition = .init(method: .binary, effects: [.init(.opacity)]) + source.value = environment + if case let .custom(custom) = effect.value.state.transition.storage { + #expect(custom.layoutDirection == .rightToLeft) + } else { + Issue.record("Expected a custom transition") + } + #expect(!effect.value.state.transition.isReplaceable) + } + } + + @Test(.disabled(if: attributeGraphVendor == .oag)) + func characterOrderRequestsTextLayoutManager() { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + let view = CharacterOrderProbe().contentTransitionPrefersCharacterOrder() + func makeView(_ view: V) { + _ = V._makeView(view: .init(Attribute(value: view)), inputs: .invalidInputs(.invalid)) + } + makeView(view) + } + } + + @Test(.disabled(if: attributeGraphVendor == .oag)) + func groupProducesTransitionDisplayList() { + let content = Color.red.frame(width: 32, height: 48) + .contentTransitionGroup().contentTransition(.opacity) + Update.perform { + let graph = ViewGraph(rootViewType: type(of: content), requestedOutputs: [.layout, .displayList]) + defer { graph.invalidate() } + graph.setRootView(content) + #expect(graph.sizeThatFits(.unspecified) == CGSize(width: 32, height: 48)) + graph.instantiate() + let (list, _) = graph.displayList() + func findState(in list: DisplayList) -> ContentTransition.State? { + for item in list.items { + if case let .effect(effect, content) = item.value { + if case let .contentTransition(state) = effect { return state } + if let state = findState(in: content) { return state } + } + } + return nil + } + let state = findState(in: list) + #expect(state?.transition.isNamed(.opacity) == true) + #expect(state?.options.contains(.formsGroup) == true) + } + } + + @Test(.disabled(if: attributeGraphVendor == .oag)) + func modifierPropagatesUpdatesAndOnlyInheritedOptions() { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + var environment = EnvironmentValues() + environment.contentTransitionState = .init( + transition: .interpolate, style: .sessionWidget, animation: .linear, + options: [.addsDrawingGroup, .formsGroup, .implicitGroup, .animatesDifferentContent] + ) + let source = Attribute(value: environment) + let modifier = Attribute(value: _ContentTransitionModifier(transition: .opacity, animation: nil, options: [])) + var inputs = _ViewInputs.invalidInputs(.invalid) + inputs.environment = source + var child = source + _ = _ContentTransitionModifier._makeView(modifier: .init(modifier), inputs: inputs) { _, inputs in + child = inputs.environment + return _ViewOutputs() + } + #expect(child.value.contentTransitionState == .init( + transition: .opacity, style: .sessionWidget, options: .addsDrawingGroup + )) + modifier.value = .init(transition: .identity, animation: .easeIn, options: .animatesDifferentContent) + #expect(child.value.contentTransitionState == .init( + transition: .identity, style: .sessionWidget, animation: .easeIn, + options: [.addsDrawingGroup, .animatesDifferentContent] + )) + #expect(source.value.contentTransition == .interpolate) + } + } +} + +private struct CharacterOrderProbe: PrimitiveView { + nonisolated static func _makeView(view: _GraphValue, inputs: _ViewInputs) -> _ViewOutputs { + #expect(inputs.prefersTextLayoutManager) + return _ViewOutputs() + } +} diff --git a/Tests/OpenSwiftUICoreTests/Animation/Transition/ImageContentTransitionTests.swift b/Tests/OpenSwiftUICoreTests/Animation/Transition/ImageContentTransitionTests.swift new file mode 100644 index 000000000..d2ae609fb --- /dev/null +++ b/Tests/OpenSwiftUICoreTests/Animation/Transition/ImageContentTransitionTests.swift @@ -0,0 +1,145 @@ +// +// ImageContentTransitionTests.swift +// OpenSwiftUICoreTests + +#if canImport(Darwin) && OPENSWIFTUI_LINK_COREUI && OPENSWIFTUI_RENDERBOX + +import CoreUI +import ObjectiveC +import OpenAttributeGraphShims +import OpenRenderBoxShims +@_spi(Private) @testable import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@MainActor +@Suite(.tags(.aigc)) +struct ImageContentTransitionTests { + enum PreviousGlyph: CaseIterable { + case absent + case compatible + case incompatible + } + + @Test(.disabled(if: attributeGraphVendor == .oag), arguments: PreviousGlyph.allCases) + func replacementRequiresAnIncompatiblePreviousGlyph(previous: PreviousGlyph) { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + let source = Attribute(value: Transaction(animation: .linear)) + var environment = EnvironmentValues() + environment.contentTransition = .init(storage: .symbolReplace(.init())) + var context = ImageResolutionContext(environment: environment, transaction: .init(source)) + let animator = RecordingAnimator() + if previous != .absent { + animator.glyph = TestGlyph.make(interpolates: previous == .compatible) + } + context.symbolAnimator = animator + #expect(!context.willUpdateVectorGlyph(to: TestGlyph.make(), variableValue: 0)) + #expect(animator.animations.count == 1) + guard let animation = animator.animations.first else { return } + #expect(animation.kind == (previous == .incompatible ? .replace : .interpolate)) + #expect(animation.options[ORBSymbolAnimation.OptionKey.timing.rawValue] is ORBAnimation) + if previous != .incompatible { + #expect(animation.options[ORBSymbolAnimation.OptionKey.interpolateOptions.rawValue] as? Bool == true) + } + } + } + + @Test(.disabled(if: attributeGraphVendor == .oag), arguments: [ + ContentTransition.default, .interpolate, .identity, .opacity, + .init(method: .none, effects: []), + ]) + func interpolationOptionsUseTheResolvedTransition(transition: ContentTransition) { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + let source = Attribute(value: Transaction(animation: .linear)) + var environment = EnvironmentValues() + environment.contentTransition = transition + var context = ImageResolutionContext(environment: environment, transaction: .init(source)) + let animator = RecordingAnimator() + context.symbolAnimator = animator + #expect(!context.willUpdateVectorGlyph(to: TestGlyph.make(), variableValue: 0)) + #expect(animator.animations.count == 1) + guard let animation = animator.animations.first else { return } + #expect(animation.kind == .interpolate) + let resolved = transition.isNamed(.default) + ? (isLinkedOnOrAfter(.v4) ? ContentTransition.interpolate : .identity) + : transition + #expect((animation.options[ORBSymbolAnimation.OptionKey.interpolateOptions.rawValue] as? Bool) == (resolved == .interpolate ? nil : true)) + } + } + + @Test(.disabled(if: attributeGraphVendor == .oag), arguments: [false, true]) + func explicitAnimationDoesNotRequireTransactionAnimation(explicitAnimation: Bool) { + let graph = Graph() + let subgraph = Subgraph(graph: graph) + defer { subgraph.invalidate() } + subgraph.apply { + let source = Attribute(value: Transaction()) + var environment = EnvironmentValues() + environment.contentTransitionState = .init( + transition: .opacity, animation: explicitAnimation ? .linear : nil + ) + var context = ImageResolutionContext(environment: environment, transaction: .init(source)) + let animator = RecordingAnimator() + context.symbolAnimator = animator + #expect(!context.willUpdateVectorGlyph(to: TestGlyph.make(), variableValue: 0)) + #expect(animator.animations.count == (explicitAnimation ? 1 : 0)) + } + } +} + +private enum TestGlyph { + // A static subclass requires _OBJC_METACLASS_$_CUINamedVectorGlyph, + // which CoreUI does not export. Create the + // subclass at runtime to avoid this link dependency. + static func make(interpolates: Bool = false) -> CUINamedVectorGlyph { + let type = interpolates ? compatibleClass : incompatibleClass + return type.init() + } + + private static let compatibleClass = makeClass(interpolates: true) + private static let incompatibleClass = makeClass(interpolates: false) + + private static func makeClass(interpolates: Bool) -> CUINamedVectorGlyph.Type { + let name = "OpenSwiftUIImageContentTransition\(interpolates ? "Compatible" : "Incompatible")Glyph" + let selector = #selector(CUINamedVectorGlyph.canBeInterpolated(with:)) + guard let type = objc_allocateClassPair(CUINamedVectorGlyph.self, name, 0), + let method = class_getInstanceMethod(CUINamedVectorGlyph.self, selector) else { + preconditionFailure("Could not create the glyph test class.") + } + let implementation: @convention(block) (CUINamedVectorGlyph, CUINamedVectorGlyph) -> Bool = { _, _ in + interpolates + } + let added = class_addMethod(type, selector, imp_implementationWithBlock(implementation), method_getTypeEncoding(method)) + precondition(added, "Could not override glyph interpolation.") + objc_registerClassPair(type) + return type as! CUINamedVectorGlyph.Type + } +} + +private final class RecordingAnimator: ORBSymbolAnimator { + struct Animation { + var kind: ORBSymbolAnimation + var options: [String: Any] + } + + var animations: [Animation] = [] + private var storedGlyph: CUINamedVectorGlyph? + + override var glyph: CUINamedVectorGlyph? { + get { storedGlyph } + set { storedGlyph = newValue } + } + + override func addAnimation(_ animation: ORBSymbolAnimation, options: Any?) -> UInt32 { + animations.append(.init(kind: animation, options: options as? [String: Any] ?? [:])) + return 0 + } +} + +#endif diff --git a/Tests/OpenSwiftUITests/Render/SymbolEffectAPITests.swift b/Tests/OpenSwiftUITests/Render/SymbolEffectAPITests.swift index bafb1b7f1..d78f74eee 100644 --- a/Tests/OpenSwiftUITests/Render/SymbolEffectAPITests.swift +++ b/Tests/OpenSwiftUITests/Render/SymbolEffectAPITests.swift @@ -329,7 +329,7 @@ struct SymbolEffectAPITests { } let effect = try #require(effects.first) #expect(effects.count == 1) - #expect(effect.type.type == .symbolAppearDisappear) + #expect(effect.type.type == .appearDisappear) #expect(effect.type.arg0 == .int(flags)) #expect(effect.type.arg1 == .float(2.5)) transition = .init(operation: .hasContentTransition, result: .none)