| author | |
| committer | |
| log | 90f3913a81380af7aeafac62f8132aeb98345a12 |
| tree | ddfdf3728bdd6695e41337b5f3c00a02b5f0a130 |
| parent | 2652fee49bdb0a7d286124cd2bd318155d4a7931 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>5 files changed, 1040 insertions(+), 954 deletions(-)
apps/mobile/package.json+1-1| ... | ... | @@ -49,7 +49,7 @@ |
| 49 | 49 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 50 | 50 | "@expo/metro-runtime": "~56.0.15", |
| 51 | 51 | "@expo/ui": "~56.0.18", |
| 52 | "@legendapp/list": "3.2.0", | |
| 52 | "@legendapp/list": "3.3.3", | |
| 53 | 53 | "@noble/curves": "catalog:", |
| 54 | 54 | "@noble/hashes": "catalog:", |
| 55 | 55 | "@pierre/diffs": "catalog:", |
patches/@legendapp__list@3.2.0.patch deleted-940| ... | ... | @@ -1,940 +0,0 @@ |
| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts | |
| 2 | index 5a115ea..2c65d31 100644 | |
| 3 | --- a/keyboard.d.ts | |
| 4 | +++ b/keyboard.d.ts | |
| 5 | @@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { | |
| 6 | type KeyboardChatComposerRef = { | |
| 7 | current: Pick<View, "measure"> | null; | |
| 8 | }; | |
| 9 | -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { | |
| 10 | +declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { | |
| 11 | contentInsetEndAdjustment: SharedValue<number>; | |
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; | |
| 13 | }; | |
| 14 | @@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb | |
| 15 | scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>; | |
| 16 | }; | |
| 17 | declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { | |
| 18 | + adjustedInsetCompensation?: number; | |
| 19 | anchoredEndSpace?: AnchoredEndSpaceConfig; | |
| 20 | contentInsetEndAdjustment?: SharedValue<number>; | |
| 21 | + contentInsetEndStaticAdjustment?: number; | |
| 22 | keyboardOffset?: number; | |
| 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; | |
| 24 | ||
| 25 | diff --git a/keyboard.js b/keyboard.js | |
| 26 | index 736286a..8218172 100644 | |
| 27 | --- a/keyboard.js | |
| 28 | +++ b/keyboard.js | |
| 29 | @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. | |
| 30 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 31 | ); | |
| 32 | } | |
| 33 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 34 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); | |
| 36 | const lastHeightRef = React.useRef(void 0); | |
| 37 | const reportHeight = React.useCallback( | |
| 38 | - (height) => { | |
| 39 | + (rawHeight) => { | |
| 40 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 41 | var _a; | |
| 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 43 | lastHeightRef.current = height; | |
| 44 | contentInsetEndAdjustment.value = height; | |
| 45 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 46 | } | |
| 47 | }, | |
| 48 | - [contentInsetEndAdjustment, listRef] | |
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 50 | ); | |
| 51 | React.useLayoutEffect(() => { | |
| 52 | var _a; | |
| 53 | @@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 54 | } | |
| 55 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 56 | const { | |
| 57 | + adjustedInsetCompensation, | |
| 58 | anchoredEndSpace, | |
| 59 | applyWorkaroundForContentInsetHitTestBug, | |
| 60 | contentInsetEndAdjustment, | |
| 61 | + contentInsetEndStaticAdjustment, | |
| 62 | freeze, | |
| 63 | keyboardLiftBehavior, | |
| 64 | keyboardOffset, | |
| 65 | @@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 66 | includeInEndInset: true, | |
| 67 | onSizeChanged: (size) => { | |
| 68 | var _a; | |
| 69 | - blankSpace.value = size; | |
| 70 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 71 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 72 | + // with the safe-area bottom — write it net of that or the end rest | |
| 73 | + // (and the anchored message) sinks one safe-area under the header. | |
| 74 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 75 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 76 | } | |
| 77 | }; | |
| 78 | - }, [anchoredEndSpace, blankSpace]); | |
| 79 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 80 | const onContentInsetChange = React.useCallback((insets) => { | |
| 81 | var _a; | |
| 82 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 83 | @@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 84 | reactNativeKeyboardController.KeyboardChatScrollView, | |
| 85 | { | |
| 86 | ...scrollProps, | |
| 87 | + adjustedInsetCompensation, | |
| 88 | applyWorkaroundForContentInsetHitTestBug, | |
| 89 | blankSpace, | |
| 90 | extraContentPadding: contentInsetEndAdjustment, | |
| 91 | @@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 92 | ); | |
| 93 | }, | |
| 94 | [ | |
| 95 | + adjustedInsetCompensation, | |
| 96 | applyWorkaroundForContentInsetHitTestBug, | |
| 97 | blankSpace, | |
| 98 | contentInsetEndAdjustment, | |
| 99 | @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 100 | AnimatedLegendListInternal, | |
| 101 | { | |
| 102 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 103 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 104 | ref: combinedRef, | |
| 105 | renderScrollComponent: memoList, | |
| 106 | ...rest | |
| 107 | diff --git a/keyboard.mjs b/keyboard.mjs | |
| 108 | index c1dd270..cb0d142 100644 | |
| 109 | --- a/keyboard.mjs | |
| 110 | +++ b/keyboard.mjs | |
| 111 | @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { | |
| 112 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 113 | ); | |
| 114 | } | |
| 115 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 116 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 117 | const contentInsetEndAdjustment = useSharedValue(initialHeight); | |
| 118 | const lastHeightRef = useRef(void 0); | |
| 119 | const reportHeight = useCallback( | |
| 120 | - (height) => { | |
| 121 | + (rawHeight) => { | |
| 122 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 123 | var _a; | |
| 124 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 125 | lastHeightRef.current = height; | |
| 126 | contentInsetEndAdjustment.value = height; | |
| 127 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 128 | } | |
| 129 | }, | |
| 130 | - [contentInsetEndAdjustment, listRef] | |
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 132 | ); | |
| 133 | useLayoutEffect(() => { | |
| 134 | var _a; | |
| 135 | @@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 136 | } | |
| 137 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 138 | const { | |
| 139 | + adjustedInsetCompensation, | |
| 140 | anchoredEndSpace, | |
| 141 | applyWorkaroundForContentInsetHitTestBug, | |
| 142 | contentInsetEndAdjustment, | |
| 143 | + contentInsetEndStaticAdjustment, | |
| 144 | freeze, | |
| 145 | keyboardLiftBehavior, | |
| 146 | keyboardOffset, | |
| 147 | @@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 148 | includeInEndInset: true, | |
| 149 | onSizeChanged: (size) => { | |
| 150 | var _a; | |
| 151 | - blankSpace.value = size; | |
| 152 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 153 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 154 | + // with the safe-area bottom — write it net of that or the end rest | |
| 155 | + // (and the anchored message) sinks one safe-area under the header. | |
| 156 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 157 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 158 | } | |
| 159 | }; | |
| 160 | - }, [anchoredEndSpace, blankSpace]); | |
| 161 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 162 | const onContentInsetChange = useCallback((insets) => { | |
| 163 | var _a; | |
| 164 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 165 | @@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 166 | KeyboardChatScrollView, | |
| 167 | { | |
| 168 | ...scrollProps, | |
| 169 | + adjustedInsetCompensation, | |
| 170 | applyWorkaroundForContentInsetHitTestBug, | |
| 171 | blankSpace, | |
| 172 | extraContentPadding: contentInsetEndAdjustment, | |
| 173 | @@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 174 | ); | |
| 175 | }, | |
| 176 | [ | |
| 177 | + adjustedInsetCompensation, | |
| 178 | applyWorkaroundForContentInsetHitTestBug, | |
| 179 | blankSpace, | |
| 180 | contentInsetEndAdjustment, | |
| 181 | @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 182 | AnimatedLegendListInternal, | |
| 183 | { | |
| 184 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 185 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 186 | ref: combinedRef, | |
| 187 | renderScrollComponent: memoList, | |
| 188 | ...rest | |
| 189 | diff --git a/react-native.d.ts b/react-native.d.ts | |
| 190 | index 72d3f59..435a5fc 100644 | |
| 191 | --- a/react-native.d.ts | |
| 192 | +++ b/react-native.d.ts | |
| 193 | @@ -284,6 +284,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 194 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 195 | */ | |
| 196 | contentInsetEndAdjustment?: number; | |
| 197 | + /** | |
| 198 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 199 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 200 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 201 | + */ | |
| 202 | + contentInsetStartAdjustment?: number; | |
| 203 | /** | |
| 204 | * Number of columns to render items in. | |
| 205 | * @default 1 | |
| 206 | diff --git a/react-native.js b/react-native.js | |
| 207 | index 8d4ff89..003b2f7 100644 | |
| 208 | --- a/react-native.js | |
| 209 | +++ b/react-native.js | |
| 210 | @@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { | |
| 211 | if (didInitialScroll) { | |
| 212 | state.didFinishInitialScroll = true; | |
| 213 | } | |
| 214 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 215 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 216 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 217 | set$(ctx, "readyToRender", true); | |
| 218 | setAdaptiveRender(ctx, "normal"); | |
| 219 | @@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 220 | } | |
| 221 | ||
| 222 | // src/core/clampScrollOffset.ts | |
| 223 | +function getContentInsetStartAdjustment(ctx) { | |
| 224 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 225 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 226 | +} | |
| 227 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 228 | const state = ctx.state; | |
| 229 | const contentSize = getContentSize(ctx); | |
| 230 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 231 | let clampedOffset = offset; | |
| 232 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 233 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 234 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 235 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 236 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 237 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 238 | clampedOffset = Math.min(offset, maxOffset); | |
| 239 | } | |
| 240 | - clampedOffset = Math.max(0, clampedOffset); | |
| 241 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 242 | return clampedOffset; | |
| 243 | } | |
| 244 | ||
| 245 | @@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 246 | finishScrollTo(ctx); | |
| 247 | } | |
| 248 | } | |
| 249 | -function scrollToFallbackOffset(ctx, offset) { | |
| 250 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 251 | var _a3; | |
| 252 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 253 | - animated: false, | |
| 254 | + animated: !!animated, | |
| 255 | x: ctx.state.props.horizontal ? offset : 0, | |
| 256 | y: ctx.state.props.horizontal ? 0 : offset | |
| 257 | }); | |
| 258 | @@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 259 | }); | |
| 260 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 261 | } else if (shouldRetryUnalignedEndScroll) { | |
| 262 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 263 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 264 | + if (!isActivelyAnimatingToEnd) { | |
| 265 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 266 | + } | |
| 267 | scheduleFallbackCheck(100); | |
| 268 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 269 | finishScrollTo(ctx); | |
| 270 | @@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 271 | } | |
| 272 | state.pendingMaintainScrollAtEnd = false; | |
| 273 | if (shouldMaintainScrollAtEnd) { | |
| 274 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 275 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 276 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 277 | + return false; | |
| 278 | + } | |
| 279 | const contentSize = getContentSize(ctx); | |
| 280 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 281 | if (contentSize < state.scrollLength) { | |
| 282 | - state.scroll = 0; | |
| 283 | + state.scroll = -maintainInsetStartAdjustment; | |
| 284 | + if (maintainInsetStartAdjustment > 0) { | |
| 285 | + return true; | |
| 286 | + } | |
| 287 | } | |
| 288 | if (!state.maintainingScrollAtEnd) { | |
| 289 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 290 | @@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 291 | y: 0 | |
| 292 | }); | |
| 293 | } else { | |
| 294 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 295 | - animated: maintainScrollAtEnd.animated | |
| 296 | - }); | |
| 297 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 298 | + if (insetStartAdjustment > 0) { | |
| 299 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 300 | + animated: maintainScrollAtEnd.animated, | |
| 301 | + x: 0, | |
| 302 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 303 | + }); | |
| 304 | + } else { | |
| 305 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 306 | + animated: maintainScrollAtEnd.animated | |
| 307 | + }); | |
| 308 | + } | |
| 309 | } | |
| 310 | setTimeout( | |
| 311 | () => { | |
| 312 | @@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 313 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 314 | return 0; | |
| 315 | } | |
| 316 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 317 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 318 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 319 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 320 | const clampDelta = maxScroll - state.scroll; | |
| 321 | if (unresolvedAmount < 0) { | |
| 322 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 323 | @@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 324 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 325 | return true; | |
| 326 | } | |
| 327 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 328 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 329 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 330 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 331 | if (isAtExpectedNativeClamp) { | |
| 332 | @@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 333 | if (diff > 0) { | |
| 334 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 335 | } else { | |
| 336 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 337 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 338 | state.scroll = maxScroll; | |
| 339 | state.scrollPending = maxScroll; | |
| 340 | diff = 0; | |
| 341 | @@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { | |
| 342 | } | |
| 343 | ||
| 344 | // src/core/initialScroll.ts | |
| 345 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 346 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 347 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 348 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 349 | +function startInsetEndSettleWatchdog(ctx) { | |
| 350 | + const state = ctx.state; | |
| 351 | + if (state.insetEndSettleWatchdogActive) { | |
| 352 | + return; | |
| 353 | + } | |
| 354 | + state.insetEndSettleWatchdogActive = true; | |
| 355 | + state.didUserDrag = false; | |
| 356 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 357 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 358 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 359 | + // never hide content for long. | |
| 360 | + state.insetEndRevealHold = true; | |
| 361 | + let frames = 0; | |
| 362 | + let settledFrames = 0; | |
| 363 | + let revealStableFrames = 0; | |
| 364 | + const releaseRevealHold = () => { | |
| 365 | + if (state.insetEndRevealHold) { | |
| 366 | + state.insetEndRevealHold = false; | |
| 367 | + setInitialRenderState(ctx, {}); | |
| 368 | + } | |
| 369 | + }; | |
| 370 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 371 | + // can move the end target one more time — do that upgrade while still | |
| 372 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 373 | + // so the first visible frame is the settled one. | |
| 374 | + let revealPhase = 1; | |
| 375 | + const onRevealStability = () => { | |
| 376 | + var _a3; | |
| 377 | + if (!state.insetEndRevealHold) { | |
| 378 | + return; | |
| 379 | + } | |
| 380 | + if (revealPhase === 1) { | |
| 381 | + revealPhase = 2; | |
| 382 | + revealStableFrames = 0; | |
| 383 | + setAdaptiveRender(ctx, "normal"); | |
| 384 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 385 | + return; | |
| 386 | + } | |
| 387 | + releaseRevealHold(); | |
| 388 | + }; | |
| 389 | + const tick = () => { | |
| 390 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 391 | + // pin the list under their finger. | |
| 392 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 393 | + state.insetEndSettleWatchdogActive = false; | |
| 394 | + releaseRevealHold(); | |
| 395 | + return; | |
| 396 | + } | |
| 397 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 398 | + releaseRevealHold(); | |
| 399 | + } | |
| 400 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 401 | + const contentSize = getContentSize(ctx); | |
| 402 | + const scrollLength = state.scrollLength; | |
| 403 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 404 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 405 | + const distance = endOffset - state.scroll; | |
| 406 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 407 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 408 | + // While still near the end (never fighting a user who scrolled away), | |
| 409 | + // re-pin to the current true end until sizes stop changing. | |
| 410 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 411 | + settledFrames = 0; | |
| 412 | + revealStableFrames = 0; | |
| 413 | + const scroller = state.refScroller.current; | |
| 414 | + if (scroller) { | |
| 415 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 416 | + } | |
| 417 | + } else { | |
| 418 | + settledFrames++; | |
| 419 | + revealStableFrames++; | |
| 420 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 421 | + onRevealStability(); | |
| 422 | + } | |
| 423 | + } | |
| 424 | + } else { | |
| 425 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 426 | + // programmatic scroll) count toward stability for the reveal. | |
| 427 | + revealStableFrames++; | |
| 428 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 429 | + onRevealStability(); | |
| 430 | + } | |
| 431 | + } | |
| 432 | + requestAnimationFrame(tick); | |
| 433 | + }; | |
| 434 | + requestAnimationFrame(tick); | |
| 435 | +} | |
| 436 | function dispatchInitialScroll(ctx, params) { | |
| 437 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 438 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 439 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 440 | + if (ctx.state.scrollLength <= 0) { | |
| 441 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 442 | + // the initial-scroll machinery re-advances after layout. | |
| 443 | + return; | |
| 444 | + } | |
| 445 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 446 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 447 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 448 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 449 | + // inset application during screen attach, so just finish the session. | |
| 450 | + finishInitialScroll(ctx, { | |
| 451 | + resolvedOffset | |
| 452 | + }); | |
| 453 | + return; | |
| 454 | + } | |
| 455 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 456 | + if (target.viewPosition === 1) { | |
| 457 | + startInsetEndSettleWatchdog(ctx); | |
| 458 | + } | |
| 459 | + } | |
| 460 | const requestedIndex = target.index; | |
| 461 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 462 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 463 | @@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 464 | return; | |
| 465 | } | |
| 466 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 467 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 468 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 469 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 470 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 471 | if (!shouldKeepEndTargetAlive) { | |
| 472 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 473 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 474 | @@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 475 | } | |
| 476 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 477 | isReady = !hasUnknownTailSize; | |
| 478 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 479 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 480 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 481 | } else if (anchorIndex >= 0) { | |
| 482 | isReady = false; | |
| 483 | } | |
| 484 | @@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 485 | updateScroll(ctx, state.scroll, true); | |
| 486 | } | |
| 487 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 488 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 489 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 490 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 491 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 492 | + updateScroll(ctx, state.scroll, true); | |
| 493 | + } | |
| 494 | } | |
| 495 | return nextSize; | |
| 496 | } | |
| 497 | @@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 498 | dataVersion, | |
| 499 | drawDistance = 250, | |
| 500 | contentInsetEndAdjustment, | |
| 501 | + contentInsetStartAdjustment, | |
| 502 | estimatedItemSize = 100, | |
| 503 | estimatedListSize, | |
| 504 | extraData, | |
| 505 | @@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 506 | onLayout: onLayoutProp, | |
| 507 | onLoad, | |
| 508 | onMomentumScrollEnd, | |
| 509 | + onScrollBeginDrag, | |
| 510 | onRefresh, | |
| 511 | onScroll: onScrollProp, | |
| 512 | onStartReached, | |
| 513 | @@ -6577,7 +6729,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 514 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 515 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 516 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 517 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 518 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 519 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); | |
| 520 | const alwaysRenderIndices = React2.useMemo(() => { | |
| 521 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 522 | @@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 523 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 524 | contentInset, | |
| 525 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 526 | + contentInsetStartAdjustment, | |
| 527 | data: dataProp, | |
| 528 | dataVersion, | |
| 529 | drawDistance, | |
| 530 | @@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 531 | return void 0; | |
| 532 | } | |
| 533 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 534 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 535 | + // With a native leading inset (transparent header + automatic insets), | |
| 536 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 537 | + // application. The gated initial-scroll dispatch positions the list once | |
| 538 | + // real sizes are known instead. | |
| 539 | + return void 0; | |
| 540 | + } | |
| 541 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 542 | }, [usesBootstrapInitialScroll]); | |
| 543 | React2.useLayoutEffect(() => { | |
| 544 | @@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 545 | onMomentumScrollEnd(event); | |
| 546 | } | |
| 547 | }, | |
| 548 | + onScrollBeginDrag: (event) => { | |
| 549 | + ctx.state.didUserDrag = true; | |
| 550 | + if (onScrollBeginDrag) { | |
| 551 | + onScrollBeginDrag(event); | |
| 552 | + } | |
| 553 | + }, | |
| 554 | onScroll: (event) => onScroll(ctx, event) | |
| 555 | }), | |
| 556 | [] | |
| 557 | @@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 558 | onLayout, | |
| 559 | onLayoutFooter, | |
| 560 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 561 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 562 | onScroll: onScrollHandler, | |
| 563 | recycleItems, | |
| 564 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | |
| 565 | diff --git a/react-native.mjs b/react-native.mjs | |
| 566 | index 2e96ca7..fc88d4b 100644 | |
| 567 | --- a/react-native.mjs | |
| 568 | +++ b/react-native.mjs | |
| 569 | @@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { | |
| 570 | if (didInitialScroll) { | |
| 571 | state.didFinishInitialScroll = true; | |
| 572 | } | |
| 573 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 574 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 575 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 576 | set$(ctx, "readyToRender", true); | |
| 577 | setAdaptiveRender(ctx, "normal"); | |
| 578 | @@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 579 | } | |
| 580 | ||
| 581 | // src/core/clampScrollOffset.ts | |
| 582 | +function getContentInsetStartAdjustment(ctx) { | |
| 583 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 584 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 585 | +} | |
| 586 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 587 | const state = ctx.state; | |
| 588 | const contentSize = getContentSize(ctx); | |
| 589 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 590 | let clampedOffset = offset; | |
| 591 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 592 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 593 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 594 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 595 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 596 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 597 | clampedOffset = Math.min(offset, maxOffset); | |
| 598 | } | |
| 599 | - clampedOffset = Math.max(0, clampedOffset); | |
| 600 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 601 | return clampedOffset; | |
| 602 | } | |
| 603 | ||
| 604 | @@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 605 | finishScrollTo(ctx); | |
| 606 | } | |
| 607 | } | |
| 608 | -function scrollToFallbackOffset(ctx, offset) { | |
| 609 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 610 | var _a3; | |
| 611 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 612 | - animated: false, | |
| 613 | + animated: !!animated, | |
| 614 | x: ctx.state.props.horizontal ? offset : 0, | |
| 615 | y: ctx.state.props.horizontal ? 0 : offset | |
| 616 | }); | |
| 617 | @@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 618 | }); | |
| 619 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 620 | } else if (shouldRetryUnalignedEndScroll) { | |
| 621 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 622 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 623 | + if (!isActivelyAnimatingToEnd) { | |
| 624 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 625 | + } | |
| 626 | scheduleFallbackCheck(100); | |
| 627 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 628 | finishScrollTo(ctx); | |
| 629 | @@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 630 | } | |
| 631 | state.pendingMaintainScrollAtEnd = false; | |
| 632 | if (shouldMaintainScrollAtEnd) { | |
| 633 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 634 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 635 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 636 | + return false; | |
| 637 | + } | |
| 638 | const contentSize = getContentSize(ctx); | |
| 639 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 640 | if (contentSize < state.scrollLength) { | |
| 641 | - state.scroll = 0; | |
| 642 | + state.scroll = -maintainInsetStartAdjustment; | |
| 643 | + if (maintainInsetStartAdjustment > 0) { | |
| 644 | + return true; | |
| 645 | + } | |
| 646 | } | |
| 647 | if (!state.maintainingScrollAtEnd) { | |
| 648 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 649 | @@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 650 | y: 0 | |
| 651 | }); | |
| 652 | } else { | |
| 653 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 654 | - animated: maintainScrollAtEnd.animated | |
| 655 | - }); | |
| 656 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 657 | + if (insetStartAdjustment > 0) { | |
| 658 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 659 | + animated: maintainScrollAtEnd.animated, | |
| 660 | + x: 0, | |
| 661 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 662 | + }); | |
| 663 | + } else { | |
| 664 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 665 | + animated: maintainScrollAtEnd.animated | |
| 666 | + }); | |
| 667 | + } | |
| 668 | } | |
| 669 | setTimeout( | |
| 670 | () => { | |
| 671 | @@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 672 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 673 | return 0; | |
| 674 | } | |
| 675 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 676 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 677 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 678 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 679 | const clampDelta = maxScroll - state.scroll; | |
| 680 | if (unresolvedAmount < 0) { | |
| 681 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 682 | @@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 683 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 684 | return true; | |
| 685 | } | |
| 686 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 687 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 688 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 689 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 690 | if (isAtExpectedNativeClamp) { | |
| 691 | @@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 692 | if (diff > 0) { | |
| 693 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 694 | } else { | |
| 695 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 696 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 697 | state.scroll = maxScroll; | |
| 698 | state.scrollPending = maxScroll; | |
| 699 | diff = 0; | |
| 700 | @@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { | |
| 701 | } | |
| 702 | ||
| 703 | // src/core/initialScroll.ts | |
| 704 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 705 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 706 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 707 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 708 | +function startInsetEndSettleWatchdog(ctx) { | |
| 709 | + const state = ctx.state; | |
| 710 | + if (state.insetEndSettleWatchdogActive) { | |
| 711 | + return; | |
| 712 | + } | |
| 713 | + state.insetEndSettleWatchdogActive = true; | |
| 714 | + state.didUserDrag = false; | |
| 715 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 716 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 717 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 718 | + // never hide content for long. | |
| 719 | + state.insetEndRevealHold = true; | |
| 720 | + let frames = 0; | |
| 721 | + let settledFrames = 0; | |
| 722 | + let revealStableFrames = 0; | |
| 723 | + const releaseRevealHold = () => { | |
| 724 | + if (state.insetEndRevealHold) { | |
| 725 | + state.insetEndRevealHold = false; | |
| 726 | + setInitialRenderState(ctx, {}); | |
| 727 | + } | |
| 728 | + }; | |
| 729 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 730 | + // can move the end target one more time — do that upgrade while still | |
| 731 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 732 | + // so the first visible frame is the settled one. | |
| 733 | + let revealPhase = 1; | |
| 734 | + const onRevealStability = () => { | |
| 735 | + var _a3; | |
| 736 | + if (!state.insetEndRevealHold) { | |
| 737 | + return; | |
| 738 | + } | |
| 739 | + if (revealPhase === 1) { | |
| 740 | + revealPhase = 2; | |
| 741 | + revealStableFrames = 0; | |
| 742 | + setAdaptiveRender(ctx, "normal"); | |
| 743 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 744 | + return; | |
| 745 | + } | |
| 746 | + releaseRevealHold(); | |
| 747 | + }; | |
| 748 | + const tick = () => { | |
| 749 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 750 | + // pin the list under their finger. | |
| 751 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 752 | + state.insetEndSettleWatchdogActive = false; | |
| 753 | + releaseRevealHold(); | |
| 754 | + return; | |
| 755 | + } | |
| 756 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 757 | + releaseRevealHold(); | |
| 758 | + } | |
| 759 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 760 | + const contentSize = getContentSize(ctx); | |
| 761 | + const scrollLength = state.scrollLength; | |
| 762 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 763 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 764 | + const distance = endOffset - state.scroll; | |
| 765 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 766 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 767 | + // While still near the end (never fighting a user who scrolled away), | |
| 768 | + // re-pin to the current true end until sizes stop changing. | |
| 769 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 770 | + settledFrames = 0; | |
| 771 | + revealStableFrames = 0; | |
| 772 | + const scroller = state.refScroller.current; | |
| 773 | + if (scroller) { | |
| 774 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 775 | + } | |
| 776 | + } else { | |
| 777 | + settledFrames++; | |
| 778 | + revealStableFrames++; | |
| 779 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 780 | + onRevealStability(); | |
| 781 | + } | |
| 782 | + } | |
| 783 | + } else { | |
| 784 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 785 | + // programmatic scroll) count toward stability for the reveal. | |
| 786 | + revealStableFrames++; | |
| 787 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 788 | + onRevealStability(); | |
| 789 | + } | |
| 790 | + } | |
| 791 | + requestAnimationFrame(tick); | |
| 792 | + }; | |
| 793 | + requestAnimationFrame(tick); | |
| 794 | +} | |
| 795 | function dispatchInitialScroll(ctx, params) { | |
| 796 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 797 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 798 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 799 | + if (ctx.state.scrollLength <= 0) { | |
| 800 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 801 | + // the initial-scroll machinery re-advances after layout. | |
| 802 | + return; | |
| 803 | + } | |
| 804 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 805 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 806 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 807 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 808 | + // inset application during screen attach, so just finish the session. | |
| 809 | + finishInitialScroll(ctx, { | |
| 810 | + resolvedOffset | |
| 811 | + }); | |
| 812 | + return; | |
| 813 | + } | |
| 814 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 815 | + if (target.viewPosition === 1) { | |
| 816 | + startInsetEndSettleWatchdog(ctx); | |
| 817 | + } | |
| 818 | + } | |
| 819 | const requestedIndex = target.index; | |
| 820 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 821 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 822 | @@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 823 | return; | |
| 824 | } | |
| 825 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 826 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 827 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 828 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 829 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 830 | if (!shouldKeepEndTargetAlive) { | |
| 831 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 832 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 833 | @@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 834 | } | |
| 835 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 836 | isReady = !hasUnknownTailSize; | |
| 837 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 838 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 839 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 840 | } else if (anchorIndex >= 0) { | |
| 841 | isReady = false; | |
| 842 | } | |
| 843 | @@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 844 | updateScroll(ctx, state.scroll, true); | |
| 845 | } | |
| 846 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 847 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 848 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 849 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 850 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 851 | + updateScroll(ctx, state.scroll, true); | |
| 852 | + } | |
| 853 | } | |
| 854 | return nextSize; | |
| 855 | } | |
| 856 | @@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 857 | dataVersion, | |
| 858 | drawDistance = 250, | |
| 859 | contentInsetEndAdjustment, | |
| 860 | + contentInsetStartAdjustment, | |
| 861 | estimatedItemSize = 100, | |
| 862 | estimatedListSize, | |
| 863 | extraData, | |
| 864 | @@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 865 | onLayout: onLayoutProp, | |
| 866 | onLoad, | |
| 867 | onMomentumScrollEnd, | |
| 868 | + onScrollBeginDrag, | |
| 869 | onRefresh, | |
| 870 | onScroll: onScrollProp, | |
| 871 | onStartReached, | |
| 872 | @@ -6556,7 +6708,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 873 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 874 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 875 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 876 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 877 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 878 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); | |
| 879 | const alwaysRenderIndices = useMemo(() => { | |
| 880 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 881 | @@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 882 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 883 | contentInset, | |
| 884 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 885 | + contentInsetStartAdjustment, | |
| 886 | data: dataProp, | |
| 887 | dataVersion, | |
| 888 | drawDistance, | |
| 889 | @@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 890 | return void 0; | |
| 891 | } | |
| 892 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 893 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 894 | + // With a native leading inset (transparent header + automatic insets), | |
| 895 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 896 | + // application. The gated initial-scroll dispatch positions the list once | |
| 897 | + // real sizes are known instead. | |
| 898 | + return void 0; | |
| 899 | + } | |
| 900 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 901 | }, [usesBootstrapInitialScroll]); | |
| 902 | useLayoutEffect(() => { | |
| 903 | @@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 904 | onMomentumScrollEnd(event); | |
| 905 | } | |
| 906 | }, | |
| 907 | + onScrollBeginDrag: (event) => { | |
| 908 | + ctx.state.didUserDrag = true; | |
| 909 | + if (onScrollBeginDrag) { | |
| 910 | + onScrollBeginDrag(event); | |
| 911 | + } | |
| 912 | + }, | |
| 913 | onScroll: (event) => onScroll(ctx, event) | |
| 914 | }), | |
| 915 | [] | |
| 916 | @@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 917 | onLayout, | |
| 918 | onLayoutFooter, | |
| 919 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 920 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 921 | onScroll: onScrollHandler, | |
| 922 | recycleItems, | |
| 923 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { | |
| 924 | diff --git a/reanimated.d.ts b/reanimated.d.ts | |
| 925 | index 7e2d11f..d5b0d66 100644 | |
| 926 | --- a/reanimated.d.ts | |
| 927 | +++ b/reanimated.d.ts | |
| 928 | @@ -285,6 +285,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 929 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 930 | */ | |
| 931 | contentInsetEndAdjustment?: number; | |
| 932 | + /** | |
| 933 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 934 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 935 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 936 | + */ | |
| 937 | + contentInsetStartAdjustment?: number; | |
| 938 | /** | |
| 939 | * Number of columns to render items in. | |
| 940 | * @default 1 |
patches/@legendapp__list@3.3.3.patch created+1014| ... | ... | @@ -0,0 +1,1014 @@ |
| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts | |
| 2 | index 7bc3bb8..75ec120 100644 | |
| 3 | --- a/keyboard.d.ts | |
| 4 | +++ b/keyboard.d.ts | |
| 5 | @@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { | |
| 6 | type KeyboardChatComposerRef = { | |
| 7 | current: Pick<View, "measure"> | null; | |
| 8 | }; | |
| 9 | -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { | |
| 10 | +declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { | |
| 11 | contentInsetEndAdjustment: SharedValue<number>; | |
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; | |
| 13 | }; | |
| 14 | @@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb | |
| 15 | scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>; | |
| 16 | }; | |
| 17 | declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { | |
| 18 | + adjustedInsetCompensation?: number; | |
| 19 | anchoredEndSpace?: AnchoredEndSpaceConfig; | |
| 20 | contentInsetEndAdjustment?: SharedValue<number>; | |
| 21 | + contentInsetEndStaticAdjustment?: number; | |
| 22 | keyboardOffset?: number; | |
| 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; | |
| 24 | ||
| 25 | diff --git a/keyboard.js b/keyboard.js | |
| 26 | index 736286a..8218172 100644 | |
| 27 | --- a/keyboard.js | |
| 28 | +++ b/keyboard.js | |
| 29 | @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. | |
| 30 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 31 | ); | |
| 32 | } | |
| 33 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 34 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); | |
| 36 | const lastHeightRef = React.useRef(void 0); | |
| 37 | const reportHeight = React.useCallback( | |
| 38 | - (height) => { | |
| 39 | + (rawHeight) => { | |
| 40 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 41 | var _a; | |
| 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 43 | lastHeightRef.current = height; | |
| 44 | contentInsetEndAdjustment.value = height; | |
| 45 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 46 | } | |
| 47 | }, | |
| 48 | - [contentInsetEndAdjustment, listRef] | |
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 50 | ); | |
| 51 | React.useLayoutEffect(() => { | |
| 52 | var _a; | |
| 53 | @@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 54 | } | |
| 55 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 56 | const { | |
| 57 | + adjustedInsetCompensation, | |
| 58 | anchoredEndSpace, | |
| 59 | applyWorkaroundForContentInsetHitTestBug, | |
| 60 | contentInsetEndAdjustment, | |
| 61 | + contentInsetEndStaticAdjustment, | |
| 62 | freeze, | |
| 63 | keyboardLiftBehavior, | |
| 64 | keyboardOffset, | |
| 65 | @@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 66 | includeInEndInset: true, | |
| 67 | onSizeChanged: (size) => { | |
| 68 | var _a; | |
| 69 | - blankSpace.value = size; | |
| 70 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 71 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 72 | + // with the safe-area bottom — write it net of that or the end rest | |
| 73 | + // (and the anchored message) sinks one safe-area under the header. | |
| 74 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 75 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 76 | } | |
| 77 | }; | |
| 78 | - }, [anchoredEndSpace, blankSpace]); | |
| 79 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 80 | const onContentInsetChange = React.useCallback((insets) => { | |
| 81 | var _a; | |
| 82 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 83 | @@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 84 | reactNativeKeyboardController.KeyboardChatScrollView, | |
| 85 | { | |
| 86 | ...scrollProps, | |
| 87 | + adjustedInsetCompensation, | |
| 88 | applyWorkaroundForContentInsetHitTestBug, | |
| 89 | blankSpace, | |
| 90 | extraContentPadding: contentInsetEndAdjustment, | |
| 91 | @@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 92 | ); | |
| 93 | }, | |
| 94 | [ | |
| 95 | + adjustedInsetCompensation, | |
| 96 | applyWorkaroundForContentInsetHitTestBug, | |
| 97 | blankSpace, | |
| 98 | contentInsetEndAdjustment, | |
| 99 | @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 100 | AnimatedLegendListInternal, | |
| 101 | { | |
| 102 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 103 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 104 | ref: combinedRef, | |
| 105 | renderScrollComponent: memoList, | |
| 106 | ...rest | |
| 107 | diff --git a/keyboard.mjs b/keyboard.mjs | |
| 108 | index c1dd270..cb0d142 100644 | |
| 109 | --- a/keyboard.mjs | |
| 110 | +++ b/keyboard.mjs | |
| 111 | @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { | |
| 112 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | |
| 113 | ); | |
| 114 | } | |
| 115 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | |
| 116 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | |
| 117 | const contentInsetEndAdjustment = useSharedValue(initialHeight); | |
| 118 | const lastHeightRef = useRef(void 0); | |
| 119 | const reportHeight = useCallback( | |
| 120 | - (height) => { | |
| 121 | + (rawHeight) => { | |
| 122 | + const height = Math.max(0, rawHeight + heightAdjustment); | |
| 123 | var _a; | |
| 124 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | |
| 125 | lastHeightRef.current = height; | |
| 126 | contentInsetEndAdjustment.value = height; | |
| 127 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | |
| 128 | } | |
| 129 | }, | |
| 130 | - [contentInsetEndAdjustment, listRef] | |
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | |
| 132 | ); | |
| 133 | useLayoutEffect(() => { | |
| 134 | var _a; | |
| 135 | @@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | |
| 136 | } | |
| 137 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | |
| 138 | const { | |
| 139 | + adjustedInsetCompensation, | |
| 140 | anchoredEndSpace, | |
| 141 | applyWorkaroundForContentInsetHitTestBug, | |
| 142 | contentInsetEndAdjustment, | |
| 143 | + contentInsetEndStaticAdjustment, | |
| 144 | freeze, | |
| 145 | keyboardLiftBehavior, | |
| 146 | keyboardOffset, | |
| 147 | @@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 148 | includeInEndInset: true, | |
| 149 | onSizeChanged: (size) => { | |
| 150 | var _a; | |
| 151 | - blankSpace.value = size; | |
| 152 | + // The anchored blank is sized against the ADJUSTED viewport, but the | |
| 153 | + // scroll component writes it as a raw contentInset that UIKit tops up | |
| 154 | + // with the safe-area bottom — write it net of that or the end rest | |
| 155 | + // (and the anchored message) sinks one safe-area under the header. | |
| 156 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | |
| 157 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | |
| 158 | } | |
| 159 | }; | |
| 160 | - }, [anchoredEndSpace, blankSpace]); | |
| 161 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | |
| 162 | const onContentInsetChange = useCallback((insets) => { | |
| 163 | var _a; | |
| 164 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | |
| 165 | @@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 166 | KeyboardChatScrollView, | |
| 167 | { | |
| 168 | ...scrollProps, | |
| 169 | + adjustedInsetCompensation, | |
| 170 | applyWorkaroundForContentInsetHitTestBug, | |
| 171 | blankSpace, | |
| 172 | extraContentPadding: contentInsetEndAdjustment, | |
| 173 | @@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 174 | ); | |
| 175 | }, | |
| 176 | [ | |
| 177 | + adjustedInsetCompensation, | |
| 178 | applyWorkaroundForContentInsetHitTestBug, | |
| 179 | blankSpace, | |
| 180 | contentInsetEndAdjustment, | |
| 181 | @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | |
| 182 | AnimatedLegendListInternal, | |
| 183 | { | |
| 184 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | |
| 185 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | |
| 186 | ref: combinedRef, | |
| 187 | renderScrollComponent: memoList, | |
| 188 | ...rest | |
| 189 | diff --git a/react-native.d.ts b/react-native.d.ts | |
| 190 | index 8204015..cdeaab7 100644 | |
| 191 | --- a/react-native.d.ts | |
| 192 | +++ b/react-native.d.ts | |
| 193 | @@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 194 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 195 | */ | |
| 196 | contentInsetEndAdjustment?: number; | |
| 197 | + /** | |
| 198 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 199 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 200 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 201 | + */ | |
| 202 | + contentInsetStartAdjustment?: number; | |
| 203 | /** | |
| 204 | * Number of columns to render items in. | |
| 205 | * @default 1 | |
| 206 | diff --git a/react-native.js b/react-native.js | |
| 207 | index 229f09a..2a1ceb6 100644 | |
| 208 | --- a/react-native.js | |
| 209 | +++ b/react-native.js | |
| 210 | @@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { | |
| 211 | if (didInitialScroll) { | |
| 212 | state.didFinishInitialScroll = true; | |
| 213 | } | |
| 214 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 215 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 216 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 217 | set$(ctx, "readyToRender", true); | |
| 218 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 219 | @@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 220 | } | |
| 221 | ||
| 222 | // src/core/clampScrollOffset.ts | |
| 223 | +function getContentInsetStartAdjustment(ctx) { | |
| 224 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 225 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 226 | +} | |
| 227 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 228 | const state = ctx.state; | |
| 229 | const contentSize = getContentSize(ctx); | |
| 230 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 231 | let clampedOffset = offset; | |
| 232 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 233 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 234 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 235 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 236 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 237 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 238 | clampedOffset = Math.min(offset, maxOffset); | |
| 239 | } | |
| 240 | - clampedOffset = Math.max(0, clampedOffset); | |
| 241 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 242 | return clampedOffset; | |
| 243 | } | |
| 244 | ||
| 245 | @@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 246 | finishScrollTo(ctx); | |
| 247 | } | |
| 248 | } | |
| 249 | -function scrollToFallbackOffset(ctx, offset) { | |
| 250 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 251 | var _a3; | |
| 252 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 253 | - animated: false, | |
| 254 | + animated: !!animated, | |
| 255 | x: ctx.state.props.horizontal ? offset : 0, | |
| 256 | y: ctx.state.props.horizontal ? 0 : offset | |
| 257 | }); | |
| 258 | @@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 259 | }); | |
| 260 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 261 | } else if (shouldRetryUnalignedEndScroll) { | |
| 262 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 263 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 264 | + if (!isActivelyAnimatingToEnd) { | |
| 265 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 266 | + } | |
| 267 | scheduleFallbackCheck(100); | |
| 268 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 269 | finishScrollTo(ctx); | |
| 270 | @@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 271 | } | |
| 272 | if (shouldMaintainScrollAtEnd) { | |
| 273 | state.pendingMaintainScrollAtEnd = false; | |
| 274 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 275 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 276 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 277 | + return false; | |
| 278 | + } | |
| 279 | const contentSize = getContentSize(ctx); | |
| 280 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 281 | if (contentSize < state.scrollLength) { | |
| 282 | - state.scroll = 0; | |
| 283 | + state.scroll = -maintainInsetStartAdjustment; | |
| 284 | + if (maintainInsetStartAdjustment > 0) { | |
| 285 | + return true; | |
| 286 | + } | |
| 287 | } | |
| 288 | if (!state.maintainingScrollAtEnd) { | |
| 289 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 290 | @@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 291 | y: 0 | |
| 292 | }); | |
| 293 | } else { | |
| 294 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 295 | - animated: maintainScrollAtEnd.animated | |
| 296 | - }); | |
| 297 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 298 | + if (insetStartAdjustment > 0) { | |
| 299 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 300 | + animated: maintainScrollAtEnd.animated, | |
| 301 | + x: 0, | |
| 302 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 303 | + }); | |
| 304 | + } else { | |
| 305 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 306 | + animated: maintainScrollAtEnd.animated | |
| 307 | + }); | |
| 308 | + } | |
| 309 | } | |
| 310 | setTimeout( | |
| 311 | () => { | |
| 312 | @@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 313 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 314 | const state = ctx.state; | |
| 315 | if (Math.abs(positionDiff) > 0.1) { | |
| 316 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 317 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 318 | + // animating them would make rows visibly lurch and slide back. | |
| 319 | + state.lastMVCPAdjustTime = Date.now(); | |
| 320 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 321 | const doit = () => { | |
| 322 | if (needsScrollWorkaround) { | |
| 323 | @@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 324 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 325 | return 0; | |
| 326 | } | |
| 327 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 328 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 329 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 330 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 331 | const clampDelta = maxScroll - state.scroll; | |
| 332 | if (unresolvedAmount < 0) { | |
| 333 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 334 | @@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 335 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 336 | return true; | |
| 337 | } | |
| 338 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 339 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 340 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 341 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 342 | if (isAtExpectedNativeClamp) { | |
| 343 | @@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 344 | if (diff > 0) { | |
| 345 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 346 | } else { | |
| 347 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 348 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 349 | state.scroll = maxScroll; | |
| 350 | state.scrollPending = maxScroll; | |
| 351 | diff = 0; | |
| 352 | @@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { | |
| 353 | } | |
| 354 | ||
| 355 | // src/core/initialScroll.ts | |
| 356 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 357 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 358 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 359 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 360 | +function startInsetEndSettleWatchdog(ctx) { | |
| 361 | + const state = ctx.state; | |
| 362 | + if (state.insetEndSettleWatchdogActive) { | |
| 363 | + return; | |
| 364 | + } | |
| 365 | + state.insetEndSettleWatchdogActive = true; | |
| 366 | + state.didUserDrag = false; | |
| 367 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 368 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 369 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 370 | + // never hide content for long. | |
| 371 | + state.insetEndRevealHold = true; | |
| 372 | + let frames = 0; | |
| 373 | + let settledFrames = 0; | |
| 374 | + let revealStableFrames = 0; | |
| 375 | + const releaseRevealHold = () => { | |
| 376 | + if (state.insetEndRevealHold) { | |
| 377 | + state.insetEndRevealHold = false; | |
| 378 | + setInitialRenderState(ctx, {}); | |
| 379 | + } | |
| 380 | + }; | |
| 381 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 382 | + // can move the end target one more time — do that upgrade while still | |
| 383 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 384 | + // so the first visible frame is the settled one. | |
| 385 | + let revealPhase = 1; | |
| 386 | + const onRevealStability = () => { | |
| 387 | + var _a3; | |
| 388 | + if (!state.insetEndRevealHold) { | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + if (revealPhase === 1) { | |
| 392 | + revealPhase = 2; | |
| 393 | + revealStableFrames = 0; | |
| 394 | + setAdaptiveRender(ctx, "normal"); | |
| 395 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 396 | + return; | |
| 397 | + } | |
| 398 | + releaseRevealHold(); | |
| 399 | + }; | |
| 400 | + const tick = () => { | |
| 401 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 402 | + // pin the list under their finger. | |
| 403 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 404 | + state.insetEndSettleWatchdogActive = false; | |
| 405 | + releaseRevealHold(); | |
| 406 | + return; | |
| 407 | + } | |
| 408 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 409 | + releaseRevealHold(); | |
| 410 | + } | |
| 411 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 412 | + const contentSize = getContentSize(ctx); | |
| 413 | + const scrollLength = state.scrollLength; | |
| 414 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 415 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 416 | + const distance = endOffset - state.scroll; | |
| 417 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 418 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 419 | + // While still near the end (never fighting a user who scrolled away), | |
| 420 | + // re-pin to the current true end until sizes stop changing. | |
| 421 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 422 | + settledFrames = 0; | |
| 423 | + revealStableFrames = 0; | |
| 424 | + const scroller = state.refScroller.current; | |
| 425 | + if (scroller) { | |
| 426 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 427 | + } | |
| 428 | + } else { | |
| 429 | + settledFrames++; | |
| 430 | + revealStableFrames++; | |
| 431 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 432 | + onRevealStability(); | |
| 433 | + } | |
| 434 | + } | |
| 435 | + } else { | |
| 436 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 437 | + // programmatic scroll) count toward stability for the reveal. | |
| 438 | + revealStableFrames++; | |
| 439 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 440 | + onRevealStability(); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + requestAnimationFrame(tick); | |
| 444 | + }; | |
| 445 | + requestAnimationFrame(tick); | |
| 446 | +} | |
| 447 | function dispatchInitialScroll(ctx, params) { | |
| 448 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 449 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 450 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 451 | + if (ctx.state.scrollLength <= 0) { | |
| 452 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 453 | + // the initial-scroll machinery re-advances after layout. | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 457 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 458 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 459 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 460 | + // inset application during screen attach, so just finish the session. | |
| 461 | + finishInitialScroll(ctx, { | |
| 462 | + resolvedOffset | |
| 463 | + }); | |
| 464 | + return; | |
| 465 | + } | |
| 466 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 467 | + if (target.viewPosition === 1) { | |
| 468 | + startInsetEndSettleWatchdog(ctx); | |
| 469 | + } | |
| 470 | + } | |
| 471 | const requestedIndex = target.index; | |
| 472 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 473 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 474 | @@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 475 | return; | |
| 476 | } | |
| 477 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 478 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 479 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 480 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 481 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 482 | if (!shouldKeepEndTargetAlive) { | |
| 483 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 484 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 485 | @@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 486 | } | |
| 487 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 488 | isReady = !hasUnknownTailSize; | |
| 489 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 490 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 491 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 492 | } else if (anchorIndex >= 0) { | |
| 493 | isReady = false; | |
| 494 | } | |
| 495 | @@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 496 | updateScroll(ctx, state.scroll, true); | |
| 497 | } | |
| 498 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 499 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 500 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 501 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 502 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 503 | + updateScroll(ctx, state.scroll, true); | |
| 504 | + } | |
| 505 | } | |
| 506 | return nextSize; | |
| 507 | } | |
| 508 | @@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 509 | dataVersion, | |
| 510 | drawDistance = 250, | |
| 511 | contentInsetEndAdjustment, | |
| 512 | + contentInsetStartAdjustment, | |
| 513 | estimatedItemSize = 100, | |
| 514 | estimatedListSize, | |
| 515 | extraData, | |
| 516 | @@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 517 | onLayout: onLayoutProp, | |
| 518 | onLoad, | |
| 519 | onMomentumScrollEnd, | |
| 520 | + onScrollBeginDrag, | |
| 521 | onRefresh, | |
| 522 | onScroll: onScrollProp, | |
| 523 | onScrollBeginDrag, | |
| 524 | @@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 525 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 526 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 527 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 528 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 529 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 530 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); | |
| 531 | const alwaysRenderIndices = React2.useMemo(() => { | |
| 532 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 533 | @@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 534 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 535 | contentInset, | |
| 536 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 537 | + contentInsetStartAdjustment, | |
| 538 | data: dataProp, | |
| 539 | dataKey, | |
| 540 | dataVersion, | |
| 541 | @@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 542 | return void 0; | |
| 543 | } | |
| 544 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 545 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 546 | + // With a native leading inset (transparent header + automatic insets), | |
| 547 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 548 | + // application. The gated initial-scroll dispatch positions the list once | |
| 549 | + // real sizes are known instead. | |
| 550 | + return void 0; | |
| 551 | + } | |
| 552 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 553 | }, [usesBootstrapInitialScroll]); | |
| 554 | React2.useLayoutEffect(() => { | |
| 555 | @@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 556 | onScroll: (event) => onScroll(ctx, event), | |
| 557 | onScrollBeginDrag: (event) => { | |
| 558 | var _a4, _b2; | |
| 559 | + ctx.state.didUserDrag = true; | |
| 560 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 561 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 562 | }, | |
| 563 | @@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 564 | onLayout, | |
| 565 | onLayoutFooter, | |
| 566 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 567 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 568 | onScroll: onScrollHandler, | |
| 569 | recycleItems, | |
| 570 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | |
| 571 | diff --git a/react-native.mjs b/react-native.mjs | |
| 572 | index c2e0f38..5313086 100644 | |
| 573 | --- a/react-native.mjs | |
| 574 | +++ b/react-native.mjs | |
| 575 | @@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { | |
| 576 | if (didInitialScroll) { | |
| 577 | state.didFinishInitialScroll = true; | |
| 578 | } | |
| 579 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | |
| 580 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | |
| 581 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | |
| 582 | set$(ctx, "readyToRender", true); | |
| 583 | setAdaptiveRender(ctx, "normal", "ready"); | |
| 584 | @@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | |
| 585 | } | |
| 586 | ||
| 587 | // src/core/clampScrollOffset.ts | |
| 588 | +function getContentInsetStartAdjustment(ctx) { | |
| 589 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | |
| 590 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | |
| 591 | +} | |
| 592 | function clampScrollOffset(ctx, offset, scrollTarget) { | |
| 593 | const state = ctx.state; | |
| 594 | const contentSize = getContentSize(ctx); | |
| 595 | + const minOffset = -getContentInsetStartAdjustment(ctx); | |
| 596 | let clampedOffset = offset; | |
| 597 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | |
| 598 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | |
| 599 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | |
| 600 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | |
| 601 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | |
| 602 | const maxOffset = baseMaxOffset + extraEndOffset; | |
| 603 | clampedOffset = Math.min(offset, maxOffset); | |
| 604 | } | |
| 605 | - clampedOffset = Math.max(0, clampedOffset); | |
| 606 | + clampedOffset = Math.max(minOffset, clampedOffset); | |
| 607 | return clampedOffset; | |
| 608 | } | |
| 609 | ||
| 610 | @@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { | |
| 611 | finishScrollTo(ctx); | |
| 612 | } | |
| 613 | } | |
| 614 | -function scrollToFallbackOffset(ctx, offset) { | |
| 615 | +function scrollToFallbackOffset(ctx, offset, animated) { | |
| 616 | var _a3; | |
| 617 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | |
| 618 | - animated: false, | |
| 619 | + animated: !!animated, | |
| 620 | x: ctx.state.props.horizontal ? offset : 0, | |
| 621 | y: ctx.state.props.horizontal ? 0 : offset | |
| 622 | }); | |
| 623 | @@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { | |
| 624 | }); | |
| 625 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | |
| 626 | } else if (shouldRetryUnalignedEndScroll) { | |
| 627 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | |
| 628 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | |
| 629 | + if (!isActivelyAnimatingToEnd) { | |
| 630 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | |
| 631 | + } | |
| 632 | scheduleFallbackCheck(100); | |
| 633 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | |
| 634 | finishScrollTo(ctx); | |
| 635 | @@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 636 | } | |
| 637 | if (shouldMaintainScrollAtEnd) { | |
| 638 | state.pendingMaintainScrollAtEnd = false; | |
| 639 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | |
| 640 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | |
| 641 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | |
| 642 | + return false; | |
| 643 | + } | |
| 644 | const contentSize = getContentSize(ctx); | |
| 645 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 646 | if (contentSize < state.scrollLength) { | |
| 647 | - state.scroll = 0; | |
| 648 | + state.scroll = -maintainInsetStartAdjustment; | |
| 649 | + if (maintainInsetStartAdjustment > 0) { | |
| 650 | + return true; | |
| 651 | + } | |
| 652 | } | |
| 653 | if (!state.maintainingScrollAtEnd) { | |
| 654 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | |
| 655 | @@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { | |
| 656 | y: 0 | |
| 657 | }); | |
| 658 | } else { | |
| 659 | - scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 660 | - animated: maintainScrollAtEnd.animated | |
| 661 | - }); | |
| 662 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 663 | + if (insetStartAdjustment > 0) { | |
| 664 | + scroller == null ? void 0 : scroller.scrollTo({ | |
| 665 | + animated: maintainScrollAtEnd.animated, | |
| 666 | + x: 0, | |
| 667 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | |
| 668 | + }); | |
| 669 | + } else { | |
| 670 | + scroller == null ? void 0 : scroller.scrollToEnd({ | |
| 671 | + animated: maintainScrollAtEnd.animated | |
| 672 | + }); | |
| 673 | + } | |
| 674 | } | |
| 675 | setTimeout( | |
| 676 | () => { | |
| 677 | @@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { | |
| 678 | function requestAdjust(ctx, positionDiff, dataChanged) { | |
| 679 | const state = ctx.state; | |
| 680 | if (Math.abs(positionDiff) > 0.1) { | |
| 681 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP | |
| 682 | + // size adjustment are already compensated by a contentOffset shift, so | |
| 683 | + // animating them would make rows visibly lurch and slide back. | |
| 684 | + state.lastMVCPAdjustTime = Date.now(); | |
| 685 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; | |
| 686 | const doit = () => { | |
| 687 | if (needsScrollWorkaround) { | |
| 688 | @@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | |
| 689 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | |
| 690 | return 0; | |
| 691 | } | |
| 692 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 693 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | |
| 694 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | |
| 695 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | |
| 696 | const clampDelta = maxScroll - state.scroll; | |
| 697 | if (unresolvedAmount < 0) { | |
| 698 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | |
| 699 | @@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | |
| 700 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | |
| 701 | return true; | |
| 702 | } | |
| 703 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | |
| 704 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | |
| 705 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | |
| 706 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | |
| 707 | if (isAtExpectedNativeClamp) { | |
| 708 | @@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { | |
| 709 | if (diff > 0) { | |
| 710 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | |
| 711 | } else { | |
| 712 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | |
| 713 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | |
| 714 | state.scroll = maxScroll; | |
| 715 | state.scrollPending = maxScroll; | |
| 716 | diff = 0; | |
| 717 | @@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { | |
| 718 | } | |
| 719 | ||
| 720 | // src/core/initialScroll.ts | |
| 721 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | |
| 722 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | |
| 723 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | |
| 724 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | |
| 725 | +function startInsetEndSettleWatchdog(ctx) { | |
| 726 | + const state = ctx.state; | |
| 727 | + if (state.insetEndSettleWatchdogActive) { | |
| 728 | + return; | |
| 729 | + } | |
| 730 | + state.insetEndSettleWatchdogActive = true; | |
| 731 | + state.didUserDrag = false; | |
| 732 | + // Hold the readyToRender opacity gate until the end landing is stable, so | |
| 733 | + // the estimated-to-measured settle chase happens before first VISIBLE | |
| 734 | + // paint instead of in front of the user. Capped so slow measurement can | |
| 735 | + // never hide content for long. | |
| 736 | + state.insetEndRevealHold = true; | |
| 737 | + let frames = 0; | |
| 738 | + let settledFrames = 0; | |
| 739 | + let revealStableFrames = 0; | |
| 740 | + const releaseRevealHold = () => { | |
| 741 | + if (state.insetEndRevealHold) { | |
| 742 | + state.insetEndRevealHold = false; | |
| 743 | + setInitialRenderState(ctx, {}); | |
| 744 | + } | |
| 745 | + }; | |
| 746 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | |
| 747 | + // can move the end target one more time — do that upgrade while still | |
| 748 | + // hidden (phase 1), then require stability again before revealing (phase 2) | |
| 749 | + // so the first visible frame is the settled one. | |
| 750 | + let revealPhase = 1; | |
| 751 | + const onRevealStability = () => { | |
| 752 | + var _a3; | |
| 753 | + if (!state.insetEndRevealHold) { | |
| 754 | + return; | |
| 755 | + } | |
| 756 | + if (revealPhase === 1) { | |
| 757 | + revealPhase = 2; | |
| 758 | + revealStableFrames = 0; | |
| 759 | + setAdaptiveRender(ctx, "normal"); | |
| 760 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | |
| 761 | + return; | |
| 762 | + } | |
| 763 | + releaseRevealHold(); | |
| 764 | + }; | |
| 765 | + const tick = () => { | |
| 766 | + // The user owns the scroll from their first drag — a re-pin here would | |
| 767 | + // pin the list under their finger. | |
| 768 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | |
| 769 | + state.insetEndSettleWatchdogActive = false; | |
| 770 | + releaseRevealHold(); | |
| 771 | + return; | |
| 772 | + } | |
| 773 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | |
| 774 | + releaseRevealHold(); | |
| 775 | + } | |
| 776 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 777 | + const contentSize = getContentSize(ctx); | |
| 778 | + const scrollLength = state.scrollLength; | |
| 779 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | |
| 780 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | |
| 781 | + const distance = endOffset - state.scroll; | |
| 782 | + // Estimated row sizes converging to measured ones can strand the initial | |
| 783 | + // end landing when the library's own end-anchor bookkeeping gives up. | |
| 784 | + // While still near the end (never fighting a user who scrolled away), | |
| 785 | + // re-pin to the current true end until sizes stop changing. | |
| 786 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | |
| 787 | + settledFrames = 0; | |
| 788 | + revealStableFrames = 0; | |
| 789 | + const scroller = state.refScroller.current; | |
| 790 | + if (scroller) { | |
| 791 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | |
| 792 | + } | |
| 793 | + } else { | |
| 794 | + settledFrames++; | |
| 795 | + revealStableFrames++; | |
| 796 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 797 | + onRevealStability(); | |
| 798 | + } | |
| 799 | + } | |
| 800 | + } else { | |
| 801 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | |
| 802 | + // programmatic scroll) count toward stability for the reveal. | |
| 803 | + revealStableFrames++; | |
| 804 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | |
| 805 | + onRevealStability(); | |
| 806 | + } | |
| 807 | + } | |
| 808 | + requestAnimationFrame(tick); | |
| 809 | + }; | |
| 810 | + requestAnimationFrame(tick); | |
| 811 | +} | |
| 812 | function dispatchInitialScroll(ctx, params) { | |
| 813 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | |
| 814 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | |
| 815 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | |
| 816 | + if (ctx.state.scrollLength <= 0) { | |
| 817 | + // The scroll length is unknown, so the resolved target is meaningless; | |
| 818 | + // the initial-scroll machinery re-advances after layout. | |
| 819 | + return; | |
| 820 | + } | |
| 821 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | |
| 822 | + // Content underflows the viewport and nothing has scrolled yet: the | |
| 823 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | |
| 824 | + // end position. Dispatching a negative scroll here would race UIKit's | |
| 825 | + // inset application during screen attach, so just finish the session. | |
| 826 | + finishInitialScroll(ctx, { | |
| 827 | + resolvedOffset | |
| 828 | + }); | |
| 829 | + return; | |
| 830 | + } | |
| 831 | + ctx.state.didDispatchInsetInitialScroll = true; | |
| 832 | + if (target.viewPosition === 1) { | |
| 833 | + startInsetEndSettleWatchdog(ctx); | |
| 834 | + } | |
| 835 | + } | |
| 836 | const requestedIndex = target.index; | |
| 837 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | |
| 838 | const itemSize = getItemSizeAtIndex(ctx, index); | |
| 839 | @@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | |
| 840 | return; | |
| 841 | } | |
| 842 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | |
| 843 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | |
| 844 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | |
| 845 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | |
| 846 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | |
| 847 | if (!shouldKeepEndTargetAlive) { | |
| 848 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | |
| 849 | clearPendingInitialScrollFooterLayout(ctx, { | |
| 850 | @@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 851 | } | |
| 852 | contentBelowAnchor += footerSize + stylePaddingBottom; | |
| 853 | isReady = !hasUnknownTailSize; | |
| 854 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 855 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | |
| 856 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | |
| 857 | } else if (anchorIndex >= 0) { | |
| 858 | isReady = false; | |
| 859 | } | |
| 860 | @@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | |
| 861 | updateScroll(ctx, state.scroll, true); | |
| 862 | } | |
| 863 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | |
| 864 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | |
| 865 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | |
| 866 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | |
| 867 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | |
| 868 | + updateScroll(ctx, state.scroll, true); | |
| 869 | + } | |
| 870 | } | |
| 871 | return nextSize; | |
| 872 | } | |
| 873 | @@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 874 | dataVersion, | |
| 875 | drawDistance = 250, | |
| 876 | contentInsetEndAdjustment, | |
| 877 | + contentInsetStartAdjustment, | |
| 878 | estimatedItemSize = 100, | |
| 879 | estimatedListSize, | |
| 880 | extraData, | |
| 881 | @@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 882 | const combinedRef = useCombinedRef(refScroller, refScrollView); | |
| 883 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); | |
| 884 | const stickyHeaderIndices = stickyHeaderIndicesProp; | |
| 885 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; | |
| 886 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; | |
| 887 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); | |
| 888 | const alwaysRenderIndices = useMemo(() => { | |
| 889 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); | |
| 890 | @@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 891 | contentContainerAlignItems: contentContainerStyle.alignItems, | |
| 892 | contentInset, | |
| 893 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | |
| 894 | + contentInsetStartAdjustment, | |
| 895 | data: dataProp, | |
| 896 | dataKey, | |
| 897 | dataVersion, | |
| 898 | @@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 899 | return void 0; | |
| 900 | } | |
| 901 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | |
| 902 | + if (getContentInsetStartAdjustment(ctx) > 0) { | |
| 903 | + // With a native leading inset (transparent header + automatic insets), | |
| 904 | + // any mount-time contentOffset races UIKit's adjustedContentInset | |
| 905 | + // application. The gated initial-scroll dispatch positions the list once | |
| 906 | + // real sizes are known instead. | |
| 907 | + return void 0; | |
| 908 | + } | |
| 909 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | |
| 910 | }, [usesBootstrapInitialScroll]); | |
| 911 | useLayoutEffect(() => { | |
| 912 | @@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 913 | onScroll: (event) => onScroll(ctx, event), | |
| 914 | onScrollBeginDrag: (event) => { | |
| 915 | var _a4, _b2; | |
| 916 | + ctx.state.didUserDrag = true; | |
| 917 | prepareReachedEdgeForNextUserScroll(ctx); | |
| 918 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); | |
| 919 | }, | |
| 920 | @@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | |
| 921 | onLayout, | |
| 922 | onLayoutFooter, | |
| 923 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | |
| 924 | + onScrollBeginDrag: fns.onScrollBeginDrag, | |
| 925 | onScroll: onScrollHandler, | |
| 926 | recycleItems, | |
| 927 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { | |
| 928 | diff --git a/reanimated.d.ts b/reanimated.d.ts | |
| 929 | index 940da28..28dccbe 100644 | |
| 930 | --- a/reanimated.d.ts | |
| 931 | +++ b/reanimated.d.ts | |
| 932 | @@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | |
| 933 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | |
| 934 | */ | |
| 935 | contentInsetEndAdjustment?: number; | |
| 936 | + /** | |
| 937 | + * Width/height of a leading content inset applied natively outside the list's knowledge | |
| 938 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | |
| 939 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | |
| 940 | + */ | |
| 941 | + contentInsetStartAdjustment?: number; | |
| 942 | /** | |
| 943 | * Number of columns to render items in. | |
| 944 | * @default 1 | |
| 945 | diff --git a/reanimated.js b/reanimated.js | |
| 946 | index f1265fa..16dcef0 100644 | |
| 947 | --- a/reanimated.js | |
| 948 | +++ b/reanimated.js | |
| 949 | @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 950 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 951 | const prevItemKeyRef = React__namespace.useRef(void 0); | |
| 952 | let shouldSkipTransitionForRecycleReuse = false; | |
| 953 | - if (recycleItems && layoutTransition) { | |
| 954 | + if (layoutTransition) { | |
| 955 | const itemKeySignal = `containerItemKey${id}`; | |
| 956 | const itemKey = peek$(ctx, itemKeySignal); | |
| 957 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 958 | @@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 959 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 960 | [horizontal, positionValue, style] | |
| 961 | ); | |
| 962 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 963 | + // which are compensated by an equal contentOffset shift so the row should | |
| 964 | + // not visibly move — animating turns the invisible correction into a | |
| 965 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 966 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 967 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 968 | + // folds), so gate it to at-rest moments. | |
| 969 | + const now = Date.now(); | |
| 970 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 971 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 972 | return /* @__PURE__ */ React__namespace.createElement( | |
| 973 | Reanimated__default.default.View, | |
| 974 | { | |
| 975 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 976 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 977 | ref: refView, | |
| 978 | style: viewStyle, | |
| 979 | ...rest | |
| 980 | diff --git a/reanimated.mjs b/reanimated.mjs | |
| 981 | index 29a00d5..9c25ec5 100644 | |
| 982 | --- a/reanimated.mjs | |
| 983 | +++ b/reanimated.mjs | |
| 984 | @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 985 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); | |
| 986 | const prevItemKeyRef = React.useRef(void 0); | |
| 987 | let shouldSkipTransitionForRecycleReuse = false; | |
| 988 | - if (recycleItems && layoutTransition) { | |
| 989 | + if (layoutTransition) { | |
| 990 | const itemKeySignal = `containerItemKey${id}`; | |
| 991 | const itemKey = peek$(ctx, itemKeySignal); | |
| 992 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; | |
| 993 | @@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( | |
| 994 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], | |
| 995 | [horizontal, positionValue, style] | |
| 996 | ); | |
| 997 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, | |
| 998 | + // which are compensated by an equal contentOffset shift so the row should | |
| 999 | + // not visibly move — animating turns the invisible correction into a | |
| 1000 | + // lurch-and-settle; (2) any reposition while the user is actively | |
| 1001 | + // scrolling, where rows shifting under the finger reads as jank. The | |
| 1002 | + // transition exists to smooth at-rest shifts (streaming text, work-log | |
| 1003 | + // folds), so gate it to at-rest moments. | |
| 1004 | + const now = Date.now(); | |
| 1005 | + const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || | |
| 1006 | + now - (ctx.state.lastNativeScrollTime || 0) < 300; | |
| 1007 | return /* @__PURE__ */ React.createElement( | |
| 1008 | Reanimated.View, | |
| 1009 | { | |
| 1010 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, | |
| 1011 | + layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition, | |
| 1012 | ref: refView, | |
| 1013 | style: viewStyle, | |
| 1014 | ...rest |
pnpm-lock.yaml+24-12| ... | ... | @@ -74,7 +74,7 @@ patchedDependencies: |
| 74 | 74 | '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425 |
| 75 | 75 | '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 |
| 76 | 76 | '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 |
| 77 | '@legendapp/list@3.2.0': 9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d | |
| 77 | '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 | |
| 78 | 78 | '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa |
| 79 | 79 | '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae |
| 80 | 80 | '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 |
| ... | ... | @@ -212,8 +212,8 @@ importers: |
| 212 | 212 | specifier: ~56.0.18 |
| 213 | 213 | version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) |
| 214 | 214 | '@legendapp/list': |
| 215 | specifier: 3.2.0 | |
| 216 | version: 3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 215 | specifier: 3.3.3 | |
| 216 | version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | |
| 217 | 217 | '@noble/curves': |
| 218 | 218 | specifier: 'catalog:' |
| 219 | 219 | version: 1.9.1 |
| ... | ... | @@ -545,7 +545,7 @@ importers: |
| 545 | 545 | version: 0.9.0 |
| 546 | 546 | '@legendapp/list': |
| 547 | 547 | specifier: 3.2.0 |
| 548 | version: 3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 548 | version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 549 | 549 | '@lexical/react': |
| 550 | 550 | specifier: ^0.41.0 |
| 551 | 551 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| ... | ... | @@ -2975,6 +2975,18 @@ packages: |
| 2975 | 2975 | react-native: |
| 2976 | 2976 | optional: true |
| 2977 | 2977 | |
| 2978 | '@legendapp/list@3.3.3': | |
| 2979 | resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} | |
| 2980 | peerDependencies: | |
| 2981 | react: '*' | |
| 2982 | react-dom: '*' | |
| 2983 | react-native: '*' | |
| 2984 | peerDependenciesMeta: | |
| 2985 | react-dom: | |
| 2986 | optional: true | |
| 2987 | react-native: | |
| 2988 | optional: true | |
| 2989 | ||
| 2978 | 2990 | '@lexical/clipboard@0.41.0': |
| 2979 | 2991 | resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} |
| 2980 | 2992 | |
| ... | ... | @@ -12982,7 +12994,14 @@ snapshots: |
| 12982 | 12994 | dependencies: |
| 12983 | 12995 | jsbi: 4.3.2 |
| 12984 | 12996 | |
| 12985 | '@legendapp/list@3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 12997 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 12998 | dependencies: | |
| 12999 | react: 19.2.6 | |
| 13000 | use-sync-external-store: 1.6.0(react@19.2.6) | |
| 13001 | optionalDependencies: | |
| 13002 | react-dom: 19.2.6(react@19.2.6) | |
| 13003 | ||
| 13004 | '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | |
| 12986 | 13005 | dependencies: |
| 12987 | 13006 | react: 19.2.3 |
| 12988 | 13007 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | ... | @@ -12990,13 +13009,6 @@ snapshots: |
| 12990 | 13009 | react-dom: 19.2.3(react@19.2.3) |
| 12991 | 13010 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12992 | 13011 | |
| 12993 | '@legendapp/list@3.2.0(patch_hash=9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 12994 | dependencies: | |
| 12995 | react: 19.2.6 | |
| 12996 | use-sync-external-store: 1.6.0(react@19.2.6) | |
| 12997 | optionalDependencies: | |
| 12998 | react-dom: 19.2.6(react@19.2.6) | |
| 12999 | ||
| 13000 | 13012 | '@lexical/clipboard@0.41.0': |
| 13001 | 13013 | dependencies: |
| 13002 | 13014 | '@lexical/html': 0.41.0 |
pnpm-workspace.yaml+1-1| ... | ... | @@ -127,7 +127,7 @@ patchedDependencies: |
| 127 | 127 | "@effect/vitest@4.0.0-beta.102": patches/@effect__vitest@4.0.0-beta.102.patch |
| 128 | 128 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch |
| 129 | 129 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch |
| 130 | "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch | |
| 130 | "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch | |
| 131 | 131 | "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch |
| 132 | 132 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch |
| 133 | 133 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch |