From 6270f4d4a17ec55e2120e68107613fbb5be20715 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 30 Mar 2026 21:52:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A7=D0=B0=D1=82:=20Telegram-style=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=8F?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20(slide-up=20spring=20+=20alpha?= =?UTF-8?q?=20fade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chats/ChatDetail/NativeMessageCell.swift | 3 + .../Chats/ChatDetail/NativeMessageList.swift | 92 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) 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.