Skip to content

Commit 2c9bdd9

Browse files
Merge upstream develop
2 parents 86abcf7 + 4192f5f commit 2c9bdd9

5 files changed

Lines changed: 181 additions & 93 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Atoms } from '@/src/common/theme';
2+
import React, { isValidElement, useState, type ReactNode } from 'react';
3+
import { LayoutChangeEvent, StyleSheet, type ViewStyle } from 'react-native';
4+
import Animated, {
5+
useAnimatedScrollHandler,
6+
useAnimatedStyle,
7+
useSharedValue,
8+
type StyleProps,
9+
} from 'react-native-reanimated';
10+
11+
// Keep the sticky header fully visible until the user has scrolled past
12+
// this distance; only then does scroll-driven hiding kick in.
13+
const HEADER_HIDE_THRESHOLD = 50;
14+
15+
export function useHidingHeader() {
16+
const lastScrollY = useSharedValue(0);
17+
const headerTranslate = useSharedValue(0);
18+
const headerHeightShared = useSharedValue(0);
19+
const isDragging = useSharedValue(false);
20+
const isMomentum = useSharedValue(false);
21+
const [headerHeight, setHeaderHeight] = useState(0);
22+
23+
const onScroll = useAnimatedScrollHandler({
24+
onBeginDrag: () => {
25+
isDragging.value = true;
26+
},
27+
onEndDrag: () => {
28+
isDragging.value = false;
29+
},
30+
onMomentumBegin: () => {
31+
isMomentum.value = true;
32+
},
33+
onMomentumEnd: () => {
34+
isMomentum.value = false;
35+
},
36+
onScroll: (event) => {
37+
const currentY = event.contentOffset.y;
38+
const h = headerHeightShared.value;
39+
40+
if (currentY <= HEADER_HIDE_THRESHOLD) {
41+
headerTranslate.value = 0;
42+
} else if (isDragging.value || isMomentum.value) {
43+
// Compute delta relative to the threshold so movement past it
44+
// starts the hide from translate 0 instead of snapping.
45+
const lastEffective = Math.max(
46+
0,
47+
lastScrollY.value - HEADER_HIDE_THRESHOLD,
48+
);
49+
const currentEffective = currentY - HEADER_HIDE_THRESHOLD;
50+
const delta = currentEffective - lastEffective;
51+
const next = headerTranslate.value - delta;
52+
headerTranslate.value = Math.min(0, Math.max(-h, next));
53+
}
54+
// Always update so the next user-driven event computes the correct
55+
// delta — even if we skipped animating this tick.
56+
lastScrollY.value = currentY;
57+
},
58+
});
59+
60+
const headerAnimatedStyle = useAnimatedStyle(() => ({
61+
transform: [{ translateY: headerTranslate.value }],
62+
}));
63+
64+
const onHeaderLayout = (event: LayoutChangeEvent) => {
65+
const next = event.nativeEvent.layout.height;
66+
headerHeightShared.value = next;
67+
if (next !== headerHeight) setHeaderHeight(next);
68+
};
69+
70+
return { onScroll, headerHeight, headerAnimatedStyle, onHeaderLayout };
71+
}
72+
73+
/** The absolutely-positioned, animated wrapper around a sticky header. */
74+
export function HidingHeader({
75+
children,
76+
style,
77+
onLayout,
78+
}: {
79+
children: ReactNode;
80+
style: StyleProps;
81+
onLayout: (event: LayoutChangeEvent) => void;
82+
}) {
83+
return (
84+
<Animated.View
85+
onLayout={onLayout}
86+
style={[Atoms.absolute, styles.stickyHeader, style]}
87+
>
88+
{children}
89+
</Animated.View>
90+
);
91+
}
92+
93+
type ReactNodeOrComponent =
94+
| React.ReactElement
95+
| React.ComponentType
96+
| null
97+
| undefined;
98+
99+
/** Render a header given either an element or a component type. */
100+
export function renderNode(node: ReactNodeOrComponent) {
101+
if (node == null) return null;
102+
if (isValidElement(node)) return node;
103+
const Component = node as React.ComponentType;
104+
return <Component />;
105+
}
106+
107+
const styles = StyleSheet.create({
108+
stickyHeader: {
109+
top: 0,
110+
left: 0,
111+
right: 0,
112+
zIndex: 1,
113+
} satisfies ViewStyle,
114+
});

apps/polycentric/src/common/components/List.tsx

Lines changed: 7 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,17 @@ import React, {
1616
useRef,
1717
useState,
1818
} from 'react';
19-
import { LayoutChangeEvent, StyleSheet, View } from 'react-native';
20-
import Animated, {
21-
useAnimatedScrollHandler,
22-
useAnimatedStyle,
23-
useSharedValue,
24-
} from 'react-native-reanimated';
19+
import { View } from 'react-native';
20+
import Animated from 'react-native-reanimated';
2521
import { Atoms } from '../theme';
22+
import { HidingHeader, renderNode, useHidingHeader } from './HidingHeader';
2623

2724
// A reanimated-compatible FlashList.
2825
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
2926

3027
const WEB_INITIAL_VISIBLE = 12;
3128
const WEB_PAGE_SIZE = 12;
3229

33-
// Keep the sticky header fully visible until the user has scrolled past
34-
// this distance; only then does scroll-driven hiding kick in.
35-
const HEADER_HIDE_THRESHOLD = 50;
36-
3730
export type { FlashListProps, ListRenderItem, ListRenderItemInfo };
3831

3932
export type ListProps<T> = FlashListProps<T> & {
@@ -78,59 +71,8 @@ function NativeList<T>({
7871
}),
7972
[],
8073
);
81-
const lastScrollY = useSharedValue(0);
82-
const headerTranslate = useSharedValue(0);
83-
const headerHeightShared = useSharedValue(0);
84-
const isDragging = useSharedValue(false);
85-
const isMomentum = useSharedValue(false);
86-
const [headerHeight, setHeaderHeight] = useState(0);
87-
88-
const onScroll = useAnimatedScrollHandler({
89-
onBeginDrag: () => {
90-
isDragging.value = true;
91-
},
92-
onEndDrag: () => {
93-
isDragging.value = false;
94-
},
95-
onMomentumBegin: () => {
96-
isMomentum.value = true;
97-
},
98-
onMomentumEnd: () => {
99-
isMomentum.value = false;
100-
},
101-
onScroll: (event) => {
102-
const currentY = event.contentOffset.y;
103-
const h = headerHeightShared.value;
104-
105-
if (currentY <= HEADER_HIDE_THRESHOLD) {
106-
headerTranslate.value = 0;
107-
} else if (isDragging.value || isMomentum.value) {
108-
// Compute delta relative to the threshold so movement past it
109-
// starts the hide from translate 0 instead of snapping.
110-
const lastEffective = Math.max(
111-
0,
112-
lastScrollY.value - HEADER_HIDE_THRESHOLD,
113-
);
114-
const currentEffective = currentY - HEADER_HIDE_THRESHOLD;
115-
const delta = currentEffective - lastEffective;
116-
const next = headerTranslate.value - delta;
117-
headerTranslate.value = Math.min(0, Math.max(-h, next));
118-
}
119-
// Always update so the next user-driven event computes the
120-
// correct delta — even if we skipped animating this tick.
121-
lastScrollY.value = currentY;
122-
},
123-
});
124-
125-
const headerAnimatedStyle = useAnimatedStyle(() => ({
126-
transform: [{ translateY: headerTranslate.value }],
127-
}));
128-
129-
const onHeaderLayout = (event: LayoutChangeEvent) => {
130-
const next = event.nativeEvent.layout.height;
131-
headerHeightShared.value = next;
132-
if (next !== headerHeight) setHeaderHeight(next);
133-
};
74+
const { onScroll, headerHeight, headerAnimatedStyle, onHeaderLayout } =
75+
useHidingHeader();
13476

13577
const renderedHeader = renderNode(HeaderComponent);
13678

@@ -149,12 +91,9 @@ function NativeList<T>({
14991
return (
15092
<View style={[Atoms.flex_1]}>
15193
{renderedHeader ? (
152-
<Animated.View
153-
onLayout={onHeaderLayout}
154-
style={[Atoms.absolute, styles.stickyHeader, headerAnimatedStyle]}
155-
>
94+
<HidingHeader style={headerAnimatedStyle} onLayout={onHeaderLayout}>
15695
{renderedHeader}
157-
</Animated.View>
96+
</HidingHeader>
15897
) : null}
15998

16099
<AnimatedFlashList
@@ -261,25 +200,3 @@ function WebFeedViewer<T>({
261200
</View>
262201
);
263202
}
264-
265-
type ReactNodeOrComponent =
266-
| React.ReactElement
267-
| React.ComponentType
268-
| null
269-
| undefined;
270-
271-
function renderNode(node: ReactNodeOrComponent) {
272-
if (node == null) return null;
273-
if (isValidElement(node)) return node;
274-
const Component = node as React.ComponentType;
275-
return <Component />;
276-
}
277-
278-
const styles = StyleSheet.create({
279-
stickyHeader: {
280-
top: 0,
281-
left: 0,
282-
right: 0,
283-
zIndex: 1,
284-
},
285-
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Atoms } from '@/src/common/theme';
2+
import React from 'react';
3+
import {
4+
type ScrollViewProps as RNScrollViewProps,
5+
StyleSheet,
6+
View,
7+
} from 'react-native';
8+
import Animated from 'react-native-reanimated';
9+
import { HidingHeader, renderNode, useHidingHeader } from './HidingHeader';
10+
11+
export type ScrollViewProps = RNScrollViewProps & {
12+
/**
13+
* A sticky header that hides as you scroll down
14+
*/
15+
HeaderComponent?:
16+
| React.ComponentType<unknown>
17+
| React.ReactElement
18+
| null
19+
| undefined;
20+
};
21+
22+
export function ScrollView({
23+
HeaderComponent,
24+
contentContainerStyle,
25+
onScroll: _ignoredOnScroll,
26+
children,
27+
...rest
28+
}: ScrollViewProps) {
29+
const { onScroll, headerHeight, headerAnimatedStyle, onHeaderLayout } =
30+
useHidingHeader();
31+
32+
const header = renderNode(HeaderComponent);
33+
34+
return (
35+
<View style={Atoms.flex_1}>
36+
{header ? (
37+
<HidingHeader style={headerAnimatedStyle} onLayout={onHeaderLayout}>
38+
{header}
39+
</HidingHeader>
40+
) : null}
41+
42+
<Animated.ScrollView
43+
{...rest}
44+
onScroll={onScroll}
45+
scrollEventThrottle={16}
46+
contentContainerStyle={StyleSheet.flatten([
47+
{ paddingTop: headerHeight },
48+
contentContainerStyle,
49+
])}
50+
>
51+
{children}
52+
</Animated.ScrollView>
53+
</View>
54+
);
55+
}

apps/polycentric/src/features/identity-pairing/screens/PairIdentityIssuerScreen.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ export default function PairIdentityIssuerScreen() {
181181
return (
182182
<Sheet
183183
open={!!claimerStr}
184-
detents={['auto', 1]}
184+
detents={[0.6, 1]}
185185
dismissible
186+
scrollable
186187
onClose={closeAndDeny}
187188
>
188189
{(() => {

apps/polycentric/src/features/settings/SettingsScreen.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@/src/common/components';
1010
import Icon from '@/src/common/components/Icon';
1111
import Topbar from '@/src/common/components/layout/Topbar';
12+
import { ScrollView } from '@/src/common/components/ScrollView';
1213
import {
1314
REPORT_BUG_URL,
1415
Routes,
@@ -19,7 +20,7 @@ import { useCurrentIdentity } from '@/src/common/lib/polycentric-hooks';
1920
import { Atoms, useTheme } from '@/src/common/theme';
2021
import { useProfile } from '@/src/features/profile/hooks/useProfile';
2122
import { router } from 'expo-router';
22-
import { Linking, ScrollView, View } from 'react-native';
23+
import { Linking, View } from 'react-native';
2324

2425
function AppearanceSettingRow() {
2526
const { theme, setActiveThemeName } = useTheme();
@@ -55,10 +56,10 @@ export default function SettingsTabScreen() {
5556
<Screen.PrimaryColumn>
5657
<View style={[Atoms.flex_1]}>
5758
<ScrollView
59+
HeaderComponent={<Topbar title="Settings" />}
5860
showsVerticalScrollIndicator={false}
5961
contentContainerStyle={[{ paddingBottom: TAB_BAR_HEIGHT + 16 }]}
6062
>
61-
<Topbar title="Settings" />
6263
<View style={[Atoms.p_lg, Atoms.gap_xl]}>
6364
<ListItemWrapper
6465
onPress={() => router.push(Routes.tabs.settings.identity)}

0 commit comments

Comments
 (0)