Fix fast-send morph starting position

The bubble-in-text-input morph ran concurrently with listNode's
scroll-to-item sublayerTransform animation, but its source rect was
computed via CALayer.convert which uses model transforms only and so
missed the pending additive sublayerTransform. The morph's `from`
ended up calibrated to the post-scroll state while playback started
from the pre-scroll state, offsetting the bubble at t=0.

Add convertAnimatingSourceRectFromWindow: walks the layer chain
top-down and at each parent->child step subtracts the parent's pending
additive sublayerTransform translation in the parent's bounds space
before descending, so the result reflects the t=0 presentation state
even through rotations/scales later in the chain. Parent
animatingItemNode under itemNode so listNode is on its ancestor chain
and its pending sublayerTransform is picked up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-04-24 22:45:15 +04:00
parent 493f3103b3
commit 6c2f370a8f

View file

@ -31,6 +31,64 @@ private func convertAnimatingSourceRect(_ rect: CGRect, fromView: UIView, toView
}
}
private func pendingAdditiveSublayerTranslation(_ layer: CALayer) -> CGPoint {
guard let keys = layer.animationKeys() else { return .zero }
var result = CGPoint.zero
for key in keys {
guard let anim = layer.animation(forKey: key) as? CABasicAnimation,
anim.keyPath == "sublayerTransform",
anim.isAdditive,
let fromValue = anim.fromValue as? NSValue else {
continue
}
let t = fromValue.caTransform3DValue
result.x += t.m41
result.y += t.m42
}
return result
}
/// Convert a rect expressed in window coordinates to `toView`'s local coordinates,
/// accounting for pending additive `sublayerTransform` animations on any ancestor
/// of `toView`. Returns the position in `toView.bounds` that will render at
/// `windowRect` visually at t=0 of the pending animations (once CA commits them).
///
/// Standard `toView.layer.convert(windowRect, from: nil)` reads model transforms
/// only, so it yields the position that will render at `windowRect` *at t=end* of
/// any pending additive animations (since additive animations don't change model
/// values). For source-side morph calibration, where the snapshot was captured at
/// pre-animation state, the t=0 position is what we want.
///
/// Walks the layer chain top-down from the root to `toView`. At each parentchild
/// step it subtracts the parent's pending additive `sublayerTransform` translation
/// *in the parent's own bounds coord space*, then does the standard one-step
/// `convert(_:to:)` into the child. Applying the correction at the right level
/// (rather than flat-summing the translations in the destination space) lets
/// `CALayer.convert` propagate each correction through any remaining
/// transforms child `transform`, further ancestors' own model
/// `sublayerTransform`, etc. so the result is correct even when the chain
/// contains non-translation transforms (rotations, scales).
private func convertAnimatingSourceRectFromWindow(_ windowRect: CGRect, toView: UIView) -> CGRect {
var chain: [CALayer] = []
var layer: CALayer? = toView.layer
while let cur = layer {
chain.append(cur)
layer = cur.superlayer
}
chain.reverse()
var r = windowRect
for i in 0..<(chain.count - 1) {
let parent = chain[i]
let child = chain[i + 1]
let pending = pendingAdditiveSublayerTranslation(parent)
let adjustedR = r.offsetBy(dx: -pending.x, dy: -pending.y)
r = parent.convert(adjustedR, to: child)
}
return r
}
private final class OverlayTransitionContainerNode: ViewControllerTracingNode {
override init() {
super.init()
@ -388,9 +446,11 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
func beginAnimation() {
if let portalTargetView = self.portalTargetView {
portalTargetView.view.alpha = 0.0
portalTargetView.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, delay: 0.15)
portalTargetView.view.layer.allowsGroupOpacity = true
portalTargetView.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.14, delay: 0.14)
self.portalSourceView.layer.animateAlpha(from: 0.01, to: 1.0, duration: 0.12, delay: 0.15)
self.portalSourceView.layer.allowsGroupOpacity = true
self.portalSourceView.layer.animateAlpha(from: 0.01, to: 1.0, duration: 0.1, delay: 0.12)
}
let verticalDuration: Double = ChatMessageTransitionNodeImpl.animationDuration
@ -428,12 +488,12 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
let targetAbsoluteRect = self.contextSourceNode.view.convert(self.contextSourceNode.contentRect, to: self.view)
let sourceRect = self.view.convert(initialTextInput.sourceRect, from: nil)
//let sourceRect = self.view.convert(initialTextInput.sourceRect, from: nil)
let sourceRect = convertAnimatingSourceRectFromWindow(initialTextInput.sourceRect, toView: self.view)
let sourceBackgroundAbsoluteRect = initialTextInput.backgroundView.frame.offsetBy(dx: sourceRect.minX, dy: sourceRect.minY)
let sourceAbsoluteRect = CGRect(origin: CGPoint(x: sourceBackgroundAbsoluteRect.minX, y: sourceBackgroundAbsoluteRect.maxY - self.contextSourceNode.contentRect.height), size: self.contextSourceNode.contentRect.size)
let textInput = ChatMessageTransitionNodeImpl.Source.TextInput(backgroundView: initialTextInput.backgroundView, contentView: initialTextInput.contentView, sourceRect: sourceRect, scrollOffset: initialTextInput.scrollOffset)
textInput.backgroundView.frame = CGRect(origin: CGPoint(x: 0.0, y: sourceAbsoluteRect.height - sourceBackgroundAbsoluteRect.height), size: textInput.backgroundView.bounds.size)
textInput.contentView.frame = textInput.contentView.frame.offsetBy(dx: 0.0, dy: sourceAbsoluteRect.height - sourceBackgroundAbsoluteRect.height)
@ -477,6 +537,7 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
self.containerNode.frame = targetAbsoluteRect.offsetBy(dx: -self.contextSourceNode.contentRect.minX, dy: -self.contextSourceNode.contentRect.minY)
self.contextSourceNode.updateAbsoluteRect?(self.containerNode.frame, UIScreen.main.bounds.size)
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: sourceAbsoluteRect.maxY - targetAbsoluteRect.maxY), to: CGPoint(), duration: verticalDuration, delay: delay, mediaTimingFunction: verticalCurve.mediaTimingFunction, additive: true, force: true, completion: { [weak self] _ in
guard let strongSelf = self else {
return
@ -484,6 +545,7 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
strongSelf.endAnimation()
})
self.containerNode.layer.animatePosition(from: CGPoint(x: sourceAbsoluteRect.minX - targetAbsoluteRect.minX, y: 0.0), to: CGPoint(), duration: horizontalDuration, delay: delay, mediaTimingFunction: horizontalCurve.mediaTimingFunction, additive: true)
self.contextSourceNode.applyAbsoluteOffset?(CGPoint(x: sourceAbsoluteRect.minX - targetAbsoluteRect.minX, y: 0.0), horizontalCurve, horizontalDuration)
self.contextSourceNode.applyAbsoluteOffset?(CGPoint(x: 0.0, y: sourceAbsoluteRect.maxY - targetAbsoluteRect.maxY), verticalCurve, verticalDuration)
@ -1098,8 +1160,10 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
overlayController.displayNode.addSubnode(animatingItemNode)
animatingItemNode.overlayController = overlayController
self.listNode.context.sharedContext.mainWindow?.presentInGlobalOverlay(overlayController)
animatingItemNode.frame = self.bounds
default:
self.addSubnode(animatingItemNode)
animatingItemNode.frame = CGRect()
itemNode.addSubnode(animatingItemNode)
}
animatingItemNode.animationEnded = { [weak self, weak animatingItemNode] in
@ -1120,7 +1184,6 @@ public final class ChatMessageTransitionNodeImpl: ASDisplayNode, ChatMessageTran
}
}
animatingItemNode.frame = self.bounds
animatingItemNode.beginAnimation()
self.onTransitionEvent(.animated(duration: ChatMessageTransitionNodeImpl.animationDuration, curve: ChatMessageTransitionNodeImpl.verticalAnimationCurve))