| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts |
| 2 | index 367945c..0ac7bf8 100644 |
| 3 | --- a/keyboard.d.ts |
| 4 | +++ b/keyboard.d.ts |
| 5 | @@ -279,7 +279,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, animationDuration?: number): { |
| 11 | contentInsetEndAdjustment: SharedValue<number>; |
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; |
| 13 | }; |
| 14 | @@ -288,8 +288,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 6645bcb..321c4c3 100644 |
| 27 | --- a/keyboard.js |
| 28 | +++ b/keyboard.js |
| 29 | @@ -33,19 +33,22 @@ 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, animationDuration = 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 | + const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; |
| 44 | lastHeightRef.current = height; |
| 45 | - contentInsetEndAdjustment.value = height; |
| 46 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); |
| 47 | + contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, { |
| 48 | + duration: animationDuration |
| 49 | + }) : height; |
| 50 | } |
| 51 | }, |
| 52 | - [contentInsetEndAdjustment, listRef] |
| 53 | + [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] |
| 54 | ); |
| 55 | React.useLayoutEffect(() => { |
| 56 | var _a; |
| 57 | @@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { |
| 58 | } |
| 59 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { |
| 60 | const { |
| 61 | + adjustedInsetCompensation, |
| 62 | anchoredEndSpace, |
| 63 | applyWorkaroundForContentInsetHitTestBug, |
| 64 | contentInsetEndAdjustment, |
| 65 | + contentInsetEndStaticAdjustment, |
| 66 | freeze, |
| 67 | keyboardLiftBehavior, |
| 68 | keyboardOffset, |
| 69 | @@ -94,6 +99,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 70 | } = props; |
| 71 | const refLegendList = React.useRef(null); |
| 72 | const combinedRef = useCombinedRef(forwardedRef, refLegendList); |
| 73 | + const adjustedStartInsetCompensation = props.contentInsetStartAdjustment; |
| 74 | const blankSpace = reactNativeReanimated.useSharedValue(0); |
| 75 | React.useEffect(() => { |
| 76 | if (!anchoredEndSpace) { |
| 77 | @@ -108,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 78 | ...anchoredEndSpace, |
| 79 | onSizeChanged: (size) => { |
| 80 | var _a; |
| 81 | - blankSpace.value = size; |
| 82 | + // The anchored blank is sized against the ADJUSTED viewport, but the |
| 83 | + // scroll component writes it as a raw contentInset that UIKit tops up |
| 84 | + // with the safe-area bottom — write it net of that or the end rest |
| 85 | + // (and the anchored message) sinks one safe-area under the header. |
| 86 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; |
| 87 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); |
| 88 | } |
| 89 | }; |
| 90 | - }, [anchoredEndSpace, blankSpace]); |
| 91 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); |
| 92 | const onContentInsetChange = React.useCallback((insets) => { |
| 93 | var _a; |
| 94 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); |
| 95 | @@ -123,6 +133,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 96 | reactNativeKeyboardController.KeyboardChatScrollView, |
| 97 | { |
| 98 | ...scrollProps, |
| 99 | + adjustedInsetCompensation, |
| 100 | + adjustedStartInsetCompensation, |
| 101 | applyWorkaroundForContentInsetHitTestBug, |
| 102 | blankSpace, |
| 103 | extraContentPadding: contentInsetEndAdjustment, |
| 104 | @@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 105 | ); |
| 106 | }, |
| 107 | [ |
| 108 | + adjustedInsetCompensation, |
| 109 | + adjustedStartInsetCompensation, |
| 110 | applyWorkaroundForContentInsetHitTestBug, |
| 111 | blankSpace, |
| 112 | contentInsetEndAdjustment, |
| 113 | @@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 114 | { |
| 115 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, |
| 116 | anchoredEndSpaceOwnerInternal: "scroll", |
| 117 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, |
| 118 | ref: combinedRef, |
| 119 | renderScrollComponent: memoList, |
| 120 | ...rest |
| 121 | diff --git a/keyboard.mjs b/keyboard.mjs |
| 122 | index 87b38b9..111eb24 100644 |
| 123 | --- a/keyboard.mjs |
| 124 | +++ b/keyboard.mjs |
| 125 | @@ -1,7 +1,7 @@ |
| 126 | import * as React from 'react'; |
| 127 | import { useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react'; |
| 128 | import { KeyboardChatScrollView, KeyboardController } from 'react-native-keyboard-controller'; |
| 129 | -import { useSharedValue } from 'react-native-reanimated'; |
| 130 | +import { useSharedValue, withTiming } from 'react-native-reanimated'; |
| 131 | import { internal } from '@legendapp/list/react-native'; |
| 132 | import { AnimatedLegendList } from '@legendapp/list/reanimated'; |
| 133 | |
| 134 | @@ -12,19 +12,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { |
| 135 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." |
| 136 | ); |
| 137 | } |
| 138 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { |
| 139 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) { |
| 140 | const contentInsetEndAdjustment = useSharedValue(initialHeight); |
| 141 | const lastHeightRef = useRef(void 0); |
| 142 | const reportHeight = useCallback( |
| 143 | - (height) => { |
| 144 | + (rawHeight) => { |
| 145 | + const height = Math.max(0, rawHeight + heightAdjustment); |
| 146 | var _a; |
| 147 | if (Number.isFinite(height) && height !== lastHeightRef.current) { |
| 148 | + const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0; |
| 149 | lastHeightRef.current = height; |
| 150 | - contentInsetEndAdjustment.value = height; |
| 151 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); |
| 152 | + contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, { |
| 153 | + duration: animationDuration |
| 154 | + }) : height; |
| 155 | } |
| 156 | }, |
| 157 | - [contentInsetEndAdjustment, listRef] |
| 158 | + [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef] |
| 159 | ); |
| 160 | useLayoutEffect(() => { |
| 161 | var _a; |
| 162 | @@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { |
| 163 | } |
| 164 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { |
| 165 | const { |
| 166 | + adjustedInsetCompensation, |
| 167 | anchoredEndSpace, |
| 168 | applyWorkaroundForContentInsetHitTestBug, |
| 169 | contentInsetEndAdjustment, |
| 170 | + contentInsetEndStaticAdjustment, |
| 171 | freeze, |
| 172 | keyboardLiftBehavior, |
| 173 | keyboardOffset, |
| 174 | @@ -73,6 +78,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 175 | } = props; |
| 176 | const refLegendList = useRef(null); |
| 177 | const combinedRef = useCombinedRef(forwardedRef, refLegendList); |
| 178 | + const adjustedStartInsetCompensation = props.contentInsetStartAdjustment; |
| 179 | const blankSpace = useSharedValue(0); |
| 180 | useEffect(() => { |
| 181 | if (!anchoredEndSpace) { |
| 182 | @@ -87,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 183 | ...anchoredEndSpace, |
| 184 | onSizeChanged: (size) => { |
| 185 | var _a; |
| 186 | - blankSpace.value = size; |
| 187 | + // The anchored blank is sized against the ADJUSTED viewport, but the |
| 188 | + // scroll component writes it as a raw contentInset that UIKit tops up |
| 189 | + // with the safe-area bottom — write it net of that or the end rest |
| 190 | + // (and the anchored message) sinks one safe-area under the header. |
| 191 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; |
| 192 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); |
| 193 | } |
| 194 | }; |
| 195 | - }, [anchoredEndSpace, blankSpace]); |
| 196 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); |
| 197 | const onContentInsetChange = useCallback((insets) => { |
| 198 | var _a; |
| 199 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); |
| 200 | @@ -102,6 +112,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 201 | KeyboardChatScrollView, |
| 202 | { |
| 203 | ...scrollProps, |
| 204 | + adjustedInsetCompensation, |
| 205 | + adjustedStartInsetCompensation, |
| 206 | applyWorkaroundForContentInsetHitTestBug, |
| 207 | blankSpace, |
| 208 | extraContentPadding: contentInsetEndAdjustment, |
| 209 | @@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 210 | ); |
| 211 | }, |
| 212 | [ |
| 213 | + adjustedInsetCompensation, |
| 214 | + adjustedStartInsetCompensation, |
| 215 | applyWorkaroundForContentInsetHitTestBug, |
| 216 | blankSpace, |
| 217 | contentInsetEndAdjustment, |
| 218 | @@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( |
| 219 | { |
| 220 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, |
| 221 | anchoredEndSpaceOwnerInternal: "scroll", |
| 222 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, |
| 223 | ref: combinedRef, |
| 224 | renderScrollComponent: memoList, |
| 225 | ...rest |
| 226 | diff --git a/react-native.d.ts b/react-native.d.ts |
| 227 | index ce1fe00..3ccf6f1 100644 |
| 228 | --- a/react-native.d.ts |
| 229 | +++ b/react-native.d.ts |
| 230 | @@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { |
| 231 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. |
| 232 | */ |
| 233 | contentInsetEndAdjustment?: number; |
| 234 | + /** |
| 235 | + * Width/height of a leading content inset applied natively outside the list's knowledge |
| 236 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). |
| 237 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. |
| 238 | + */ |
| 239 | + contentInsetStartAdjustment?: number; |
| 240 | /** |
| 241 | * Number of columns to render items in. |
| 242 | * @default 1 |
| 243 | diff --git a/react-native.js b/react-native.js |
| 244 | index b3c5a30..5ac6bbe 100644 |
| 245 | --- a/react-native.js |
| 246 | +++ b/react-native.js |
| 247 | @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { |
| 248 | return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; |
| 249 | } |
| 250 | |
| 251 | +// Size-only changes may not emit a scroll event to refresh the edge signal. |
| 252 | +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { |
| 253 | + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; |
| 254 | + if (!(contentSize > 0 && queuedInitialLayout)) { |
| 255 | + return peek$(ctx, "isAtEnd"); |
| 256 | + } |
| 257 | + return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; |
| 258 | +} |
| 259 | + |
| 260 | // src/utils/checkAtBottom.ts |
| 261 | function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { |
| 262 | var _a3; |
| 263 | @@ -737,7 +746,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { |
| 264 | const insetEnd = getContentInsetEnd(ctx); |
| 265 | const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; |
| 266 | const isContentLess = contentSize < scrollLength; |
| 267 | - set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); |
| 268 | + set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); |
| 269 | set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); |
| 270 | set$( |
| 271 | ctx, |
| 272 | @@ -954,7 +963,16 @@ function setInitialRenderState(ctx, { |
| 273 | if (didInitialScroll) { |
| 274 | state.didFinishInitialScroll = true; |
| 275 | } |
| 276 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); |
| 277 | + // Native contentOffset can seed an end target without dispatchInitialScroll. |
| 278 | + // Both overflow completion paths must wait for the native end landing. |
| 279 | + // Underflow needs no scroll: preserve UIKit's untouched resting position. |
| 280 | + if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { |
| 281 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 282 | + if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { |
| 283 | + startInsetEndSettleWatchdog(ctx); |
| 284 | + } |
| 285 | + } |
| 286 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); |
| 287 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 288 | set$(ctx, "readyToRender", true); |
| 289 | setAdaptiveRender(ctx, "normal", "ready"); |
| 290 | @@ -1090,7 +1108,7 @@ function getRawContentLength(ctx) { |
| 291 | function getAlignItemsAtEndPadding(ctx) { |
| 292 | const { state } = ctx; |
| 293 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; |
| 294 | - return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; |
| 295 | + return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; |
| 296 | } |
| 297 | function updateContentMetricsState(ctx) { |
| 298 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; |
| 299 | @@ -1115,6 +1133,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { |
| 300 | totalSize += add; |
| 301 | } |
| 302 | if (prevTotalSize !== totalSize) { |
| 303 | + const now = Date.now(); |
| 304 | + const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0; |
| 305 | + state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1; |
| 306 | + state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300); |
| 307 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 308 | state.pendingTotalSize = totalSize; |
| 309 | } else { |
| 310 | @@ -1304,18 +1326,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { |
| 311 | } |
| 312 | |
| 313 | // src/core/clampScrollOffset.ts |
| 314 | +function getContentInsetStartAdjustment(ctx) { |
| 315 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; |
| 316 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; |
| 317 | +} |
| 318 | function clampScrollOffset(ctx, offset, scrollTarget) { |
| 319 | const state = ctx.state; |
| 320 | const contentSize = getContentSize(ctx); |
| 321 | + const minOffset = -getContentInsetStartAdjustment(ctx); |
| 322 | let clampedOffset = offset; |
| 323 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { |
| 324 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); |
| 325 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); |
| 326 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; |
| 327 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; |
| 328 | const maxOffset = baseMaxOffset + extraEndOffset; |
| 329 | clampedOffset = Math.min(offset, maxOffset); |
| 330 | } |
| 331 | - clampedOffset = Math.max(0, clampedOffset); |
| 332 | + clampedOffset = Math.max(minOffset, clampedOffset); |
| 333 | return clampedOffset; |
| 334 | } |
| 335 | |
| 336 | @@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) { |
| 337 | finishScrollTo(ctx); |
| 338 | } |
| 339 | } |
| 340 | -function scrollToFallbackOffset(ctx, offset) { |
| 341 | +function scrollToFallbackOffset(ctx, offset, animated) { |
| 342 | var _a3; |
| 343 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ |
| 344 | - animated: false, |
| 345 | + animated: !!animated, |
| 346 | x: ctx.state.props.horizontal ? offset : 0, |
| 347 | y: ctx.state.props.horizontal ? 0 : offset |
| 348 | }); |
| 349 | @@ -1503,7 +1530,10 @@ function checkFinishedScrollFallback(ctx) { |
| 350 | ); |
| 351 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 352 | } else if (shouldRetryUnalignedEndScroll) { |
| 353 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); |
| 354 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; |
| 355 | + if (!isActivelyAnimatingToEnd) { |
| 356 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); |
| 357 | + } |
| 358 | scheduleFallbackCheck(100); |
| 359 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 360 | finishScrollTo(ctx); |
| 361 | @@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) { |
| 362 | } = state; |
| 363 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 364 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| 365 | + if (state.contentSizeAnimationActiveEpoch !== void 0) { |
| 366 | + state.pendingMaintainScrollAtEnd = false; |
| 367 | + return false; |
| 368 | + } |
| 369 | if (pendingNativeMVCPAdjust) { |
| 370 | state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd; |
| 371 | return false; |
| 372 | } |
| 373 | if (shouldMaintainScrollAtEnd) { |
| 374 | state.pendingMaintainScrollAtEnd = false; |
| 375 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; |
| 376 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; |
| 377 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { |
| 378 | + return false; |
| 379 | + } |
| 380 | const contentSize = getContentSize(ctx); |
| 381 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 382 | if (contentSize < state.scrollLength) { |
| 383 | - state.scroll = 0; |
| 384 | + state.scroll = -maintainInsetStartAdjustment; |
| 385 | + if (maintainInsetStartAdjustment > 0) { |
| 386 | + return true; |
| 387 | + } |
| 388 | } |
| 389 | if (!state.maintainingScrollAtEnd) { |
| 390 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; |
| 391 | @@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) { |
| 392 | y: 0 |
| 393 | }); |
| 394 | } else { |
| 395 | - scroller == null ? void 0 : scroller.scrollToEnd({ |
| 396 | - animated: maintainScrollAtEnd.animated |
| 397 | - }); |
| 398 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 399 | + if (insetStartAdjustment > 0) { |
| 400 | + scroller == null ? void 0 : scroller.scrollTo({ |
| 401 | + animated: maintainScrollAtEnd.animated, |
| 402 | + x: 0, |
| 403 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) |
| 404 | + }); |
| 405 | + } else { |
| 406 | + scroller == null ? void 0 : scroller.scrollToEnd({ |
| 407 | + animated: maintainScrollAtEnd.animated |
| 408 | + }); |
| 409 | + } |
| 410 | } |
| 411 | setTimeout( |
| 412 | () => { |
| 413 | @@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) { |
| 414 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 415 | const state = ctx.state; |
| 416 | if (Math.abs(positionDiff) > 0.1) { |
| 417 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP |
| 418 | + // size adjustment are already compensated by a contentOffset shift, so |
| 419 | + // animating them would make rows visibly lurch and slide back. |
| 420 | + state.lastMVCPAdjustTime = Date.now(); |
| 421 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 422 | const doit = () => { |
| 423 | if (needsScrollWorkaround) { |
| 424 | @@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { |
| 425 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 426 | return 0; |
| 427 | } |
| 428 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); |
| 429 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; |
| 430 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; |
| 431 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); |
| 432 | const clampDelta = maxScroll - state.scroll; |
| 433 | if (unresolvedAmount < 0) { |
| 434 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); |
| 435 | @@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { |
| 436 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 437 | return true; |
| 438 | } |
| 439 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); |
| 440 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); |
| 441 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 442 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 443 | if (isAtExpectedNativeClamp) { |
| 444 | @@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) { |
| 445 | if (diff > 0) { |
| 446 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 447 | } else { |
| 448 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); |
| 449 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); |
| 450 | state.scroll = maxScroll; |
| 451 | state.scrollPending = maxScroll; |
| 452 | diff = 0; |
| 453 | @@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, { |
| 454 | } |
| 455 | |
| 456 | // src/core/initialScroll.ts |
| 457 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; |
| 458 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; |
| 459 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; |
| 460 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; |
| 461 | +function startInsetEndSettleWatchdog(ctx) { |
| 462 | + const state = ctx.state; |
| 463 | + if (state.insetEndSettleWatchdogStarted || state.didLoad) { |
| 464 | + return; |
| 465 | + } |
| 466 | + state.insetEndSettleWatchdogStarted = true; |
| 467 | + state.insetEndSettleWatchdogActive = true; |
| 468 | + // Hold the readyToRender opacity gate until the end landing is stable, so |
| 469 | + // the estimated-to-measured settle chase happens before first VISIBLE |
| 470 | + // paint instead of in front of the user. Capped so slow measurement can |
| 471 | + // never hide content for long. |
| 472 | + state.insetEndRevealHold = true; |
| 473 | + let frames = 0; |
| 474 | + let settledFrames = 0; |
| 475 | + let revealStableFrames = 0; |
| 476 | + let previousEndOffset; |
| 477 | + const releaseRevealHold = () => { |
| 478 | + if (state.insetEndRevealHold) { |
| 479 | + state.insetEndRevealHold = false; |
| 480 | + setInitialRenderState(ctx, {}); |
| 481 | + } |
| 482 | + }; |
| 483 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and |
| 484 | + // can move the end target one more time — do that upgrade while still |
| 485 | + // hidden (phase 1), then require stability again before revealing (phase 2) |
| 486 | + // so the first visible frame is the settled one. |
| 487 | + let revealPhase = 1; |
| 488 | + const onRevealStability = () => { |
| 489 | + var _a3; |
| 490 | + if (!state.insetEndRevealHold) { |
| 491 | + return; |
| 492 | + } |
| 493 | + if (revealPhase === 1) { |
| 494 | + revealPhase = 2; |
| 495 | + revealStableFrames = 0; |
| 496 | + setAdaptiveRender(ctx, "normal"); |
| 497 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); |
| 498 | + return; |
| 499 | + } |
| 500 | + releaseRevealHold(); |
| 501 | + }; |
| 502 | + const tick = () => { |
| 503 | + // The user owns the scroll from their first drag — a re-pin here would |
| 504 | + // pin the list under their finger. |
| 505 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { |
| 506 | + state.insetEndSettleWatchdogActive = false; |
| 507 | + releaseRevealHold(); |
| 508 | + return; |
| 509 | + } |
| 510 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { |
| 511 | + releaseRevealHold(); |
| 512 | + } |
| 513 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 514 | + const contentSize = getContentSize(ctx); |
| 515 | + const scrollLength = state.scrollLength; |
| 516 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); |
| 517 | + // state.scroll is optimistic: non-animated scrollTo writes it before UIKit |
| 518 | + // moves. Only a native scroll event proves that the requested offset landed. |
| 519 | + const nativeOffset = state.lastNativeScroll; |
| 520 | + const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); |
| 521 | + const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; |
| 522 | + const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; |
| 523 | + const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); |
| 524 | + const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; |
| 525 | + previousEndOffset = endOffset; |
| 526 | + if (isStable) { |
| 527 | + settledFrames++; |
| 528 | + revealStableFrames++; |
| 529 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { |
| 530 | + onRevealStability(); |
| 531 | + } |
| 532 | + } else { |
| 533 | + settledFrames = 0; |
| 534 | + revealStableFrames = 0; |
| 535 | + // Keep the existing near-end correction limit. In-flight scrolls and |
| 536 | + // unknown native offsets wait; neither counts as a settled frame. |
| 537 | + if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { |
| 538 | + const scroller = state.refScroller.current; |
| 539 | + if (scroller) { |
| 540 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); |
| 541 | + } |
| 542 | + } |
| 543 | + } |
| 544 | + requestAnimationFrame(tick); |
| 545 | + }; |
| 546 | + requestAnimationFrame(tick); |
| 547 | +} |
| 548 | function dispatchInitialScroll(ctx, params) { |
| 549 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; |
| 550 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 551 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { |
| 552 | + if (ctx.state.scrollLength <= 0) { |
| 553 | + // The scroll length is unknown, so the resolved target is meaningless; |
| 554 | + // the initial-scroll machinery re-advances after layout. |
| 555 | + return; |
| 556 | + } |
| 557 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { |
| 558 | + // Content underflows the viewport and nothing has scrolled yet: the |
| 559 | + // untouched native rest position (UIKit's adjustedContentInset) IS the |
| 560 | + // end position. Dispatching a negative scroll here would race UIKit's |
| 561 | + // inset application during screen attach, so just finish the session. |
| 562 | + finishInitialScroll(ctx, { |
| 563 | + resolvedOffset |
| 564 | + }); |
| 565 | + return; |
| 566 | + } |
| 567 | + ctx.state.didDispatchInsetInitialScroll = true; |
| 568 | + if (target.viewPosition === 1) { |
| 569 | + startInsetEndSettleWatchdog(ctx); |
| 570 | + } |
| 571 | + } |
| 572 | const requestedIndex = target.index; |
| 573 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 574 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 575 | @@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { |
| 576 | return; |
| 577 | } |
| 578 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| 579 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); |
| 580 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); |
| 581 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; |
| 582 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); |
| 583 | if (!shouldKeepEndTargetAlive) { |
| 584 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 585 | clearPendingInitialScrollFooterLayout(ctx, { |
| 586 | @@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 587 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 588 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 589 | isReady = !hasUnknownTailSize; |
| 590 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 591 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 592 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; |
| 593 | } else if (anchorIndex >= 0) { |
| 594 | isReady = false; |
| 595 | } |
| 596 | @@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 597 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 598 | } |
| 599 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| 600 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { |
| 601 | + set$(ctx, "anchoredEndSpaceSize", nextSize); |
| 602 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); |
| 603 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { |
| 604 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 605 | + } |
| 606 | } |
| 607 | return nextSize; |
| 608 | } |
| 609 | @@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 610 | horizontal |
| 611 | }) { |
| 612 | const ctx = useStateContext(); |
| 613 | + const SizeComponent = ctx.state.props.sizeComponentInternal; |
| 614 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 615 | const animSize = useValue$("totalSize"); |
| 616 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); |
| 617 | @@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 618 | opacity: isVisible ? 1 : 0, |
| 619 | width: animSize |
| 620 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| 621 | + if (SizeComponent) { |
| 622 | + if (horizontal) { |
| 623 | + delete style.width; |
| 624 | + } else { |
| 625 | + delete style.height; |
| 626 | + } |
| 627 | + } |
| 628 | if (columnWrapperStyle) { |
| 629 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 630 | const gapX = columnGap || gap || 0; |
| 631 | @@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 632 | } |
| 633 | } |
| 634 | } |
| 635 | - return /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children)); |
| 636 | + const content = /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children); |
| 637 | + return SizeComponent ? /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content); |
| 638 | }); |
| 639 | var Containers = typedMemo(function Containers2({ |
| 640 | freshDataTransitionEpoch, |
| 641 | @@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet; |
| 642 | |
| 643 | // src/components/ListComponent.tsx |
| 644 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| 645 | + const ctx = useStateContext(); |
| 646 | const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]); |
| 647 | + const SizeComponent = ctx.state.props.sizeComponentInternal; |
| 648 | + if (SizeComponent) { |
| 649 | + return /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } }); |
| 650 | + } |
| 651 | if (alignItemsAtEndPadding <= 0) { |
| 652 | return null; |
| 653 | } |
| 654 | @@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 655 | refScrollView, |
| 656 | renderScrollComponent, |
| 657 | onLayoutFooter, |
| 658 | + onInternalMomentumScrollBegin, |
| 659 | onInternalScrollBeginDrag, |
| 660 | + onInternalScrollEndDrag, |
| 661 | onInternalScrollEnd, |
| 662 | + onMomentumScrollBegin, |
| 663 | + onScrollEndDrag, |
| 664 | scrollAdjustHandler, |
| 665 | snapToIndices, |
| 666 | stickyHeaderConfig, |
| 667 | @@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 668 | SnapOrScroll, |
| 669 | { |
| 670 | ...rest, |
| 671 | - ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag }, |
| 672 | + ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { |
| 673 | + onMomentumScrollBegin: (event) => { |
| 674 | + onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event); |
| 675 | + onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event); |
| 676 | + }, |
| 677 | + onScrollBeginDrag: onInternalScrollBeginDrag, |
| 678 | + onScrollEndDrag: (event) => { |
| 679 | + onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event); |
| 680 | + onScrollEndDrag == null ? void 0 : onScrollEndDrag(event); |
| 681 | + } |
| 682 | + }, |
| 683 | contentContainerStyle: [ |
| 684 | horizontal ? { height: "100%" } : {}, |
| 685 | contentContainerStyle, |
| 686 | @@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 687 | ], |
| 688 | contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, |
| 689 | horizontal, |
| 690 | - maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, |
| 691 | + // Keep iOS anchored to ScrollAdjust even when JS position restoration is |
| 692 | + // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor |
| 693 | + // with the 1e7 sentinel and scroll the entire list out of view. |
| 694 | + maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, |
| 695 | onLayout, |
| 696 | onScroll: onScroll2, |
| 697 | ref: refScrollView, |
| 698 | @@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { |
| 699 | endBuffered: state.endBuffered, |
| 700 | getAverageItemSizes: () => getAverageItemSizes(state), |
| 701 | indexByKey: (key) => state.indexByKey.get(key), |
| 702 | - isAtEnd: peek$(ctx, "isAtEnd"), |
| 703 | + isAtEnd: getIsAtEnd(ctx), |
| 704 | isAtStart: peek$(ctx, "isAtStart"), |
| 705 | isEndReached: state.isEndReached, |
| 706 | isNearEnd: peek$(ctx, "isNearEnd"), |
| 707 | @@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 708 | dataVersion, |
| 709 | drawDistance = 250, |
| 710 | contentInsetEndAdjustment, |
| 711 | + contentInsetStartAdjustment, |
| 712 | estimatedItemSize = 100, |
| 713 | estimatedListSize, |
| 714 | extraData, |
| 715 | @@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 716 | const animatedPropsInternal = props.animatedPropsInternal; |
| 717 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 718 | const positionComponentInternal = props.positionComponentInternal; |
| 719 | + const sizeComponentInternal = props.sizeComponentInternal; |
| 720 | const stickyPositionComponentInternal = props.stickyPositionComponentInternal; |
| 721 | const { |
| 722 | anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal, |
| 723 | positionComponentInternal: _positionComponentInternal, |
| 724 | + sizeComponentInternal: _sizeComponentInternal, |
| 725 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 726 | ...restProps |
| 727 | } = rest; |
| 728 | @@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 729 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 730 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 731 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| 732 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; |
| 733 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; |
| 734 | const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); |
| 735 | const alwaysRenderIndices = React2.useMemo(() => { |
| 736 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 737 | @@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 738 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 739 | contentInset, |
| 740 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| 741 | + contentInsetStartAdjustment, |
| 742 | data: dataProp, |
| 743 | dataKey, |
| 744 | dataVersion, |
| 745 | @@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 746 | renderItem, |
| 747 | rtl, |
| 748 | snapToIndices, |
| 749 | + sizeComponentInternal, |
| 750 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 751 | stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 752 | stickyPositionComponentInternal, |
| 753 | @@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 754 | return void 0; |
| 755 | } |
| 756 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| 757 | + if (getContentInsetStartAdjustment(ctx) > 0) { |
| 758 | + // With a native leading inset (transparent header + automatic insets), |
| 759 | + // any mount-time contentOffset races UIKit's adjustedContentInset |
| 760 | + // application. The gated initial-scroll dispatch positions the list once |
| 761 | + // real sizes are known instead. |
| 762 | + return void 0; |
| 763 | + } |
| 764 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 765 | }, [usesBootstrapInitialScroll]); |
| 766 | React2.useLayoutEffect(() => { |
| 767 | @@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 768 | [ |
| 769 | dataKey, |
| 770 | dataVersion, |
| 771 | + contentInsetStartAdjustment, |
| 772 | memoizedLastItemKeys.join(","), |
| 773 | numColumnsProp, |
| 774 | nextScrollAxisGap, |
| 775 | @@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 776 | () => ({ |
| 777 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 778 | onMomentumScrollEnd: (event) => { |
| 779 | + state.isMomentumScrolling = false; |
| 780 | checkFinishedScrollFallback(ctx); |
| 781 | if (state.props.onMomentumScrollEnd) { |
| 782 | state.props.onMomentumScrollEnd(event); |
| 783 | @@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 784 | onScroll: (event) => onScroll(ctx, event), |
| 785 | onScrollBeginDrag: (event) => { |
| 786 | var _a4, _b2; |
| 787 | + ctx.state.didUserDrag = true; |
| 788 | + ctx.state.isUserDragging = true; |
| 789 | prepareReachedEdgeForNextUserScroll(ctx); |
| 790 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); |
| 791 | }, |
| 792 | @@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 793 | ListFooterComponent, |
| 794 | ListFooterComponentStyle, |
| 795 | ListHeaderComponent, |
| 796 | + onInternalMomentumScrollBegin: () => { |
| 797 | + ctx.state.isMomentumScrolling = true; |
| 798 | + }, |
| 799 | onInternalScrollBeginDrag: fns.onScrollBeginDrag, |
| 800 | + onInternalScrollEndDrag: () => { |
| 801 | + ctx.state.isUserDragging = false; |
| 802 | + }, |
| 803 | onInternalScrollEnd: fns.onScrollEnd, |
| 804 | onLayout, |
| 805 | onLayoutFooter, |
| 806 | onMomentumScrollEnd: fns.onMomentumScrollEnd, |
| 807 | + onScrollBeginDrag: fns.onScrollBeginDrag, |
| 808 | onScroll: onScrollHandler, |
| 809 | recycleItems, |
| 810 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { |
| 811 | diff --git a/react-native.mjs b/react-native.mjs |
| 812 | index 40e87cd..93aac74 100644 |
| 813 | --- a/react-native.mjs |
| 814 | +++ b/react-native.mjs |
| 815 | @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { |
| 816 | return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; |
| 817 | } |
| 818 | |
| 819 | +// Size-only changes may not emit a scroll event to refresh the edge signal. |
| 820 | +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { |
| 821 | + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; |
| 822 | + if (!(contentSize > 0 && queuedInitialLayout)) { |
| 823 | + return peek$(ctx, "isAtEnd"); |
| 824 | + } |
| 825 | + return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON; |
| 826 | +} |
| 827 | + |
| 828 | // src/utils/checkAtBottom.ts |
| 829 | function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { |
| 830 | var _a3; |
| 831 | @@ -716,7 +725,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) { |
| 832 | const insetEnd = getContentInsetEnd(ctx); |
| 833 | const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd; |
| 834 | const isContentLess = contentSize < scrollLength; |
| 835 | - set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON); |
| 836 | + set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize)); |
| 837 | set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength); |
| 838 | set$( |
| 839 | ctx, |
| 840 | @@ -933,7 +942,16 @@ function setInitialRenderState(ctx, { |
| 841 | if (didInitialScroll) { |
| 842 | state.didFinishInitialScroll = true; |
| 843 | } |
| 844 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); |
| 845 | + // Native contentOffset can seed an end target without dispatchInitialScroll. |
| 846 | + // Both overflow completion paths must wait for the native end landing. |
| 847 | + // Underflow needs no scroll: preserve UIKit's untouched resting position. |
| 848 | + if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) { |
| 849 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 850 | + if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) { |
| 851 | + startInsetEndSettleWatchdog(ctx); |
| 852 | + } |
| 853 | + } |
| 854 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); |
| 855 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { |
| 856 | set$(ctx, "readyToRender", true); |
| 857 | setAdaptiveRender(ctx, "normal", "ready"); |
| 858 | @@ -1069,7 +1087,7 @@ function getRawContentLength(ctx) { |
| 859 | function getAlignItemsAtEndPadding(ctx) { |
| 860 | const { state } = ctx; |
| 861 | const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0; |
| 862 | - return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; |
| 863 | + return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0; |
| 864 | } |
| 865 | function updateContentMetricsState(ctx) { |
| 866 | const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0; |
| 867 | @@ -1094,6 +1112,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) { |
| 868 | totalSize += add; |
| 869 | } |
| 870 | if (prevTotalSize !== totalSize) { |
| 871 | + const now = Date.now(); |
| 872 | + const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0; |
| 873 | + state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1; |
| 874 | + state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300); |
| 875 | if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) { |
| 876 | state.pendingTotalSize = totalSize; |
| 877 | } else { |
| 878 | @@ -1283,18 +1305,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { |
| 879 | } |
| 880 | |
| 881 | // src/core/clampScrollOffset.ts |
| 882 | +function getContentInsetStartAdjustment(ctx) { |
| 883 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; |
| 884 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; |
| 885 | +} |
| 886 | function clampScrollOffset(ctx, offset, scrollTarget) { |
| 887 | const state = ctx.state; |
| 888 | const contentSize = getContentSize(ctx); |
| 889 | + const minOffset = -getContentInsetStartAdjustment(ctx); |
| 890 | let clampedOffset = offset; |
| 891 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { |
| 892 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); |
| 893 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); |
| 894 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; |
| 895 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; |
| 896 | const maxOffset = baseMaxOffset + extraEndOffset; |
| 897 | clampedOffset = Math.min(offset, maxOffset); |
| 898 | } |
| 899 | - clampedOffset = Math.max(0, clampedOffset); |
| 900 | + clampedOffset = Math.max(minOffset, clampedOffset); |
| 901 | return clampedOffset; |
| 902 | } |
| 903 | |
| 904 | @@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) { |
| 905 | finishScrollTo(ctx); |
| 906 | } |
| 907 | } |
| 908 | -function scrollToFallbackOffset(ctx, offset) { |
| 909 | +function scrollToFallbackOffset(ctx, offset, animated) { |
| 910 | var _a3; |
| 911 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ |
| 912 | - animated: false, |
| 913 | + animated: !!animated, |
| 914 | x: ctx.state.props.horizontal ? offset : 0, |
| 915 | y: ctx.state.props.horizontal ? 0 : offset |
| 916 | }); |
| 917 | @@ -1482,7 +1509,10 @@ function checkFinishedScrollFallback(ctx) { |
| 918 | ); |
| 919 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); |
| 920 | } else if (shouldRetryUnalignedEndScroll) { |
| 921 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); |
| 922 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; |
| 923 | + if (!isActivelyAnimatingToEnd) { |
| 924 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); |
| 925 | + } |
| 926 | scheduleFallbackCheck(100); |
| 927 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { |
| 928 | finishScrollTo(ctx); |
| 929 | @@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) { |
| 930 | } = state; |
| 931 | const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold"); |
| 932 | const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout); |
| 933 | + if (state.contentSizeAnimationActiveEpoch !== void 0) { |
| 934 | + state.pendingMaintainScrollAtEnd = false; |
| 935 | + return false; |
| 936 | + } |
| 937 | if (pendingNativeMVCPAdjust) { |
| 938 | state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd; |
| 939 | return false; |
| 940 | } |
| 941 | if (shouldMaintainScrollAtEnd) { |
| 942 | state.pendingMaintainScrollAtEnd = false; |
| 943 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; |
| 944 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; |
| 945 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { |
| 946 | + return false; |
| 947 | + } |
| 948 | const contentSize = getContentSize(ctx); |
| 949 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 950 | if (contentSize < state.scrollLength) { |
| 951 | - state.scroll = 0; |
| 952 | + state.scroll = -maintainInsetStartAdjustment; |
| 953 | + if (maintainInsetStartAdjustment > 0) { |
| 954 | + return true; |
| 955 | + } |
| 956 | } |
| 957 | if (!state.maintainingScrollAtEnd) { |
| 958 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; |
| 959 | @@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) { |
| 960 | y: 0 |
| 961 | }); |
| 962 | } else { |
| 963 | - scroller == null ? void 0 : scroller.scrollToEnd({ |
| 964 | - animated: maintainScrollAtEnd.animated |
| 965 | - }); |
| 966 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 967 | + if (insetStartAdjustment > 0) { |
| 968 | + scroller == null ? void 0 : scroller.scrollTo({ |
| 969 | + animated: maintainScrollAtEnd.animated, |
| 970 | + x: 0, |
| 971 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) |
| 972 | + }); |
| 973 | + } else { |
| 974 | + scroller == null ? void 0 : scroller.scrollToEnd({ |
| 975 | + animated: maintainScrollAtEnd.animated |
| 976 | + }); |
| 977 | + } |
| 978 | } |
| 979 | setTimeout( |
| 980 | () => { |
| 981 | @@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) { |
| 982 | function requestAdjust(ctx, positionDiff, dataChanged) { |
| 983 | const state = ctx.state; |
| 984 | if (Math.abs(positionDiff) > 0.1) { |
| 985 | + // Timestamp for ReanimatedPositionView: repositions caused by an MVCP |
| 986 | + // size adjustment are already compensated by a contentOffset shift, so |
| 987 | + // animating them would make rows visibly lurch and slide back. |
| 988 | + state.lastMVCPAdjustTime = Date.now(); |
| 989 | const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; |
| 990 | const doit = () => { |
| 991 | if (needsScrollWorkaround) { |
| 992 | @@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { |
| 993 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { |
| 994 | return 0; |
| 995 | } |
| 996 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); |
| 997 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; |
| 998 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; |
| 999 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); |
| 1000 | const clampDelta = maxScroll - state.scroll; |
| 1001 | if (unresolvedAmount < 0) { |
| 1002 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); |
| 1003 | @@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { |
| 1004 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); |
| 1005 | return true; |
| 1006 | } |
| 1007 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); |
| 1008 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); |
| 1009 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); |
| 1010 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; |
| 1011 | if (isAtExpectedNativeClamp) { |
| 1012 | @@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) { |
| 1013 | if (diff > 0) { |
| 1014 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); |
| 1015 | } else { |
| 1016 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); |
| 1017 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); |
| 1018 | state.scroll = maxScroll; |
| 1019 | state.scrollPending = maxScroll; |
| 1020 | diff = 0; |
| 1021 | @@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, { |
| 1022 | } |
| 1023 | |
| 1024 | // src/core/initialScroll.ts |
| 1025 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; |
| 1026 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; |
| 1027 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; |
| 1028 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; |
| 1029 | +function startInsetEndSettleWatchdog(ctx) { |
| 1030 | + const state = ctx.state; |
| 1031 | + if (state.insetEndSettleWatchdogStarted || state.didLoad) { |
| 1032 | + return; |
| 1033 | + } |
| 1034 | + state.insetEndSettleWatchdogStarted = true; |
| 1035 | + state.insetEndSettleWatchdogActive = true; |
| 1036 | + // Hold the readyToRender opacity gate until the end landing is stable, so |
| 1037 | + // the estimated-to-measured settle chase happens before first VISIBLE |
| 1038 | + // paint instead of in front of the user. Capped so slow measurement can |
| 1039 | + // never hide content for long. |
| 1040 | + state.insetEndRevealHold = true; |
| 1041 | + let frames = 0; |
| 1042 | + let settledFrames = 0; |
| 1043 | + let revealStableFrames = 0; |
| 1044 | + let previousEndOffset; |
| 1045 | + const releaseRevealHold = () => { |
| 1046 | + if (state.insetEndRevealHold) { |
| 1047 | + state.insetEndRevealHold = false; |
| 1048 | + setInitialRenderState(ctx, {}); |
| 1049 | + } |
| 1050 | + }; |
| 1051 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and |
| 1052 | + // can move the end target one more time — do that upgrade while still |
| 1053 | + // hidden (phase 1), then require stability again before revealing (phase 2) |
| 1054 | + // so the first visible frame is the settled one. |
| 1055 | + let revealPhase = 1; |
| 1056 | + const onRevealStability = () => { |
| 1057 | + var _a3; |
| 1058 | + if (!state.insetEndRevealHold) { |
| 1059 | + return; |
| 1060 | + } |
| 1061 | + if (revealPhase === 1) { |
| 1062 | + revealPhase = 2; |
| 1063 | + revealStableFrames = 0; |
| 1064 | + setAdaptiveRender(ctx, "normal"); |
| 1065 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); |
| 1066 | + return; |
| 1067 | + } |
| 1068 | + releaseRevealHold(); |
| 1069 | + }; |
| 1070 | + const tick = () => { |
| 1071 | + // The user owns the scroll from their first drag — a re-pin here would |
| 1072 | + // pin the list under their finger. |
| 1073 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { |
| 1074 | + state.insetEndSettleWatchdogActive = false; |
| 1075 | + releaseRevealHold(); |
| 1076 | + return; |
| 1077 | + } |
| 1078 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { |
| 1079 | + releaseRevealHold(); |
| 1080 | + } |
| 1081 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 1082 | + const contentSize = getContentSize(ctx); |
| 1083 | + const scrollLength = state.scrollLength; |
| 1084 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); |
| 1085 | + // state.scroll is optimistic: non-animated scrollTo writes it before UIKit |
| 1086 | + // moves. Only a native scroll event proves that the requested offset landed. |
| 1087 | + const nativeOffset = state.lastNativeScroll; |
| 1088 | + const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset); |
| 1089 | + const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity; |
| 1090 | + const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd; |
| 1091 | + const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize); |
| 1092 | + const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1; |
| 1093 | + previousEndOffset = endOffset; |
| 1094 | + if (isStable) { |
| 1095 | + settledFrames++; |
| 1096 | + revealStableFrames++; |
| 1097 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { |
| 1098 | + onRevealStability(); |
| 1099 | + } |
| 1100 | + } else { |
| 1101 | + settledFrames = 0; |
| 1102 | + revealStableFrames = 0; |
| 1103 | + // Keep the existing near-end correction limit. In-flight scrolls and |
| 1104 | + // unknown native offsets wait; neither counts as a settled frame. |
| 1105 | + if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { |
| 1106 | + const scroller = state.refScroller.current; |
| 1107 | + if (scroller) { |
| 1108 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); |
| 1109 | + } |
| 1110 | + } |
| 1111 | + } |
| 1112 | + requestAnimationFrame(tick); |
| 1113 | + }; |
| 1114 | + requestAnimationFrame(tick); |
| 1115 | +} |
| 1116 | function dispatchInitialScroll(ctx, params) { |
| 1117 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; |
| 1118 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); |
| 1119 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { |
| 1120 | + if (ctx.state.scrollLength <= 0) { |
| 1121 | + // The scroll length is unknown, so the resolved target is meaningless; |
| 1122 | + // the initial-scroll machinery re-advances after layout. |
| 1123 | + return; |
| 1124 | + } |
| 1125 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { |
| 1126 | + // Content underflows the viewport and nothing has scrolled yet: the |
| 1127 | + // untouched native rest position (UIKit's adjustedContentInset) IS the |
| 1128 | + // end position. Dispatching a negative scroll here would race UIKit's |
| 1129 | + // inset application during screen attach, so just finish the session. |
| 1130 | + finishInitialScroll(ctx, { |
| 1131 | + resolvedOffset |
| 1132 | + }); |
| 1133 | + return; |
| 1134 | + } |
| 1135 | + ctx.state.didDispatchInsetInitialScroll = true; |
| 1136 | + if (target.viewPosition === 1) { |
| 1137 | + startInsetEndSettleWatchdog(ctx); |
| 1138 | + } |
| 1139 | + } |
| 1140 | const requestedIndex = target.index; |
| 1141 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; |
| 1142 | const itemSize = getItemSizeAtIndex(ctx, index); |
| 1143 | @@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { |
| 1144 | return; |
| 1145 | } |
| 1146 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { |
| 1147 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); |
| 1148 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); |
| 1149 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; |
| 1150 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList); |
| 1151 | if (!shouldKeepEndTargetAlive) { |
| 1152 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { |
| 1153 | clearPendingInitialScrollFooterLayout(ctx, { |
| 1154 | @@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1155 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 1156 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 1157 | isReady = !hasUnknownTailSize; |
| 1158 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1159 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1160 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; |
| 1161 | } else if (anchorIndex >= 0) { |
| 1162 | isReady = false; |
| 1163 | } |
| 1164 | @@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1165 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1166 | } |
| 1167 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| 1168 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { |
| 1169 | + set$(ctx, "anchoredEndSpaceSize", nextSize); |
| 1170 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); |
| 1171 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { |
| 1172 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1173 | + } |
| 1174 | } |
| 1175 | return nextSize; |
| 1176 | } |
| 1177 | @@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 1178 | horizontal |
| 1179 | }) { |
| 1180 | const ctx = useStateContext(); |
| 1181 | + const SizeComponent = ctx.state.props.sizeComponentInternal; |
| 1182 | const columnWrapperStyle = ctx.columnWrapperStyle; |
| 1183 | const animSize = useValue$("totalSize"); |
| 1184 | const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); |
| 1185 | @@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 1186 | opacity: isVisible ? 1 : 0, |
| 1187 | width: animSize |
| 1188 | } : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 }; |
| 1189 | + if (SizeComponent) { |
| 1190 | + if (horizontal) { |
| 1191 | + delete style.width; |
| 1192 | + } else { |
| 1193 | + delete style.height; |
| 1194 | + } |
| 1195 | + } |
| 1196 | if (columnWrapperStyle) { |
| 1197 | const { columnGap, rowGap, gap } = columnWrapperStyle; |
| 1198 | const gapX = columnGap || gap || 0; |
| 1199 | @@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ |
| 1200 | } |
| 1201 | } |
| 1202 | } |
| 1203 | - return /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children)); |
| 1204 | + const content = /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children); |
| 1205 | + return SizeComponent ? /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, pointerEvents: isVisible ? void 0 : "none", signalName: "totalSize", style }, content) : /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, content); |
| 1206 | }); |
| 1207 | var Containers = typedMemo(function Containers2({ |
| 1208 | freshDataTransitionEpoch, |
| 1209 | @@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1; |
| 1210 | |
| 1211 | // src/components/ListComponent.tsx |
| 1212 | var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) { |
| 1213 | + const ctx = useStateContext(); |
| 1214 | const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]); |
| 1215 | + const SizeComponent = ctx.state.props.sizeComponentInternal; |
| 1216 | + if (SizeComponent) { |
| 1217 | + return /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } }); |
| 1218 | + } |
| 1219 | if (alignItemsAtEndPadding <= 0) { |
| 1220 | return null; |
| 1221 | } |
| 1222 | @@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 1223 | refScrollView, |
| 1224 | renderScrollComponent, |
| 1225 | onLayoutFooter, |
| 1226 | + onInternalMomentumScrollBegin, |
| 1227 | onInternalScrollBeginDrag, |
| 1228 | + onInternalScrollEndDrag, |
| 1229 | onInternalScrollEnd, |
| 1230 | + onMomentumScrollBegin, |
| 1231 | + onScrollEndDrag, |
| 1232 | scrollAdjustHandler, |
| 1233 | snapToIndices, |
| 1234 | stickyHeaderConfig, |
| 1235 | @@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 1236 | SnapOrScroll, |
| 1237 | { |
| 1238 | ...rest, |
| 1239 | - ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag }, |
| 1240 | + ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { |
| 1241 | + onMomentumScrollBegin: (event) => { |
| 1242 | + onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event); |
| 1243 | + onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event); |
| 1244 | + }, |
| 1245 | + onScrollBeginDrag: onInternalScrollBeginDrag, |
| 1246 | + onScrollEndDrag: (event) => { |
| 1247 | + onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event); |
| 1248 | + onScrollEndDrag == null ? void 0 : onScrollEndDrag(event); |
| 1249 | + } |
| 1250 | + }, |
| 1251 | contentContainerStyle: [ |
| 1252 | horizontal ? { height: "100%" } : {}, |
| 1253 | contentContainerStyle, |
| 1254 | @@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({ |
| 1255 | ], |
| 1256 | contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0, |
| 1257 | horizontal, |
| 1258 | - maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, |
| 1259 | + // Keep iOS anchored to ScrollAdjust even when JS position restoration is |
| 1260 | + // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor |
| 1261 | + // with the 1e7 sentinel and scroll the entire list out of view. |
| 1262 | + maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0, |
| 1263 | onLayout, |
| 1264 | onScroll: onScroll2, |
| 1265 | ref: refScrollView, |
| 1266 | @@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) { |
| 1267 | endBuffered: state.endBuffered, |
| 1268 | getAverageItemSizes: () => getAverageItemSizes(state), |
| 1269 | indexByKey: (key) => state.indexByKey.get(key), |
| 1270 | - isAtEnd: peek$(ctx, "isAtEnd"), |
| 1271 | + isAtEnd: getIsAtEnd(ctx), |
| 1272 | isAtStart: peek$(ctx, "isAtStart"), |
| 1273 | isEndReached: state.isEndReached, |
| 1274 | isNearEnd: peek$(ctx, "isNearEnd"), |
| 1275 | @@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1276 | dataVersion, |
| 1277 | drawDistance = 250, |
| 1278 | contentInsetEndAdjustment, |
| 1279 | + contentInsetStartAdjustment, |
| 1280 | estimatedItemSize = 100, |
| 1281 | estimatedListSize, |
| 1282 | extraData, |
| 1283 | @@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1284 | const animatedPropsInternal = props.animatedPropsInternal; |
| 1285 | const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list"; |
| 1286 | const positionComponentInternal = props.positionComponentInternal; |
| 1287 | + const sizeComponentInternal = props.sizeComponentInternal; |
| 1288 | const stickyPositionComponentInternal = props.stickyPositionComponentInternal; |
| 1289 | const { |
| 1290 | anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal, |
| 1291 | positionComponentInternal: _positionComponentInternal, |
| 1292 | + sizeComponentInternal: _sizeComponentInternal, |
| 1293 | stickyPositionComponentInternal: _stickyPositionComponentInternal, |
| 1294 | ...restProps |
| 1295 | } = rest; |
| 1296 | @@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1297 | const combinedRef = useCombinedRef(refScroller, refScrollView); |
| 1298 | const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); |
| 1299 | const stickyHeaderIndices = stickyHeaderIndicesProp; |
| 1300 | - const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0; |
| 1301 | + const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment; |
| 1302 | const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); |
| 1303 | const alwaysRenderIndices = useMemo(() => { |
| 1304 | const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); |
| 1305 | @@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1306 | contentContainerAlignItems: contentContainerStyle.alignItems, |
| 1307 | contentInset, |
| 1308 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, |
| 1309 | + contentInsetStartAdjustment, |
| 1310 | data: dataProp, |
| 1311 | dataKey, |
| 1312 | dataVersion, |
| 1313 | @@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1314 | renderItem, |
| 1315 | rtl, |
| 1316 | snapToIndices, |
| 1317 | + sizeComponentInternal, |
| 1318 | stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [], |
| 1319 | stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]), |
| 1320 | stickyPositionComponentInternal, |
| 1321 | @@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1322 | return void 0; |
| 1323 | } |
| 1324 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); |
| 1325 | + if (getContentInsetStartAdjustment(ctx) > 0) { |
| 1326 | + // With a native leading inset (transparent header + automatic insets), |
| 1327 | + // any mount-time contentOffset races UIKit's adjustedContentInset |
| 1328 | + // application. The gated initial-scroll dispatch positions the list once |
| 1329 | + // real sizes are known instead. |
| 1330 | + return void 0; |
| 1331 | + } |
| 1332 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; |
| 1333 | }, [usesBootstrapInitialScroll]); |
| 1334 | useLayoutEffect(() => { |
| 1335 | @@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1336 | [ |
| 1337 | dataKey, |
| 1338 | dataVersion, |
| 1339 | + contentInsetStartAdjustment, |
| 1340 | memoizedLastItemKeys.join(","), |
| 1341 | numColumnsProp, |
| 1342 | nextScrollAxisGap, |
| 1343 | @@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1344 | () => ({ |
| 1345 | getRenderedItem: (key) => getRenderedItem(ctx, key), |
| 1346 | onMomentumScrollEnd: (event) => { |
| 1347 | + state.isMomentumScrolling = false; |
| 1348 | checkFinishedScrollFallback(ctx); |
| 1349 | if (state.props.onMomentumScrollEnd) { |
| 1350 | state.props.onMomentumScrollEnd(event); |
| 1351 | @@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1352 | onScroll: (event) => onScroll(ctx, event), |
| 1353 | onScrollBeginDrag: (event) => { |
| 1354 | var _a4, _b2; |
| 1355 | + ctx.state.didUserDrag = true; |
| 1356 | + ctx.state.isUserDragging = true; |
| 1357 | prepareReachedEdgeForNextUserScroll(ctx); |
| 1358 | (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); |
| 1359 | }, |
| 1360 | @@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded |
| 1361 | ListFooterComponent, |
| 1362 | ListFooterComponentStyle, |
| 1363 | ListHeaderComponent, |
| 1364 | + onInternalMomentumScrollBegin: () => { |
| 1365 | + ctx.state.isMomentumScrolling = true; |
| 1366 | + }, |
| 1367 | onInternalScrollBeginDrag: fns.onScrollBeginDrag, |
| 1368 | + onInternalScrollEndDrag: () => { |
| 1369 | + ctx.state.isUserDragging = false; |
| 1370 | + }, |
| 1371 | onInternalScrollEnd: fns.onScrollEnd, |
| 1372 | onLayout, |
| 1373 | onLayoutFooter, |
| 1374 | onMomentumScrollEnd: fns.onMomentumScrollEnd, |
| 1375 | + onScrollBeginDrag: fns.onScrollBeginDrag, |
| 1376 | onScroll: onScrollHandler, |
| 1377 | recycleItems, |
| 1378 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { |
| 1379 | diff --git a/react-native.web.js b/react-native.web.js |
| 1380 | index 914d2da..8b88b09 100644 |
| 1381 | --- a/react-native.web.js |
| 1382 | +++ b/react-native.web.js |
| 1383 | @@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { |
| 1384 | }); |
| 1385 | |
| 1386 | // src/utils/reordering.ts |
| 1387 | +// Element.moveBefore keeps the moved subtree's state and does not count as an |
| 1388 | +// insertion, so CSS @starting-style transitions and iframes inside a row do not |
| 1389 | +// restart when the list reorders its containers. insertBefore is the fallback. |
| 1390 | +function moveChildBefore(container, element, reference) { |
| 1391 | + if (typeof container.moveBefore === "function") { |
| 1392 | + container.moveBefore(element, reference); |
| 1393 | + } else if (reference) { |
| 1394 | + container.insertBefore(element, reference); |
| 1395 | + } else { |
| 1396 | + container.appendChild(element); |
| 1397 | + } |
| 1398 | +} |
| 1399 | function sortDOMElements(container, indexByElement) { |
| 1400 | const elements = Array.from(container.children); |
| 1401 | if (elements.length <= 1) return elements; |
| 1402 | @@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) { |
| 1403 | } |
| 1404 | } |
| 1405 | if (nextStableElement) { |
| 1406 | - container.insertBefore(element, nextStableElement); |
| 1407 | + moveChildBefore(container, element, nextStableElement); |
| 1408 | } else { |
| 1409 | - container.appendChild(element); |
| 1410 | + moveChildBefore(container, element, null); |
| 1411 | } |
| 1412 | } |
| 1413 | } |
| 1414 | diff --git a/react-native.web.mjs b/react-native.web.mjs |
| 1415 | index 95465f2..d7cbe1c 100644 |
| 1416 | --- a/react-native.web.mjs |
| 1417 | +++ b/react-native.web.mjs |
| 1418 | @@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { |
| 1419 | }); |
| 1420 | |
| 1421 | // src/utils/reordering.ts |
| 1422 | +// Element.moveBefore keeps the moved subtree's state and does not count as an |
| 1423 | +// insertion, so CSS @starting-style transitions and iframes inside a row do not |
| 1424 | +// restart when the list reorders its containers. insertBefore is the fallback. |
| 1425 | +function moveChildBefore(container, element, reference) { |
| 1426 | + if (typeof container.moveBefore === "function") { |
| 1427 | + container.moveBefore(element, reference); |
| 1428 | + } else if (reference) { |
| 1429 | + container.insertBefore(element, reference); |
| 1430 | + } else { |
| 1431 | + container.appendChild(element); |
| 1432 | + } |
| 1433 | +} |
| 1434 | function sortDOMElements(container, indexByElement) { |
| 1435 | const elements = Array.from(container.children); |
| 1436 | if (elements.length <= 1) return elements; |
| 1437 | @@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) { |
| 1438 | } |
| 1439 | } |
| 1440 | if (nextStableElement) { |
| 1441 | - container.insertBefore(element, nextStableElement); |
| 1442 | + moveChildBefore(container, element, nextStableElement); |
| 1443 | } else { |
| 1444 | - container.appendChild(element); |
| 1445 | + moveChildBefore(container, element, null); |
| 1446 | } |
| 1447 | } |
| 1448 | } |
| 1449 | diff --git a/react.js b/react.js |
| 1450 | index 914d2da..0c2917d 100644 |
| 1451 | --- a/react.js |
| 1452 | +++ b/react.js |
| 1453 | @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1454 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 1455 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 1456 | isReady = !hasUnknownTailSize; |
| 1457 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1458 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1459 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; |
| 1460 | } else if (anchorIndex >= 0) { |
| 1461 | isReady = false; |
| 1462 | } |
| 1463 | @@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1464 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1465 | } |
| 1466 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| 1467 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { |
| 1468 | + set$(ctx, "anchoredEndSpaceSize", nextSize); |
| 1469 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); |
| 1470 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { |
| 1471 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1472 | + } |
| 1473 | } |
| 1474 | return nextSize; |
| 1475 | } |
| 1476 | @@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { |
| 1477 | }); |
| 1478 | |
| 1479 | // src/utils/reordering.ts |
| 1480 | +// Element.moveBefore keeps the moved subtree's state and does not count as an |
| 1481 | +// insertion, so CSS @starting-style transitions and iframes inside a row do not |
| 1482 | +// restart when the list reorders its containers. insertBefore is the fallback. |
| 1483 | +function moveChildBefore(container, element, reference) { |
| 1484 | + if (typeof container.moveBefore === "function") { |
| 1485 | + container.moveBefore(element, reference); |
| 1486 | + } else if (reference) { |
| 1487 | + container.insertBefore(element, reference); |
| 1488 | + } else { |
| 1489 | + container.appendChild(element); |
| 1490 | + } |
| 1491 | +} |
| 1492 | function sortDOMElements(container, indexByElement) { |
| 1493 | const elements = Array.from(container.children); |
| 1494 | if (elements.length <= 1) return elements; |
| 1495 | @@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) { |
| 1496 | } |
| 1497 | } |
| 1498 | if (nextStableElement) { |
| 1499 | - container.insertBefore(element, nextStableElement); |
| 1500 | + moveChildBefore(container, element, nextStableElement); |
| 1501 | } else { |
| 1502 | - container.appendChild(element); |
| 1503 | + moveChildBefore(container, element, null); |
| 1504 | } |
| 1505 | } |
| 1506 | } |
| 1507 | @@ -6446,8 +6465,8 @@ function ScrollAdjust() { |
| 1508 | window.getComputedStyle(contentNode)[axis.paddingEndProp] |
| 1509 | ); |
| 1510 | const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; |
| 1511 | - temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; |
| 1512 | contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; |
| 1513 | + temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; |
| 1514 | void contentNode.offsetHeight; |
| 1515 | scrollBy(); |
| 1516 | if (resetPaddingRafRef.current !== void 0) { |
| 1517 | diff --git a/react.mjs b/react.mjs |
| 1518 | index 95465f2..b73bfdf 100644 |
| 1519 | --- a/react.mjs |
| 1520 | +++ b/react.mjs |
| 1521 | @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1522 | contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); |
| 1523 | contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); |
| 1524 | isReady = !hasUnknownTailSize; |
| 1525 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1526 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); |
| 1527 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; |
| 1528 | } else if (anchorIndex >= 0) { |
| 1529 | isReady = false; |
| 1530 | } |
| 1531 | @@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { |
| 1532 | updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1533 | } |
| 1534 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); |
| 1535 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { |
| 1536 | + set$(ctx, "anchoredEndSpaceSize", nextSize); |
| 1537 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); |
| 1538 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { |
| 1539 | + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); |
| 1540 | + } |
| 1541 | } |
| 1542 | return nextSize; |
| 1543 | } |
| 1544 | @@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { |
| 1545 | }); |
| 1546 | |
| 1547 | // src/utils/reordering.ts |
| 1548 | +// Element.moveBefore keeps the moved subtree's state and does not count as an |
| 1549 | +// insertion, so CSS @starting-style transitions and iframes inside a row do not |
| 1550 | +// restart when the list reorders its containers. insertBefore is the fallback. |
| 1551 | +function moveChildBefore(container, element, reference) { |
| 1552 | + if (typeof container.moveBefore === "function") { |
| 1553 | + container.moveBefore(element, reference); |
| 1554 | + } else if (reference) { |
| 1555 | + container.insertBefore(element, reference); |
| 1556 | + } else { |
| 1557 | + container.appendChild(element); |
| 1558 | + } |
| 1559 | +} |
| 1560 | function sortDOMElements(container, indexByElement) { |
| 1561 | const elements = Array.from(container.children); |
| 1562 | if (elements.length <= 1) return elements; |
| 1563 | @@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) { |
| 1564 | } |
| 1565 | } |
| 1566 | if (nextStableElement) { |
| 1567 | - container.insertBefore(element, nextStableElement); |
| 1568 | + moveChildBefore(container, element, nextStableElement); |
| 1569 | } else { |
| 1570 | - container.appendChild(element); |
| 1571 | + moveChildBefore(container, element, null); |
| 1572 | } |
| 1573 | } |
| 1574 | } |
| 1575 | @@ -6425,8 +6444,8 @@ function ScrollAdjust() { |
| 1576 | window.getComputedStyle(contentNode)[axis.paddingEndProp] |
| 1577 | ); |
| 1578 | const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; |
| 1579 | - temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd }; |
| 1580 | contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd; |
| 1581 | + temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] }; |
| 1582 | void contentNode.offsetHeight; |
| 1583 | scrollBy(); |
| 1584 | if (resetPaddingRafRef.current !== void 0) { |
| 1585 | diff --git a/reanimated.d.ts b/reanimated.d.ts |
| 1586 | index e504332..2ce6383 100644 |
| 1587 | --- a/reanimated.d.ts |
| 1588 | +++ b/reanimated.d.ts |
| 1589 | @@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { |
| 1590 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. |
| 1591 | */ |
| 1592 | contentInsetEndAdjustment?: number; |
| 1593 | + /** |
| 1594 | + * Width/height of a leading content inset applied natively outside the list's knowledge |
| 1595 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). |
| 1596 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. |
| 1597 | + */ |
| 1598 | + contentInsetStartAdjustment?: number; |
| 1599 | /** |
| 1600 | * Number of columns to render items in. |
| 1601 | * @default 1 |
| 1602 | diff --git a/reanimated.js b/reanimated.js |
| 1603 | index f1265fa..fc03be2 100644 |
| 1604 | --- a/reanimated.js |
| 1605 | +++ b/reanimated.js |
| 1606 | @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1607 | const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props; |
| 1608 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); |
| 1609 | const prevItemKeyRef = React__namespace.useRef(void 0); |
| 1610 | + const previousPositionRef = React__namespace.useRef(positionValue); |
| 1611 | + const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1612 | let shouldSkipTransitionForRecycleReuse = false; |
| 1613 | - if (recycleItems && layoutTransition) { |
| 1614 | + if (layoutTransition) { |
| 1615 | const itemKeySignal = `containerItemKey${id}`; |
| 1616 | const itemKey = peek$(ctx, itemKeySignal); |
| 1617 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; |
| 1618 | @@ -130,10 +132,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1619 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], |
| 1620 | [horizontal, positionValue, style] |
| 1621 | ); |
| 1622 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, |
| 1623 | + // which are compensated by an equal contentOffset shift so the row should |
| 1624 | + // not visibly move — animating turns the invisible correction into a |
| 1625 | + // lurch-and-settle; (2) any reposition while the user is actively |
| 1626 | + // scrolling, where rows shifting under the finger reads as jank. The |
| 1627 | + // transition exists to smooth at-rest shifts (streaming text, work-log |
| 1628 | + // folds), so gate it to at-rest moments. |
| 1629 | + const now = Date.now(); |
| 1630 | + const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1631 | + const didPositionChange = positionValue !== previousPositionRef.current; |
| 1632 | + const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function"; |
| 1633 | + React__namespace.useLayoutEffect(() => { |
| 1634 | + if (didPositionChange) { |
| 1635 | + previousPositionRef.current = positionValue; |
| 1636 | + lastContentSizeEpochRef.current = contentSizeEpoch; |
| 1637 | + } |
| 1638 | + }, [contentSizeEpoch, didPositionChange, positionValue]); |
| 1639 | + const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300; |
| 1640 | + const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition; |
| 1641 | return /* @__PURE__ */ React__namespace.createElement( |
| 1642 | Reanimated__default.default.View, |
| 1643 | { |
| 1644 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, |
| 1645 | + layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, |
| 1646 | ref: refView, |
| 1647 | style: viewStyle, |
| 1648 | ...rest |
| 1649 | @@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1650 | children |
| 1651 | ); |
| 1652 | }); |
| 1653 | +var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) { |
| 1654 | + const ctx = useStateContext(); |
| 1655 | + const { horizontal, signalName, style, children, ...rest } = props; |
| 1656 | + const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]); |
| 1657 | + const baseSize = Reanimated.useSharedValue(logicalSize); |
| 1658 | + const animatedDelta = Reanimated.useSharedValue(0); |
| 1659 | + const previousLogicalSizeRef = React__namespace.useRef(logicalSize); |
| 1660 | + const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1661 | + const animationRunRef = React__namespace.useRef(0); |
| 1662 | + const isAnimatingRef = React__namespace.useRef(false); |
| 1663 | + const transition = props.layoutTransition; |
| 1664 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1665 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1666 | + // Keep the animated size attached after transitions finish. Reanimated |
| 1667 | + // retains removed animated props, so switching to a static size leaves |
| 1668 | + // native scroll bounds stuck while logical measurements keep changing. |
| 1669 | + const animatedStyle = Reanimated.useAnimatedStyle(() => { |
| 1670 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1671 | + return horizontal ? { width: size } : { height: size }; |
| 1672 | + }, [horizontal]); |
| 1673 | + const completeAnimation = React__namespace.useCallback((run, epoch) => { |
| 1674 | + if (animationRunRef.current !== run) { |
| 1675 | + return; |
| 1676 | + } |
| 1677 | + isAnimatingRef.current = false; |
| 1678 | + const state = ctx.state; |
| 1679 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1680 | + return; |
| 1681 | + } |
| 1682 | + state.contentSizeAnimationActiveSignals.delete(signalName); |
| 1683 | + if (state.contentSizeAnimationActiveSignals.size === 0) { |
| 1684 | + state.contentSizeAnimationActiveEpoch = void 0; |
| 1685 | + state.contentSizeAnimationEligible = false; |
| 1686 | + } |
| 1687 | + }, [ctx.state, signalName]); |
| 1688 | + React__namespace.useLayoutEffect(() => { |
| 1689 | + const state = ctx.state; |
| 1690 | + const epoch = state.contentSizeAnimationEpoch || 0; |
| 1691 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1692 | + const delta = logicalSize - previousLogicalSize; |
| 1693 | + const isNewEpoch = epoch !== previousEpochRef.current; |
| 1694 | + // Retarget measurements received mid-animation, even in the same epoch. |
| 1695 | + // Updating only the base keeps the old delta and can shrink scroll bounds. |
| 1696 | + const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); |
| 1697 | + previousLogicalSizeRef.current = logicalSize; |
| 1698 | + previousEpochRef.current = epoch; |
| 1699 | + if (!delta) { |
| 1700 | + return; |
| 1701 | + } |
| 1702 | + if (!shouldAnimate) { |
| 1703 | + baseSize.value = logicalSize; |
| 1704 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1705 | + state.contentSizeAnimationEligible = false; |
| 1706 | + } |
| 1707 | + return; |
| 1708 | + } |
| 1709 | + const [animation, config] = transition.getAnimationAndConfig(); |
| 1710 | + const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value; |
| 1711 | + const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0; |
| 1712 | + if (typeof animation !== "function" || typeof delayFunction !== "function") { |
| 1713 | + baseSize.value = logicalSize; |
| 1714 | + if (signalName === "totalSize") { |
| 1715 | + state.contentSizeAnimationEligible = false; |
| 1716 | + } |
| 1717 | + return; |
| 1718 | + } |
| 1719 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1720 | + state.contentSizeAnimationActiveEpoch = epoch; |
| 1721 | + state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set(); |
| 1722 | + } |
| 1723 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1724 | + const wasAnimating = isAnimatingRef.current; |
| 1725 | + isAnimatingRef.current = true; |
| 1726 | + const run = ++animationRunRef.current; |
| 1727 | + Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1728 | + "worklet"; |
| 1729 | + const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize; |
| 1730 | + baseSize.value = absoluteSize; |
| 1731 | + animatedDelta.value = currentVisualSize - absoluteSize; |
| 1732 | + animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => { |
| 1733 | + "worklet"; |
| 1734 | + if (finished) { |
| 1735 | + Reanimated.runOnJS(completeAnimation)(animationRun, animationEpoch); |
| 1736 | + } |
| 1737 | + })); |
| 1738 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1739 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1740 | + return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children); |
| 1741 | +}); |
| 1742 | function setSharedValueValue(sharedValue, value) { |
| 1743 | if (!sharedValue) { |
| 1744 | return; |
| 1745 | @@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo( |
| 1746 | ); |
| 1747 | }; |
| 1748 | }, [hasItemLayoutAnimation, recycleItems]); |
| 1749 | + const sizeComponentInternal = React__namespace.useMemo(() => { |
| 1750 | + if (!hasItemLayoutAnimation) { |
| 1751 | + return void 0; |
| 1752 | + } |
| 1753 | + return function SizeComponent(sizeProps) { |
| 1754 | + return /* @__PURE__ */ React__namespace.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current }); |
| 1755 | + }; |
| 1756 | + }, [hasItemLayoutAnimation]); |
| 1757 | const legendListProps = { |
| 1758 | ...rest, |
| 1759 | positionComponentInternal, |
| 1760 | recycleItems, |
| 1761 | + sizeComponentInternal, |
| 1762 | ...{ |
| 1763 | renderScrollComponent: renderReanimatedScrollComponent, |
| 1764 | ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} |
| 1765 | diff --git a/reanimated.mjs b/reanimated.mjs |
| 1766 | index 29a00d5..6ab9292 100644 |
| 1767 | --- a/reanimated.mjs |
| 1768 | +++ b/reanimated.mjs |
| 1769 | @@ -1,7 +1,7 @@ |
| 1770 | import * as React from 'react'; |
| 1771 | import { useCallback } from 'react'; |
| 1772 | import { View } from 'react-native'; |
| 1773 | -import Reanimated, { useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated'; |
| 1774 | +import Reanimated, { runOnJS, runOnUI, useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated'; |
| 1775 | import { internal, LegendList } from '@legendapp/list/react-native'; |
| 1776 | |
| 1777 | // src/integrations/reanimated.tsx |
| 1778 | @@ -91,8 +91,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1779 | const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props; |
| 1780 | const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]); |
| 1781 | const prevItemKeyRef = React.useRef(void 0); |
| 1782 | + const previousPositionRef = React.useRef(positionValue); |
| 1783 | + const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1784 | let shouldSkipTransitionForRecycleReuse = false; |
| 1785 | - if (recycleItems && layoutTransition) { |
| 1786 | + if (layoutTransition) { |
| 1787 | const itemKeySignal = `containerItemKey${id}`; |
| 1788 | const itemKey = peek$(ctx, itemKeySignal); |
| 1789 | shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey; |
| 1790 | @@ -106,10 +108,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1791 | () => [style, horizontal ? { left: positionValue } : { top: positionValue }], |
| 1792 | [horizontal, positionValue, style] |
| 1793 | ); |
| 1794 | + // Two kinds of repositions must not animate: (1) MVCP size adjustments, |
| 1795 | + // which are compensated by an equal contentOffset shift so the row should |
| 1796 | + // not visibly move — animating turns the invisible correction into a |
| 1797 | + // lurch-and-settle; (2) any reposition while the user is actively |
| 1798 | + // scrolling, where rows shifting under the finger reads as jank. The |
| 1799 | + // transition exists to smooth at-rest shifts (streaming text, work-log |
| 1800 | + // folds), so gate it to at-rest moments. |
| 1801 | + const now = Date.now(); |
| 1802 | + const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1803 | + const didPositionChange = positionValue !== previousPositionRef.current; |
| 1804 | + const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function"; |
| 1805 | + React.useLayoutEffect(() => { |
| 1806 | + if (didPositionChange) { |
| 1807 | + previousPositionRef.current = positionValue; |
| 1808 | + lastContentSizeEpochRef.current = contentSizeEpoch; |
| 1809 | + } |
| 1810 | + }, [contentSizeEpoch, didPositionChange, positionValue]); |
| 1811 | + const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300; |
| 1812 | + const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition; |
| 1813 | return /* @__PURE__ */ React.createElement( |
| 1814 | Reanimated.View, |
| 1815 | { |
| 1816 | - layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition, |
| 1817 | + layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition, |
| 1818 | ref: refView, |
| 1819 | style: viewStyle, |
| 1820 | ...rest |
| 1821 | @@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( |
| 1822 | children |
| 1823 | ); |
| 1824 | }); |
| 1825 | +var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) { |
| 1826 | + const ctx = useStateContext(); |
| 1827 | + const { horizontal, signalName, style, children, ...rest } = props; |
| 1828 | + const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]); |
| 1829 | + const baseSize = useSharedValue(logicalSize); |
| 1830 | + const animatedDelta = useSharedValue(0); |
| 1831 | + const previousLogicalSizeRef = React.useRef(logicalSize); |
| 1832 | + const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0); |
| 1833 | + const animationRunRef = React.useRef(0); |
| 1834 | + const isAnimatingRef = React.useRef(false); |
| 1835 | + const transition = props.layoutTransition; |
| 1836 | + const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function"; |
| 1837 | + const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0; |
| 1838 | + // Keep the animated size attached after transitions finish. Reanimated |
| 1839 | + // retains removed animated props, so switching to a static size leaves |
| 1840 | + // native scroll bounds stuck while logical measurements keep changing. |
| 1841 | + const animatedStyle = useAnimatedStyle(() => { |
| 1842 | + const size = Math.max(0, baseSize.value + animatedDelta.value); |
| 1843 | + return horizontal ? { width: size } : { height: size }; |
| 1844 | + }, [horizontal]); |
| 1845 | + const completeAnimation = useCallback((run, epoch) => { |
| 1846 | + if (animationRunRef.current !== run) { |
| 1847 | + return; |
| 1848 | + } |
| 1849 | + isAnimatingRef.current = false; |
| 1850 | + const state = ctx.state; |
| 1851 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1852 | + return; |
| 1853 | + } |
| 1854 | + state.contentSizeAnimationActiveSignals.delete(signalName); |
| 1855 | + if (state.contentSizeAnimationActiveSignals.size === 0) { |
| 1856 | + state.contentSizeAnimationActiveEpoch = void 0; |
| 1857 | + state.contentSizeAnimationEligible = false; |
| 1858 | + } |
| 1859 | + }, [ctx.state, signalName]); |
| 1860 | + React.useLayoutEffect(() => { |
| 1861 | + const state = ctx.state; |
| 1862 | + const epoch = state.contentSizeAnimationEpoch || 0; |
| 1863 | + const previousLogicalSize = previousLogicalSizeRef.current; |
| 1864 | + const delta = logicalSize - previousLogicalSize; |
| 1865 | + const isNewEpoch = epoch !== previousEpochRef.current; |
| 1866 | + // Retarget measurements received mid-animation, even in the same epoch. |
| 1867 | + // Updating only the base keeps the old delta and can shrink scroll bounds. |
| 1868 | + const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible); |
| 1869 | + previousLogicalSizeRef.current = logicalSize; |
| 1870 | + previousEpochRef.current = epoch; |
| 1871 | + if (!delta) { |
| 1872 | + return; |
| 1873 | + } |
| 1874 | + if (!shouldAnimate) { |
| 1875 | + baseSize.value = logicalSize; |
| 1876 | + if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) { |
| 1877 | + state.contentSizeAnimationEligible = false; |
| 1878 | + } |
| 1879 | + return; |
| 1880 | + } |
| 1881 | + const [animation, config] = transition.getAnimationAndConfig(); |
| 1882 | + const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value; |
| 1883 | + const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0; |
| 1884 | + if (typeof animation !== "function" || typeof delayFunction !== "function") { |
| 1885 | + baseSize.value = logicalSize; |
| 1886 | + if (signalName === "totalSize") { |
| 1887 | + state.contentSizeAnimationEligible = false; |
| 1888 | + } |
| 1889 | + return; |
| 1890 | + } |
| 1891 | + if (state.contentSizeAnimationActiveEpoch !== epoch) { |
| 1892 | + state.contentSizeAnimationActiveEpoch = epoch; |
| 1893 | + state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set(); |
| 1894 | + } |
| 1895 | + state.contentSizeAnimationActiveSignals.add(signalName); |
| 1896 | + const wasAnimating = isAnimatingRef.current; |
| 1897 | + isAnimatingRef.current = true; |
| 1898 | + const run = ++animationRunRef.current; |
| 1899 | + runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => { |
| 1900 | + "worklet"; |
| 1901 | + const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize; |
| 1902 | + baseSize.value = absoluteSize; |
| 1903 | + animatedDelta.value = currentVisualSize - absoluteSize; |
| 1904 | + animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => { |
| 1905 | + "worklet"; |
| 1906 | + if (finished) { |
| 1907 | + runOnJS(completeAnimation)(animationRun, animationEpoch); |
| 1908 | + } |
| 1909 | + })); |
| 1910 | + })(logicalSize, previousLogicalSize, wasAnimating, run, epoch); |
| 1911 | + }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]); |
| 1912 | + return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children); |
| 1913 | +}); |
| 1914 | function setSharedValueValue(sharedValue, value) { |
| 1915 | if (!sharedValue) { |
| 1916 | return; |
| 1917 | @@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo( |
| 1918 | ); |
| 1919 | }; |
| 1920 | }, [hasItemLayoutAnimation, recycleItems]); |
| 1921 | + const sizeComponentInternal = React.useMemo(() => { |
| 1922 | + if (!hasItemLayoutAnimation) { |
| 1923 | + return void 0; |
| 1924 | + } |
| 1925 | + return function SizeComponent(sizeProps) { |
| 1926 | + return /* @__PURE__ */ React.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current }); |
| 1927 | + }; |
| 1928 | + }, [hasItemLayoutAnimation]); |
| 1929 | const legendListProps = { |
| 1930 | ...rest, |
| 1931 | positionComponentInternal, |
| 1932 | recycleItems, |
| 1933 | + sizeComponentInternal, |
| 1934 | ...{ |
| 1935 | renderScrollComponent: renderReanimatedScrollComponent, |
| 1936 | ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} |