Display/ListView: snap-scroll to pinned item + synchronous history transactions

Adds an experimentalSnapScrollToPinnedItem path that resnaps scroll to
the first pinToEdgeWithInset item after inset/visible-size updates and
on empty itemNodes. Promotes ListViewItem to AnyObject and adds a
pinToEdgeWithInset protocol requirement with a default of false.

Also runs chat history transactions synchronously when updating scroll
around correlated messages, and tightens beganInteractiveDragging weak-
self handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Isaac 2026-04-18 15:50:04 +02:00
parent 684796144c
commit 791bb62148
3 changed files with 41 additions and 12 deletions

View file

@ -212,6 +212,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
public final var rotated = false
public final var experimentalSnapScrollToItem = false
public final var experimentalSnapScrollToPinnedItem = false
public final var useMainQueueTransactions = false
public final var scrollEnabled: Bool = true {
@ -863,6 +864,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
self.snapToBottomInsetUntilFirstInteraction = false
}
self.scrolledToItem = nil
self.experimentalSnapScrollToPinnedItem = false
self.scroller.forceDecelerating = false
self.isDragging = true
@ -2670,6 +2672,9 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
if self.experimentalSnapScrollToItem {
self.scrolledToItem = (originalScrollToItem.index, originalScrollToItem.position)
}
if self.items[originalScrollToItem.index].pinToEdgeWithInset {
self.experimentalSnapScrollToPinnedItem = true
}
} else if let scrolledToItem = self.scrolledToItem, self.experimentalSnapScrollToItem {
var curve: ListViewAnimationCurve = .Default(duration: nil)
var animated = false
@ -2679,6 +2684,23 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
}
scrollToItem = ListViewScrollToItem(index: scrolledToItem.0, position: scrolledToItem.1, animated: animated, curve: curve, directionHint: .Down)
isExperimentalSnapToScrollToItem = true
} else if self.experimentalSnapScrollToPinnedItem {
if let index = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) {
isExperimentalSnapToScrollToItem = true
var curve: ListViewAnimationCurve = .Default(duration: nil)
var animated = false
if let updateSizeAndInsets = updateSizeAndInsets {
curve = updateSizeAndInsets.curve
animated = !updateSizeAndInsets.duration.isZero
}
scrollToItem = ListViewScrollToItem(index: index, position: self.rotated ? .bottom(0.0) : .top(0.0), animated: animated, curve: curve, directionHint: .Down)
}
}
if scrollToItem == nil {
if self.itemNodes.isEmpty, self.items.contains(where: { $0.pinToEdgeWithInset }) {
scrollToItem = ListViewScrollToItem(index: 0, position: self.rotated ? .bottom(0.0) : .top(0.0), animated: false, curve: .Default(duration: 0.0), directionHint: .Down)
}
}
weak var highlightedItemNode: ListViewItemNode?
@ -4851,7 +4873,7 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
}
if requestUpdateVisibleItems {
self.enqueueUpdateVisibleItems(synchronous: false)
self.enqueueUpdateVisibleItems(synchronous: self.experimentalSnapScrollToPinnedItem)
}
if scrollingForReorder {

View file

@ -69,7 +69,7 @@ public final class ListViewItemApply {
}
}
public protocol ListViewItem {
public protocol ListViewItem: AnyObject {
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void)
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void)
@ -77,6 +77,7 @@ public protocol ListViewItem {
var headerAccessoryItem: ListViewAccessoryItem? { get }
var selectable: Bool { get }
var approximateHeight: CGFloat { get }
var pinToEdgeWithInset: Bool { get }
func selected(listView: ListView)
}
@ -98,6 +99,10 @@ public extension ListViewItem {
return 44.0
}
var pinToEdgeWithInset: Bool {
return false
}
func selected(listView: ListView) {
}

View file

@ -1171,10 +1171,12 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
}).startStrict()
self.beganInteractiveDragging = { [weak self] _ in
self?.isInteractivelyScrollingValue = true
self?.isInteractivelyScrollingPromise.set(true)
self?.beganDragging?()
//self?.updateHistoryScrollingArea(transition: .immediate)
guard let self else {
return
}
self.isInteractivelyScrollingValue = true
self.isInteractivelyScrollingPromise.set(true)
self.beganDragging?()
}
self.endedInteractiveDragging = { [weak self] _ in
@ -4349,16 +4351,16 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
if foundCorrelationMessage {
self.layoutActionOnViewTransition = nil
let (mappedTransition, updateSizeAndInsets) = layoutActionOnViewTransition(transition)
self.transaction(deleteIndices: mappedTransition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: mappedTransition.options, scrollToItem: mappedTransition.scrollToItem, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: mappedTransition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
self.transaction(deleteIndices: mappedTransition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: mappedTransition.options.union(.Synchronous), scrollToItem: mappedTransition.scrollToItem, updateSizeAndInsets: updateSizeAndInsets, stationaryItemRange: mappedTransition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
completion(true, result)
})
} else {
self.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
self.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options.union(.Synchronous), scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
completion(false, result)
})
}
} else {
self.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options, scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
self.transaction(deleteIndices: transition.deleteItems, insertIndicesAndItems: transition.insertItems, updateIndicesAndItems: transition.updateItems, options: transition.options.union(.Synchronous), scrollToItem: transition.scrollToItem, stationaryItemRange: transition.stationaryItemRange, updateOpaqueState: ChatHistoryTransactionOpaqueState(historyView: transition.historyView), completion: { result in
completion(false, result)
})
}
@ -4740,7 +4742,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true)
}
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
break loop
}
case let .MessageGroupEntry(_, messages, presentationData):
@ -4761,7 +4763,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
scrollToItem = ListViewScrollToItem(index: index, position: .center(.top), animated: true, curve: .Spring(duration: 0.4), directionHint: .Down, displayLink: true)
}
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: scrollToItem, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
break loop
}
default:
@ -4812,7 +4814,7 @@ public final class ChatHistoryListNodeImpl: ListViewImpl, ChatHistoryNode, ChatH
item = ListMessageItem(presentationData: presentationData, systemStyle: self.systemStyle, context: self.context, chatLocation: self.chatLocation, interaction: ListMessageItemInteraction(controllerInteraction: self.controllerInteraction), message: message, translateToLanguage: associatedData.translateToLanguage, selection: selection, displayHeader: displayHeader, hintIsLink: hintLinks, isGlobalSearchResult: isGlobalSearch)
}
let updateItem = ListViewUpdateItem(index: index, previousIndex: index, item: item, directionHint: nil)
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion], scrollToItem: nil, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [updateItem], options: [.AnimateInsertion, .Synchronous], scrollToItem: nil, additionalScrollDistance: 0.0, updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
break loop
}
default: