WIP: каркас кастомного RosettaListView (Telegram-parity ListView)

- RosettaListView: core UIScrollView с ручным node management, visibility culling (500pt), CADisplayLink анимации
- RosettaListNode: базовый класс ячейки с animation state (height/alpha/spring)
- RosettaListItem: протокол с async layout closure (Telegram asyncLayout pattern)
- RosettaTransactionQueue: FIFO сериализатор обновлений
- ChatMessageListItem: bridge ChatMessage → RosettaListItem (WIP, не подключен)

Следующий шаг: NativeMessageCell → NativeMessageView (UIView) рефакторинг

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 11:57:12 +05:00
parent 426de363cd
commit dedef48a55
5 changed files with 1220 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
import UIKit
// MARK: - ChatMessageListItem
/// Bridges ChatMessage + pre-calculated layout to RosettaListView.
/// Telegram equivalent: ChatMessageItemImpl.
///
/// Layout is pre-calculated by LayoutEngine (background thread).
/// This class just holds the results and creates/configures nodes.
final class ChatMessageListItem: RosettaListItem {
let message: ChatMessage
let layout: MessageCellLayout
let textLayout: CoreTextTextLayout?
let actions: MessageCellActions
let replyName: String?
let replyText: String?
let replyMessageId: String?
let forwardSenderName: String?
let forwardSenderKey: String?
// MARK: - RosettaListItem
var id: String { message.id }
var approximateHeight: CGFloat { layout.totalHeight }
var reuseIdentifier: String { "msg" }
init(
message: ChatMessage,
layout: MessageCellLayout,
textLayout: CoreTextTextLayout?,
actions: MessageCellActions,
replyName: String? = nil,
replyText: String? = nil,
replyMessageId: String? = nil,
forwardSenderName: String? = nil,
forwardSenderKey: String? = nil
) {
self.message = message
self.layout = layout
self.textLayout = textLayout
self.actions = actions
self.replyName = replyName
self.replyText = replyText
self.replyMessageId = replyMessageId
self.forwardSenderName = forwardSenderName
self.forwardSenderKey = forwardSenderKey
}
func nodeConfiguredForParams(
async: @escaping (@escaping () -> Void) -> Void,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void
) {
let node = MessageListNode()
applyToNode(node)
let nodeLayout = RosettaListNodeLayout(
contentSize: CGSize(width: params.width, height: layout.totalHeight),
insets: .zero
)
completion(node, nodeLayout)
}
func updateNode(
async: @escaping (@escaping () -> Void) -> Void,
node: RosettaListNode,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
animation: RosettaListUpdateAnimation,
completion: @escaping (RosettaListNodeLayout) -> Void
) {
if let messageNode = node as? MessageListNode {
applyToNode(messageNode)
}
let nodeLayout = RosettaListNodeLayout(
contentSize: CGSize(width: params.width, height: layout.totalHeight),
insets: .zero
)
completion(nodeLayout)
}
private func applyToNode(_ node: MessageListNode) {
node.cell.apply(layout: layout)
node.cell.configure(
message: message,
timestamp: layout.timestampText,
textLayout: textLayout,
actions: actions,
replyName: replyName,
replyText: replyText,
replyMessageId: replyMessageId,
forwardSenderName: forwardSenderName,
forwardSenderKey: forwardSenderKey
)
}
}
// MARK: - MessageListNode
/// RosettaListNode containing a NativeMessageCell.
/// NativeMessageCell is UICollectionViewCell but works as plain UIView
/// when instantiated directly (not dequeued from UICollectionView).
final class MessageListNode: RosettaListNode {
let cell = NativeMessageCell(frame: .zero)
override init(frame: CGRect) {
super.init(frame: frame)
// UICollectionViewCell.contentView is the actual content container
cell.frame = bounds
cell.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(cell.contentView)
}
override func prepareForReuse() {
super.prepareForReuse()
cell.prepareForReuse()
}
override func layoutSubviews() {
super.layoutSubviews()
cell.contentView.frame = contentView.bounds
cell.bounds = contentView.bounds
cell.layoutSubviews()
}
}
// MARK: - UnreadSeparatorListItem
/// Unread separator for RosettaListView.
final class UnreadSeparatorListItem: RosettaListItem {
var id: String { "__unread_separator__" }
var approximateHeight: CGFloat { 25 }
var reuseIdentifier: String { "sep" }
func nodeConfiguredForParams(
async: @escaping (@escaping () -> Void) -> Void,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void
) {
let node = UnreadSeparatorNode()
let nodeLayout = RosettaListNodeLayout(
contentSize: CGSize(width: params.width, height: 25),
insets: .zero
)
completion(node, nodeLayout)
}
func updateNode(
async: @escaping (@escaping () -> Void) -> Void,
node: RosettaListNode,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
animation: RosettaListUpdateAnimation,
completion: @escaping (RosettaListNodeLayout) -> Void
) {
completion(RosettaListNodeLayout(
contentSize: CGSize(width: params.width, height: 25),
insets: .zero
))
}
}
// MARK: - UnreadSeparatorNode
final class UnreadSeparatorNode: RosettaListNode {
private let bar: UIView = {
let v = UIView()
v.backgroundColor = UIColor(red: 0.16, green: 0.16, blue: 0.18, alpha: 1)
return v
}()
private let label: UILabel = {
let l = UILabel()
l.text = "Unread Messages"
l.font = .systemFont(ofSize: 13, weight: .regular)
l.textColor = .white
l.textAlignment = .center
return l
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(bar)
bar.addSubview(label)
}
override func layoutSubviews() {
super.layoutSubviews()
bar.frame = contentView.bounds
label.frame = bar.bounds
}
}

View File

@@ -0,0 +1,139 @@
import UIKit
// MARK: - Layout Params
/// Parameters for layout calculation (screen width, insets).
/// Telegram equivalent: `ListViewItemLayoutParams`.
struct RosettaListLayoutParams: Sendable {
let width: CGFloat
let leftInset: CGFloat
let rightInset: CGFloat
}
// MARK: - Node Layout
/// Calculated layout result content size + insets.
/// Telegram equivalent: `ListViewItemNodeLayout`.
struct RosettaListNodeLayout {
let contentSize: CGSize
let insets: UIEdgeInsets
var size: CGSize {
CGSize(
width: contentSize.width + insets.left + insets.right,
height: contentSize.height + insets.top + insets.bottom
)
}
}
// MARK: - Update Animation
/// Animation type for node updates.
/// Telegram equivalent: `ListViewItemUpdateAnimation`.
enum RosettaListUpdateAnimation {
case none
case system(duration: Double, transition: ControlledTransitionConfig)
}
struct ControlledTransitionConfig {
let duration: Double
let curve: AnimationCurve
enum AnimationCurve {
case spring(damping: CGFloat)
case easeInOut
}
static let `default` = ControlledTransitionConfig(
duration: 0.4,
curve: .spring(damping: 0.78)
)
}
// MARK: - Direction Hint
/// Hint for insertion/deletion animation direction.
/// Telegram equivalent: `ListViewItemOperationDirectionHint`.
enum RosettaListDirectionHint {
case up
case down
}
// MARK: - Scroll Position
/// Target scroll position for programmatic scrolling.
/// Telegram equivalent: `ListViewScrollPosition`.
enum RosettaListScrollPosition {
case top(CGFloat)
case center
case bottom(CGFloat)
case visible
}
// MARK: - List Item Protocol
/// Data model protocol for list items.
/// Telegram equivalent: `ListViewItem` (ListViewItem.swift:72-106).
///
/// Two-phase async layout pattern:
/// 1. `nodeConfiguredForParams` creates node + calculates layout on background
/// 2. Completion delivers ready-to-display node + layout to main thread
protocol RosettaListItem: AnyObject {
/// Unique identifier for diffing.
var id: String { get }
/// Estimated height before layout calculation. Used for scroll position
/// estimation when exact layout isn't ready yet.
/// Telegram: `approximateHeight` property.
var approximateHeight: CGFloat { get }
/// Reuse identifier for node pool recycling.
var reuseIdentifier: String { get }
/// Create a new node and calculate its layout.
/// `async` closure schedules work on background queue.
/// Telegram: `nodeConfiguredForParams` (ListViewItem.swift:72-75).
func nodeConfiguredForParams(
async: @escaping (@escaping () -> Void) -> Void,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
completion: @escaping (RosettaListNode, RosettaListNodeLayout) -> Void
)
/// Update an existing (recycled) node with new data.
/// Telegram: `updateNode` (ListViewItem.swift:78-88).
func updateNode(
async: @escaping (@escaping () -> Void) -> Void,
node: RosettaListNode,
params: RosettaListLayoutParams,
previousItem: RosettaListItem?,
nextItem: RosettaListItem?,
animation: RosettaListUpdateAnimation,
completion: @escaping (RosettaListNodeLayout) -> Void
)
}
// MARK: - Insert/Delete/Update Operations
/// Batch operations for list updates.
/// Telegram equivalent: `ListViewInsertItem`, `ListViewDeleteItem`, `ListViewUpdateItem`.
struct RosettaListInsertItem {
let index: Int
let previousIndex: Int?
let item: RosettaListItem
let directionHint: RosettaListDirectionHint?
}
struct RosettaListDeleteItem {
let index: Int
let directionHint: RosettaListDirectionHint?
}
struct RosettaListUpdateItem {
let index: Int
let previousIndex: Int
let item: RosettaListItem
let directionHint: RosettaListDirectionHint?
}

View File

@@ -0,0 +1,208 @@
import UIKit
import QuartzCore
// MARK: - RosettaListNode
/// Base class for list view cells. Manages its own animations.
/// Telegram equivalent: `ListViewItemNode` (ListViewItemNode.swift).
///
/// Each node tracks:
/// - Its index in the data source
/// - Active animations (height, alpha, position)
/// - Layout information
///
/// Subclass or wrap existing UIView (e.g., NativeMessageCell) in this.
class RosettaListNode: UIView {
// MARK: - State
/// Index in the data source. nil = pending removal.
var index: Int?
/// Reuse identifier for node pool.
var reuseIdentifier: String = ""
/// Current layout (set by RosettaListView after layout calculation).
private(set) var layout: RosettaListNodeLayout?
/// The content view subclasses add their UI here.
/// Using a content view allows the node to animate height independently.
let contentView = UIView()
// MARK: - Animation State
/// Animated height (used during insertion/deletion).
/// When nil, uses layout.size.height.
var apparentHeight: CGFloat?
/// Active animations managed by the display link loop.
private(set) var activeAnimations: [NodeAnimation] = []
/// Whether this node has pending animations.
var hasActiveAnimations: Bool { !activeAnimations.isEmpty }
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
contentView.frame = bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(contentView)
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
func applyLayout(_ layout: RosettaListNodeLayout) {
self.layout = layout
}
/// Effective height for positioning.
/// During animations, uses animating height. Otherwise uses layout height.
var effectiveHeight: CGFloat {
if let apparentHeight { return apparentHeight }
return layout?.size.height ?? 50
}
// MARK: - Prepare for Reuse
/// Called when node is returned to pool. Reset transient state.
func prepareForReuse() {
index = nil
apparentHeight = nil
activeAnimations.removeAll()
alpha = 1
layer.removeAllAnimations()
}
// MARK: - Animations
/// Add height animation (for insertion: 0 target, for deletion: current 0).
/// Telegram: `addApparentHeightAnimation` (ListViewItemNode.swift:132-147).
func addHeightAnimation(
from: CGFloat,
to: CGFloat,
duration: Double,
beginAt: CFTimeInterval,
curve: NodeAnimation.Curve = .spring(damping: 0.78)
) {
apparentHeight = from
let animation = NodeAnimation(
property: .height,
from: from,
to: to,
duration: duration,
beginAt: beginAt,
curve: curve
)
activeAnimations.append(animation)
}
/// Add alpha animation (for insertion: 0 1).
func addAlphaAnimation(
from: CGFloat,
to: CGFloat,
duration: Double,
beginAt: CFTimeInterval
) {
alpha = from
let animation = NodeAnimation(
property: .alpha,
from: from,
to: to,
duration: duration,
beginAt: beginAt,
curve: .easeInOut
)
activeAnimations.append(animation)
}
/// Update animations at current timestamp. Returns true if any animation is active.
@discardableResult
func updateAnimations(at timestamp: CFTimeInterval) -> Bool {
var i = activeAnimations.count - 1
while i >= 0 {
let anim = activeAnimations[i]
let progress = anim.progress(at: timestamp)
let value = anim.interpolatedValue(progress: progress)
switch anim.property {
case .height:
apparentHeight = value
case .alpha:
alpha = value
case .offsetY:
// Handled by parent during layout pass
break
}
if progress >= 1.0 {
// Animation complete
switch anim.property {
case .height:
apparentHeight = nil // Revert to layout height
case .alpha:
break
case .offsetY:
break
}
activeAnimations.remove(at: i)
}
i -= 1
}
return !activeAnimations.isEmpty
}
}
// MARK: - Node Animation
/// Animation state for a single property.
/// Telegram uses spring physics (ListViewItemNode.swift:28-39).
struct NodeAnimation {
enum Property {
case height
case alpha
case offsetY
}
enum Curve {
case spring(damping: CGFloat)
case easeInOut
case linear
}
let property: Property
let from: CGFloat
let to: CGFloat
let duration: Double
let beginAt: CFTimeInterval
let curve: Curve
func progress(at timestamp: CFTimeInterval) -> Double {
let elapsed = timestamp - beginAt
return min(1.0, max(0.0, elapsed / duration))
}
func interpolatedValue(progress: Double) -> CGFloat {
let t: CGFloat
switch curve {
case .spring(let damping):
// Underdamped spring approximation (Telegram: testSpringFriction/Constant)
let omega = CGFloat.pi * 2 / CGFloat(duration)
let decay = exp(-damping * CGFloat(progress) * omega * 0.1)
t = 1.0 - decay * cos(omega * CGFloat(progress) * 0.5)
return from + (to - from) * min(1, max(0, t))
case .easeInOut:
// Cubic ease-in-out
let p = CGFloat(progress)
t = p < 0.5 ? 2 * p * p : 1 - pow(-2 * p + 2, 2) / 2
return from + (to - from) * t
case .linear:
return from + (to - from) * CGFloat(progress)
}
}
}

View File

@@ -0,0 +1,629 @@
import UIKit
import QuartzCore
import os
// MARK: - RosettaListView
/// Custom high-performance list view for chat messages.
/// Telegram-parity replacement for UICollectionView.
///
/// Key differences from UICollectionView:
/// - Manual node management (addSubview/removeFromSuperview)
/// - Frame-based positioning (no autolayout)
/// - Async layout calculation (non-blocking main thread)
/// - Visibility culling (only ~30 nodes in memory)
/// - Inverted scroll (newest messages at bottom)
/// - CADisplayLink-driven animations
///
/// Telegram source: Display/Source/ListView.swift (5358 lines)
@MainActor
final class RosettaListView: UIView, UIScrollViewDelegate {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ListView")
// MARK: - Configuration
/// Preload buffer above/below viewport. Nodes outside this range are culled.
/// Telegram: `invisibleInset = 500.0` (ListView.swift:223).
var invisibleInset: CGFloat = 500.0
/// Stack items from bottom (chat mode). Newest items at visual bottom.
/// Telegram: `stackFromBottom` (ListView.swift:240).
var stackFromBottom: Bool = true
/// Virtual scroll height. Large enough to scroll freely in both directions.
/// Telegram: `infiniteScrollSize = 10000.0`.
private let infiniteScrollSize: CGFloat = 10000.0
// MARK: - Callbacks
/// Called when visible item range changes (for pagination triggers).
var displayedItemRangeChanged: ((_ range: ClosedRange<Int>?) -> Void)?
/// Called when scroll position changes.
var onScroll: ((_ offset: CGFloat, _ isDragging: Bool) -> Void)?
/// Called when user taps empty area.
var onTapBackground: (() -> Void)?
// MARK: - State
/// Data source items.
private(set) var items: [RosettaListItem] = []
/// Currently displayed nodes (ordered by visual position, bottom to top in inverted mode).
private(set) var visibleNodes: [RosettaListNode] = []
/// Node recycling pool keyed by reuse identifier.
private var nodePool: [String: [RosettaListNode]] = [:]
/// Maps item ID index for O(1) lookup.
private var itemIdToIndex: [String: Int] = [:]
/// Transaction queue for serialized updates.
let transactionQueue = RosettaTransactionQueue()
/// Layout params (computed from view width).
private var layoutParams: RosettaListLayoutParams {
RosettaListLayoutParams(
width: bounds.width,
leftInset: safeAreaInsets.left,
rightInset: safeAreaInsets.right
)
}
// MARK: - Insets
/// Content insets (keyboard, composer, etc.).
var contentInsets: UIEdgeInsets = .zero {
didSet {
guard contentInsets != oldValue else { return }
let delta = contentInsets.bottom - oldValue.bottom
scroller.contentInset = contentInsets
// Compensate offset for bottom inset changes (keyboard)
if delta > 0, !scroller.isDragging, !scroller.isDecelerating {
var offset = scroller.contentOffset
offset.y += delta
scroller.contentOffset = offset
}
}
}
// MARK: - Subviews
/// The scroll view that handles touch/scroll physics.
private let scroller: UIScrollView = {
let sv = UIScrollView()
sv.scrollsToTop = false
sv.delaysContentTouches = false
sv.canCancelContentTouches = true
sv.showsVerticalScrollIndicator = true
sv.showsHorizontalScrollIndicator = false
sv.alwaysBounceVertical = true
return sv
}()
/// Container inside scroller for node views.
private let nodeContainer = UIView()
// MARK: - Display Link
private var displayLink: CADisplayLink?
private var needsAnimationUpdate = false
// MARK: - Scroll Tracking
/// Anchor point for maintaining scroll position during layout changes.
private var anchorItemId: String?
private var anchorOffset: CGFloat = 0
/// Whether initial content has been laid out.
private var hasLaidOutInitialContent = false
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
setupDisplayLink()
setupGestures()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit {
displayLink?.invalidate()
transactionQueue.cancelAll()
}
// MARK: - Setup
private func setupViews() {
scroller.delegate = self
scroller.frame = bounds
scroller.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(scroller)
// Set initial content size for infinite scroll
scroller.contentSize = CGSize(width: bounds.width, height: infiniteScrollSize * 2)
nodeContainer.frame = CGRect(origin: .zero, size: scroller.contentSize)
scroller.addSubview(nodeContainer)
}
private func setupDisplayLink() {
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkFired))
displayLink?.add(to: .main, forMode: .common)
displayLink?.isPaused = true
}
private func setupGestures() {
let tap = UITapGestureRecognizer(target: self, action: #selector(backgroundTapped))
tap.cancelsTouchesInView = false
scroller.addGestureRecognizer(tap)
}
override func layoutSubviews() {
super.layoutSubviews()
let oldWidth = scroller.frame.width
scroller.frame = bounds
if bounds.width != oldWidth {
scroller.contentSize.width = bounds.width
nodeContainer.frame.size.width = bounds.width
invalidateAllLayouts()
}
}
// MARK: - Public API: Set Items (Full Replace)
/// Replace all items with a new list. Calculates layouts async.
/// Telegram: `transaction()` with deleteItems + insertItems.
func setItems(_ newItems: [RosettaListItem], animated: Bool = false) {
transactionQueue.enqueue { [weak self] completion in
guard let self else { completion(); return }
self.performSetItems(newItems, animated: animated, completion: completion)
}
}
/// Insert items at specific indices.
func insertItems(_ inserts: [RosettaListInsertItem], animated: Bool = true) {
transactionQueue.enqueue { [weak self] completion in
guard let self else { completion(); return }
self.performInsertItems(inserts, animated: animated, completion: completion)
}
}
// MARK: - Scroll API
/// Scroll to bottom (newest messages in inverted mode).
/// Telegram: `scrollToEndOfHistory()` (ChatHistoryListNode.swift:3494).
func scrollToBottom(animated: Bool) {
let targetY: CGFloat
if stackFromBottom {
targetY = infiniteScrollSize - bounds.height + contentInsets.bottom
} else {
targetY = -contentInsets.top
}
scroller.setContentOffset(CGPoint(x: 0, y: targetY), animated: animated)
}
/// Scroll to item with given ID.
func scrollToItem(id: String, position: RosettaListScrollPosition = .center, animated: Bool = true) {
guard let node = visibleNodes.first(where: { $0.index.flatMap({ items[$0].id }) == id }) else { return }
let nodeFrame = node.frame
let targetY: CGFloat
switch position {
case .top(let offset):
targetY = nodeFrame.minY - contentInsets.top - offset
case .center:
targetY = nodeFrame.midY - bounds.height / 2
case .bottom(let offset):
targetY = nodeFrame.maxY - bounds.height + contentInsets.bottom + offset
case .visible:
let visibleRect = CGRect(
x: 0, y: scroller.contentOffset.y + contentInsets.top,
width: bounds.width,
height: bounds.height - contentInsets.top - contentInsets.bottom
)
if visibleRect.contains(nodeFrame) { return }
targetY = nodeFrame.midY - bounds.height / 2
}
scroller.setContentOffset(CGPoint(x: 0, y: targetY), animated: animated)
}
// MARK: - Node Pool
private func dequeueNode(reuseId: String) -> RosettaListNode? {
nodePool[reuseId]?.popLast()
}
private func enqueueNode(_ node: RosettaListNode) {
node.prepareForReuse()
node.removeFromSuperview()
nodePool[node.reuseIdentifier, default: []].append(node)
}
// MARK: - Internal: Set Items
private func performSetItems(
_ newItems: [RosettaListItem],
animated: Bool,
completion: @escaping () -> Void
) {
// Remove all existing nodes
for node in visibleNodes {
enqueueNode(node)
}
visibleNodes.removeAll()
items = newItems
rebuildIdIndex()
guard !items.isEmpty else {
completion()
return
}
// Calculate layouts for visible window + buffer
let params = layoutParams
let visibleRange = estimateVisibleRange()
var pendingLayouts = visibleRange.count
guard pendingLayouts > 0 else { completion(); return }
for i in visibleRange {
let item = items[i]
let prevItem = i > 0 ? items[i - 1] : nil
let nextItem = i < items.count - 1 ? items[i + 1] : nil
item.nodeConfiguredForParams(
async: { f in
// For initial load: synchronous to avoid blank screen
f()
},
params: params,
previousItem: prevItem,
nextItem: nextItem
) { [weak self] node, layout in
guard let self else { return }
node.index = i
node.reuseIdentifier = item.reuseIdentifier
node.applyLayout(layout)
self.visibleNodes.append(node)
pendingLayouts -= 1
if pendingLayouts == 0 {
self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) }
self.layoutVisibleNodes()
self.addVisibleNodesToView()
if !self.hasLaidOutInitialContent {
self.hasLaidOutInitialContent = true
if self.stackFromBottom {
self.scrollToBottom(animated: false)
}
}
completion()
}
}
}
}
// MARK: - Internal: Insert Items
private func performInsertItems(
_ inserts: [RosettaListInsertItem],
animated: Bool,
completion: @escaping () -> Void
) {
let timestamp = CACurrentMediaTime()
// Update items array
let sorted = inserts.sorted { $0.index < $1.index }
for insert in sorted.reversed() {
items.insert(insert.item, at: min(insert.index, items.count))
}
rebuildIdIndex()
// Re-index existing nodes
for node in visibleNodes {
if let oldIndex = node.index,
let id = findItemId(for: node),
let newIndex = itemIdToIndex[id] {
node.index = newIndex
}
}
let params = layoutParams
var pendingLayouts = sorted.count
guard pendingLayouts > 0 else { completion(); return }
for insert in sorted {
let i = min(insert.index, items.count - 1)
let item = items[i]
let prevItem = i > 0 ? items[i - 1] : nil
let nextItem = i < items.count - 1 ? items[i + 1] : nil
item.nodeConfiguredForParams(
async: { f in f() },
params: params,
previousItem: prevItem,
nextItem: nextItem
) { [weak self] node, layout in
guard let self else { return }
node.index = i
node.reuseIdentifier = item.reuseIdentifier
node.applyLayout(layout)
if animated {
let targetHeight = layout.size.height
node.addHeightAnimation(
from: 0,
to: targetHeight,
duration: 0.4,
beginAt: timestamp
)
node.addAlphaAnimation(from: 0, to: 1, duration: 0.2, beginAt: timestamp)
self.setNeedsAnimationUpdate()
}
self.visibleNodes.append(node)
pendingLayouts -= 1
if pendingLayouts == 0 {
self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) }
self.layoutVisibleNodes()
self.addVisibleNodesToView()
completion()
}
}
}
}
// MARK: - Layout Engine
/// Position all visible nodes based on their layout heights.
/// Inverted mode: items stack from bottom of visible area upward.
private func layoutVisibleNodes() {
guard !visibleNodes.isEmpty else { return }
if stackFromBottom {
// Bottom-to-top: newest item at bottom, oldest at top.
// Reference point: bottom of visible area = infiniteScrollSize
var y = infiniteScrollSize
for node in visibleNodes.reversed() {
let h = node.effectiveHeight
y -= h
node.frame = CGRect(x: 0, y: y, width: bounds.width, height: h)
node.contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: node.layout?.size.height ?? h)
}
} else {
var y: CGFloat = 0
for node in visibleNodes {
let h = node.effectiveHeight
node.frame = CGRect(x: 0, y: y, width: bounds.width, height: h)
node.contentView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: h)
y += h
}
}
}
/// Add visible nodes to the node container (if not already added).
private func addVisibleNodesToView() {
for node in visibleNodes {
if node.superview !== nodeContainer {
nodeContainer.addSubview(node)
}
}
}
// MARK: - Visibility Culling
/// Remove nodes outside the visible area + buffer.
/// Telegram: `removeInvisibleNodes()` (ListViewIntermediateState.swift:615-692).
private func performVisibilityCulling() {
let visibleTop = scroller.contentOffset.y - invisibleInset
let visibleBottom = scroller.contentOffset.y + bounds.height + invisibleInset
var i = visibleNodes.count - 1
while i >= 0 {
let node = visibleNodes[i]
let frame = node.frame
if frame.maxY < visibleTop || frame.minY > visibleBottom {
enqueueNode(node)
visibleNodes.remove(at: i)
}
i -= 1
}
// Load nodes that should be visible but aren't
loadMissingVisibleNodes()
}
/// Check if there are items that should be visible but don't have nodes.
private func loadMissingVisibleNodes() {
guard !items.isEmpty else { return }
let visibleTop = scroller.contentOffset.y - invisibleInset
let visibleBottom = scroller.contentOffset.y + bounds.height + invisibleInset
// Determine which indices should be visible based on approximate heights
let existingIndices = Set(visibleNodes.compactMap(\.index))
// Find range boundaries from existing nodes
guard let minVisibleIndex = visibleNodes.compactMap(\.index).min(),
let maxVisibleIndex = visibleNodes.compactMap(\.index).max() else { return }
// Check items above current visible range
if minVisibleIndex > 0 {
let topNode = visibleNodes.first(where: { $0.index == minVisibleIndex })
if let topNode, topNode.frame.minY > visibleTop {
// Need to load items above
let startIndex = max(0, minVisibleIndex - 5) // Load 5 at a time
for i in startIndex..<minVisibleIndex {
if !existingIndices.contains(i) {
loadNodeForItem(at: i, synchronous: true)
}
}
}
}
// Check items below current visible range
if maxVisibleIndex < items.count - 1 {
let bottomNode = visibleNodes.first(where: { $0.index == maxVisibleIndex })
if let bottomNode, bottomNode.frame.maxY < visibleBottom {
let endIndex = min(items.count, maxVisibleIndex + 6)
for i in (maxVisibleIndex + 1)..<endIndex {
if !existingIndices.contains(i) {
loadNodeForItem(at: i, synchronous: true)
}
}
}
}
}
/// Load a single node for item at index.
private func loadNodeForItem(at index: Int, synchronous: Bool) {
guard index >= 0, index < items.count else { return }
let item = items[index]
let params = layoutParams
let prevItem = index > 0 ? items[index - 1] : nil
let nextItem = index < items.count - 1 ? items[index + 1] : nil
item.nodeConfiguredForParams(
async: { f in
if synchronous { f() }
else { DispatchQueue.global(qos: .userInitiated).async { f() } }
},
params: params,
previousItem: prevItem,
nextItem: nextItem
) { [weak self] node, layout in
guard let self else { return }
node.index = index
node.reuseIdentifier = item.reuseIdentifier
node.applyLayout(layout)
self.visibleNodes.append(node)
self.visibleNodes.sort { ($0.index ?? 0) < ($1.index ?? 0) }
self.layoutVisibleNodes()
self.addVisibleNodesToView()
}
}
// MARK: - UIScrollViewDelegate
func scrollViewDidScroll(_ scrollView: UIScrollView) {
performVisibilityCulling()
notifyVisibleRangeChanged()
onScroll?(scrollView.contentOffset.y, scrollView.isDragging)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// Capture anchor for position restoration
captureScrollAnchor()
}
// MARK: - Display Link
@objc private func displayLinkFired() {
let timestamp = CACurrentMediaTime()
var anyActive = false
for node in visibleNodes {
if node.updateAnimations(at: timestamp) {
anyActive = true
}
}
if anyActive {
layoutVisibleNodes()
} else {
displayLink?.isPaused = true
needsAnimationUpdate = false
// Clean up completed deletion animations
visibleNodes.removeAll { $0.index == nil && !$0.hasActiveAnimations }
}
}
private func setNeedsAnimationUpdate() {
if !needsAnimationUpdate {
needsAnimationUpdate = true
displayLink?.isPaused = false
}
}
// MARK: - Helpers
private func rebuildIdIndex() {
itemIdToIndex = Dictionary(uniqueKeysWithValues: items.enumerated().map { ($1.id, $0) })
}
private func findItemId(for node: RosettaListNode) -> String? {
guard let index = node.index, index < items.count else { return nil }
return items[index].id
}
private func estimateVisibleRange() -> Range<Int> {
guard !items.isEmpty else { return 0..<0 }
// For initial load, estimate based on approximate heights
// Start from the end (newest messages) for stackFromBottom
if stackFromBottom {
let viewportHeight = bounds.height + invisibleInset * 2
var totalHeight: CGFloat = 0
var startIndex = items.count
while startIndex > 0 && totalHeight < viewportHeight {
startIndex -= 1
totalHeight += items[startIndex].approximateHeight
}
return startIndex..<items.count
} else {
let viewportHeight = bounds.height + invisibleInset * 2
var totalHeight: CGFloat = 0
var endIndex = 0
while endIndex < items.count && totalHeight < viewportHeight {
totalHeight += items[endIndex].approximateHeight
endIndex += 1
}
return 0..<endIndex
}
}
private func captureScrollAnchor() {
// Find the node closest to the top of the visible area
let topY = scroller.contentOffset.y + contentInsets.top
for node in visibleNodes {
if node.frame.maxY >= topY {
if let index = node.index, index < items.count {
anchorItemId = items[index].id
anchorOffset = node.frame.minY - topY
}
break
}
}
}
private func invalidateAllLayouts() {
// TODO: Recalculate all visible node layouts for new width
}
private func notifyVisibleRangeChanged() {
guard !visibleNodes.isEmpty else {
displayedItemRangeChanged?(nil)
return
}
let indices = visibleNodes.compactMap(\.index)
guard let minIdx = indices.min(), let maxIdx = indices.max() else {
displayedItemRangeChanged?(nil)
return
}
displayedItemRangeChanged?(minIdx...maxIdx)
}
// MARK: - Gestures
@objc private func backgroundTapped() {
onTapBackground?()
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
// MARK: - Transaction Queue
/// Serializes list view updates to prevent concurrent modification.
/// Telegram equivalent: `ListViewTransactionQueue` (ListViewTransactionQueue.swift:1-67).
///
/// Pattern: FIFO queue. Next transaction starts only after previous completes.
/// Prevents UI glitches from overlapping snapshot + layout operations.
final class RosettaTransactionQueue {
typealias Transaction = (@escaping () -> Void) -> Void
private var pending: [Transaction] = []
private var isExecuting = false
/// Enqueue a transaction. If queue is idle, executes immediately.
/// Transaction MUST call the completion closure when done.
func enqueue(_ transaction: @escaping Transaction) {
pending.append(transaction)
if !isExecuting {
executeNext()
}
}
private func executeNext() {
guard !pending.isEmpty else {
isExecuting = false
return
}
isExecuting = true
let transaction = pending.removeFirst()
transaction { [weak self] in
self?.executeNext()
}
}
/// Cancel all pending transactions (e.g., when view is deallocated).
func cancelAll() {
pending.removeAll()
isExecuting = false
}
}