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