Skip to content

Commit 1413af2

Browse files
MatiPl01Copilot
andauthored
fix: Teleported active item flickering (#405)
## Description Describe what was changed in this pull request ## Changes showcase Include example images/recordings if the new feature introduces some visual changes or the PR fixes a UI bug --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent df484bd commit 1413af2

11 files changed

Lines changed: 144 additions & 260 deletions

File tree

packages/react-native-sortables/src/components/shared/AnimatedOnLayoutView.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { componentWithRef } from '../../utils/react';
1111
* (onLayout is called with 0 dimensions for views which have display: none,
1212
* so it gets called on navigation between screens)
1313
*/
14-
const AnimatedViewWeb = componentWithRef<
14+
const AnimatedOnLayoutView = componentWithRef<
1515
View,
1616
Omit<AnimatedProps<ViewProps>, 'onLayout'> & {
17-
onLayout: NonNullable<ViewProps['onLayout']>;
17+
onLayout: ViewProps['onLayout'];
1818
}
19-
>(function AnimatedViewWeb({ onLayout, ...rest }, ref) {
19+
>(function AnimatedOnLayoutView({ onLayout, ...rest }, ref) {
2020
return (
2121
<Animated.View
2222
{...rest}
@@ -26,11 +26,11 @@ const AnimatedViewWeb = componentWithRef<
2626
// We want to call onLayout only for displayed views to prevent
2727
// layout updates on navigation between screens
2828
if (el?.offsetParent) {
29-
onLayout(e);
29+
onLayout?.(e);
3030
}
3131
}}
3232
/>
3333
);
3434
});
3535

36-
export default IS_WEB ? AnimatedViewWeb : Animated.View;
36+
export default IS_WEB ? AnimatedOnLayoutView : Animated.View;

packages/react-native-sortables/src/components/shared/DraggableView/ActiveItemPortal.tsx

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,58 @@
1-
import type { PropsWithChildren, ReactNode } from 'react';
2-
import { useEffect } from 'react';
3-
import type { SharedValue } from 'react-native-reanimated';
4-
import { runOnJS, useAnimatedReaction } from 'react-native-reanimated';
1+
import { type PropsWithChildren, type ReactNode, useEffect } from 'react';
2+
import {
3+
runOnJS,
4+
type SharedValue,
5+
useAnimatedReaction,
6+
useSharedValue
7+
} from 'react-native-reanimated';
58

69
import { usePortalContext } from '../../../providers';
7-
import { ItemPortalState } from '../../../types';
810

911
type ActiveItemPortalProps = PropsWithChildren<{
1012
teleportedItemId: string;
1113
activationAnimationProgress: SharedValue<number>;
12-
portalState: SharedValue<ItemPortalState>;
1314
renderTeleportedItemCell: () => ReactNode;
15+
setIsTeleported: (isTeleported: boolean) => void;
1416
}>;
1517

1618
export default function ActiveItemPortal({
1719
activationAnimationProgress,
1820
children,
19-
portalState,
2021
renderTeleportedItemCell,
22+
setIsTeleported,
2123
teleportedItemId
2224
}: ActiveItemPortalProps) {
23-
const { subscribe, teleport } = usePortalContext()!;
25+
const { teleport } = usePortalContext()!;
26+
const teleportEnabled = useSharedValue(false);
2427

2528
useEffect(() => {
26-
const unsubscribe = subscribe(teleportedItemId, teleported => {
27-
if (teleported) {
28-
portalState.value = ItemPortalState.TELEPORTED;
29-
}
30-
});
31-
32-
return () => {
33-
unsubscribe();
34-
teleport(teleportedItemId, null);
35-
};
36-
}, [portalState, subscribe, teleport, teleportedItemId]);
37-
38-
useEffect(() => {
39-
if (portalState.value === ItemPortalState.TELEPORTED) {
40-
// Renders a component in the portal outlet
29+
if (teleportEnabled.value) {
4130
teleport(teleportedItemId, renderTeleportedItemCell());
4231
}
43-
}, [
44-
portalState,
45-
teleportedItemId,
46-
renderTeleportedItemCell,
47-
teleport,
48-
children
49-
]);
32+
// This is fine, we want to update the teleported item cell only when
33+
// the children change
34+
// eslint-disable-next-line react-hooks/exhaustive-deps
35+
}, [children]);
5036

5137
const enableTeleport = () => {
5238
teleport(teleportedItemId, renderTeleportedItemCell());
39+
setIsTeleported(true);
40+
};
41+
42+
const disableTeleport = () => {
43+
runOnJS(teleport)(teleportedItemId, null);
44+
setIsTeleported(false);
5345
};
5446

5547
useAnimatedReaction(
5648
() => activationAnimationProgress.value,
5749
progress => {
58-
if (progress > 0 && portalState.value === ItemPortalState.IDLE) {
59-
portalState.value = ItemPortalState.TELEPORTING;
50+
if (progress > 0 && !teleportEnabled.value) {
51+
teleportEnabled.value = true;
6052
runOnJS(enableTeleport)();
61-
} else if (
62-
progress === 0 &&
63-
portalState.value === ItemPortalState.TELEPORTED
64-
) {
65-
portalState.value = ItemPortalState.EXITING;
53+
} else if (progress === 0 && teleportEnabled.value) {
54+
teleportEnabled.value = false;
55+
runOnJS(disableTeleport)();
6656
}
6757
}
6858
);

packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PropsWithChildren, ReactNode } from 'react';
2-
import { Fragment, memo, useEffect } from 'react';
2+
import { Fragment, memo, useEffect, useState } from 'react';
3+
import { StyleSheet, type ViewStyle } from 'react-native';
34
import { GestureDetector } from 'react-native-gesture-handler';
45
import {
56
LayoutAnimationConfig,
@@ -17,12 +18,7 @@ import {
1718
useMeasurementsContext,
1819
usePortalContext
1920
} from '../../../providers';
20-
import {
21-
type AnimatedStyleProp,
22-
ItemPortalState,
23-
type LayoutAnimation,
24-
type MeasureCallback
25-
} from '../../../types';
21+
import type { AnimatedStyleProp, LayoutAnimation } from '../../../types';
2622
import { getContextProvider } from '../../../utils';
2723
import ActiveItemPortal from './ActiveItemPortal';
2824
import ItemCell from './ItemCell';
@@ -50,21 +46,22 @@ function DraggableView({
5046
const { activeItemKey, componentId, customHandle, itemsOverridesStyle } =
5147
commonValuesContext;
5248

49+
const teleportedItemId = `${componentId}-${key}`;
50+
51+
const [isTeleported, setIsTeleported] = useState(false);
5352
const activationAnimationProgress = useSharedValue(0);
5453
const isActive = useDerivedValue(() => activeItemKey.value === key);
55-
const portalState = useSharedValue(ItemPortalState.IDLE);
5654
const layoutStyles = useItemLayoutStyles(key, activationAnimationProgress);
5755
const decorationStyles = useItemDecorationStyles(
5856
key,
5957
isActive,
60-
activationAnimationProgress,
61-
portalState
58+
activationAnimationProgress
6259
);
6360
const gesture = useItemPanGesture(key, activationAnimationProgress);
6461

6562
useEffect(() => {
6663
return () => removeItemMeasurements(key);
67-
}, [key, removeItemMeasurements]);
64+
}, [key, removeItemMeasurements, teleportedItemId]);
6865

6966
const withItemContext = (component: ReactNode) => (
7067
<ItemContextProvider
@@ -76,14 +73,16 @@ function DraggableView({
7673
</ItemContextProvider>
7774
);
7875

79-
const renderItemCell = (onMeasure: MeasureCallback) => {
76+
const renderItemCell = (styleOverride?: ViewStyle) => {
8077
const innerComponent = (
8178
<ItemCell
8279
{...layoutAnimations}
83-
cellStyle={[style, layoutStyles]}
80+
cellStyle={[style, layoutStyles, styleOverride]}
8481
decorationStyles={decorationStyles}
8582
itemsOverridesStyle={itemsOverridesStyle}
86-
onMeasure={onMeasure}>
83+
onMeasure={(width, height) =>
84+
handleItemMeasurement(key, { height, width })
85+
}>
8786
<LayoutAnimationConfig skipEntering={false} skipExiting={false}>
8887
{children}
8988
</LayoutAnimationConfig>
@@ -104,27 +103,11 @@ function DraggableView({
104103
// NORMAL CASE (no portal)
105104

106105
if (!portalContext) {
107-
return renderItemCell((width, height) =>
108-
handleItemMeasurement(key, { height, width })
109-
);
106+
return renderItemCell();
110107
}
111108

112109
// PORTAL CASE
113110

114-
const teleportedItemId = `${componentId}-${key}`;
115-
116-
const onMeasureItem = (width: number, height: number) => {
117-
const state = portalState.value;
118-
if (state === ItemPortalState.EXITING) {
119-
if (height > 0 && width > 0) {
120-
portalContext.teleport(teleportedItemId, null);
121-
portalState.value = ItemPortalState.IDLE;
122-
}
123-
} else if (state !== ItemPortalState.TELEPORTED) {
124-
handleItemMeasurement(key, { height, width });
125-
}
126-
};
127-
128111
const renderTeleportedItemCell = () => (
129112
// We have to wrap the TeleportedItemCell in context providers as they won't
130113
// be accessible otherwise, when the item is rendered in the portal outlet
@@ -135,9 +118,7 @@ function DraggableView({
135118
baseCellStyle={style}
136119
isActive={isActive}
137120
itemKey={key}
138-
itemsOverridesStyle={itemsOverridesStyle}
139-
teleportedItemId={teleportedItemId}
140-
onMeasure={onMeasureItem}>
121+
itemsOverridesStyle={itemsOverridesStyle}>
141122
{children}
142123
</TeleportedItemCell>
143124
)}
@@ -146,16 +127,24 @@ function DraggableView({
146127

147128
return (
148129
<Fragment>
149-
{renderItemCell(onMeasureItem)}
130+
{/* We cannot unmount this item as its gesture detector must be still
131+
mounted to continue handling the pan gesture */}
132+
{renderItemCell(isTeleported ? styles.hidden : undefined)}
150133
<ActiveItemPortal
151134
activationAnimationProgress={activationAnimationProgress}
152-
portalState={portalState}
153135
renderTeleportedItemCell={renderTeleportedItemCell}
136+
setIsTeleported={setIsTeleported}
154137
teleportedItemId={teleportedItemId}>
155138
{children}
156139
</ActiveItemPortal>
157140
</Fragment>
158141
);
159142
}
160143

144+
const styles = StyleSheet.create({
145+
hidden: {
146+
opacity: 0
147+
}
148+
});
149+
161150
export default memo(DraggableView);

packages/react-native-sortables/src/components/shared/DraggableView/ItemCell.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { type PropsWithChildren } from 'react';
2-
import { type ViewStyle } from 'react-native';
1+
import type { PropsWithChildren } from 'react';
2+
import type { LayoutChangeEvent, ViewStyle } from 'react-native';
33
import type { AnimatedStyle } from 'react-native-reanimated';
44
import Animated from 'react-native-reanimated';
55

@@ -14,7 +14,7 @@ export type ItemCellProps = PropsWithChildren<{
1414
decorationStyles: AnimatedStyleProp;
1515
itemsOverridesStyle: AnimatedStyle<ViewStyle>;
1616
cellStyle: AnimatedStyleProp;
17-
onMeasure: MeasureCallback;
17+
onMeasure?: MeasureCallback;
1818
entering?: LayoutAnimation;
1919
exiting?: LayoutAnimation;
2020
}>;
@@ -28,19 +28,23 @@ export default function ItemCell({
2828
itemsOverridesStyle,
2929
onMeasure
3030
}: ItemCellProps) {
31+
const maybeOnLayout = onMeasure
32+
? ({
33+
nativeEvent: {
34+
layout: { height, width }
35+
}
36+
}: LayoutChangeEvent) => {
37+
onMeasure(width, height);
38+
}
39+
: undefined;
40+
3141
return (
3242
<Animated.View style={cellStyle}>
3343
<AnimatedOnLayoutView
3444
entering={entering}
3545
exiting={exiting}
3646
style={[itemsOverridesStyle, decorationStyles]}
37-
onLayout={({
38-
nativeEvent: {
39-
layout: { height, width }
40-
}
41-
}) => {
42-
onMeasure(width, height);
43-
}}>
47+
onLayout={maybeOnLayout}>
4448
{children}
4549
</AnimatedOnLayoutView>
4650
</Animated.View>

packages/react-native-sortables/src/components/shared/DraggableView/TeleportedItemCell.tsx

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,31 @@
1-
import type { SharedValue } from 'react-native-reanimated';
1+
import type { PropsWithChildren } from 'react';
2+
import type { ViewStyle } from 'react-native';
3+
import type { AnimatedStyle, SharedValue } from 'react-native-reanimated';
24
import { LayoutAnimationConfig } from 'react-native-reanimated';
35

46
import {
57
useItemDecorationStyles,
6-
usePortalContext,
78
useTeleportedItemStyles
89
} from '../../../providers';
910
import type { AnimatedStyleProp } from '../../../types';
10-
import type { ItemCellProps } from './ItemCell';
1111
import ItemCell from './ItemCell';
1212

13-
type TeleportedItemCellProps = Omit<
14-
ItemCellProps,
15-
'cellStyle' | 'decorationStyles' | 'entering' | 'exiting' | 'layout'
16-
> & {
13+
type TeleportedItemCellProps = PropsWithChildren<{
14+
itemsOverridesStyle: AnimatedStyle<ViewStyle>;
1715
activationAnimationProgress: SharedValue<number>;
1816
baseCellStyle: AnimatedStyleProp;
1917
isActive: SharedValue<boolean>;
20-
teleportedItemId: string;
2118
itemKey: string;
22-
};
19+
}>;
2320

2421
export default function TeleportedItemCell({
2522
activationAnimationProgress,
2623
baseCellStyle,
2724
children,
2825
isActive,
2926
itemKey,
30-
itemsOverridesStyle,
31-
onMeasure,
32-
teleportedItemId
27+
itemsOverridesStyle
3328
}: TeleportedItemCellProps) {
34-
const { notifyRendered } = usePortalContext() ?? {};
35-
3629
const teleportedItemStyles = useTeleportedItemStyles(
3730
itemKey,
3831
isActive,
@@ -44,23 +37,11 @@ export default function TeleportedItemCell({
4437
activationAnimationProgress
4538
);
4639

47-
if (!notifyRendered) {
48-
return null;
49-
}
50-
5140
return (
5241
<ItemCell
5342
cellStyle={[baseCellStyle, teleportedItemStyles]}
5443
decorationStyles={decorationStyles}
55-
itemsOverridesStyle={itemsOverridesStyle}
56-
onMeasure={(width, height) => {
57-
onMeasure(width, height);
58-
// Mark the teleported item as rendered only after it appeared
59-
// on the screen and its layout calculation is completed
60-
// (see useTeleportedItemStyles in which we set display property
61-
// to 'none' when the animated style is not ready)
62-
notifyRendered(teleportedItemId);
63-
}}>
44+
itemsOverridesStyle={itemsOverridesStyle}>
6445
<LayoutAnimationConfig skipEntering>{children}</LayoutAnimationConfig>
6546
</ItemCell>
6647
);

0 commit comments

Comments
 (0)