t3-code-android-nightly/patches/@legendapp__list@3.3.5.patch
Theo Browne c07575f573
feat(server): show finished paragraphs and code blocks while the response streams (#11062)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 19:02:01 -07:00

1936 lines
97 KiB
Diff

diff --git a/keyboard.d.ts b/keyboard.d.ts
index 367945c..0ac7bf8 100644
--- a/keyboard.d.ts
+++ b/keyboard.d.ts
@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
type KeyboardChatComposerRef = {
current: Pick<View, "measure"> | null;
};
-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number, animationDuration?: number): {
contentInsetEndAdjustment: SharedValue<number>;
onComposerLayout: (event: LayoutChangeEvent) => void;
};
@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb
scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>;
};
declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & {
+ adjustedInsetCompensation?: number;
anchoredEndSpace?: AnchoredEndSpaceConfig;
contentInsetEndAdjustment?: SharedValue<number>;
+ contentInsetEndStaticAdjustment?: number;
keyboardOffset?: number;
} & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
diff --git a/keyboard.js b/keyboard.js
index 6645bcb..321c4c3 100644
--- a/keyboard.js
+++ b/keyboard.js
@@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
"[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
);
}
-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
const lastHeightRef = React.useRef(void 0);
const reportHeight = React.useCallback(
- (height) => {
+ (rawHeight) => {
+ const height = Math.max(0, rawHeight + heightAdjustment);
var _a;
if (Number.isFinite(height) && height !== lastHeightRef.current) {
+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
lastHeightRef.current = height;
- contentInsetEndAdjustment.value = height;
- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
+ contentInsetEndAdjustment.value = shouldAnimate ? reactNativeReanimated.withTiming(height, {
+ duration: animationDuration
+ }) : height;
}
},
- [contentInsetEndAdjustment, listRef]
+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
);
React.useLayoutEffect(() => {
var _a;
@@ -84,9 +87,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
}
var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
const {
+ adjustedInsetCompensation,
anchoredEndSpace,
applyWorkaroundForContentInsetHitTestBug,
contentInsetEndAdjustment,
+ contentInsetEndStaticAdjustment,
freeze,
keyboardLiftBehavior,
keyboardOffset,
@@ -94,6 +99,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
} = props;
const refLegendList = React.useRef(null);
const combinedRef = useCombinedRef(forwardedRef, refLegendList);
+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
const blankSpace = reactNativeReanimated.useSharedValue(0);
React.useEffect(() => {
if (!anchoredEndSpace) {
@@ -108,11 +114,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
...anchoredEndSpace,
onSizeChanged: (size) => {
var _a;
- blankSpace.value = size;
+ // The anchored blank is sized against the ADJUSTED viewport, but the
+ // scroll component writes it as a raw contentInset that UIKit tops up
+ // with the safe-area bottom — write it net of that or the end rest
+ // (and the anchored message) sinks one safe-area under the header.
+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
(_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
}
};
- }, [anchoredEndSpace, blankSpace]);
+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
const onContentInsetChange = React.useCallback((insets) => {
var _a;
(_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
@@ -123,6 +133,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
reactNativeKeyboardController.KeyboardChatScrollView,
{
...scrollProps,
+ adjustedInsetCompensation,
+ adjustedStartInsetCompensation,
applyWorkaroundForContentInsetHitTestBug,
blankSpace,
extraContentPadding: contentInsetEndAdjustment,
@@ -134,6 +146,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
);
},
[
+ adjustedInsetCompensation,
+ adjustedStartInsetCompensation,
applyWorkaroundForContentInsetHitTestBug,
blankSpace,
contentInsetEndAdjustment,
@@ -149,6 +163,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
{
anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
anchoredEndSpaceOwnerInternal: "scroll",
+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
ref: combinedRef,
renderScrollComponent: memoList,
...rest
diff --git a/keyboard.mjs b/keyboard.mjs
index 87b38b9..111eb24 100644
--- a/keyboard.mjs
+++ b/keyboard.mjs
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useRef, useEffect, useMemo, useCallback, useLayoutEffect } from 'react';
import { KeyboardChatScrollView, KeyboardController } from 'react-native-keyboard-controller';
-import { useSharedValue } from 'react-native-reanimated';
+import { useSharedValue, withTiming } from 'react-native-reanimated';
import { internal } from '@legendapp/list/react-native';
import { AnimatedLegendList } from '@legendapp/list/reanimated';
@@ -12,19 +12,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
"[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
);
}
-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0, animationDuration = 0) {
const contentInsetEndAdjustment = useSharedValue(initialHeight);
const lastHeightRef = useRef(void 0);
const reportHeight = useCallback(
- (height) => {
+ (rawHeight) => {
+ const height = Math.max(0, rawHeight + heightAdjustment);
var _a;
if (Number.isFinite(height) && height !== lastHeightRef.current) {
+ const shouldAnimate = lastHeightRef.current !== void 0 && animationDuration > 0;
lastHeightRef.current = height;
- contentInsetEndAdjustment.value = height;
- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
+ contentInsetEndAdjustment.value = shouldAnimate ? withTiming(height, {
+ duration: animationDuration
+ }) : height;
}
},
- [contentInsetEndAdjustment, listRef]
+ [animationDuration, contentInsetEndAdjustment, heightAdjustment, listRef]
);
useLayoutEffect(() => {
var _a;
@@ -63,9 +66,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
}
var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
const {
+ adjustedInsetCompensation,
anchoredEndSpace,
applyWorkaroundForContentInsetHitTestBug,
contentInsetEndAdjustment,
+ contentInsetEndStaticAdjustment,
freeze,
keyboardLiftBehavior,
keyboardOffset,
@@ -73,6 +78,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
} = props;
const refLegendList = useRef(null);
const combinedRef = useCombinedRef(forwardedRef, refLegendList);
+ const adjustedStartInsetCompensation = props.contentInsetStartAdjustment;
const blankSpace = useSharedValue(0);
useEffect(() => {
if (!anchoredEndSpace) {
@@ -87,11 +93,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
...anchoredEndSpace,
onSizeChanged: (size) => {
var _a;
- blankSpace.value = size;
+ // The anchored blank is sized against the ADJUSTED viewport, but the
+ // scroll component writes it as a raw contentInset that UIKit tops up
+ // with the safe-area bottom — write it net of that or the end rest
+ // (and the anchored message) sinks one safe-area under the header.
+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
(_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
}
};
- }, [anchoredEndSpace, blankSpace]);
+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
const onContentInsetChange = useCallback((insets) => {
var _a;
(_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
@@ -102,6 +112,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
KeyboardChatScrollView,
{
...scrollProps,
+ adjustedInsetCompensation,
+ adjustedStartInsetCompensation,
applyWorkaroundForContentInsetHitTestBug,
blankSpace,
extraContentPadding: contentInsetEndAdjustment,
@@ -113,6 +125,8 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
);
},
[
+ adjustedInsetCompensation,
+ adjustedStartInsetCompensation,
applyWorkaroundForContentInsetHitTestBug,
blankSpace,
contentInsetEndAdjustment,
@@ -128,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
{
anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
anchoredEndSpaceOwnerInternal: "scroll",
+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
ref: combinedRef,
renderScrollComponent: memoList,
...rest
diff --git a/react-native.d.ts b/react-native.d.ts
index ce1fe00..3ccf6f1 100644
--- a/react-native.d.ts
+++ b/react-native.d.ts
@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
* The adjustment is also rendered as real content padding so the browser scroll range includes it.
*/
contentInsetEndAdjustment?: number;
+ /**
+ * Width/height of a leading content inset applied natively outside the list's knowledge
+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
+ */
+ contentInsetStartAdjustment?: number;
/**
* Number of columns to render items in.
* @default 1
diff --git a/react-native.js b/react-native.js
index b3c5a30..5ac6bbe 100644
--- a/react-native.js
+++ b/react-native.js
@@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) {
return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
}
+// Size-only changes may not emit a scroll event to refresh the edge signal.
+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
+ if (!(contentSize > 0 && queuedInitialLayout)) {
+ return peek$(ctx, "isAtEnd");
+ }
+ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON;
+}
+
// src/utils/checkAtBottom.ts
function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
var _a3;
@@ -737,7 +746,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
const insetEnd = getContentInsetEnd(ctx);
const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd;
const isContentLess = contentSize < scrollLength;
- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON);
+ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize));
set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength);
set$(
ctx,
@@ -954,7 +963,16 @@ function setInitialRenderState(ctx, {
if (didInitialScroll) {
state.didFinishInitialScroll = true;
}
- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
+ // Native contentOffset can seed an end target without dispatchInitialScroll.
+ // Both overflow completion paths must wait for the native end landing.
+ // Underflow needs no scroll: preserve UIKit's untouched resting position.
+ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) {
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) {
+ startInsetEndSettleWatchdog(ctx);
+ }
+ }
+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
if (isReadyToRender && !peek$(ctx, "readyToRender")) {
set$(ctx, "readyToRender", true);
setAdaptiveRender(ctx, "normal", "ready");
@@ -1090,7 +1108,7 @@ function getRawContentLength(ctx) {
function getAlignItemsAtEndPadding(ctx) {
const { state } = ctx;
const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
}
function updateContentMetricsState(ctx) {
const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
@@ -1115,6 +1133,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
totalSize += add;
}
if (prevTotalSize !== totalSize) {
+ const now = Date.now();
+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
state.pendingTotalSize = totalSize;
} else {
@@ -1304,18 +1326,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
}
// src/core/clampScrollOffset.ts
+function getContentInsetStartAdjustment(ctx) {
+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
+}
function clampScrollOffset(ctx, offset, scrollTarget) {
const state = ctx.state;
const contentSize = getContentSize(ctx);
+ const minOffset = -getContentInsetStartAdjustment(ctx);
let clampedOffset = offset;
if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
const maxOffset = baseMaxOffset + extraEndOffset;
clampedOffset = Math.min(offset, maxOffset);
}
- clampedOffset = Math.max(0, clampedOffset);
+ clampedOffset = Math.max(minOffset, clampedOffset);
return clampedOffset;
}
@@ -1451,10 +1478,10 @@ function checkFinishedScrollFrame(ctx) {
finishScrollTo(ctx);
}
}
-function scrollToFallbackOffset(ctx, offset) {
+function scrollToFallbackOffset(ctx, offset, animated) {
var _a3;
(_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
- animated: false,
+ animated: !!animated,
x: ctx.state.props.horizontal ? offset : 0,
y: ctx.state.props.horizontal ? 0 : offset
});
@@ -1503,7 +1530,10 @@ function checkFinishedScrollFallback(ctx) {
);
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
+ if (!isActivelyAnimatingToEnd) {
+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
+ }
scheduleFallbackCheck(100);
} else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
finishScrollTo(ctx);
@@ -1560,15 +1590,28 @@ function doMaintainScrollAtEnd(ctx) {
} = state;
const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
+ state.pendingMaintainScrollAtEnd = false;
+ return false;
+ }
if (pendingNativeMVCPAdjust) {
state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
return false;
}
if (shouldMaintainScrollAtEnd) {
state.pendingMaintainScrollAtEnd = false;
+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
+ return false;
+ }
const contentSize = getContentSize(ctx);
+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
if (contentSize < state.scrollLength) {
- state.scroll = 0;
+ state.scroll = -maintainInsetStartAdjustment;
+ if (maintainInsetStartAdjustment > 0) {
+ return true;
+ }
}
if (!state.maintainingScrollAtEnd) {
const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
@@ -1591,9 +1634,18 @@ function doMaintainScrollAtEnd(ctx) {
y: 0
});
} else {
- scroller == null ? void 0 : scroller.scrollToEnd({
- animated: maintainScrollAtEnd.animated
- });
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0) {
+ scroller == null ? void 0 : scroller.scrollTo({
+ animated: maintainScrollAtEnd.animated,
+ x: 0,
+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
+ });
+ } else {
+ scroller == null ? void 0 : scroller.scrollToEnd({
+ animated: maintainScrollAtEnd.animated
+ });
+ }
}
setTimeout(
() => {
@@ -1624,6 +1676,10 @@ function doMaintainScrollAtEnd(ctx) {
function requestAdjust(ctx, positionDiff, dataChanged) {
const state = ctx.state;
if (Math.abs(positionDiff) > 0.1) {
+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
+ // size adjustment are already compensated by a contentOffset shift, so
+ // animating them would make rows visibly lurch and slide back.
+ state.lastMVCPAdjustTime = Date.now();
const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
const doit = () => {
if (needsScrollWorkaround) {
@@ -1728,7 +1784,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
return 0;
}
- const maxScroll = Math.max(0, totalSize - state.scrollLength);
+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
const clampDelta = maxScroll - state.scroll;
if (unresolvedAmount < 0) {
return Math.max(unresolvedAmount, Math.min(0, clampDelta));
@@ -1790,7 +1848,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
return true;
}
- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
if (isAtExpectedNativeClamp) {
@@ -1923,7 +1981,7 @@ function prepareMVCP(ctx, dataChanged) {
if (diff > 0) {
diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
} else {
- const maxScroll = Math.max(0, totalSize - state.scrollLength);
+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
state.scroll = maxScroll;
state.scrollPending = maxScroll;
diff = 0;
@@ -2320,8 +2378,121 @@ function scrollToIndex(ctx, {
}
// src/core/initialScroll.ts
+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
+var INSET_END_REVEAL_STABLE_FRAMES = 3;
+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
+function startInsetEndSettleWatchdog(ctx) {
+ const state = ctx.state;
+ if (state.insetEndSettleWatchdogStarted || state.didLoad) {
+ return;
+ }
+ state.insetEndSettleWatchdogStarted = true;
+ state.insetEndSettleWatchdogActive = true;
+ // Hold the readyToRender opacity gate until the end landing is stable, so
+ // the estimated-to-measured settle chase happens before first VISIBLE
+ // paint instead of in front of the user. Capped so slow measurement can
+ // never hide content for long.
+ state.insetEndRevealHold = true;
+ let frames = 0;
+ let settledFrames = 0;
+ let revealStableFrames = 0;
+ let previousEndOffset;
+ const releaseRevealHold = () => {
+ if (state.insetEndRevealHold) {
+ state.insetEndRevealHold = false;
+ setInitialRenderState(ctx, {});
+ }
+ };
+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
+ // can move the end target one more time — do that upgrade while still
+ // hidden (phase 1), then require stability again before revealing (phase 2)
+ // so the first visible frame is the settled one.
+ let revealPhase = 1;
+ const onRevealStability = () => {
+ var _a3;
+ if (!state.insetEndRevealHold) {
+ return;
+ }
+ if (revealPhase === 1) {
+ revealPhase = 2;
+ revealStableFrames = 0;
+ setAdaptiveRender(ctx, "normal");
+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
+ return;
+ }
+ releaseRevealHold();
+ };
+ const tick = () => {
+ // The user owns the scroll from their first drag — a re-pin here would
+ // pin the list under their finger.
+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
+ state.insetEndSettleWatchdogActive = false;
+ releaseRevealHold();
+ return;
+ }
+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
+ releaseRevealHold();
+ }
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ const contentSize = getContentSize(ctx);
+ const scrollLength = state.scrollLength;
+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
+ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit
+ // moves. Only a native scroll event proves that the requested offset landed.
+ const nativeOffset = state.lastNativeScroll;
+ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset);
+ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity;
+ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd;
+ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize);
+ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1;
+ previousEndOffset = endOffset;
+ if (isStable) {
+ settledFrames++;
+ revealStableFrames++;
+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
+ onRevealStability();
+ }
+ } else {
+ settledFrames = 0;
+ revealStableFrames = 0;
+ // Keep the existing near-end correction limit. In-flight scrolls and
+ // unknown native offsets wait; neither counts as a settled frame.
+ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
+ const scroller = state.refScroller.current;
+ if (scroller) {
+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
+ }
+ }
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+}
function dispatchInitialScroll(ctx, params) {
const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
+ if (ctx.state.scrollLength <= 0) {
+ // The scroll length is unknown, so the resolved target is meaningless;
+ // the initial-scroll machinery re-advances after layout.
+ return;
+ }
+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
+ // Content underflows the viewport and nothing has scrolled yet: the
+ // untouched native rest position (UIKit's adjustedContentInset) IS the
+ // end position. Dispatching a negative scroll here would race UIKit's
+ // inset application during screen attach, so just finish the session.
+ finishInitialScroll(ctx, {
+ resolvedOffset
+ });
+ return;
+ }
+ ctx.state.didDispatchInsetInitialScroll = true;
+ if (target.viewPosition === 1) {
+ startInsetEndSettleWatchdog(ctx);
+ }
+ }
const requestedIndex = target.index;
const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
const itemSize = getItemSizeAtIndex(ctx, index);
@@ -2747,7 +2918,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
return;
}
if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList);
if (!shouldKeepEndTargetAlive) {
if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
clearPendingInitialScrollFooterLayout(ctx, {
@@ -4672,7 +4845,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
isReady = !hasUnknownTailSize;
- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
} else if (anchorIndex >= 0) {
isReady = false;
}
@@ -4692,6 +4866,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
}
(_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
+ set$(ctx, "anchoredEndSpaceSize", nextSize);
+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
+ }
}
return nextSize;
}
@@ -5715,6 +5895,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
horizontal
}) {
const ctx = useStateContext();
+ const SizeComponent = ctx.state.props.sizeComponentInternal;
const columnWrapperStyle = ctx.columnWrapperStyle;
const animSize = useValue$("totalSize");
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
@@ -5725,6 +5906,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
opacity: isVisible ? 1 : 0,
width: animSize
} : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
+ if (SizeComponent) {
+ if (horizontal) {
+ delete style.width;
+ } else {
+ delete style.height;
+ }
+ }
if (columnWrapperStyle) {
const { columnGap, rowGap, gap } = columnWrapperStyle;
const gapX = columnGap || gap || 0;
@@ -5745,7 +5933,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}
}
}
- return /* @__PURE__ */ React2__namespace.createElement(ReactNative.Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children));
+ const content = /* @__PURE__ */ React2__namespace.createElement(ContainerLayoutCoordinator, null, children);
+ 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);
});
var Containers = typedMemo(function Containers2({
freshDataTransitionEpoch,
@@ -5896,7 +6085,12 @@ var StyleSheet = ReactNative.StyleSheet;
// src/components/ListComponent.tsx
var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
+ const ctx = useStateContext();
const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
+ const SizeComponent = ctx.state.props.sizeComponentInternal;
+ if (SizeComponent) {
+ return /* @__PURE__ */ React2__namespace.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
+ }
if (alignItemsAtEndPadding <= 0) {
return null;
}
@@ -5929,8 +6123,12 @@ var ListComponent = typedMemo(function ListComponent2({
refScrollView,
renderScrollComponent,
onLayoutFooter,
+ onInternalMomentumScrollBegin,
onInternalScrollBeginDrag,
+ onInternalScrollEndDrag,
onInternalScrollEnd,
+ onMomentumScrollBegin,
+ onScrollEndDrag,
scrollAdjustHandler,
snapToIndices,
stickyHeaderConfig,
@@ -6001,7 +6199,17 @@ var ListComponent = typedMemo(function ListComponent2({
SnapOrScroll,
{
...rest,
- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
+ onMomentumScrollBegin: (event) => {
+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
+ },
+ onScrollBeginDrag: onInternalScrollBeginDrag,
+ onScrollEndDrag: (event) => {
+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
+ }
+ },
contentContainerStyle: [
horizontal ? { height: "100%" } : {},
contentContainerStyle,
@@ -6010,7 +6218,10 @@ var ListComponent = typedMemo(function ListComponent2({
],
contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
horizontal,
- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
+ // Keep iOS anchored to ScrollAdjust even when JS position restoration is
+ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor
+ // with the 1e7 sentinel and scroll the entire list out of view.
+ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
onLayout,
onScroll: onScroll2,
ref: refScrollView,
@@ -6751,7 +6962,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
endBuffered: state.endBuffered,
getAverageItemSizes: () => getAverageItemSizes(state),
indexByKey: (key) => state.indexByKey.get(key),
- isAtEnd: peek$(ctx, "isAtEnd"),
+ isAtEnd: getIsAtEnd(ctx),
isAtStart: peek$(ctx, "isAtStart"),
isEndReached: state.isEndReached,
isNearEnd: peek$(ctx, "isNearEnd"),
@@ -7075,6 +7286,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance = 250,
contentInsetEndAdjustment,
+ contentInsetStartAdjustment,
estimatedItemSize = 100,
estimatedListSize,
extraData,
@@ -7132,10 +7344,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
const animatedPropsInternal = props.animatedPropsInternal;
const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
const positionComponentInternal = props.positionComponentInternal;
+ const sizeComponentInternal = props.sizeComponentInternal;
const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
const {
anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
positionComponentInternal: _positionComponentInternal,
+ sizeComponentInternal: _sizeComponentInternal,
stickyPositionComponentInternal: _stickyPositionComponentInternal,
...restProps
} = rest;
@@ -7200,7 +7414,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
const combinedRef = useCombinedRef(refScroller, refScrollView);
const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
const stickyHeaderIndices = stickyHeaderIndicesProp;
- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
const alwaysRenderIndices = React2.useMemo(() => {
const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
@@ -7341,6 +7555,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
contentContainerAlignItems: contentContainerStyle.alignItems,
contentInset,
contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
+ contentInsetStartAdjustment,
data: dataProp,
dataKey,
dataVersion,
@@ -7372,6 +7587,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
renderItem,
rtl,
snapToIndices,
+ sizeComponentInternal,
stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
stickyHeaderIndicesSet: React2.useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
stickyPositionComponentInternal,
@@ -7423,6 +7639,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
return void 0;
}
const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
+ if (getContentInsetStartAdjustment(ctx) > 0) {
+ // With a native leading inset (transparent header + automatic insets),
+ // any mount-time contentOffset races UIKit's adjustedContentInset
+ // application. The gated initial-scroll dispatch positions the list once
+ // real sizes are known instead.
+ return void 0;
+ }
return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
}, [usesBootstrapInitialScroll]);
React2.useLayoutEffect(() => {
@@ -7547,6 +7770,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
[
dataKey,
dataVersion,
+ contentInsetStartAdjustment,
memoizedLastItemKeys.join(","),
numColumnsProp,
nextScrollAxisGap,
@@ -7643,6 +7867,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
() => ({
getRenderedItem: (key) => getRenderedItem(ctx, key),
onMomentumScrollEnd: (event) => {
+ state.isMomentumScrolling = false;
checkFinishedScrollFallback(ctx);
if (state.props.onMomentumScrollEnd) {
state.props.onMomentumScrollEnd(event);
@@ -7651,6 +7876,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
onScroll: (event) => onScroll(ctx, event),
onScrollBeginDrag: (event) => {
var _a4, _b2;
+ ctx.state.didUserDrag = true;
+ ctx.state.isUserDragging = true;
prepareReachedEdgeForNextUserScroll(ctx);
(_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
},
@@ -7676,11 +7903,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
ListFooterComponent,
ListFooterComponentStyle,
ListHeaderComponent,
+ onInternalMomentumScrollBegin: () => {
+ ctx.state.isMomentumScrolling = true;
+ },
onInternalScrollBeginDrag: fns.onScrollBeginDrag,
+ onInternalScrollEndDrag: () => {
+ ctx.state.isUserDragging = false;
+ },
onInternalScrollEnd: fns.onScrollEnd,
onLayout,
onLayoutFooter,
onMomentumScrollEnd: fns.onMomentumScrollEnd,
+ onScrollBeginDrag: fns.onScrollBeginDrag,
onScroll: onScrollHandler,
recycleItems,
refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
diff --git a/react-native.mjs b/react-native.mjs
index 40e87cd..93aac74 100644
--- a/react-native.mjs
+++ b/react-native.mjs
@@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) {
return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll;
}
+// Size-only changes may not emit a scroll event to refresh the edge signal.
+function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) {
+ const { queuedInitialLayout, scroll, scrollLength } = ctx.state;
+ if (!(contentSize > 0 && queuedInitialLayout)) {
+ return peek$(ctx, "isAtEnd");
+ }
+ return contentSize < scrollLength || contentSize - scroll - scrollLength - getContentInsetEnd(ctx) <= EDGE_POSITION_EPSILON;
+}
+
// src/utils/checkAtBottom.ts
function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
var _a3;
@@ -716,7 +725,7 @@ function checkAtBottom(ctx, allowedEdge, allowGateCreatedInCurrentCheck) {
const insetEnd = getContentInsetEnd(ctx);
const distanceFromEnd = contentSize - scroll - scrollLength - insetEnd;
const isContentLess = contentSize < scrollLength;
- set$(ctx, "isAtEnd", isContentLess || distanceFromEnd <= EDGE_POSITION_EPSILON);
+ set$(ctx, "isAtEnd", getIsAtEnd(ctx, contentSize));
set$(ctx, "isNearEnd", isContentLess || distanceFromEnd <= onEndReachedThreshold * scrollLength);
set$(
ctx,
@@ -933,7 +942,16 @@ function setInitialRenderState(ctx, {
if (didInitialScroll) {
state.didFinishInitialScroll = true;
}
- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
+ // Native contentOffset can seed an end target without dispatchInitialScroll.
+ // Both overflow completion paths must wait for the native end landing.
+ // Underflow needs no scroll: preserve UIKit's untouched resting position.
+ if (state.didContainersLayout && state.didFinishInitialScroll && state.initialScroll && state.initialScroll.viewPosition === 1 && state.initialScroll.index === state.props.data.length - 1 && state.props.data.length > 0) {
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0 && getContentSize(ctx) > state.scrollLength - insetStartAdjustment + 1) {
+ startInsetEndSettleWatchdog(ctx);
+ }
+ }
+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
if (isReadyToRender && !peek$(ctx, "readyToRender")) {
set$(ctx, "readyToRender", true);
setAdaptiveRender(ctx, "normal", "ready");
@@ -1069,7 +1087,7 @@ function getRawContentLength(ctx) {
function getAlignItemsAtEndPadding(ctx) {
const { state } = ctx;
const shouldPad = !!state.props.alignItemsAtEndPaddingEnabled && !state.props.horizontal && state.props.data.length > 0 && state.scrollLength > 0;
- return shouldPad ? Math.max(0, state.scrollLength - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
+ return shouldPad ? Math.max(0, state.scrollLength - getContentInsetStartAdjustment(ctx) - getRawContentLength(ctx) - getContentInsetEnd(ctx)) : 0;
}
function updateContentMetricsState(ctx) {
const previousPadding = peek$(ctx, "alignItemsAtEndPadding") || 0;
@@ -1094,6 +1112,10 @@ function addTotalSize(ctx, key, add, notifyTotalSize = true) {
totalSize += add;
}
if (prevTotalSize !== totalSize) {
+ const now = Date.now();
+ const isContinuingContentSizeAnimation = state.contentSizeAnimationActiveEpoch !== void 0;
+ state.contentSizeAnimationEpoch = (state.contentSizeAnimationEpoch || 0) + 1;
+ state.contentSizeAnimationEligible = !!state.props.sizeComponentInternal && state.didContainersLayout && (isContinuingContentSizeAnimation || !state.props.maintainScrollAtEnd && !state.isUserDragging && !state.isMomentumScrolling && now - (state.lastMVCPAdjustTime || 0) >= 300);
if (!IsNewArchitecture && state.initialScroll && totalSize < prevTotalSize) {
state.pendingTotalSize = totalSize;
} else {
@@ -1283,18 +1305,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
}
// src/core/clampScrollOffset.ts
+function getContentInsetStartAdjustment(ctx) {
+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
+}
function clampScrollOffset(ctx, offset, scrollTarget) {
const state = ctx.state;
const contentSize = getContentSize(ctx);
+ const minOffset = -getContentInsetStartAdjustment(ctx);
let clampedOffset = offset;
if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
const maxOffset = baseMaxOffset + extraEndOffset;
clampedOffset = Math.min(offset, maxOffset);
}
- clampedOffset = Math.max(0, clampedOffset);
+ clampedOffset = Math.max(minOffset, clampedOffset);
return clampedOffset;
}
@@ -1430,10 +1457,10 @@ function checkFinishedScrollFrame(ctx) {
finishScrollTo(ctx);
}
}
-function scrollToFallbackOffset(ctx, offset) {
+function scrollToFallbackOffset(ctx, offset, animated) {
var _a3;
(_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
- animated: false,
+ animated: !!animated,
x: ctx.state.props.horizontal ? offset : 0,
y: ctx.state.props.horizontal ? 0 : offset
});
@@ -1482,7 +1509,10 @@ function checkFinishedScrollFallback(ctx) {
);
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
+ if (!isActivelyAnimatingToEnd) {
+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
+ }
scheduleFallbackCheck(100);
} else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
finishScrollTo(ctx);
@@ -1539,15 +1569,28 @@ function doMaintainScrollAtEnd(ctx) {
} = state;
const isWithinMaintainScrollAtEndThreshold = peek$(ctx, "isWithinMaintainScrollAtEndThreshold");
const shouldMaintainScrollAtEnd = !!(isWithinMaintainScrollAtEndThreshold && maintainScrollAtEnd && didContainersLayout);
+ if (state.contentSizeAnimationActiveEpoch !== void 0) {
+ state.pendingMaintainScrollAtEnd = false;
+ return false;
+ }
if (pendingNativeMVCPAdjust) {
state.pendingMaintainScrollAtEnd = shouldMaintainScrollAtEnd;
return false;
}
if (shouldMaintainScrollAtEnd) {
state.pendingMaintainScrollAtEnd = false;
+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
+ return false;
+ }
const contentSize = getContentSize(ctx);
+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
if (contentSize < state.scrollLength) {
- state.scroll = 0;
+ state.scroll = -maintainInsetStartAdjustment;
+ if (maintainInsetStartAdjustment > 0) {
+ return true;
+ }
}
if (!state.maintainingScrollAtEnd) {
const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
@@ -1570,9 +1613,18 @@ function doMaintainScrollAtEnd(ctx) {
y: 0
});
} else {
- scroller == null ? void 0 : scroller.scrollToEnd({
- animated: maintainScrollAtEnd.animated
- });
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0) {
+ scroller == null ? void 0 : scroller.scrollTo({
+ animated: maintainScrollAtEnd.animated,
+ x: 0,
+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
+ });
+ } else {
+ scroller == null ? void 0 : scroller.scrollToEnd({
+ animated: maintainScrollAtEnd.animated
+ });
+ }
}
setTimeout(
() => {
@@ -1603,6 +1655,10 @@ function doMaintainScrollAtEnd(ctx) {
function requestAdjust(ctx, positionDiff, dataChanged) {
const state = ctx.state;
if (Math.abs(positionDiff) > 0.1) {
+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
+ // size adjustment are already compensated by a contentOffset shift, so
+ // animating them would make rows visibly lurch and slide back.
+ state.lastMVCPAdjustTime = Date.now();
const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
const doit = () => {
if (needsScrollWorkaround) {
@@ -1707,7 +1763,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
return 0;
}
- const maxScroll = Math.max(0, totalSize - state.scrollLength);
+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
const clampDelta = maxScroll - state.scroll;
if (unresolvedAmount < 0) {
return Math.max(unresolvedAmount, Math.min(0, clampDelta));
@@ -1769,7 +1827,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
return true;
}
- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
if (isAtExpectedNativeClamp) {
@@ -1902,7 +1960,7 @@ function prepareMVCP(ctx, dataChanged) {
if (diff > 0) {
diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
} else {
- const maxScroll = Math.max(0, totalSize - state.scrollLength);
+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
state.scroll = maxScroll;
state.scrollPending = maxScroll;
diff = 0;
@@ -2299,8 +2357,121 @@ function scrollToIndex(ctx, {
}
// src/core/initialScroll.ts
+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
+var INSET_END_REVEAL_STABLE_FRAMES = 3;
+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
+function startInsetEndSettleWatchdog(ctx) {
+ const state = ctx.state;
+ if (state.insetEndSettleWatchdogStarted || state.didLoad) {
+ return;
+ }
+ state.insetEndSettleWatchdogStarted = true;
+ state.insetEndSettleWatchdogActive = true;
+ // Hold the readyToRender opacity gate until the end landing is stable, so
+ // the estimated-to-measured settle chase happens before first VISIBLE
+ // paint instead of in front of the user. Capped so slow measurement can
+ // never hide content for long.
+ state.insetEndRevealHold = true;
+ let frames = 0;
+ let settledFrames = 0;
+ let revealStableFrames = 0;
+ let previousEndOffset;
+ const releaseRevealHold = () => {
+ if (state.insetEndRevealHold) {
+ state.insetEndRevealHold = false;
+ setInitialRenderState(ctx, {});
+ }
+ };
+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
+ // can move the end target one more time — do that upgrade while still
+ // hidden (phase 1), then require stability again before revealing (phase 2)
+ // so the first visible frame is the settled one.
+ let revealPhase = 1;
+ const onRevealStability = () => {
+ var _a3;
+ if (!state.insetEndRevealHold) {
+ return;
+ }
+ if (revealPhase === 1) {
+ revealPhase = 2;
+ revealStableFrames = 0;
+ setAdaptiveRender(ctx, "normal");
+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
+ return;
+ }
+ releaseRevealHold();
+ };
+ const tick = () => {
+ // The user owns the scroll from their first drag — a re-pin here would
+ // pin the list under their finger.
+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
+ state.insetEndSettleWatchdogActive = false;
+ releaseRevealHold();
+ return;
+ }
+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
+ releaseRevealHold();
+ }
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ const contentSize = getContentSize(ctx);
+ const scrollLength = state.scrollLength;
+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
+ // state.scroll is optimistic: non-animated scrollTo writes it before UIKit
+ // moves. Only a native scroll event proves that the requested offset landed.
+ const nativeOffset = state.lastNativeScroll;
+ const hasNativeOffset = typeof nativeOffset === "number" && Number.isFinite(nativeOffset);
+ const distance = hasNativeOffset ? endOffset - nativeOffset : Infinity;
+ const isIdle = !state.scrollingTo && !state.maintainingScrollAtEnd;
+ const hasViewport = insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize);
+ const isStable = hasViewport && isIdle && Math.abs(distance) <= 2 && previousEndOffset !== void 0 && Math.abs(endOffset - previousEndOffset) <= 1;
+ previousEndOffset = endOffset;
+ if (isStable) {
+ settledFrames++;
+ revealStableFrames++;
+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
+ onRevealStability();
+ }
+ } else {
+ settledFrames = 0;
+ revealStableFrames = 0;
+ // Keep the existing near-end correction limit. In-flight scrolls and
+ // unknown native offsets wait; neither counts as a settled frame.
+ if (hasViewport && isIdle && Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
+ const scroller = state.refScroller.current;
+ if (scroller) {
+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
+ }
+ }
+ }
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+}
function dispatchInitialScroll(ctx, params) {
const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
+ if (ctx.state.scrollLength <= 0) {
+ // The scroll length is unknown, so the resolved target is meaningless;
+ // the initial-scroll machinery re-advances after layout.
+ return;
+ }
+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
+ // Content underflows the viewport and nothing has scrolled yet: the
+ // untouched native rest position (UIKit's adjustedContentInset) IS the
+ // end position. Dispatching a negative scroll here would race UIKit's
+ // inset application during screen attach, so just finish the session.
+ finishInitialScroll(ctx, {
+ resolvedOffset
+ });
+ return;
+ }
+ ctx.state.didDispatchInsetInitialScroll = true;
+ if (target.viewPosition === 1) {
+ startInsetEndSettleWatchdog(ctx);
+ }
+ }
const requestedIndex = target.index;
const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
const itemSize = getItemSizeAtIndex(ctx, index);
@@ -2726,7 +2897,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
return;
}
if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || !state.didUserDrag && isNearEndForInsetList);
if (!shouldKeepEndTargetAlive) {
if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
clearPendingInitialScrollFooterLayout(ctx, {
@@ -4651,7 +4824,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
isReady = !hasUnknownTailSize;
- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
} else if (anchorIndex >= 0) {
isReady = false;
}
@@ -4671,6 +4845,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
}
(_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
+ set$(ctx, "anchoredEndSpaceSize", nextSize);
+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
+ }
}
return nextSize;
}
@@ -5694,6 +5874,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
horizontal
}) {
const ctx = useStateContext();
+ const SizeComponent = ctx.state.props.sizeComponentInternal;
const columnWrapperStyle = ctx.columnWrapperStyle;
const animSize = useValue$("totalSize");
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
@@ -5704,6 +5885,13 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
opacity: isVisible ? 1 : 0,
width: animSize
} : { height: animSize, minWidth: otherAxisSize, opacity: isVisible ? 1 : 0 };
+ if (SizeComponent) {
+ if (horizontal) {
+ delete style.width;
+ } else {
+ delete style.height;
+ }
+ }
if (columnWrapperStyle) {
const { columnGap, rowGap, gap } = columnWrapperStyle;
const gapX = columnGap || gap || 0;
@@ -5724,7 +5912,8 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}
}
}
- return /* @__PURE__ */ React2.createElement(Animated.View, { pointerEvents: isVisible ? void 0 : "none", style }, /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children));
+ const content = /* @__PURE__ */ React2.createElement(ContainerLayoutCoordinator, null, children);
+ 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);
});
var Containers = typedMemo(function Containers2({
freshDataTransitionEpoch,
@@ -5875,7 +6064,12 @@ var StyleSheet = StyleSheet$1;
// src/components/ListComponent.tsx
var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizontal }) {
+ const ctx = useStateContext();
const [alignItemsAtEndPadding = 0] = useArr$(["alignItemsAtEndPadding"]);
+ const SizeComponent = ctx.state.props.sizeComponentInternal;
+ if (SizeComponent) {
+ return /* @__PURE__ */ React2.createElement(SizeComponent, { horizontal, signalName: "alignItemsAtEndPadding", style: { flexShrink: 0 } });
+ }
if (alignItemsAtEndPadding <= 0) {
return null;
}
@@ -5908,8 +6102,12 @@ var ListComponent = typedMemo(function ListComponent2({
refScrollView,
renderScrollComponent,
onLayoutFooter,
+ onInternalMomentumScrollBegin,
onInternalScrollBeginDrag,
+ onInternalScrollEndDrag,
onInternalScrollEnd,
+ onMomentumScrollBegin,
+ onScrollEndDrag,
scrollAdjustHandler,
snapToIndices,
stickyHeaderConfig,
@@ -5980,7 +6178,17 @@ var ListComponent = typedMemo(function ListComponent2({
SnapOrScroll,
{
...rest,
- ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : { onScrollBeginDrag: onInternalScrollBeginDrag },
+ ...Platform.OS === "web" ? ScrollComponent === ListComponentScrollView ? { onInternalScrollEnd, useWindowScroll } : {} : {
+ onMomentumScrollBegin: (event) => {
+ onInternalMomentumScrollBegin == null ? void 0 : onInternalMomentumScrollBegin(event);
+ onMomentumScrollBegin == null ? void 0 : onMomentumScrollBegin(event);
+ },
+ onScrollBeginDrag: onInternalScrollBeginDrag,
+ onScrollEndDrag: (event) => {
+ onInternalScrollEndDrag == null ? void 0 : onInternalScrollEndDrag(event);
+ onScrollEndDrag == null ? void 0 : onScrollEndDrag(event);
+ }
+ },
contentContainerStyle: [
horizontal ? { height: "100%" } : {},
contentContainerStyle,
@@ -5989,7 +6197,10 @@ var ListComponent = typedMemo(function ListComponent2({
],
contentOffset: initialContentOffset !== void 0 ? horizontal ? { x: initialContentOffset, y: 0 } : { x: 0, y: initialContentOffset } : void 0,
horizontal,
- maintainVisibleContentPosition: maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
+ // Keep iOS anchored to ScrollAdjust even when JS position restoration is
+ // disabled. Re-enabling native MVCP mid-drag can compare its stale anchor
+ // with the 1e7 sentinel and scroll the entire list out of view.
+ maintainVisibleContentPosition: Platform.OS === "ios" || maintainVisibleContentPosition.size || maintainVisibleContentPosition.data ? { minIndexForVisible: 0 } : void 0,
onLayout,
onScroll: onScroll2,
ref: refScrollView,
@@ -6730,7 +6941,7 @@ function createImperativeHandle(ctx, scheduleImperativeScrollCommit) {
endBuffered: state.endBuffered,
getAverageItemSizes: () => getAverageItemSizes(state),
indexByKey: (key) => state.indexByKey.get(key),
- isAtEnd: peek$(ctx, "isAtEnd"),
+ isAtEnd: getIsAtEnd(ctx),
isAtStart: peek$(ctx, "isAtStart"),
isEndReached: state.isEndReached,
isNearEnd: peek$(ctx, "isNearEnd"),
@@ -7054,6 +7265,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance = 250,
contentInsetEndAdjustment,
+ contentInsetStartAdjustment,
estimatedItemSize = 100,
estimatedListSize,
extraData,
@@ -7111,10 +7323,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
const animatedPropsInternal = props.animatedPropsInternal;
const anchoredEndSpaceOwner = (_a3 = props.anchoredEndSpaceOwnerInternal) != null ? _a3 : "list";
const positionComponentInternal = props.positionComponentInternal;
+ const sizeComponentInternal = props.sizeComponentInternal;
const stickyPositionComponentInternal = props.stickyPositionComponentInternal;
const {
anchoredEndSpaceOwnerInternal: _anchoredEndSpaceOwnerInternal,
positionComponentInternal: _positionComponentInternal,
+ sizeComponentInternal: _sizeComponentInternal,
stickyPositionComponentInternal: _stickyPositionComponentInternal,
...restProps
} = rest;
@@ -7179,7 +7393,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
const combinedRef = useCombinedRef(refScroller, refScrollView);
const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
const stickyHeaderIndices = stickyHeaderIndicesProp;
- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
const alwaysRenderIndices = useMemo(() => {
const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
@@ -7320,6 +7534,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
contentContainerAlignItems: contentContainerStyle.alignItems,
contentInset,
contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
+ contentInsetStartAdjustment,
data: dataProp,
dataKey,
dataVersion,
@@ -7351,6 +7566,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
renderItem,
rtl,
snapToIndices,
+ sizeComponentInternal,
stickyHeaderIndicesArr: stickyHeaderIndices != null ? stickyHeaderIndices : [],
stickyHeaderIndicesSet: useMemo(() => new Set(stickyHeaderIndices != null ? stickyHeaderIndices : []), [stickyHeaderIndices == null ? void 0 : stickyHeaderIndices.join(",")]),
stickyPositionComponentInternal,
@@ -7402,6 +7618,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
return void 0;
}
const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
+ if (getContentInsetStartAdjustment(ctx) > 0) {
+ // With a native leading inset (transparent header + automatic insets),
+ // any mount-time contentOffset races UIKit's adjustedContentInset
+ // application. The gated initial-scroll dispatch positions the list once
+ // real sizes are known instead.
+ return void 0;
+ }
return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
}, [usesBootstrapInitialScroll]);
useLayoutEffect(() => {
@@ -7526,6 +7749,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
[
dataKey,
dataVersion,
+ contentInsetStartAdjustment,
memoizedLastItemKeys.join(","),
numColumnsProp,
nextScrollAxisGap,
@@ -7622,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
() => ({
getRenderedItem: (key) => getRenderedItem(ctx, key),
onMomentumScrollEnd: (event) => {
+ state.isMomentumScrolling = false;
checkFinishedScrollFallback(ctx);
if (state.props.onMomentumScrollEnd) {
state.props.onMomentumScrollEnd(event);
@@ -7630,6 +7855,8 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
onScroll: (event) => onScroll(ctx, event),
onScrollBeginDrag: (event) => {
var _a4, _b2;
+ ctx.state.didUserDrag = true;
+ ctx.state.isUserDragging = true;
prepareReachedEdgeForNextUserScroll(ctx);
(_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
},
@@ -7655,11 +7882,18 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
ListFooterComponent,
ListFooterComponentStyle,
ListHeaderComponent,
+ onInternalMomentumScrollBegin: () => {
+ ctx.state.isMomentumScrolling = true;
+ },
onInternalScrollBeginDrag: fns.onScrollBeginDrag,
+ onInternalScrollEndDrag: () => {
+ ctx.state.isUserDragging = false;
+ },
onInternalScrollEnd: fns.onScrollEnd,
onLayout,
onLayoutFooter,
onMomentumScrollEnd: fns.onMomentumScrollEnd,
+ onScrollBeginDrag: fns.onScrollBeginDrag,
onScroll: onScrollHandler,
recycleItems,
refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
diff --git a/react-native.web.js b/react-native.web.js
index 914d2da..8b88b09 100644
--- a/react-native.web.js
+++ b/react-native.web.js
@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
});
// src/utils/reordering.ts
+// Element.moveBefore keeps the moved subtree's state and does not count as an
+// insertion, so CSS @starting-style transitions and iframes inside a row do not
+// restart when the list reorders its containers. insertBefore is the fallback.
+function moveChildBefore(container, element, reference) {
+ if (typeof container.moveBefore === "function") {
+ container.moveBefore(element, reference);
+ } else if (reference) {
+ container.insertBefore(element, reference);
+ } else {
+ container.appendChild(element);
+ }
+}
function sortDOMElements(container, indexByElement) {
const elements = Array.from(container.children);
if (elements.length <= 1) return elements;
@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) {
}
}
if (nextStableElement) {
- container.insertBefore(element, nextStableElement);
+ moveChildBefore(container, element, nextStableElement);
} else {
- container.appendChild(element);
+ moveChildBefore(container, element, null);
}
}
}
diff --git a/react-native.web.mjs b/react-native.web.mjs
index 95465f2..d7cbe1c 100644
--- a/react-native.web.mjs
+++ b/react-native.web.mjs
@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
});
// src/utils/reordering.ts
+// Element.moveBefore keeps the moved subtree's state and does not count as an
+// insertion, so CSS @starting-style transitions and iframes inside a row do not
+// restart when the list reorders its containers. insertBefore is the fallback.
+function moveChildBefore(container, element, reference) {
+ if (typeof container.moveBefore === "function") {
+ container.moveBefore(element, reference);
+ } else if (reference) {
+ container.insertBefore(element, reference);
+ } else {
+ container.appendChild(element);
+ }
+}
function sortDOMElements(container, indexByElement) {
const elements = Array.from(container.children);
if (elements.length <= 1) return elements;
@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) {
}
}
if (nextStableElement) {
- container.insertBefore(element, nextStableElement);
+ moveChildBefore(container, element, nextStableElement);
} else {
- container.appendChild(element);
+ moveChildBefore(container, element, null);
}
}
}
diff --git a/react.js b/react.js
index 914d2da..0c2917d 100644
--- a/react.js
+++ b/react.js
@@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
isReady = !hasUnknownTailSize;
- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
} else if (anchorIndex >= 0) {
isReady = false;
}
@@ -4722,6 +4723,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
}
(_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
+ set$(ctx, "anchoredEndSpaceSize", nextSize);
+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
+ }
}
return nextSize;
}
@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
});
// src/utils/reordering.ts
+// Element.moveBefore keeps the moved subtree's state and does not count as an
+// insertion, so CSS @starting-style transitions and iframes inside a row do not
+// restart when the list reorders its containers. insertBefore is the fallback.
+function moveChildBefore(container, element, reference) {
+ if (typeof container.moveBefore === "function") {
+ container.moveBefore(element, reference);
+ } else if (reference) {
+ container.insertBefore(element, reference);
+ } else {
+ container.appendChild(element);
+ }
+}
function sortDOMElements(container, indexByElement) {
const elements = Array.from(container.children);
if (elements.length <= 1) return elements;
@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) {
}
}
if (nextStableElement) {
- container.insertBefore(element, nextStableElement);
+ moveChildBefore(container, element, nextStableElement);
} else {
- container.appendChild(element);
+ moveChildBefore(container, element, null);
}
}
}
@@ -6446,8 +6465,8 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
diff --git a/react.mjs b/react.mjs
index 95465f2..b73bfdf 100644
--- a/react.mjs
+++ b/react.mjs
@@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
isReady = !hasUnknownTailSize;
- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
} else if (anchorIndex >= 0) {
isReady = false;
}
@@ -4701,6 +4702,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
}
(_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
+ set$(ctx, "anchoredEndSpaceSize", nextSize);
+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
+ }
}
return nextSize;
}
@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) {
});
// src/utils/reordering.ts
+// Element.moveBefore keeps the moved subtree's state and does not count as an
+// insertion, so CSS @starting-style transitions and iframes inside a row do not
+// restart when the list reorders its containers. insertBefore is the fallback.
+function moveChildBefore(container, element, reference) {
+ if (typeof container.moveBefore === "function") {
+ container.moveBefore(element, reference);
+ } else if (reference) {
+ container.insertBefore(element, reference);
+ } else {
+ container.appendChild(element);
+ }
+}
function sortDOMElements(container, indexByElement) {
const elements = Array.from(container.children);
if (elements.length <= 1) return elements;
@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) {
}
}
if (nextStableElement) {
- container.insertBefore(element, nextStableElement);
+ moveChildBefore(container, element, nextStableElement);
} else {
- container.appendChild(element);
+ moveChildBefore(container, element, null);
}
}
}
@@ -6425,8 +6444,8 @@ function ScrollAdjust() {
window.getComputedStyle(contentNode)[axis.paddingEndProp]
);
const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`;
- temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: temporaryPaddingEnd };
contentNode.style[axis.paddingEndProp] = temporaryPaddingEnd;
+ temporaryPaddingRef.current = { baseline: baselinePaddingEnd, value: contentNode.style[axis.paddingEndProp] };
void contentNode.offsetHeight;
scrollBy();
if (resetPaddingRafRef.current !== void 0) {
diff --git a/reanimated.d.ts b/reanimated.d.ts
index e504332..2ce6383 100644
--- a/reanimated.d.ts
+++ b/reanimated.d.ts
@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
* The adjustment is also rendered as real content padding so the browser scroll range includes it.
*/
contentInsetEndAdjustment?: number;
+ /**
+ * Width/height of a leading content inset applied natively outside the list's knowledge
+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
+ */
+ contentInsetStartAdjustment?: number;
/**
* Number of columns to render items in.
* @default 1
diff --git a/reanimated.js b/reanimated.js
index f1265fa..fc03be2 100644
--- a/reanimated.js
+++ b/reanimated.js
@@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
const prevItemKeyRef = React__namespace.useRef(void 0);
+ const previousPositionRef = React__namespace.useRef(positionValue);
+ const lastContentSizeEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
let shouldSkipTransitionForRecycleReuse = false;
- if (recycleItems && layoutTransition) {
+ if (layoutTransition) {
const itemKeySignal = `containerItemKey${id}`;
const itemKey = peek$(ctx, itemKeySignal);
shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
@@ -130,10 +132,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
() => [style, horizontal ? { left: positionValue } : { top: positionValue }],
[horizontal, positionValue, style]
);
+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
+ // which are compensated by an equal contentOffset shift so the row should
+ // not visibly move — animating turns the invisible correction into a
+ // lurch-and-settle; (2) any reposition while the user is actively
+ // scrolling, where rows shifting under the finger reads as jank. The
+ // transition exists to smooth at-rest shifts (streaming text, work-log
+ // folds), so gate it to at-rest moments.
+ const now = Date.now();
+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
+ const didPositionChange = positionValue !== previousPositionRef.current;
+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
+ React__namespace.useLayoutEffect(() => {
+ if (didPositionChange) {
+ previousPositionRef.current = positionValue;
+ lastContentSizeEpochRef.current = contentSizeEpoch;
+ }
+ }, [contentSizeEpoch, didPositionChange, positionValue]);
+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
return /* @__PURE__ */ React__namespace.createElement(
Reanimated__default.default.View,
{
- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
ref: refView,
style: viewStyle,
...rest
@@ -141,6 +162,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
children
);
});
+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
+ const ctx = useStateContext();
+ const { horizontal, signalName, style, children, ...rest } = props;
+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
+ const baseSize = Reanimated.useSharedValue(logicalSize);
+ const animatedDelta = Reanimated.useSharedValue(0);
+ const previousLogicalSizeRef = React__namespace.useRef(logicalSize);
+ const previousEpochRef = React__namespace.useRef(ctx.state.contentSizeAnimationEpoch || 0);
+ const animationRunRef = React__namespace.useRef(0);
+ const isAnimatingRef = React__namespace.useRef(false);
+ const transition = props.layoutTransition;
+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
+ // Keep the animated size attached after transitions finish. Reanimated
+ // retains removed animated props, so switching to a static size leaves
+ // native scroll bounds stuck while logical measurements keep changing.
+ const animatedStyle = Reanimated.useAnimatedStyle(() => {
+ const size = Math.max(0, baseSize.value + animatedDelta.value);
+ return horizontal ? { width: size } : { height: size };
+ }, [horizontal]);
+ const completeAnimation = React__namespace.useCallback((run, epoch) => {
+ if (animationRunRef.current !== run) {
+ return;
+ }
+ isAnimatingRef.current = false;
+ const state = ctx.state;
+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
+ return;
+ }
+ state.contentSizeAnimationActiveSignals.delete(signalName);
+ if (state.contentSizeAnimationActiveSignals.size === 0) {
+ state.contentSizeAnimationActiveEpoch = void 0;
+ state.contentSizeAnimationEligible = false;
+ }
+ }, [ctx.state, signalName]);
+ React__namespace.useLayoutEffect(() => {
+ const state = ctx.state;
+ const epoch = state.contentSizeAnimationEpoch || 0;
+ const previousLogicalSize = previousLogicalSizeRef.current;
+ const delta = logicalSize - previousLogicalSize;
+ const isNewEpoch = epoch !== previousEpochRef.current;
+ // Retarget measurements received mid-animation, even in the same epoch.
+ // Updating only the base keeps the old delta and can shrink scroll bounds.
+ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible);
+ previousLogicalSizeRef.current = logicalSize;
+ previousEpochRef.current = epoch;
+ if (!delta) {
+ return;
+ }
+ if (!shouldAnimate) {
+ baseSize.value = logicalSize;
+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
+ state.contentSizeAnimationEligible = false;
+ }
+ return;
+ }
+ const [animation, config] = transition.getAnimationAndConfig();
+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
+ baseSize.value = logicalSize;
+ if (signalName === "totalSize") {
+ state.contentSizeAnimationEligible = false;
+ }
+ return;
+ }
+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
+ state.contentSizeAnimationActiveEpoch = epoch;
+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
+ }
+ state.contentSizeAnimationActiveSignals.add(signalName);
+ const wasAnimating = isAnimatingRef.current;
+ isAnimatingRef.current = true;
+ const run = ++animationRunRef.current;
+ Reanimated.runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
+ "worklet";
+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
+ baseSize.value = absoluteSize;
+ animatedDelta.value = currentVisualSize - absoluteSize;
+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
+ "worklet";
+ if (finished) {
+ Reanimated.runOnJS(completeAnimation)(animationRun, animationEpoch);
+ }
+ }));
+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
+ return /* @__PURE__ */ React__namespace.createElement(Reanimated__default.default.View, { ...rest, style: [style, animatedStyle] }, children);
+});
function setSharedValueValue(sharedValue, value) {
if (!sharedValue) {
return;
@@ -245,10 +355,19 @@ var LegendListForwardedRef = typedMemo(
);
};
}, [hasItemLayoutAnimation, recycleItems]);
+ const sizeComponentInternal = React__namespace.useMemo(() => {
+ if (!hasItemLayoutAnimation) {
+ return void 0;
+ }
+ return function SizeComponent(sizeProps) {
+ return /* @__PURE__ */ React__namespace.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
+ };
+ }, [hasItemLayoutAnimation]);
const legendListProps = {
...rest,
positionComponentInternal,
recycleItems,
+ sizeComponentInternal,
...{
renderScrollComponent: renderReanimatedScrollComponent,
...IsNewArchitecture ? { stickyPositionComponentInternal } : {}
diff --git a/reanimated.mjs b/reanimated.mjs
index 29a00d5..6ab9292 100644
--- a/reanimated.mjs
+++ b/reanimated.mjs
@@ -1,7 +1,7 @@
import * as React from 'react';
import { useCallback } from 'react';
import { View } from 'react-native';
-import Reanimated, { useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
+import Reanimated, { runOnJS, runOnUI, useAnimatedRef, useAnimatedStyle, useSharedValue, useScrollViewOffset } from 'react-native-reanimated';
import { internal, LegendList } from '@legendapp/list/react-native';
// src/integrations/reanimated.tsx
@@ -91,8 +91,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
const { id, horizontal, style, refView, children, recycleItems, layoutTransition, ...rest } = props;
const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
const prevItemKeyRef = React.useRef(void 0);
+ const previousPositionRef = React.useRef(positionValue);
+ const lastContentSizeEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
let shouldSkipTransitionForRecycleReuse = false;
- if (recycleItems && layoutTransition) {
+ if (layoutTransition) {
const itemKeySignal = `containerItemKey${id}`;
const itemKey = peek$(ctx, itemKeySignal);
shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
@@ -106,10 +108,29 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
() => [style, horizontal ? { left: positionValue } : { top: positionValue }],
[horizontal, positionValue, style]
);
+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
+ // which are compensated by an equal contentOffset shift so the row should
+ // not visibly move — animating turns the invisible correction into a
+ // lurch-and-settle; (2) any reposition while the user is actively
+ // scrolling, where rows shifting under the finger reads as jank. The
+ // transition exists to smooth at-rest shifts (streaming text, work-log
+ // folds), so gate it to at-rest moments.
+ const now = Date.now();
+ const contentSizeEpoch = ctx.state.contentSizeAnimationEpoch || 0;
+ const didPositionChange = positionValue !== previousPositionRef.current;
+ const isRawSizeReposition = didPositionChange && contentSizeEpoch !== lastContentSizeEpochRef.current && ctx.state.contentSizeAnimationEligible && !!layoutTransition && typeof layoutTransition.getAnimationAndConfig === "function";
+ React.useLayoutEffect(() => {
+ if (didPositionChange) {
+ previousPositionRef.current = positionValue;
+ lastContentSizeEpochRef.current = contentSizeEpoch;
+ }
+ }, [contentSizeEpoch, didPositionChange, positionValue]);
+ const isRecentNativeReposition = now - (ctx.state.lastNativeScrollTime || 0) < 300;
+ const shouldSkipLayoutTransition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 || ctx.state.isUserDragging || ctx.state.isMomentumScrolling || isRecentNativeReposition && !isRawSizeReposition;
return /* @__PURE__ */ React.createElement(
Reanimated.View,
{
- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
+ layout: shouldSkipTransitionForRecycleReuse || shouldSkipLayoutTransition ? void 0 : layoutTransition,
ref: refView,
style: viewStyle,
...rest
@@ -117,6 +138,95 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
children
);
});
+var ReanimatedSizeView = typedMemo(function ReanimatedSizeViewComponent(props) {
+ const ctx = useStateContext();
+ const { horizontal, signalName, style, children, ...rest } = props;
+ const [logicalSize = 0] = useArr$(signalName === "totalSize" ? [signalName] : [signalName, "totalSize"]);
+ const baseSize = useSharedValue(logicalSize);
+ const animatedDelta = useSharedValue(0);
+ const previousLogicalSizeRef = React.useRef(logicalSize);
+ const previousEpochRef = React.useRef(ctx.state.contentSizeAnimationEpoch || 0);
+ const animationRunRef = React.useRef(0);
+ const isAnimatingRef = React.useRef(false);
+ const transition = props.layoutTransition;
+ const canAnimate = !!transition && typeof transition.getAnimationAndConfig === "function";
+ const renderEpoch = ctx.state.contentSizeAnimationEpoch || 0;
+ // Keep the animated size attached after transitions finish. Reanimated
+ // retains removed animated props, so switching to a static size leaves
+ // native scroll bounds stuck while logical measurements keep changing.
+ const animatedStyle = useAnimatedStyle(() => {
+ const size = Math.max(0, baseSize.value + animatedDelta.value);
+ return horizontal ? { width: size } : { height: size };
+ }, [horizontal]);
+ const completeAnimation = useCallback((run, epoch) => {
+ if (animationRunRef.current !== run) {
+ return;
+ }
+ isAnimatingRef.current = false;
+ const state = ctx.state;
+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
+ return;
+ }
+ state.contentSizeAnimationActiveSignals.delete(signalName);
+ if (state.contentSizeAnimationActiveSignals.size === 0) {
+ state.contentSizeAnimationActiveEpoch = void 0;
+ state.contentSizeAnimationEligible = false;
+ }
+ }, [ctx.state, signalName]);
+ React.useLayoutEffect(() => {
+ const state = ctx.state;
+ const epoch = state.contentSizeAnimationEpoch || 0;
+ const previousLogicalSize = previousLogicalSizeRef.current;
+ const delta = logicalSize - previousLogicalSize;
+ const isNewEpoch = epoch !== previousEpochRef.current;
+ // Retarget measurements received mid-animation, even in the same epoch.
+ // Updating only the base keeps the old delta and can shrink scroll bounds.
+ const shouldAnimate = canAnimate && (isAnimatingRef.current || isNewEpoch && state.contentSizeAnimationEligible);
+ previousLogicalSizeRef.current = logicalSize;
+ previousEpochRef.current = epoch;
+ if (!delta) {
+ return;
+ }
+ if (!shouldAnimate) {
+ baseSize.value = logicalSize;
+ if (signalName === "totalSize" && isNewEpoch && state.contentSizeAnimationEligible && !canAnimate) {
+ state.contentSizeAnimationEligible = false;
+ }
+ return;
+ }
+ const [animation, config] = transition.getAnimationAndConfig();
+ const delayFunction = typeof transition.getDelayFunction === "function" ? transition.getDelayFunction() : (_, value) => value;
+ const delay = typeof transition.getDelay === "function" ? transition.getDelay() : 0;
+ if (typeof animation !== "function" || typeof delayFunction !== "function") {
+ baseSize.value = logicalSize;
+ if (signalName === "totalSize") {
+ state.contentSizeAnimationEligible = false;
+ }
+ return;
+ }
+ if (state.contentSizeAnimationActiveEpoch !== epoch) {
+ state.contentSizeAnimationActiveEpoch = epoch;
+ state.contentSizeAnimationActiveSignals = /* @__PURE__ */ new Set();
+ }
+ state.contentSizeAnimationActiveSignals.add(signalName);
+ const wasAnimating = isAnimatingRef.current;
+ isAnimatingRef.current = true;
+ const run = ++animationRunRef.current;
+ runOnUI((absoluteSize, previousSize, continuesAnimation, animationRun, animationEpoch) => {
+ "worklet";
+ const currentVisualSize = continuesAnimation ? baseSize.value + animatedDelta.value : previousSize;
+ baseSize.value = absoluteSize;
+ animatedDelta.value = currentVisualSize - absoluteSize;
+ animatedDelta.value = delayFunction(delay, animation(0, config, (finished) => {
+ "worklet";
+ if (finished) {
+ runOnJS(completeAnimation)(animationRun, animationEpoch);
+ }
+ }));
+ })(logicalSize, previousLogicalSize, wasAnimating, run, epoch);
+ }, [baseSize, animatedDelta, canAnimate, completeAnimation, ctx.state, logicalSize, renderEpoch, signalName, transition]);
+ return /* @__PURE__ */ React.createElement(Reanimated.View, { ...rest, style: [style, animatedStyle] }, children);
+});
function setSharedValueValue(sharedValue, value) {
if (!sharedValue) {
return;
@@ -221,10 +331,19 @@ var LegendListForwardedRef = typedMemo(
);
};
}, [hasItemLayoutAnimation, recycleItems]);
+ const sizeComponentInternal = React.useMemo(() => {
+ if (!hasItemLayoutAnimation) {
+ return void 0;
+ }
+ return function SizeComponent(sizeProps) {
+ return /* @__PURE__ */ React.createElement(ReanimatedSizeView, { ...sizeProps, layoutTransition: itemLayoutAnimationRef.current });
+ };
+ }, [hasItemLayoutAnimation]);
const legendListProps = {
...rest,
positionComponentInternal,
recycleItems,
+ sizeComponentInternal,
...{
renderScrollComponent: renderReanimatedScrollComponent,
...IsNewArchitecture ? { stickyPositionComponentInternal } : {}