NativeMessageCell рефакторинг на UIView + wrapper для UICollectionView совместимости

This commit is contained in:
2026-04-18 12:29:27 +05:00
parent dedef48a55
commit 2f80ab5cc1
3 changed files with 83 additions and 24 deletions

View File

@@ -12,6 +12,7 @@ final class ChatMessageListItem: RosettaListItem {
let message: ChatMessage let message: ChatMessage
let layout: MessageCellLayout let layout: MessageCellLayout
let textLayout: CoreTextTextLayout? let textLayout: CoreTextTextLayout?
let timestampText: String
let actions: MessageCellActions let actions: MessageCellActions
let replyName: String? let replyName: String?
let replyText: String? let replyText: String?
@@ -29,6 +30,7 @@ final class ChatMessageListItem: RosettaListItem {
message: ChatMessage, message: ChatMessage,
layout: MessageCellLayout, layout: MessageCellLayout,
textLayout: CoreTextTextLayout?, textLayout: CoreTextTextLayout?,
timestampText: String,
actions: MessageCellActions, actions: MessageCellActions,
replyName: String? = nil, replyName: String? = nil,
replyText: String? = nil, replyText: String? = nil,
@@ -39,6 +41,7 @@ final class ChatMessageListItem: RosettaListItem {
self.message = message self.message = message
self.layout = layout self.layout = layout
self.textLayout = textLayout self.textLayout = textLayout
self.timestampText = timestampText
self.actions = actions self.actions = actions
self.replyName = replyName self.replyName = replyName
self.replyText = replyText self.replyText = replyText
@@ -86,7 +89,7 @@ final class ChatMessageListItem: RosettaListItem {
node.cell.apply(layout: layout) node.cell.apply(layout: layout)
node.cell.configure( node.cell.configure(
message: message, message: message,
timestamp: layout.timestampText, timestamp: timestampText,
textLayout: textLayout, textLayout: textLayout,
actions: actions, actions: actions,
replyName: replyName, replyName: replyName,

View File

@@ -10,7 +10,12 @@ import SwiftUI
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing /// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
/// ///
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead). /// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
final class NativeMessageCell: UICollectionViewCell { /// Base class: UIView (not UICollectionViewCell) works in both RosettaListView and UICollectionView wrapper.
final class NativeMessageCell: UIView {
/// Content container. UICollectionViewCell provides this automatically;
/// as a plain UIView we create our own for API compatibility.
let contentView = UIView()
// MARK: - Constants // MARK: - Constants
@@ -299,6 +304,9 @@ final class NativeMessageCell: UICollectionViewCell {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
contentView.frame = bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(contentView)
setupViews() setupViews()
} }
@@ -2144,13 +2152,9 @@ final class NativeMessageCell: UICollectionViewCell {
// MARK: - Self-sizing (from pre-calculated layout) // MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting( /// Effective height for external layout systems.
_ layoutAttributes: UICollectionViewLayoutAttributes var preferredHeight: CGFloat {
) -> UICollectionViewLayoutAttributes { currentLayout?.totalHeight ?? 50
// Always return concrete height never fall to super (expensive self-sizing)
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = currentLayout?.totalHeight ?? 50
return attrs
} }
// MARK: - Link Tap // MARK: - Link Tap
@@ -3840,8 +3844,7 @@ final class NativeMessageCell: UICollectionViewCell {
// MARK: - Reuse // MARK: - Reuse
override func prepareForReuse() { func prepareForReuse() {
super.prepareForReuse()
uploadOverlayShowTime = 0 uploadOverlayShowTime = 0
if let observer = uploadProgressObserver { if let observer = uploadProgressObserver {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
@@ -4516,3 +4519,43 @@ final class ForwardItemSubview: UIView {
} }
} }
} }
// MARK: - UICollectionView Wrapper
/// Thin UICollectionViewCell wrapper for NativeMessageCell (UIView).
/// Allows existing UICollectionView-based NativeMessageList to continue working
/// while NativeMessageCell itself is a plain UIView for RosettaListView.
final class NativeMessageCollectionCell: UICollectionViewCell {
let messageView = NativeMessageCell(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
messageView.frame = contentView.bounds
messageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(messageView)
backgroundColor = .clear
contentView.backgroundColor = .clear
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func prepareForReuse() {
super.prepareForReuse()
messageView.prepareForReuse()
}
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = messageView.preferredHeight
return attrs
}
override func layoutSubviews() {
super.layoutSubviews()
messageView.frame = contentView.bounds
}
}

View File

@@ -109,8 +109,19 @@ final class NativeMessageListController: UIViewController {
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private static let unreadSeparatorId = "__unread_separator__" private static let unreadSeparatorId = "__unread_separator__"
/// Extract NativeMessageCell from a collection view cell (wrapper pattern).
private func messageCell(from cell: UICollectionViewCell) -> NativeMessageCell? {
(cell as? NativeMessageCollectionCell)?.messageView
}
/// Extract NativeMessageCell at index path.
private func messageCell(at indexPath: IndexPath) -> NativeMessageCell? {
guard let cell = collectionView.cellForItem(at: indexPath) else { return nil }
return messageCell(from: cell)
}
private var dataSource: UICollectionViewDiffableDataSource<Int, String>! private var dataSource: UICollectionViewDiffableDataSource<Int, String>!
private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>! private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCollectionCell, ChatMessage>!
private var separatorCellRegistration: UICollectionView.CellRegistration<UnreadSeparatorCell, Bool>! private var separatorCellRegistration: UICollectionView.CellRegistration<UnreadSeparatorCell, Bool>!
// MARK: - Insertion Animation // MARK: - Insertion Animation
@@ -348,7 +359,7 @@ final class NativeMessageListController: UIViewController {
let sortedIndexPaths = collectionView.indexPathsForVisibleItems let sortedIndexPaths = collectionView.indexPathsForVisibleItems
.sorted { $0.item < $1.item } .sorted { $0.item < $1.item }
for ip in sortedIndexPaths { for ip in sortedIndexPaths {
guard let cell = collectionView.cellForItem(at: ip) as? NativeMessageCell, guard let cell = messageCell(at: ip),
let msgId = messageId(for: ip) else { continue } let msgId = messageId(for: ip) else { continue }
let isOutgoing = layoutCache[msgId]?.isOutgoing ?? false let isOutgoing = layoutCache[msgId]?.isOutgoing ?? false
// Hide cells before animation they'll fade in via skeleton transition // Hide cells before animation they'll fade in via skeleton transition
@@ -476,8 +487,9 @@ final class NativeMessageListController: UIViewController {
} }
private func setupNativeCellRegistration() { private func setupNativeCellRegistration() {
nativeCellRegistration = UICollectionView.CellRegistration<NativeMessageCell, ChatMessage> { nativeCellRegistration = UICollectionView.CellRegistration<NativeMessageCollectionCell, ChatMessage> {
[weak self] cell, indexPath, message in [weak self] wrapper, indexPath, message in
let cell = wrapper.messageView
guard let self else { return } guard let self else { return }
// Patch group messages without attachment password (legacy messages before fix). // Patch group messages without attachment password (legacy messages before fix).
@@ -1445,9 +1457,9 @@ final class NativeMessageListController: UIViewController {
// Update all visible cells // Update all visible cells
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard let msgCell = cell as? NativeMessageCell else { continue } guard let msgCell = messageCell(from: cell) else { continue }
msgCell.setSelectionMode(enabled, animated: animated) msgCell.setSelectionMode(enabled, animated: animated)
if enabled, let indexPath = collectionView.indexPath(for: msgCell), if enabled, let indexPath = collectionView.indexPath(for: cell),
let msgId = messageId(for: indexPath) { let msgId = messageId(for: indexPath) {
msgCell.setMessageSelected(selectedMessageIds.contains(msgId), animated: false) msgCell.setMessageSelected(selectedMessageIds.contains(msgId), animated: false)
} }
@@ -1462,8 +1474,9 @@ final class NativeMessageListController: UIViewController {
selectedMessageIds = ids selectedMessageIds = ids
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard let msgCell = cell as? NativeMessageCell, guard let wrapper = cell as? NativeMessageCollectionCell,
let indexPath = collectionView.indexPath(for: msgCell), let msgCell = messageCell(from: wrapper),
let indexPath = collectionView.indexPath(for: wrapper),
let msgId = messageId(for: indexPath) else { continue } let msgId = messageId(for: indexPath) else { continue }
let wasSelected = oldIds.contains(msgId) let wasSelected = oldIds.contains(msgId)
let isSelected = ids.contains(msgId) let isSelected = ids.contains(msgId)
@@ -1889,13 +1902,13 @@ final class NativeMessageListController: UIViewController {
func animateHighlight(messageId: String?) { func animateHighlight(messageId: String?) {
// Hide any previously highlighted cell // Hide any previously highlighted cell
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
(cell as? NativeMessageCell)?.hideHighlight() messageCell(from: cell)?.hideHighlight()
} }
guard let messageId, guard let messageId,
let snapshot = dataSource?.snapshot(), let snapshot = dataSource?.snapshot(),
let itemIndex = snapshot.indexOfItem(messageId) else { return } let itemIndex = snapshot.indexOfItem(messageId) else { return }
let indexPath = IndexPath(item: itemIndex, section: 0) let indexPath = IndexPath(item: itemIndex, section: 0)
guard let cell = collectionView.cellForItem(at: indexPath) as? NativeMessageCell else { return } guard let cell = messageCell(at: indexPath) else { return }
cell.showHighlight() cell.showHighlight()
} }
@@ -2214,7 +2227,7 @@ extension NativeMessageListController: ComposerViewDelegate {
let player = VoiceMessagePlayer.shared let player = VoiceMessagePlayer.shared
guard let playingId = player.currentMessageId else { return } guard let playingId = player.currentMessageId else { return }
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard let messageCell = cell as? NativeMessageCell else { continue } guard let messageCell = self.messageCell(from: cell) else { continue }
// Match main voice view OR any forward item voice view // Match main voice view OR any forward item voice view
let matchesMain = messageCell.currentMessageId == playingId let matchesMain = messageCell.currentMessageId == playingId
let matchesForward = messageCell.hasForwardVoiceWithPlaybackId(playingId) let matchesForward = messageCell.hasForwardVoiceWithPlaybackId(playingId)
@@ -2232,7 +2245,7 @@ extension NativeMessageListController: ComposerViewDelegate {
private func updateAllVisibleVoiceCells() { private func updateAllVisibleVoiceCells() {
let player = VoiceMessagePlayer.shared let player = VoiceMessagePlayer.shared
for cell in collectionView.visibleCells { for cell in collectionView.visibleCells {
guard let messageCell = cell as? NativeMessageCell else { continue } guard let messageCell = self.messageCell(from: cell) else { continue }
let isThisMessage = messageCell.currentMessageId == player.currentMessageId let isThisMessage = messageCell.currentMessageId == player.currentMessageId
|| messageCell.hasForwardVoiceWithPlaybackId(player.currentMessageId) || messageCell.hasForwardVoiceWithPlaybackId(player.currentMessageId)
messageCell.updateVoicePlayback( messageCell.updateVoicePlayback(
@@ -2409,7 +2422,7 @@ extension NativeMessageListController: ComposerViewDelegate {
collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false) collectionView.scrollToItem(at: indexPath, at: .bottom, animated: false)
collectionView.layoutIfNeeded() collectionView.layoutIfNeeded()
} }
guard let cell = collectionView.cellForItem(at: indexPath) as? NativeMessageCell else { guard let cell = messageCell(at: indexPath) else {
return nil return nil
} }
return cell.voiceTransitionTargetFrame(in: window) ?? cell.bubbleFrameInWindow(window) return cell.voiceTransitionTargetFrame(in: window) ?? cell.bubbleFrameInWindow(window)