Чат: Telegram-style анимация появления сообщений (slide-up spring + alpha fade)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -66,6 +66,9 @@ final class NativeMessageListController: UIViewController {
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
|
||||
private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>!
|
||||
|
||||
// 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<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)
|
||||
|
||||
/// Recalculate layouts for ALL messages using the full array.
|
||||
|
||||
Reference in New Issue
Block a user