Чат: Telegram-style анимация появления сообщений (slide-up spring + alpha fade)

This commit is contained in:
2026-03-30 21:52:21 +05:00
parent f3d5897b2b
commit 6270f4d4a1
2 changed files with 94 additions and 1 deletions

View File

@@ -1987,6 +1987,9 @@ final class NativeMessageCell: UICollectionViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
layer.removeAnimation(forKey: "insertionSlide")
layer.removeAnimation(forKey: "insertionMove")
contentView.layer.removeAnimation(forKey: "insertionAlpha")
message = nil message = nil
actions = nil actions = nil
currentLayout = nil currentLayout = nil

View File

@@ -66,6 +66,9 @@ final class NativeMessageListController: UIViewController {
private var dataSource: UICollectionViewDiffableDataSource<Int, String>! private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>! private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>!
// MARK: - Insertion Animation
private var hasCompletedInitialLoad = false
// MARK: - Composer // MARK: - Composer
/// Pure UIKit composer (iOS < 26). nil on iOS 26+ (SwiftUI overlay). /// 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. /// Called from SwiftUI when messages array changes.
func update(messages: [ChatMessage], animated: Bool = false) { func update(messages: [ChatMessage], animated: Bool = false) {
let oldIds = Set(self.messages.map(\.id)) 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 self.messages = messages
// Recalculate ALL layouts BubblePosition depends on neighbors in the FULL // Recalculate ALL layouts BubblePosition depends on neighbors in the FULL
@@ -586,10 +610,76 @@ final class NativeMessageListController: UIViewController {
snapshot.reconfigureItems(existingItems) 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() 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<String>, 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) // MARK: - Layout Calculation (Telegram asyncLayout pattern)
/// Recalculate layouts for ALL messages using the full array. /// Recalculate layouts for ALL messages using the full array.