Skip to content

Commit 2569d6b

Browse files
committed
Refactor KavButton into SceneWrapper
- Avoids duplicate inset logic and having to set a sibling component at the callsite. - Also make it more generic in preparation for other button layouts.
1 parent 523df9b commit 2569d6b

File tree

8 files changed

+691
-548
lines changed

8 files changed

+691
-548
lines changed

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@
144144
"react-native-image-colors": "^2.4.0",
145145
"react-native-image-picker": "^8.2.1",
146146
"react-native-in-app-review": "^4.3.5",
147-
"react-native-keyboard-accessory": "^0.1.16",
148147
"react-native-keyboard-aware-scroll-view": "^0.9.5",
149148
"react-native-keyboard-controller": "^1.19.0",
150149
"react-native-linear-gradient": "^2.8.3",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as React from 'react'
2+
3+
import { useHandler } from '../../hooks/useHandler'
4+
import { BlurBackgroundNoRoundedCorners } from '../common/BlurBackground'
5+
import { EdgeAnim, fadeInDown10 } from '../common/EdgeAnim'
6+
import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext'
7+
import type { ButtonInfo } from './ButtonsView'
8+
import { EdgeButton } from './EdgeButton'
9+
10+
interface Props {
11+
primary: ButtonInfo
12+
tertiary?: ButtonInfo
13+
}
14+
15+
// Renders KAV buttons content:
16+
// - If only primary: one full-width primary button
17+
// - If tertiary provided: two mini buttons (tertiary left, primary right)
18+
export const KavButtons: React.FC<Props> = props => {
19+
const { primary, tertiary } = props
20+
const theme = useTheme()
21+
const styles = getStyles(theme)
22+
23+
const handlePrimaryPress = useHandler(() => {
24+
const res = primary.onPress?.()
25+
Promise.resolve(res).catch(() => {})
26+
})
27+
const handleTertiaryPress = useHandler(() => {
28+
const res = tertiary?.onPress?.()
29+
Promise.resolve(res).catch(() => {})
30+
})
31+
32+
if (tertiary == null) {
33+
return (
34+
<EdgeButton
35+
type="primary"
36+
layout="fullWidth"
37+
label={primary.label}
38+
onPress={handlePrimaryPress}
39+
disabled={primary.disabled}
40+
spinner={primary.spinner}
41+
testID={primary.testID}
42+
/>
43+
)
44+
}
45+
46+
return (
47+
<EdgeAnim enter={fadeInDown10} style={styles.container}>
48+
<BlurBackgroundNoRoundedCorners />
49+
<EdgeButton
50+
type="tertiary"
51+
mini
52+
label={tertiary.label}
53+
// We don't want row layout because the buttons will fill the entire
54+
// space
55+
layout="column"
56+
onPress={handleTertiaryPress}
57+
disabled={tertiary.disabled}
58+
spinner={tertiary.spinner}
59+
testID={tertiary.testID}
60+
marginRem={[0.25, 0, 0, 0]} // HACK: The column configuration is the only way to avoid full-width hitslop, but it tightens tertiary spacing to make it look evenly spaced. This undos that spacing.
61+
/>
62+
<EdgeButton
63+
type="primary"
64+
mini
65+
label={primary.label}
66+
// We don't want row layout because the buttons will fill the entire
67+
// space
68+
layout="column"
69+
onPress={handlePrimaryPress}
70+
disabled={primary.disabled}
71+
spinner={primary.spinner}
72+
testID={primary.testID}
73+
/>
74+
</EdgeAnim>
75+
)
76+
}
77+
78+
const getStyles = cacheStyles((theme: Theme) => ({
79+
container: {
80+
position: 'relative',
81+
flexDirection: 'row',
82+
justifyContent: 'space-between',
83+
alignItems: 'center',
84+
padding: theme.rem(0.5)
85+
},
86+
tertiary: {
87+
marginTop: theme.rem(0.25)
88+
}
89+
}))

src/components/common/SceneWrapper.tsx

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,21 @@ import {
66
} from '@react-navigation/native'
77
import * as React from 'react'
88
import { useEffect, useMemo, useState } from 'react'
9-
import { Keyboard, StyleSheet, View, type ViewStyle } from 'react-native'
9+
import {
10+
Keyboard,
11+
Platform,
12+
StyleSheet,
13+
View,
14+
type ViewStyle
15+
} from 'react-native'
1016
import {
1117
useKeyboardHandler,
1218
useReanimatedKeyboardAnimation
1319
} from 'react-native-keyboard-controller'
1420
import Reanimated, {
1521
useAnimatedReaction,
16-
useAnimatedStyle
22+
useAnimatedStyle,
23+
useSharedValue
1724
} from 'react-native-reanimated'
1825
import {
1926
type EdgeInsets,
@@ -118,6 +125,13 @@ interface SceneWrapperProps {
118125

119126
// True to make the scene scrolling (if avoidKeyboard is false):
120127
scroll?: boolean
128+
129+
// Optional KeyboardAccessoryView rendered as a sibling to the SceneWrapper content:
130+
kavProps?: {
131+
children: React.ReactNode
132+
alwaysVisible?: boolean
133+
contentContainerStyle?: ViewStyle
134+
}
121135
}
122136

123137
/**
@@ -147,12 +161,14 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
147161
hasTabs = false,
148162
padding = 0,
149163
renderFooter,
150-
scroll = false
164+
scroll = false,
165+
kavProps
151166
} = props
152167

153168
const notificationHeight = useSelector(state => state.ui.notificationHeight)
154169

155170
const navigation = useNavigation<NavigationBase>()
171+
const isIos = Platform.OS === 'ios'
156172

157173
// We need to track this state in the JS thread because insets are not shared values
158174
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
@@ -163,6 +179,48 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
163179
}
164180
})
165181

182+
// Local keyboard visible state for KAV parity (iOS willShow vs Android didShow):
183+
const [isKeyboardVisibleFull, setKeyboardVisibleKav] = useState(false)
184+
useEffect(() => {
185+
const keyboardDidShowListener = Keyboard.addListener(
186+
isIos ? 'keyboardWillShow' : 'keyboardDidShow',
187+
() => {
188+
setKeyboardVisibleKav(true)
189+
}
190+
)
191+
const keyboardDidHideListener = Keyboard.addListener(
192+
isIos ? 'keyboardWillHide' : 'keyboardDidHide',
193+
() => {
194+
setKeyboardVisibleKav(false)
195+
}
196+
)
197+
return () => {
198+
keyboardDidShowListener.remove()
199+
keyboardDidHideListener.remove()
200+
}
201+
}, [isIos])
202+
203+
// Track closing/opening state explicitly for animation direction:
204+
const isClosingSv = useSharedValue(false)
205+
useEffect(() => {
206+
const showListener = Keyboard.addListener(
207+
isIos ? 'keyboardWillShow' : 'keyboardDidShow',
208+
() => {
209+
isClosingSv.value = false
210+
}
211+
)
212+
const hideListener = Keyboard.addListener(
213+
isIos ? 'keyboardWillHide' : 'keyboardDidHide',
214+
() => {
215+
isClosingSv.value = true
216+
}
217+
)
218+
return () => {
219+
showListener.remove()
220+
hideListener.remove()
221+
}
222+
}, [isIos, isClosingSv])
223+
166224
// Reset the footer ratio when focused
167225
// We can do this because multiple calls to resetFooterRatio isn't costly
168226
// because it just sets snapTo SharedValue to `1`
@@ -233,6 +291,22 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
233291
[insets.top, insets.right, insets.bottom, insets.left]
234292
)
235293

294+
// Collapsed bottom inset that ignores keyboard-open state (used to clamp during close):
295+
const collapsedInsetBottom = useMemo(
296+
() =>
297+
safeAreaInsets.bottom +
298+
(hasNotifications ? notificationHeight : 0) +
299+
(hasTabs ? MAX_TAB_BAR_HEIGHT : 0) +
300+
footerHeight,
301+
[
302+
footerHeight,
303+
hasNotifications,
304+
hasTabs,
305+
notificationHeight,
306+
safeAreaInsets.bottom
307+
]
308+
)
309+
236310
// This is a convenient styles object which may be applied to scene container
237311
// components to offset the inset styles applied to the SceneWrapper.
238312
const undoInsetStyle: UndoInsetStyle = useMemo(
@@ -267,6 +341,54 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
267341
return children
268342
}, [children, sceneWrapperInfo])
269343

344+
// Build KAV element (custom, no third-party lib):
345+
const alwaysVisibleKav = kavProps?.alwaysVisible === true
346+
const kavBaseStyle = useMemo(
347+
() => ({
348+
position: 'absolute' as const,
349+
left: 0,
350+
right: 0,
351+
bottom: 0,
352+
backgroundColor: 'transparent'
353+
}),
354+
[]
355+
)
356+
const insetBottomSv = useSharedValue(insets.bottom)
357+
const collapsedInsetSv = useSharedValue(collapsedInsetBottom)
358+
useEffect(() => {
359+
insetBottomSv.value = insets.bottom
360+
collapsedInsetSv.value = collapsedInsetBottom
361+
}, [collapsedInsetBottom, insets.bottom, collapsedInsetSv, insetBottomSv])
362+
const kavAnimatedStyle = useAnimatedStyle(() => {
363+
// keyboardHeightDiff.value is negative when open; invert to get height
364+
const keyboardHeight =
365+
keyboardHeightDiff.value < 0 ? -keyboardHeightDiff.value : 0
366+
const isClosing = isClosingSv.value
367+
368+
let bottom: number
369+
if (keyboardHeight > 0) {
370+
// While opening, ignore insets to hug keyboard.
371+
// While closing, never dip below the insets (avoid flicker under tab bar).
372+
bottom = isClosing
373+
? Math.max(keyboardHeight, collapsedInsetSv.value)
374+
: keyboardHeight
375+
} else {
376+
// Settled closed: rest above insets.
377+
bottom = collapsedInsetSv.value
378+
}
379+
380+
return { bottom }
381+
})
382+
const shouldShowKav =
383+
kavProps != null && (alwaysVisibleKav || isKeyboardVisibleFull)
384+
const kavElement = !shouldShowKav ? null : (
385+
<Reanimated.View
386+
style={[kavBaseStyle, kavAnimatedStyle, kavProps?.contentContainerStyle]}
387+
>
388+
{kavProps?.children}
389+
</Reanimated.View>
390+
)
391+
270392
if (scroll) {
271393
return (
272394
<>
@@ -300,6 +422,7 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
300422
navigation={navigation}
301423
/>
302424
) : null}
425+
{kavElement}
303426
<FloatingNavFixer navigation={navigation} />
304427
</>
305428
)
@@ -342,6 +465,7 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
342465
navigation={navigation}
343466
/>
344467
) : null}
468+
{kavElement}
345469
<FloatingNavFixer navigation={navigation} />
346470
</>
347471
)
@@ -377,6 +501,7 @@ function SceneWrapperComponent(props: SceneWrapperProps): React.ReactElement {
377501
navigation={navigation}
378502
/>
379503
) : null}
504+
{kavElement}
380505
<FloatingNavFixer navigation={navigation} />
381506
</>
382507
)

0 commit comments

Comments
 (0)