Чат: Telegram-style анимация появления сообщений (slide-up spring + alpha fade)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user