Skip to content

Commit 1eae120

Browse files
committed
feat: Add useScrollToFocusedInput() hook
1 parent 4ef4fc1 commit 1eae120

File tree

3 files changed

+167
-5
lines changed

3 files changed

+167
-5
lines changed

features/@app-core/components/styled.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
View as RNView,
77
Pressable as RNPressable,
88
ScrollView as RNScrollView,
9+
KeyboardAvoidingView as RNKeyboardAvoidingView,
910
} from 'react-native'
1011
import { Link as UniversalLink } from '@green-stack/navigation/Link'
1112
import { UniversalLinkProps } from '@green-stack/navigation/Link.types'
@@ -34,6 +35,8 @@ export const ScrollView = remapProps(styled(RNScrollView), {
3435
contentContainerClassName: 'contentContainerStyle',
3536
})
3637

38+
export const KeyboardAvoidingView = styled(RNKeyboardAvoidingView, '')
39+
3740
/* --- Typography ------------------------------------------------------------------------------ */
3841

3942
export const H1 = styled(RNText, 'font-bold text-3xl')

features/@app-core/screens/FormsScreen.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useRef } from 'react'
22
import { StatusBar } from 'expo-status-bar'
3-
import { View, Text, H1, H2, H3, Link, ScrollView } from '../components/styled'
3+
import { View, Text, H1, H2, H3, Link, ScrollView, KeyboardAvoidingView } from '../components/styled'
44
import BackButton from '../components/BackButton'
55
import { TextInput } from '../forms/TextInput.styled'
6-
import { NumberStepper } from '../forms/NumberStepper'
6+
import { NumberStepper } from '../forms/NumberStepper.styled'
77
import { Checkbox } from '../forms/Checkbox.styled'
88
import { useFormState } from '@green-stack/forms/useFormState'
99
import { z, schema } from '@green-stack/schemas'
@@ -12,6 +12,8 @@ import { CheckList } from '../forms/CheckList.styled'
1212
import { RadioGroup } from '../forms/RadioGroup.styled'
1313
import { Select } from '../forms/Select.styled'
1414
import { isEmpty } from '@green-stack/utils/commonUtils'
15+
import { useScrollToFocusedInput } from '@green-stack/hooks/useScrollToFocusedInput'
16+
import { TextArea } from '../forms/TextArea.styled'
1517

1618
/* --- Schema --------------------------------------------------------------------------------- */
1719

@@ -21,6 +23,7 @@ const TestForm = schema('TestForm', {
2123
identifiesWith: z.string().optional(),
2224
excitingFeatures: z.array(z.string()).default([]),
2325
minHourlyPrice: z.number().optional(),
26+
feedbackSuggestions: z.string().optional(),
2427
})
2528

2629
type TestForm = z.input<typeof TestForm>
@@ -32,6 +35,14 @@ const FormsScreen = (props: TestForm) => {
3235
const { setParams } = useRouter()
3336
const params = useRouteParams(props)
3437

38+
// Refs
39+
const emailInputRef = useRef<any$Ignore>(null)
40+
const ageInputRef = useRef<any$Ignore>(null)
41+
const feedbackInputRef = useRef<any$Ignore>(null)
42+
43+
// Hooks
44+
const kbScroller = useScrollToFocusedInput()
45+
3546
// State
3647
const [validateOnChange, setValidateOnChange] = useState(!!params.validateOnChange)
3748
const [showFormState, setShowFormState] = useState(false)
@@ -61,9 +72,10 @@ const FormsScreen = (props: TestForm) => {
6172
// -- Render --
6273

6374
return (
64-
<>
75+
<KeyboardAvoidingView {...kbScroller.avoidingViewProps}>
6576
<StatusBar style="dark" />
6677
<ScrollView
78+
{...kbScroller.scrollViewProps}
6779
className="flex flex-1 min-h-screen bg-white"
6880
contentContainerClassName="min-h-screen"
6981
>
@@ -79,6 +91,7 @@ const FormsScreen = (props: TestForm) => {
7991
<TextInput
8092
placeholder="e.g. [email protected]"
8193
{...formState.getTextInputProps('email')}
94+
{...kbScroller.registerInput(emailInputRef)}
8295
/>
8396

8497
<Text className="text-sm text-secondary mt-2">
@@ -95,6 +108,7 @@ const FormsScreen = (props: TestForm) => {
95108
max={150}
96109
step={1}
97110
{...formState.getInputProps('age')}
111+
{...kbScroller.registerInput(ageInputRef)}
98112
/>
99113

100114
<Text className="text-sm text-secondary mt-2">
@@ -187,6 +201,26 @@ const FormsScreen = (props: TestForm) => {
187201

188202
<View className="h-1 w-12 my-6 bg-slate-300" />
189203

204+
{/* -- TextArea -- */}
205+
206+
<H2 className="text-black">
207+
What's missing?
208+
</H2>
209+
210+
<View className="h-4" />
211+
212+
<TextArea
213+
placeholder="How could we further improve your workflow?"
214+
{...formState.getTextInputProps('feedbackSuggestions')}
215+
{...kbScroller.registerInput(feedbackInputRef)}
216+
/>
217+
218+
<Text className="text-sm text-secondary mt-2">
219+
Feedback or suggestions appreciated
220+
</Text>
221+
222+
<View className="h-1 w-12 my-6 bg-slate-300" />
223+
190224
{/* -- useFormstate -- */}
191225

192226
{validateOnChange && (
@@ -219,11 +253,14 @@ const FormsScreen = (props: TestForm) => {
219253
</>
220254
)}
221255

256+
{kbScroller.keyboardPaddedView}
257+
222258
</View>
223259
</View>
260+
224261
</ScrollView>
225262
<BackButton backLink="/subpages/Universal%20Nav" color="#333333" />
226-
</>
263+
</KeyboardAvoidingView>
227264
)
228265
}
229266

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useState, useEffect, useRef, ComponentProps, MutableRefObject } from 'react'
2+
import { Platform, Keyboard, TextInput, KeyboardAvoidingView } from 'react-native'
3+
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';
4+
5+
/* --- useScrollToFocusedInput() --------------------------------------------------------------- */
6+
7+
export const useScrollToFocusedInput = () => {
8+
// Refs
9+
const scrollViewRef = useRef<any$Ignore>(null)
10+
const scrollYRef = useRef(0)
11+
const focusedInputY = useRef(0)
12+
13+
// State
14+
const [isKeyboardVisible, setIsKeyboardVisible] = useState(Keyboard.isVisible())
15+
16+
// Vars
17+
type KeyboardBehaviour = ComponentProps<typeof KeyboardAvoidingView>['behavior']
18+
const keyboardBehaviour = Platform.OS === 'ios' ? 'padding' : 'height'
19+
const keyboardPadding = Platform.OS === 'ios' ? 300 : 250
20+
const scrollY = scrollYRef.current
21+
22+
// -- Keyboard Management --
23+
24+
useEffect(() => {
25+
26+
const keyboardDidShow = () => setIsKeyboardVisible(true)
27+
const keyboardDidHide = () => {
28+
setIsKeyboardVisible(false)
29+
focusedInputY.current = 0
30+
}
31+
32+
const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', keyboardDidShow)
33+
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', keyboardDidHide)
34+
35+
return () => {
36+
keyboardDidShowListener.remove()
37+
keyboardDidHideListener.remove()
38+
}
39+
}, [])
40+
41+
useEffect(() => {
42+
setTimeout(() => {
43+
scrollViewRef.current?.scrollTo?.({
44+
x: 0,
45+
y: focusedInputY.current || scrollYRef.current,
46+
animated: true,
47+
})
48+
}, 100)
49+
}, [isKeyboardVisible])
50+
51+
// -- Scroll Management --
52+
53+
const handleScroll = (e: any$Ignore) => (scrollYRef.current = e.nativeEvent.contentOffset.y)
54+
55+
const handleFocusInput = (inputRef: MutableRefObject<TextInput | null>) => {
56+
return (nativeEvent: any$Ignore) => {
57+
inputRef?.current?.measure((_x, y, _w, h, _px, py) => {
58+
const focusY = scrollYRef.current + py // = Input position inside the ScrollView
59+
const shouldScroll = focusY > keyboardPadding
60+
const newInputY = focusY - keyboardPadding - h - 8
61+
focusedInputY.current = shouldScroll ? newInputY : 0
62+
})
63+
}
64+
}
65+
66+
// -- Target Management --
67+
68+
const registerInput = (inputRef: MutableRefObject<any$Ignore>) => {
69+
return {
70+
ref: inputRef,
71+
onFocus: handleFocusInput(inputRef),
72+
}
73+
}
74+
75+
// -- Prerender --
76+
77+
const height = useSharedValue(0)
78+
const animatedStyle = useAnimatedStyle(() => ({
79+
height: withTiming(height.value, { duration: 90 }),
80+
}), [height.value])
81+
82+
useEffect(() => {
83+
height.value = isKeyboardVisible ? keyboardPadding : 0
84+
}, [isKeyboardVisible])
85+
86+
const keyboardPaddedView = Platform.OS !== 'web' ? (
87+
<Animated.View
88+
style={[
89+
{
90+
position: 'relative',
91+
width: '100%',
92+
height: 0,
93+
},
94+
animatedStyle,
95+
]}
96+
/>
97+
) : null
98+
99+
// -- Return --
100+
101+
return {
102+
scrollViewRef,
103+
scrollPosition: scrollY,
104+
handleScroll,
105+
handleFocusInput,
106+
keyboardBehaviour,
107+
keyboardPadding,
108+
keyboardPaddedView,
109+
isKeyboardVisible,
110+
avoidingViewProps: {
111+
behavior: keyboardBehaviour as KeyboardBehaviour,
112+
style: { flex: 1 },
113+
enabled: true,
114+
},
115+
scrollViewProps: {
116+
ref: scrollViewRef,
117+
scrollEventThrottle: 16,
118+
onScroll: handleScroll,
119+
},
120+
registerInput,
121+
}
122+
}

0 commit comments

Comments
 (0)