diff --git a/Example/OpenSwiftUIUITests/Animation/Animation/MatchGeometryEffectUITests.swift b/Example/OpenSwiftUIUITests/Animation/Animation/MatchGeometryEffectUITests.swift new file mode 100644 index 000000000..eeb9943ba --- /dev/null +++ b/Example/OpenSwiftUIUITests/Animation/Animation/MatchGeometryEffectUITests.swift @@ -0,0 +1,61 @@ +// +// MatchGeometryEffectUITests.swift +// OpenSwiftUIUITests + +import Testing +import SnapshotTesting + +@MainActor +@Suite(.snapshots(record: .never, diffTool: diffTool)) +struct MatchGeometryEffectExampleTests { + @Test + func axisChange() { + struct ContentView: AnimationTestView { + nonisolated static var model: AnimationTestModel { + AnimationTestModel(duration: 2, count: 5) + } + + @State private var isVertical = true + @Namespace private var animation + + var body: some View { + VStack { + if isVertical { + VStack { + Ellipse() + .fill(.red) + .matchedGeometryEffect(id: "ellipse", in: animation) + Rectangle() + .fill(.blue) + .matchedGeometryEffect(id: "rectangle", in: animation, properties: .size) + .transition(.opacity) + } + } else { + HStack { + Ellipse() + .fill(.red) + .matchedGeometryEffect(id: "ellipse", in: animation) + Rectangle() + .fill(.blue) + .matchedGeometryEffect(id: "rectangle", in: animation, properties: .size) + .transition(.opacity) + } + + } + } + .onAppear { + withAnimation(.easeInOut(duration: 2)) { + isVertical.toggle() + } + } + } + } + // When run seperately, precision: 1.0 works fine + // but in the full suite, it will hit the same issue of #340 + withKnownIssue("#690", isIntermittent: true) { + openSwiftUIAssertAnimationSnapshot( + of: ContentView() + ) + } + } +} diff --git a/Example/Shared/Animation/Animation/MatchGeometryEffectExample.swift b/Example/Shared/Animation/Animation/MatchGeometryEffectExample.swift new file mode 100644 index 000000000..0d184fe60 --- /dev/null +++ b/Example/Shared/Animation/Animation/MatchGeometryEffectExample.swift @@ -0,0 +1,46 @@ +// +// MatchGeometryEffectExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct MatchGeometryEffectExample: View { + @State private var isVertical = true + @Namespace private var animation + + var body: some View { + VStack { + if isVertical { + VStack { + Ellipse() + .fill(.red) + .matchedGeometryEffect(id: "ellipse", in: animation) + Rectangle() + .fill(.blue) + .matchedGeometryEffect(id: "rectangle", in: animation, properties: .size) + .transition(.opacity) + } + } else { + HStack { + Ellipse() + .fill(.red) + .matchedGeometryEffect(id: "ellipse", in: animation) + Rectangle() + .fill(.blue) + .matchedGeometryEffect(id: "rectangle", in: animation, properties: .size) + .transition(.opacity) + } + + } + } + .onAppear { + withAnimation(.easeInOut(duration: 2)) { + isVertical.toggle() + } + } + } +} diff --git a/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift b/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift index c8a2c9d0b..5b6f11ac0 100644 --- a/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift +++ b/Sources/OpenSwiftUICore/Layout/View/ViewTransform.swift @@ -248,7 +248,7 @@ public struct ViewTransform: Equatable, CustomStringConvertible { var stop = false if inverted { if pendingTranslation != .zero { - body(.translation(pendingTranslation), &stop) + body(.translation(-pendingTranslation), &stop) if stop { return } } var element: AnyElement = head @@ -265,7 +265,7 @@ public struct ViewTransform: Equatable, CustomStringConvertible { ) { bufferPointer in bufferPointer.initializeElement(at: 0, to: head) var element = head - var index = 0 + var index = 1 while let next = element.next { bufferPointer.initializeElement(at: index, to: next) element = next @@ -296,26 +296,119 @@ public struct ViewTransform: Equatable, CustomStringConvertible { } else if case .local = space2 { return true } else { + let name1: CoordinateSpace.Name = switch space1 { + case let .named(name): .name(name) + case let .id(id): .id(id) + default: _openSwiftUIUnreachableCode() + } + let name2: CoordinateSpace.Name = switch space2 { + case let .named(name): .name(name) + case let .id(id): .id(id) + default: _openSwiftUIUnreachableCode() + } + var found1 = false + var found2 = false forEach(inverted: false) { item, stop in - // TODO + switch item { + case let .coordinateSpace(name), let .sizedSpace(name, _): + found1 = name1 == name + found2 = name2 == name + default: + break + } + stop = found1 || found2 } - _openSwiftUIUnimplementedFailure() + return found1 && !found2 } } package func convert(_ conversion: ViewTransform.Conversion, _ body: (ViewTransform.Item) -> Void) { guard !isEmpty else { return } - _openSwiftUIUnimplementedFailure() + let conversion = conversion.normalized() + + var active = false + switch conversion { + case let .rootToSpace(space): + guard space != .root else { return } + active = true + case let .spaceToRoot(space): + active = space == .local + case let .localToSpace(space): + guard space != .local else { return } + active = true + case let .spaceToLocal(space): + active = space == .root + case .spaceToSpace: + active = false + } + + let inverted = switch conversion { + case .rootToSpace, .spaceToLocal: + false + case .spaceToRoot, .localToSpace: + true + case let .spaceToSpace(space1, space2): + !spaceBeforeSpace(space1, space2) + } + + forEach(inverted: inverted) { item, stop in + switch item { + case let .coordinateSpace(name), let .sizedSpace(name, _): + let space = name.space + switch conversion { + case let .rootToSpace(target), let .localToSpace(target): + guard space != target else { + stop = true + return + } + case let .spaceToRoot(source), let .spaceToLocal(source): + if space == source { + active = true + } + case let .spaceToSpace(source, target): + guard space != target else { + stop = true + return + } + if space == source { + active = true + } + } + default: + break + } + if active { + body(item) + } + } + } + + package func convert( + _ conversion: ViewTransform.Conversion, + points: inout Points + ) where Points: MutableCollection, Points.Element == CGPoint { + guard !isEmpty else { return } + guard !points.isEmpty else { return } + convert(conversion) { item in + points._applyTransform(item: item) + } } package func convert(_ conversion: ViewTransform.Conversion, points: inout [CGPoint]) { guard !isEmpty else { return } - _openSwiftUIUnimplementedFailure() + guard !points.isEmpty else { return } + convert(conversion) { item in + points._applyTransform(item: item) + } } package func convert(_ conversion: ViewTransform.Conversion, point: CGPoint) -> CGPoint { guard !isEmpty else { return point } - _openSwiftUIUnimplementedFailure() + var point = point + convert(conversion) { item in + point.applyTransform(item: item) + } + return point } package var containingScrollGeometry: ScrollGeometry? { @@ -351,6 +444,37 @@ public struct ViewTransform: Equatable, CustomStringConvertible { @available(*, unavailable) extension ViewTransform: Sendable {} +// Mark: MutableCollection + Extension + +extension MutableCollection where Element == CGPoint { + fileprivate mutating func _apply(_ matrix: ProjectionTransform, inverse: Bool) { + for index in indices { + let point = self[index] + self[index] = inverse ? point.unapplying(matrix) : point.applying(matrix) + } + } + + fileprivate mutating func _applyTransform(item: ViewTransform.Item) { + switch item { + case let .translation(offset): + for index in indices { + self[index].x += offset.width + self[index].y += offset.height + } + case let .affineTransform(matrix, inverse): + let transform = inverse ? matrix.inverted() : matrix + for index in indices { + let point = self[index] + self[index] = point.applying(transform) + } + case let .projectionTransform(matrix, inverse): + _apply(matrix, inverse: inverse) + default: + break + } + } +} + @_spi(ForOpenSwiftUIOnly) extension ViewTransform { package struct UnsafeBuffer: Equatable { diff --git a/Sources/OpenSwiftUICore/Render/GeometryEffect/MatchedGeometryEffect.swift b/Sources/OpenSwiftUICore/Render/GeometryEffect/MatchedGeometryEffect.swift new file mode 100644 index 000000000..e943be0c3 --- /dev/null +++ b/Sources/OpenSwiftUICore/Render/GeometryEffect/MatchedGeometryEffect.swift @@ -0,0 +1,656 @@ + // + // MatchedGeometryEffect.swift + // OpenSwiftUICore + // + // Audited for 6.5.4 + // Status: Complete + // ID: F035CBEF00D3D777B3359545F684D774 (SwiftUICore) + +import Foundation +import OpenAttributeGraphShims + +// MARK: - View + matchedGeometryEffect + +extension View { + /// Defines a group of views with synchronized geometry using an + /// identifier and namespace that you provide. + /// + /// This method sets the geometry of each view in the group from the + /// inserted view with `isSource = true` (known as the "source" view), + /// updating the values marked by `properties`. + /// + /// If inserting a view in the same transaction that another view + /// with the same key is removed, the system will interpolate their + /// frame rectangles in window space to make it appear that there + /// is a single view moving from its old position to its new + /// position. The usual transition mechanisms define how each of + /// the two views is rendered during the transition (e.g. fade + /// in/out, scale, etc), the `matchedGeometryEffect()` modifier + /// only arranges for the geometry of the views to be linked, not + /// their rendering. + /// + /// If the number of currently-inserted views in the group with + /// `isSource = true` is not exactly one results are undefined, due + /// to it not being clear which is the source view. + /// + /// - Parameters: + /// - id: The identifier, often derived from the identifier of + /// the data being displayed by the view. + /// - namespace: The namespace in which defines the `id`. New + /// namespaces are created by adding an `@Namespace` variable + /// to a ``View`` type and reading its value in the view's body + /// method. + /// - properties: The properties to copy from the source view. + /// - anchor: The relative location in the view used to produce + /// its shared position value. + /// - isSource: True if the view should be used as the source of + /// geometry for other views in the group. + /// + /// - Returns: A new view that defines an entry in the global + /// database of views synchronizing their geometry. + /// + @inlinable + nonisolated public func matchedGeometryEffect( + id: ID, + in namespace: Namespace.ID, + properties: MatchedGeometryProperties = .frame, + anchor: UnitPoint = .center, + isSource: Bool = true + ) -> some View where ID : Hashable { + modifier(_MatchedGeometryEffect(id: id, namespace: namespace, properties: properties, anchor: anchor, isSource: isSource)) + } + } + +// MARK: - _MatchedGeometryEffect + +public struct _MatchedGeometryEffect: MultiViewModifier, PrimitiveViewModifier where ID: Hashable { + public var id: ID + public var namespace: Namespace.ID + public var args: (properties: MatchedGeometryProperties, anchor: UnitPoint, isSource: Bool) + + public init( + id: ID, + namespace: Namespace.ID, + properties: MatchedGeometryProperties, + anchor: UnitPoint, + isSource: Bool + ) { + self.id = id + self.namespace = namespace + self.args = (properties, anchor, isSource) + } + + nonisolated private static func makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + clipShape: OptionalAttribute, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + guard inputs.needsGeometry || inputs.preferences.requiresDisplayList else { + return body(_Graph(), inputs) + } + + let args = modifier[offset: { .of(&$0.args) }].value + guard let scope = inputs[MatchedGeometryScope.self] else { + return body(_Graph(), inputs) + } + let matchedSharedFrame = Attribute( + MatchedSharedFrame( + modifier: modifier.value, + args: args, + transaction: inputs.transaction, + phase: inputs.viewPhase, + size: inputs.size, + position: inputs.position, + transform: inputs.transform, + scope: scope, + frameIndex: nil, + selfAttribute: .nil, + resetSeed: .zero, + isRemoved: false + ) + ) + matchedSharedFrame.flags = .transactional + + var newInputs = inputs + var viewFrame: Attribute? + if inputs.needsGeometry { + let frame = Attribute( + MatchedFrame( + sharedFrame: matchedSharedFrame, + args: args, + size: inputs.size, + position: inputs.position, + transform: inputs.transform, + childLayoutComputer: .init() + ) + ) + newInputs.position = frame.origin + newInputs.containerPosition = inputs.animatedPosition() + newInputs.size = frame.size + newInputs.requestsLayoutComputer = true + viewFrame = frame + } + var outputs = body(_Graph(), newInputs) + if let viewFrame { + viewFrame.mutateBody(as: MatchedFrame.self, invalidating: true) { viewFrame in + viewFrame.$childLayoutComputer = outputs.layoutComputer + } + } + if inputs.preferences.requiresDisplayList, let displayList = outputs.displayList { + let identity = DisplayList.Identity() + inputs.pushIdentity(identity) + outputs.displayList = Attribute( + MatchedDisplayList( + identity: identity, + sharedFrame: matchedSharedFrame, + args: args, + content: displayList, + position: inputs.animatedPosition(), + size: inputs.animatedSize(), + transform: inputs.transform, + containerPosition: inputs.containerPosition, + clipShape: clipShape, + options: inputs.displayListOptions + ) + ) + } + return outputs + } + + nonisolated public static func _makeView( + modifier: _GraphValue, + inputs: _ViewInputs, + body: @escaping (_Graph, _ViewInputs) -> _ViewOutputs + ) -> _ViewOutputs { + makeView( + modifier: modifier, + inputs: inputs, + clipShape: OptionalAttribute(), + body: body + ) + } + + var qualifiedID: Pair { .init(id, namespace) } + } + +// MARK: - MatchedGeometryProperties + +public struct MatchedGeometryProperties: OptionSet { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let position: MatchedGeometryProperties = .init(rawValue: 1 << 0) + + public static let size: MatchedGeometryProperties = .init(rawValue: 1 << 1) + + public static let frame: MatchedGeometryProperties = [.position, .size] + + public static let clipRect: MatchedGeometryProperties = .init(rawValue: 1 << 2) +} + + // MARK: - MatchedGeometryScope + +private class MatchedGeometryScope: ViewInput, PropertyKey { + let subgraph: Subgraph + let inputs: _ViewInputs + var frames: [MatchedGeometryScope.Frame] + var keyedFrames: [AnyHashable: Int] + + static var defaultValue: MatchedGeometryScope? { nil } + + struct Frame { + @Attribute var frame: (ViewFrame?, AnyOptionalAttribute) + var key: AnyHashable + var views: [MatchedGeometryScope.Frame.View] + var viewsSeed: UInt32 + var logged: Bool + + struct View { + var attribute: AnyAttribute + @Attribute var args: (properties: MatchedGeometryProperties, anchor: UnitPoint, isSource: Bool) + @Attribute var transaction: Transaction + @Attribute var phase: _GraphInputs.Phase + @Attribute var size: ViewSize + @Attribute var position: CGPoint + @Attribute var transform: ViewTransform + } + } + + struct EmptyKey: Hashable {} + + init( + subgraph: Subgraph, + inputs: _ViewInputs, + frames: [MatchedGeometryScope.Frame], + keyedFrames: [AnyHashable: Int] + ) { + self.subgraph = subgraph + self.inputs = inputs + self.frames = frames + self.keyedFrames = keyedFrames + } + + func frame( + index: inout Int?, + for id: ID, + view: MatchedGeometryScope.Frame.View + ) -> (ViewFrame?, AnyOptionalAttribute) where ID: Hashable { + let key = AnyHashable(id) + if let currentIndex = index { + if frames[currentIndex].key == key { + return frames[currentIndex].frame + } else { + releaseFrame(index: currentIndex, owner: view.attribute) + } + } + let frameIndex: Int + let needsUpdate: Bool + if let keyedFrameIndex = keyedFrames[key] { + frameIndex = keyedFrameIndex + needsUpdate = true + } else if let emptyIndex = frames.firstIndex(where: { $0.views.isEmpty }) { + frames[emptyIndex].key = key + frames[emptyIndex].logged = false + frames[emptyIndex].$frame.mutateBody(as: SharedFrame.self, invalidating: true) { sharedFrame in + sharedFrame.reset() + } + frameIndex = emptyIndex + needsUpdate = true + } else { + let newIndex = frames.count + subgraph.apply { + let sharedFrame = Attribute(SharedFrame( + time: inputs.time, + environment: inputs.environment, + scope: self, + frameIndex: newIndex, + listeners: [], + animatorState: nil, + resetSeed: .zero, + lastSourceAttribute: .init() + )) + sharedFrame.flags = .transactional + let frame = Frame( + frame: sharedFrame, + key: key, + views: [], + viewsSeed: .zero, + logged: false + ) + frames.append(frame) + } + frameIndex = newIndex + needsUpdate = false + } + keyedFrames[key] = frameIndex + frames[frameIndex].views.insert(view, at: 0) + frames[frameIndex].viewsSeed &+= 1 + if needsUpdate { + let weakFrame = WeakAttribute(frames[frameIndex].$frame) + GraphHost.currentHost.continueTransaction { + guard let frame = weakFrame.attribute else { + return + } + frame.invalidateValue() + } + } + index = frameIndex + return frames[frameIndex].frame + } + + func releaseFrame(index: Int, owner: AnyAttribute) { + guard let viewIndex = frames[index].views.firstIndex(where: { $0.attribute == owner }) else { + return + } + frames[index].views.remove(at: viewIndex) + if frames[index].views.isEmpty { + keyedFrames.removeValue(forKey: frames[index].key) + frames[index].key = AnyHashable(EmptyKey()) + } else { + frames[index].viewsSeed &+= 1 + } + } + + func sourceViewIndex(frameIndex: Int) -> Int? { + var counter = 0 + repeat { + let viewsSeed = frames[frameIndex].viewsSeed + let views = frames[frameIndex].views + let index = views.firstIndex { view in + !view.phase.isBeingRemoved && view.args.isSource + } + if frames[frameIndex].viewsSeed == viewsSeed { + return index + } + counter &+= 1 + } while counter != 8 + return nil + } +} + +extension _ViewInputs { + package mutating func makeRootMatchedGeometryScope() { + if self[MatchedGeometryScope.self] != nil { return } + self[MatchedGeometryScope.self] = MatchedGeometryScope( + subgraph: Subgraph.current!, + inputs: self, + frames: [], + keyedFrames: [:] + ) + } +} + +// MARK: - MatchedSharedFrame + +private struct MatchedSharedFrame: StatefulRule, AsyncAttribute, RemovableAttribute, ObservedAttribute where ID: Hashable { + @Attribute var modifier: _MatchedGeometryEffect + @Attribute var args: (properties: MatchedGeometryProperties, anchor: UnitPoint, isSource: Bool) + @Attribute var transaction: Transaction + @Attribute var phase: _GraphInputs.Phase + @Attribute var size: ViewSize + @Attribute var position: CGPoint + @Attribute var transform: ViewTransform + let scope: MatchedGeometryScope + var frameIndex: Int? + var selfAttribute: AnyAttribute + var resetSeed: UInt32 + var isRemoved: Bool + + typealias Value = (ViewFrame?, AnyOptionalAttribute) + + mutating func updateValue() { + if selfAttribute == .nil { + selfAttribute = .current! + } + let latestResetSeed = phase.resetSeed + if resetSeed != latestResetSeed { + resetSeed = latestResetSeed + destroy() + } + guard !isRemoved else { + value = (nil, AnyOptionalAttribute(selfAttribute)) + return + } + value = scope.frame( + index: &frameIndex, + for: modifier.qualifiedID, + view: .init( + attribute: selfAttribute, + args: $args, + transaction: $transaction, + phase: $phase, + size: $size, + position: $position, + transform: $transform + ) + ) + } + + static func willRemove(attribute: AnyAttribute) { + let matchedSharedFramePointer = UnsafeMutableRawPointer(mutating: attribute.info.body) + .assumingMemoryBound(to: MatchedSharedFrame.self) + if let frameIndex = matchedSharedFramePointer.pointee.frameIndex { + matchedSharedFramePointer.pointee.scope.releaseFrame(index: frameIndex, owner: matchedSharedFramePointer.pointee.selfAttribute) + matchedSharedFramePointer.pointee.frameIndex = nil + } + attribute.mutateBody(as: MatchedSharedFrame.self, invalidating: true) { matchedSharedFrame in + matchedSharedFrame.isRemoved = true + } + } + + static func didReinsert(attribute: AnyAttribute) { + attribute.mutateBody(as: MatchedSharedFrame.self, invalidating: true) { matchedSharedFrame in + matchedSharedFrame.isRemoved = false + } + } + + mutating func destroy() { + guard let frameIndex else { return } + scope.releaseFrame(index: frameIndex, owner: selfAttribute) + self.frameIndex = nil + } +} + +// MARK: - MatchedDisplayList + +private struct MatchedDisplayList: Rule, AsyncAttribute where S: Shape { + let identity: DisplayList.Identity + @Attribute var sharedFrame: (ViewFrame?, AnyOptionalAttribute) + @Attribute var args: (properties: MatchedGeometryProperties, anchor: UnitPoint, isSource: Bool) + @Attribute var content: DisplayList + @Attribute var position: CGPoint + @Attribute var size: ViewSize + @Attribute var transform: ViewTransform + @Attribute var containerPosition: CGPoint + @OptionalAttribute var clipShape: S? + let options: DisplayList.Options + + var value: DisplayList { + var effect: DisplayList.Effect = .identity + if args.properties.contains(.clipRect) { + let (sharedFrame, sourceAttribute) = sharedFrame + if let sharedFrame, $sharedFrame.identifier != sourceAttribute.identifier { + var rect = CGRect( + position: sharedFrame.origin, + size: sharedFrame.size.value, + anchor: args.anchor + ) + rect.convert(from: .global, transform: transform.withPosition(position)) + let path = if let clipShape { + clipShape.path(in: rect) + } else { + Path(rect) + } + effect = .clip(path, FillStyle()) + } + } + var item = DisplayList.Item( + .effect(effect, content), + frame: CGRect( + origin: CGPoint(position - containerPosition), + size: size.value + ), + identity: identity, + version: DisplayList.Version(forUpdate: ()) + ) + item.canonicalize(options: options) + return DisplayList(item) + } +} + +// MARK: - MatchedFrame + +private struct MatchedFrame: Rule, AsyncAttribute { + @Attribute var sharedFrame: (ViewFrame?, AnyOptionalAttribute) + @Attribute var args: (properties: MatchedGeometryProperties, anchor: UnitPoint, isSource: Bool) + @Attribute var size: ViewSize + @Attribute var position: CGPoint + @Attribute var transform: ViewTransform + @OptionalAttribute var childLayoutComputer: LayoutComputer? + + var value: ViewFrame { + let (sharedFrame, sourceAttribute) = sharedFrame + guard let sharedFrame, $sharedFrame.identifier != sourceAttribute.identifier else { + return ViewFrame(origin: position, size: size) + } + var matchedSize = sharedFrame.size + if !args.properties.contains(.size) || args.properties.contains(.clipRect) { + matchedSize = size + } else if let childLayoutComputer { + let proposal = _ProposedSize(sharedFrame.size.value) + matchedSize = ViewSize( + childLayoutComputer.sizeThatFits(proposal), + proposal: proposal + ) + } + guard args.properties.contains(.position) else { + return ViewFrame(origin: position, size: matchedSize) + } + var sharedPosition = sharedFrame.origin + sharedPosition.convert(from: .global, transform: transform.withPosition(position)) + + let origin = .init(position) + sharedPosition - CGSize(args.anchor.in(matchedSize.value)) + return ViewFrame(origin: origin, size: matchedSize) + } +} + +// MARK: - SharedFrame + +private struct SharedFrame: StatefulRule, AsyncAttribute, ObservedAttribute { + @Attribute var time: Time + @Attribute var environment: EnvironmentValues + let scope: MatchedGeometryScope + let frameIndex: Int + var listeners: [AnimationListener] + var animatorState: AnimatorState, AnimatablePair>>? + var resetSeed: UInt32 + var lastSourceAttribute: AnyWeakAttribute + + mutating func destroy() {} + + mutating func removeListeners() { + listeners.forEach { $0.animationWasRemoved() } + listeners.removeAll() + } + + mutating func reset() { + removeListeners() + animatorState = nil + resetSeed = .zero + lastSourceAttribute = .init() + } + + typealias Value = (ViewFrame?, AnyOptionalAttribute) + + mutating func updateValue() { + guard scope.frames[frameIndex].views.contains(where: { $0.args.isSource }) else { + reset() + value = (nil, AnyOptionalAttribute()) + return + } + var animationTime = -Time.infinity + if animatorState != nil { + let (time, timeChanged) = $time.changedValue() + if timeChanged { + animationTime = time + } + } + let previousSourceAttribute = lastSourceAttribute.attribute + if let previousSourceAttribute, + let lastSourceView = scope.frames[frameIndex].views.first(where: { $0.attribute == previousSourceAttribute }), + resetSeed != lastSourceView.phase.resetSeed { + reset() + } + var needUpdates: Bool = false + if scope.frames[frameIndex].views.count >= 2, let sourceIndex = scope.sourceViewIndex(frameIndex: frameIndex) { + if !scope.frames[frameIndex].logged { + needUpdates = (sourceIndex + 1) < scope.frames[frameIndex].views.count + } + if sourceIndex != 0 { + let sourceView = scope.frames[frameIndex].views.remove(at: sourceIndex) + scope.frames[frameIndex].views.insert(sourceView, at: 0) + scope.frames[frameIndex].viewsSeed &+= 1 + } + } + guard let currentView = scope.frames[frameIndex].views.first else { + reset() + value = (nil, AnyOptionalAttribute()) + return + } + if scope.frames[frameIndex].views.count > 1, previousSourceAttribute != currentView.attribute { + let transaction = Graph.withoutUpdate { currentView.transaction } + let animation = if transaction.disablesAnimations { + transaction.animationIgnoringTransitionPhase + } else { + transaction.animation + } + if let animation { + let previousView = scope.frames[frameIndex].views[1] + + let previousSize = previousView.size + var previousOrigin = previousView.args.anchor.in(previousSize.value) + previousOrigin.convert(to: .global, transform: previousView.transform.withPosition(previousView.position)) + let previousViewFrame = ViewFrame(origin: previousOrigin, size: previousSize) + + let currentSize = currentView.size + var currentOrigin = currentView.args.anchor.in(currentSize.value) + currentOrigin.convert(to: .global, transform: currentView.transform.withPosition(currentView.position)) + let currentViewFrame = ViewFrame(origin: currentOrigin, size: currentSize) + + let positionDelta = currentViewFrame.origin - previousViewFrame.origin + let sizeDelta = currentViewFrame.size.value - previousViewFrame.size.value + if positionDelta != .zero || sizeDelta != .zero { + animationTime = time + let interval = AnimatablePair( + AnimatablePair(positionDelta.width, positionDelta.height), + AnimatablePair(sizeDelta.width, sizeDelta.height) + ) + if let animatorState { + animatorState.combine( + newAnimation: animation, + newInterval: interval, + at: animationTime, + in: transaction, + environment: $environment + ) + } else { + animatorState = AnimatorState( + animation: animation, + interval: interval, + at: animationTime, + in: transaction + ) + } + if let listener = transaction.animationListener { + listeners.append(listener) + listener.animationWasAdded() + } + } + + } + } + + if needUpdates { + Graph.withoutUpdate { + let views = scope.frames[frameIndex].views + guard views.count >= 2 else { return } + guard views.dropFirst().contains(where: { $0.phase.isInserted && $0.args.isSource }) else { return } + Log.externalWarning( + "Multiple inserted views in matched geometry group \(scope.frames[frameIndex].key) have `isSource: true`, results are undefined." + ) + scope.frames[frameIndex].logged = true + } + } + + self.lastSourceAttribute = .init(currentView.attribute) + self.resetSeed = currentView.phase.resetSeed + + let currentSize = currentView.size + var currentOrigin = currentView.args.anchor.in(currentSize.value) + currentOrigin.convert(to: .global, transform: currentView.transform.withPosition(currentView.position)) + + var viewFrame = ViewFrame(origin: currentOrigin, size: currentSize) + var sourceAttribute = AnyOptionalAttribute(currentView.attribute) + if let animatorState { + var animatableData = viewFrame.animatableData + let isAnimationOver = animatorState.update( + &animatableData, + at: animationTime, + environment: $environment + ) + viewFrame.animatableData = animatableData + if isAnimationOver { + self.animatorState = nil + removeListeners() + } else { + animatorState.nextUpdate() + } + sourceAttribute = AnyOptionalAttribute() + } + value = (viewFrame, sourceAttribute) + } +} diff --git a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift index cd8d50eb0..c261f48f7 100644 --- a/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift +++ b/Sources/OpenSwiftUICore/View/Graph/ViewGraph.swift @@ -243,7 +243,7 @@ package final class ViewGraph: GraphHost { } _ViewDebug.initialize(inputs: &inputs) if inputs.needsGeometry { - // inputs.makeRootMatchedGeometryScope() + inputs.makeRootMatchedGeometryScope() } inputs.base.pushStableType(rootViewType) $rootGeometry.mutateBody(