Skip to content

Commit 6f8c82b

Browse files
committed
feat: implement navigation priority stacking
1 parent 06b9eed commit 6f8c82b

3 files changed

Lines changed: 86 additions & 29 deletions

File tree

package/src/components/UIComponents/PortalWhileClosingView.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import { Portal } from 'react-native-teleport';
88
import { useStableCallback } from '../../hooks';
99
import {
1010
clearClosingPortalLayout,
11+
createClosingPortalLayoutRegistrationId,
1112
setClosingPortalLayout,
12-
useOverlayController,
13+
useShouldTeleportToClosingPortal,
1314
} from '../../state-store';
1415

1516
type PortalWhileClosingViewProps = {
@@ -39,15 +40,16 @@ type PortalWhileClosingViewProps = {
3940
* This wrapper moves that UI into the overlay host layer for the closing phase, so stacking stays correct.
4041
*
4142
* To use it, simply wrap any view that should remain on top while the overlay is closing, and pass a `portalHostName`
42-
* and a `portalName`. Registration within the host layer will happen automatically, as will calculating layout.
43+
* and a `portalName`. Once the wrapped view has a valid measured layout, it can participate in the closing host layer.
4344
*
4445
* Behavior:
4546
* - renders children in place during normal operation
46-
* - registers absolute layout for `portalHostName`
47+
* - registers absolute layout for `portalHostName` once a valid measurement exists
4748
* - while overlay state is `closing`, teleports children to the matching closing host
4849
* - renders a local placeholder while closing to preserve original layout space
4950
*
50-
* Host registration is done once per key; subsequent layout updates are pushed via shared values.
51+
* Stack presence only starts after first valid measurement. That prevents unmeasured entries from taking over a host
52+
* slot and rendering with incomplete geometry.
5153
*
5254
* Note: As the `PortalWhileClosingView` relies heavily on being able to calculate the layout and positioning
5355
* properties of its children automatically, make sure that you do not wrap absolutely positioned views with
@@ -65,11 +67,18 @@ export const PortalWhileClosingView = ({
6567
portalHostName,
6668
portalName,
6769
}: PortalWhileClosingViewProps) => {
68-
const { closing } = useOverlayController();
6970
const containerRef = useRef<View | null>(null);
71+
const registrationIdRef = useRef<string | null>(null);
7072
const placeholderLayout = useSharedValue({ h: 0, w: 0 });
7173
const insets = useSafeAreaInsets();
7274

75+
if (!registrationIdRef.current) {
76+
registrationIdRef.current = createClosingPortalLayoutRegistrationId();
77+
}
78+
79+
const registrationId = registrationIdRef.current;
80+
const shouldTeleport = useShouldTeleportToClosingPortal(portalHostName, registrationId);
81+
7382
const syncPortalLayout = useStableCallback(() => {
7483
containerRef.current?.measureInWindow((x, y, width, height) => {
7584
const absolute = {
@@ -83,14 +92,20 @@ export const PortalWhileClosingView = ({
8392

8493
placeholderLayout.value = { h: height, w: width };
8594

86-
setClosingPortalLayout(portalHostName, {
95+
setClosingPortalLayout(portalHostName, registrationId, {
8796
...absolute,
8897
h: height,
8998
w: width,
9099
});
91100
});
92101
});
93102

103+
useEffect(() => {
104+
return () => {
105+
clearClosingPortalLayout(portalHostName, registrationId);
106+
};
107+
}, [portalHostName, registrationId]);
108+
94109
useEffect(() => {
95110
// Measure once after mount and layout settle.
96111
requestAnimationFrame(() => {
@@ -100,27 +115,19 @@ export const PortalWhileClosingView = ({
100115
});
101116
}, [insets.top, portalHostName, syncPortalLayout]);
102117

103-
const unregisterPortalHost = useStableCallback(() => clearClosingPortalLayout(portalHostName));
104-
105-
useEffect(() => {
106-
return () => {
107-
unregisterPortalHost();
108-
};
109-
}, [unregisterPortalHost]);
110-
111118
const placeholderStyle = useAnimatedStyle(() => ({
112119
height: placeholderLayout.value.h,
113120
width: placeholderLayout.value.w > 0 ? placeholderLayout.value.w : '100%',
114121
}));
115122

116123
return (
117124
<>
118-
<Portal hostName={closing ? portalHostName : undefined} name={portalName}>
125+
<Portal hostName={shouldTeleport ? portalHostName : undefined} name={portalName}>
119126
<View collapsable={false} ref={containerRef} onLayout={syncPortalLayout}>
120127
{children}
121128
</View>
122129
</Portal>
123-
{closing ? <Animated.View pointerEvents='none' style={placeholderStyle} /> : null}
130+
{shouldTeleport ? <Animated.View pointerEvents='none' style={placeholderStyle} /> : null}
124131
</>
125132
);
126133
};

package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import { StyleSheet } from 'react-native';
33
import Animated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated';
44
import { PortalHost } from 'react-native-teleport';
@@ -42,16 +42,31 @@ type ClosingPortalHostsLayerProps = {
4242
};
4343

4444
export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHostsLayerProps) => {
45-
const closingPortalLayouts = useClosingPortalLayouts();
45+
const closingPortalLayoutStacks = useClosingPortalLayouts();
46+
const closingPortalHosts = useMemo(() => {
47+
const topHosts: Array<{
48+
hostName: string;
49+
layout: ClosingPortalLayoutEntry['layout'];
50+
}> = [];
51+
52+
Object.entries(closingPortalLayoutStacks).forEach(([hostName, entries]) => {
53+
const topEntry = entries[entries.length - 1];
54+
if (topEntry) {
55+
topHosts.push({ hostName, layout: topEntry.layout });
56+
}
57+
});
58+
59+
return topHosts;
60+
}, [closingPortalLayoutStacks]);
4661

4762
return (
4863
<>
49-
{Object.entries(closingPortalLayouts).map(([hostName, entry]) => (
64+
{closingPortalHosts.map(({ hostName, layout }) => (
5065
<ClosingPortalHostSlot
5166
closeCoverOpacity={closeCoverOpacity}
5267
hostName={hostName}
5368
key={hostName}
54-
layout={entry.layout}
69+
layout={layout}
5570
/>
5671
))}
5772
</>

package/src/state-store/message-overlay-store.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ type OverlayState = {
1414

1515
export type Rect = { x: number; y: number; w: number; h: number } | undefined;
1616
export type ClosingPortalLayoutEntry = {
17+
id: string;
1718
layout: SharedValue<Rect>;
1819
};
1920
type ClosingPortalLayoutsState = {
20-
layouts: Record<string, ClosingPortalLayoutEntry>;
21+
layouts: Record<string, ClosingPortalLayoutEntry[]>;
2122
};
2223

2324
const DefaultState = {
@@ -28,6 +29,8 @@ const DefaultClosingPortalLayoutsState: ClosingPortalLayoutsState = {
2829
layouts: {},
2930
};
3031

32+
let closingPortalLayoutRegistrationCounter = 0;
33+
3134
type OverlaySharedValueController = {
3235
incrementCloseCorrectionY: (deltaY: number) => void;
3336
resetCloseCorrectionY: () => void;
@@ -96,9 +99,13 @@ const closingPortalLayoutsStore = new StateStore<ClosingPortalLayoutsState>(
9699
DefaultClosingPortalLayoutsState,
97100
);
98101

99-
export const setClosingPortalLayout = (hostName: string, layout: Rect) => {
102+
export const createClosingPortalLayoutRegistrationId = () =>
103+
`closing-portal-layout-${closingPortalLayoutRegistrationCounter++}`;
104+
105+
export const setClosingPortalLayout = (hostName: string, id: string, layout: Rect) => {
100106
const { layouts } = closingPortalLayoutsStore.getLatestValue();
101-
const existingEntry = layouts[hostName];
107+
const hostEntries = layouts[hostName] ?? [];
108+
const existingEntry = hostEntries.find((entry) => entry.id === id);
102109

103110
if (existingEntry) {
104111
existingEntry.layout.value = layout;
@@ -110,17 +117,28 @@ export const setClosingPortalLayout = (hostName: string, layout: Rect) => {
110117
closingPortalLayoutsStore.next({
111118
layouts: {
112119
...layouts,
113-
[hostName]: {
114-
layout: makeMutable<Rect>(layout),
115-
},
120+
[hostName]: [...hostEntries, { id, layout: makeMutable<Rect>(layout) }],
116121
},
117122
});
118123
};
119124

120-
export const clearClosingPortalLayout = (hostName: string) => {
125+
export const clearClosingPortalLayout = (hostName: string, id: string) => {
121126
const { layouts } = closingPortalLayoutsStore.getLatestValue();
127+
const hostEntries = layouts[hostName];
128+
129+
if (!hostEntries?.length) {
130+
return;
131+
}
132+
133+
const nextHostEntries = hostEntries.filter((entry) => entry.id !== id);
122134
const nextLayouts = { ...layouts };
123-
delete nextLayouts[hostName];
135+
136+
if (nextHostEntries.length === 0) {
137+
delete nextLayouts[hostName];
138+
} else {
139+
nextLayouts[hostName] = nextHostEntries;
140+
}
141+
124142
closingPortalLayoutsStore.next({ layouts: nextLayouts });
125143
};
126144

@@ -148,14 +166,31 @@ export const useOverlayController = () => {
148166
return useStateStore(overlayStore, selector);
149167
};
150168

169+
const overlayClosingSelector = (nextState: OverlayState) => ({
170+
closing: nextState.closing,
171+
});
172+
173+
export const useIsOverlayClosing = () => {
174+
return useStateStore(overlayStore, overlayClosingSelector).closing;
175+
};
176+
177+
export const useShouldTeleportToClosingPortal = (hostName: string, id: string) => {
178+
const closing = useIsOverlayClosing();
179+
const closingPortalLayouts = useClosingPortalLayouts();
180+
const hostEntries = closingPortalLayouts[hostName];
181+
182+
return !!closing && hostEntries?.[hostEntries.length - 1]?.id === id;
183+
};
184+
151185
/**
152186
* NOTE:
153187
* Do not swap this back to `useStateStore(closingPortalLayoutsStore, selector)`.
154188
*
155189
* Why this is special:
156190
* - `layouts` is a dynamic-key map (hosts are added/removed at runtime)
191+
* - each host key maintains a stack of registrations
157192
* - We only need React updates when the key set changes (add/remove/reset)
158-
* - Per-layout movement is already on UI thread via `entry.layout.value`
193+
* - Per-layout movement is already on UI thread via the top `entry.layout.value`
159194
*
160195
* Why `useStateStore` is unsafe here:
161196
* - Both `stream-chat`'s `subscribeWithSelector` and our `useStateStore` snapshot

0 commit comments

Comments
 (0)