diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index ae5b43a..bb16c5b 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -1987,6 +1987,9 @@ final class NativeMessageCell: UICollectionViewCell { override func prepareForReuse() { super.prepareForReuse() + layer.removeAnimation(forKey: "insertionSlide") + layer.removeAnimation(forKey: "insertionMove") + contentView.layer.removeAnimation(forKey: "insertionAlpha") message = nil actions = nil currentLayout = nil diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index b56f063..9bdbbc6 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -66,6 +66,9 @@ final class NativeMessageListController: UIViewController { private var dataSource: UICollectionViewDiffableDataSource! private var nativeCellRegistration: UICollectionView.CellRegistration! + // MARK: - Insertion Animation + private var hasCompletedInitialLoad = false + // MARK: - Composer /// Pure UIKit composer (iOS < 26). nil on iOS 26+ (SwiftUI overlay). @@ -567,6 +570,27 @@ final class NativeMessageListController: UIViewController { /// Called from SwiftUI when messages array changes. func update(messages: [ChatMessage], animated: Bool = false) { let oldIds = Set(self.messages.map(\.id)) + let oldNewestId = self.messages.last?.id + + // Detect new messages at the tail (sent/received in real-time). + // Skip animation for: initial load, pagination, batch sync (>3 messages). + let newIds = Set(messages.map(\.id)).subtracting(oldIds) + let isInteractive = hasCompletedInitialLoad + && !newIds.isEmpty + && newIds.count <= 3 + && messages.last?.id != oldNewestId + + // Capture visible cell positions BEFORE applying snapshot (for position animation) + var oldPositions: [String: CGFloat] = [:] + if isInteractive { + for ip in collectionView.indexPathsForVisibleItems { + if let cellId = dataSource.itemIdentifier(for: ip), + let cell = collectionView.cellForItem(at: ip) { + oldPositions[cellId] = cell.layer.position.y + } + } + } + self.messages = messages // Recalculate ALL layouts — BubblePosition depends on neighbors in the FULL @@ -586,10 +610,76 @@ final class NativeMessageListController: UIViewController { snapshot.reconfigureItems(existingItems) } - dataSource.apply(snapshot, animatingDifferences: animated) + dataSource.apply(snapshot, animatingDifferences: false) + + // Apply Telegram-style insertion animations after layout settles + if isInteractive { + collectionView.layoutIfNeeded() + applyInsertionAnimations(newIds: newIds, oldPositions: oldPositions) + } + + if !hasCompletedInitialLoad && !messages.isEmpty { + hasCompletedInitialLoad = true + } + updateScrollToBottomBadge() } + /// Telegram-style message insertion animation. + /// New messages: slide up from below (-height*1.6 offset) + alpha fade (0.2s). + /// Existing messages: spring position animation from old Y to new Y. + /// All position animations use CASpringAnimation (stiffness=443.7, damping=31.82). + /// Source: ChatMessageItemView.animateInsertion + ListView.insertNodeAtIndex. + private func applyInsertionAnimations(newIds: Set, oldPositions: [String: CGFloat]) { + for ip in collectionView.indexPathsForVisibleItems { + guard let cellId = dataSource.itemIdentifier(for: ip), + let cell = collectionView.cellForItem(at: ip) else { continue } + + if newIds.contains(cellId) { + // NEW cell: slide up from below + alpha fade + // In inverted CV: negative offset = below on screen + let slideOffset = -cell.bounds.height * 1.6 + + let slide = CASpringAnimation(keyPath: "position.y") + slide.fromValue = slideOffset + slide.toValue = 0.0 + slide.isAdditive = true + slide.stiffness = 443.7 + slide.damping = 31.82 + slide.mass = 1.0 + slide.initialVelocity = 0 + slide.duration = slide.settlingDuration + slide.fillMode = .backwards + cell.layer.add(slide, forKey: "insertionSlide") + + // Alpha fade: 0 → 1 (0.2s) + let alpha = CABasicAnimation(keyPath: "opacity") + alpha.fromValue = 0.0 + alpha.toValue = 1.0 + alpha.duration = 0.2 + alpha.fillMode = .backwards + cell.contentView.layer.add(alpha, forKey: "insertionAlpha") + + } else if let oldY = oldPositions[cellId] { + // EXISTING cell: spring from old position to new position + let delta = oldY - cell.layer.position.y + guard abs(delta) > 0.5 else { continue } + + let move = CASpringAnimation(keyPath: "position.y") + move.fromValue = delta + move.toValue = 0.0 + move.isAdditive = true + move.stiffness = 443.7 + move.damping = 31.82 + move.mass = 1.0 + move.initialVelocity = 0 + move.duration = move.settlingDuration + move.fillMode = .backwards + cell.layer.add(move, forKey: "insertionMove") + } + } + } + // MARK: - Layout Calculation (Telegram asyncLayout pattern) /// Recalculate layouts for ALL messages using the full array.