ListView: cap pin-to-edge item visible portion at half area

When a pinToEdgeWithInset item is taller than half of the visible
area (visibleSize.height - insets.top - insets.bottom), cap its
visible portion at halfArea. The remaining height extends past
visibleSize - insets.bottom into the bottom-inset region (occluded
by overlay UI like the chat input panel).

A new private helper `pinToEdgeBottomExtension(forPinnedHeight:)`
returns max(0, pinnedHeight - halfArea). Three sites consume it:
- calculatePinToEdgeTopInset caps the pinned item's contribution
  to totalAboveAndPinned via `pinnedHeight - extension`.
- replayOperations isPinToEdgeTarget anchors the pinned item's
  apparent maxY at visibleSize - insets.bottom + extension.
- isStrictlyScrolledToPinToEdgeItem matches the new anchor.

Both isPinToEdgeTarget and isStrictlyScrolledToPinToEdgeItem fire
when either calculatePinToEdgeTopInset() > 0 OR extension > 0, so
the cap also applies when items above the pinned item overflow
the visible area (in which case calculatePinToEdgeTopInset returns
0 but extension is still positive).

When the pinned item fits within halfArea, extension == 0 and
behavior is identical to before this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-01 12:50:49 +02:00
parent f25d0776c7
commit 999aac6eb3
3 changed files with 32 additions and 402 deletions

View file

@ -1101,6 +1101,8 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
}
guard lowestPinnedIndex != Int.max else { return 0.0 }
let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom
var totalAboveAndPinned: CGFloat = 0.0
var sawIndexZero = false
for itemNode in self.itemNodes {
@ -1108,16 +1110,25 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
if index == 0 {
sawIndexZero = true
}
if index <= lowestPinnedIndex {
if index < lowestPinnedIndex {
totalAboveAndPinned += itemNode.apparentBounds.height
} else if index == lowestPinnedIndex {
let pinnedHeight = itemNode.apparentBounds.height
totalAboveAndPinned += pinnedHeight - self.pinToEdgeBottomExtension(forPinnedHeight: pinnedHeight)
}
}
guard sawIndexZero else { return 0.0 }
let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom
return max(0.0, visibleArea - totalAboveAndPinned)
}
private func pinToEdgeBottomExtension(forPinnedHeight pinnedHeight: CGFloat) -> CGFloat {
let visibleArea = self.visibleSize.height - self.insets.top - self.insets.bottom
let halfArea = visibleArea * 0.5
guard halfArea > 0.0 else { return 0.0 }
return max(0.0, pinnedHeight - halfArea)
}
private func areAllItemsOnScreen() -> Bool {
if self.itemNodes.count == 0 {
return true
@ -2672,15 +2683,17 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
}
public func isStrictlyScrolledToPinToEdgeItem() -> Bool {
if self.calculatePinToEdgeTopInset() <= 0.0 {
return false
}
guard let targetIndex = self.items.firstIndex(where: { $0.pinToEdgeWithInset }) else {
return false
}
let pinToEdgeTopInset = self.calculatePinToEdgeTopInset()
for itemNode in self.itemNodes {
if itemNode.index == targetIndex {
let expectedMaxY = (self.visibleSize.height - self.insets.bottom) + itemNode.scrollPositioningInsets.bottom
let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height)
guard pinToEdgeTopInset > 0.0 || extensionOffset > 0.0 else {
return false
}
let expectedMaxY = (self.visibleSize.height - self.insets.bottom + extensionOffset) + itemNode.scrollPositioningInsets.bottom
return abs(itemNode.apparentFrame.maxY - expectedMaxY) < 0.5
}
}
@ -3108,23 +3121,25 @@ open class ListViewImpl: ASDisplayNode, ListView, ASScrollViewDelegate, ASGestur
let insets = self.insets// updateSizeAndInsets?.insets ?? self.insets
var isPinToEdgeTarget = false
if self.calculatePinToEdgeTopInset() > 0.0,
index >= 0, index < self.items.count,
self.items[index].pinToEdgeWithInset {
isPinToEdgeTarget = true
for otherNode in self.itemNodes {
guard let otherIndex = otherNode.index else { continue }
guard otherIndex >= 0, otherIndex < self.items.count else { continue }
if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset {
isPinToEdgeTarget = false
break
if index >= 0, index < self.items.count, self.items[index].pinToEdgeWithInset {
let pinExtension = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height)
if self.calculatePinToEdgeTopInset() > 0.0 || pinExtension > 0.0 {
isPinToEdgeTarget = true
for otherNode in self.itemNodes {
guard let otherIndex = otherNode.index else { continue }
guard otherIndex >= 0, otherIndex < self.items.count else { continue }
if otherIndex < index, self.items[otherIndex].pinToEdgeWithInset {
isPinToEdgeTarget = false
break
}
}
}
}
var offset: CGFloat
if isPinToEdgeTarget {
offset = (self.visibleSize.height - insets.bottom) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom
let extensionOffset = self.pinToEdgeBottomExtension(forPinnedHeight: itemNode.apparentBounds.height)
offset = (self.visibleSize.height - insets.bottom + extensionOffset) - itemNode.apparentFrame.maxY + itemNode.scrollPositioningInsets.bottom
} else {
switch scrollToItem.position {
case let .bottom(additionalOffset):