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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
139
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListItem.swift
Normal file
139
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListItem.swift
Normal 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?
|
||||||
|
}
|
||||||
208
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListNode.swift
Normal file
208
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListNode.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
629
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListView.swift
Normal file
629
Rosetta/Features/Chats/ChatDetail/ListView/RosettaListView.swift
Normal 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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user