Skip to content

Commit f6b628f

Browse files
committed
Cleanup the scroll logic for the claims form
1 parent 9e8016a commit f6b628f

4 files changed

Lines changed: 107 additions & 48 deletions

File tree

apps/polycentric/src/features/verifications/CreateClaim.tsx

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,44 @@
11
import { Text } from '@/src/common/components';
22
import { Atoms, useTheme } from '@/src/common/theme';
3-
import { useMemo, useState } from 'react';
3+
import { useMemo, useRef, useState } from 'react';
44
import { View } from 'react-native';
5-
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated';
5+
import Animated, {
6+
FadeInDown,
7+
FadeOutDown,
8+
useAnimatedRef,
9+
} from 'react-native-reanimated';
610
import { ClaimForm } from './ClaimForm';
711
import { CLAIM_TYPES, ClaimType } from './utils/forms';
812
import { PlatformPicker } from './PlatformPicker';
913
import { SelectChip } from './SelectChip';
14+
import { useScrollIntoView } from './VerificationsScrollContext';
1015

11-
export function CreateClaim({
12-
onSubmitted,
13-
onPlatformSelected,
14-
}: {
15-
onSubmitted?: () => void;
16-
onPlatformSelected?: () => void;
17-
}) {
16+
export function CreateClaim({ onSubmitted }: { onSubmitted?: () => void }) {
1817
const { theme } = useTheme();
1918
const [selectedClaimType, setSelectedClaimType] =
2019
useState<ClaimType['name']>();
2120

21+
const scrollIntoView = useScrollIntoView();
22+
const formRef = useAnimatedRef<Animated.View>();
23+
// Scroll on the layout pass after a selection, not on later relayouts.
24+
const pendingScroll = useRef(false);
25+
2226
const selected = useMemo(
2327
() => CLAIM_TYPES.find((s) => s.name === selectedClaimType),
2428
[selectedClaimType],
2529
);
2630

27-
const onSelectClaimType = (name: ClaimType['name']) =>
28-
setSelectedClaimType(name === selectedClaimType ? undefined : name);
31+
const onSelectClaimType = (name: ClaimType['name']) => {
32+
const next = name === selectedClaimType ? undefined : name;
33+
setSelectedClaimType(next);
34+
if (next) pendingScroll.current = true;
35+
};
36+
37+
const onFormLayout = () => {
38+
if (!pendingScroll.current) return;
39+
pendingScroll.current = false;
40+
scrollIntoView(formRef);
41+
};
2942

3043
const handleSubmitted = () => {
3144
setSelectedClaimType(undefined);
@@ -61,24 +74,20 @@ export function CreateClaim({
6174
</View>
6275
</View>
6376

64-
{/* Platform claims pick a platform and link an account. */}
65-
{selected?.platform && (
66-
<Animated.View
67-
entering={FadeInDown.duration(200)}
68-
exiting={FadeOutDown.duration(150)}
69-
>
70-
<PlatformPicker onSelect={onPlatformSelected} />
71-
</Animated.View>
72-
)}
73-
74-
{/* Everything else is a field form. Keyed so state resets per type. */}
75-
{selected && !selected.platform && (
77+
{/* Keyed so state resets and the enter animation replays per type. */}
78+
{selected && (
7679
<Animated.View
80+
ref={formRef}
7781
key={selected.name}
7882
entering={FadeInDown.duration(200)}
7983
exiting={FadeOutDown.duration(150)}
84+
onLayout={onFormLayout}
8085
>
81-
<ClaimForm claimType={selected} onSubmitted={handleSubmitted} />
86+
{selected.platform ? (
87+
<PlatformPicker />
88+
) : (
89+
<ClaimForm claimType={selected} onSubmitted={handleSubmitted} />
90+
)}
8291
</Animated.View>
8392
)}
8493
</View>

apps/polycentric/src/features/verifications/PlatformPicker.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { Button, Text, TextInput } from '@/src/common/components';
22
import { Atoms, PaletteColorToken, useTheme } from '@/src/common/theme';
33
import { FontAwesome6 } from '@expo/vector-icons';
44
import { useCurrentIdentity } from '@/src/common/lib/polycentric-hooks';
5-
import { ReactNode, useState } from 'react';
5+
import { ReactNode, useRef, useState } from 'react';
66
import { View } from 'react-native';
7-
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated';
7+
import Animated, {
8+
FadeInDown,
9+
FadeOutDown,
10+
useAnimatedRef,
11+
} from 'react-native-reanimated';
812
import { SelectChip } from './SelectChip';
13+
import { useScrollIntoView } from './VerificationsScrollContext';
914

1015
// A platform a claim can be verified against. `logo` matches SelectChip's icon
1116
// render-prop; `color` tints the logo and its chip; `location` is where the
@@ -105,14 +110,16 @@ function isProfileUrl(value: string): boolean {
105110
}
106111

107112
// Pick a platform, then link the account by pasting a pairing token into it.
108-
// `onSelect` fires after a platform is chosen so the parent can scroll the
109-
// freshly revealed link form into view.
110-
export function PlatformPicker({ onSelect }: { onSelect?: () => void }) {
113+
export function PlatformPicker() {
111114
const { theme } = useTheme();
112115
const { identityKey } = useCurrentIdentity();
113116
const [selected, setSelected] = useState<Platform>();
114117
const [profileUrl, setProfileUrl] = useState('');
115118

119+
const scrollIntoView = useScrollIntoView();
120+
const linkFormRef = useAnimatedRef<Animated.View>();
121+
const pendingScroll = useRef(false);
122+
116123
// Loop-back link the user adds to their profile to prove ownership.
117124
const loopbackLink = identityKey
118125
? `https://polycentric.io/${identityKey}`
@@ -121,7 +128,13 @@ export function PlatformPicker({ onSelect }: { onSelect?: () => void }) {
121128
const onSelectPlatform = (platform: Platform) => {
122129
setSelected(platform);
123130
setProfileUrl('');
124-
onSelect?.();
131+
pendingScroll.current = true;
132+
};
133+
134+
const onLinkFormLayout = () => {
135+
if (!pendingScroll.current) return;
136+
pendingScroll.current = false;
137+
scrollIntoView(linkFormRef);
125138
};
126139

127140
return (
@@ -153,9 +166,11 @@ export function PlatformPicker({ onSelect }: { onSelect?: () => void }) {
153166

154167
{selected && (
155168
<Animated.View
169+
ref={linkFormRef}
156170
key={selected.name}
157171
entering={FadeInDown.duration(200)}
158172
exiting={FadeOutDown.duration(150)}
173+
onLayout={onLinkFormLayout}
159174
style={[Atoms.gap_sm, Atoms.mt_sm]}
160175
>
161176
<Text
@@ -187,6 +202,7 @@ export function PlatformPicker({ onSelect }: { onSelect?: () => void }) {
187202
<Text
188203
variant="body"
189204
style={[theme.atoms.text, { fontFamily: 'monospace' }]}
205+
selectable={true}
190206
>
191207
{loopbackLink}
192208
</Text>

apps/polycentric/src/features/verifications/VerificationsScreen.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,46 +4,60 @@ import Topbar from '@/src/common/components/layout/Topbar';
44
import { ScrollView } from '@/src/common/components/ScrollView';
55
import { Atoms, Spacing, useTheme } from '@/src/common/theme';
66
import { isWeb } from '@/src/common/util/platform';
7-
import { useRef, useState } from 'react';
7+
import { type Ref, useCallback, useState } from 'react';
88
import { View } from 'react-native';
9-
import Animated from 'react-native-reanimated';
9+
import Animated, {
10+
type AnimatedRef,
11+
measure,
12+
scrollTo,
13+
useAnimatedRef,
14+
useScrollOffset,
15+
} from 'react-native-reanimated';
16+
import { scheduleOnUI } from 'react-native-worklets';
1017
import { CreateClaim } from './CreateClaim';
1118
import { SelectChip } from './SelectChip';
19+
import { VerificationsScrollProvider } from './VerificationsScrollContext';
1220
import { useSafeAreaInsets } from 'react-native-safe-area-context';
1321

1422
type Mode = 'create' | 'verify';
1523

24+
const MARGIN = Spacing.lg;
25+
1626
export default function VerificationsScreen() {
1727
const { theme } = useTheme();
1828
const insets = useSafeAreaInsets();
1929
const [mode, setMode] = useState<Mode>();
20-
const scrollRef = useRef<Animated.ScrollView>(null);
21-
// Set when something below the fold is revealed; the scroll happens once the
22-
// content actually grows (see onContentSizeChange), so we measure the new
23-
// height rather than the old one.
24-
const pendingScroll = useRef(false);
30+
const scrollRef = useAnimatedRef<Animated.ScrollView>();
31+
const scrollOffset = useScrollOffset(scrollRef);
2532

2633
const select = (next: Mode) =>
2734
setMode((prev) => (prev === next ? undefined : next));
2835

29-
const scrollToBottom = () => {
30-
pendingScroll.current = true;
31-
};
36+
// Align a revealed form section to the top of the viewport.
37+
const scrollIntoView = useCallback(
38+
(target: AnimatedRef<Animated.View>) => {
39+
scheduleOnUI(() => {
40+
'worklet';
41+
const el = measure(target);
42+
const scroll = measure(scrollRef);
43+
if (el === null || scroll === null) return;
44+
const y = scrollOffset.value + el.pageY - scroll.pageY - MARGIN;
45+
scrollTo(scrollRef, 0, Math.max(0, y), true);
46+
});
47+
},
48+
[scrollRef, scrollOffset],
49+
);
3250

3351
return (
34-
<Screen>
52+
<Screen keyboardAvoiding>
3553
<Screen.PrimaryColumn>
3654
<ScrollView
37-
ref={scrollRef}
55+
// useAnimatedRef works as a ref at runtime but isn't typed as one.
56+
ref={scrollRef as Ref<Animated.ScrollView>}
3857
HeaderComponent={() => (
3958
<Topbar title="Verifications" left={isWeb ? <></> : undefined} />
4059
)}
4160
showsVerticalScrollIndicator={false}
42-
onContentSizeChange={() => {
43-
if (!pendingScroll.current) return;
44-
pendingScroll.current = false;
45-
scrollRef.current?.scrollToEnd({ animated: true });
46-
}}
4761
>
4862
<View
4963
style={[
@@ -72,7 +86,9 @@ export default function VerificationsScreen() {
7286
</View>
7387

7488
{mode === 'create' && (
75-
<CreateClaim onPlatformSelected={scrollToBottom} />
89+
<VerificationsScrollProvider value={scrollIntoView}>
90+
<CreateClaim />
91+
</VerificationsScrollProvider>
7692
)}
7793

7894
{mode === 'verify' && (
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createContext, useContext } from 'react';
2+
import type Animated from 'react-native-reanimated';
3+
import type { AnimatedRef } from 'react-native-reanimated';
4+
5+
// Scrolls the given section to the top of the screen's scroll viewport.
6+
export type ScrollIntoView = (target: AnimatedRef<Animated.View>) => void;
7+
8+
const Context = createContext<ScrollIntoView | null>(null);
9+
10+
export const VerificationsScrollProvider = Context.Provider;
11+
12+
export function useScrollIntoView() {
13+
const ctx = useContext(Context);
14+
if (!ctx) {
15+
throw new Error('useScrollIntoView must be used within its provider');
16+
}
17+
return ctx;
18+
}

0 commit comments

Comments
 (0)