refactor AZScroller component for improved overlay handling and gesture management

This commit is contained in:
skalthoff
2025-10-24 09:56:22 -07:00
parent 8588d13b24
commit 41f66d0ca4

View File

@@ -1,5 +1,5 @@
import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { LayoutChangeEvent, View as RNView } from 'react-native'
import { View as RNView } from 'react-native'
import { getToken, useTheme, View, YStack } from 'tamagui'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
@@ -7,6 +7,7 @@ import Animated, {
useAnimatedStyle,
runOnJS,
withTiming,
withDelay,
} from 'react-native-reanimated'
import { Text } from '../helpers/text'
import { useSafeAreaFrame } from 'react-native-safe-area-context'
@@ -38,48 +39,59 @@ export default function AZScroller({
const alphabetSelectorRef = useRef<RNView>(null)
const alphabetSelectorTopY = useRef(0)
const letterHeight = useRef(0)
// Shared values for use on the UI thread
const alphabetSelectorTopY = useSharedValue(0)
const letterHeight = useSharedValue(0)
const selectedLetter = useSharedValue('')
const [overlayLetter, setOverlayLetter] = useState('')
const showOverlay = () => {
'worklet'
overlayOpacity.value = withTiming(1)
if (overlayOpacity.value !== 1) {
overlayOpacity.value = withTiming(1)
}
}
const hideOverlay = () => {
const hideOverlay = (delayMs: number = 200) => {
'worklet'
overlayOpacity.value = withTiming(0)
overlayOpacity.value = withDelay(delayMs, withTiming(0))
}
const getLetterFromAbsoluteY = (absoluteY: number) => {
'worklet'
if (letterHeight.value <= 0) return ''
const relativeY = absoluteY - alphabetSelectorTopY.value
const rawIndex = Math.floor(relativeY / letterHeight.value)
const clampedIndex = Math.max(0, Math.min(alphabet.length - 1, rawIndex))
return alphabet[clampedIndex] ?? ''
}
const updateSelectionFromY = (absoluteY: number) => {
'worklet'
const letter = getLetterFromAbsoluteY(absoluteY)
if (letter && letter !== selectedLetter.value) {
selectedLetter.value = letter
runOnJS(setOverlayLetter)(letter)
showOverlay()
} else if (letter) {
showOverlay()
}
}
const panGesture = useMemo(
() =>
Gesture.Pan()
.runOnJS(true)
.minDistance(10)
.hitSlop(8)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
runOnJS(showOverlay)()
}
updateSelectionFromY(e.absoluteY)
})
.onUpdate((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
runOnJS(showOverlay)()
}
updateSelectionFromY(e.absoluteY)
})
.onEnd(() => {
runOnJS(hideOverlay)()
hideOverlay(200)
if (selectedLetter.value) {
runOnJS(onLetterSelect)(selectedLetter.value.toLowerCase())
}
@@ -90,37 +102,29 @@ export default function AZScroller({
const tapGesture = useMemo(
() =>
Gesture.Tap()
.runOnJS(true)
.maxDistance(10)
.hitSlop(8)
.onBegin((e) => {
const relativeY = e.absoluteY - alphabetSelectorTopY.current
const index = Math.floor(relativeY / letterHeight.current)
if (alphabet[index]) {
const letter = alphabet[index]
selectedLetter.value = letter
setOverlayLetter(letter)
runOnJS(showOverlay)()
}
updateSelectionFromY(e.absoluteY)
})
.onEnd(() => {
runOnJS(hideOverlay)()
hideOverlay(200)
if (selectedLetter.value)
runOnJS(onLetterSelect)(selectedLetter.value.toLowerCase())
}),
[onLetterSelect],
)
const gesture = Gesture.Simultaneous(panGesture, tapGesture)
// Prefer pan exclusively when movement occurs to avoid duplicate taps
const gesture = Gesture.Exclusive(panGesture, tapGesture)
const animatedOverlayStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
transform: [{ scale: overlayOpacity.value }],
}))
const handleLetterLayout = (event: LayoutChangeEvent) => {
letterHeight.current = event.nativeEvent.layout.height
}
useEffect(() => {
// Keep haptics behavior as-is per request
trigger('impactLight')
}, [overlayLetter])
@@ -135,37 +139,33 @@ export default function AZScroller({
justifyContent='flex-start'
alignItems='center'
alignContent='center'
accessible
accessibilityRole='adjustable'
accessibilityLabel='Alphabet scroller'
onLayout={() => {
requestAnimationFrame(() => {
alphabetSelectorRef.current?.measureInWindow((x, y, width, height) => {
alphabetSelectorTopY.current = y
alphabetSelectorRef.current?.measureInWindow((x, y, w, h) => {
alphabetSelectorTopY.value = y
if (h && h > 0) {
letterHeight.value = h / alphabet.length
}
})
})
}}
ref={alphabetSelectorRef}
>
{alphabet.map((letter, index) => {
const letterElement = (
<Text
key={letter}
fontSize='$6'
textAlign='center'
color='$neutral'
height={'$1'}
userSelect='none'
>
{letter}
</Text>
)
return index === 0 ? (
<View height={'$1'} key={letter} onLayout={handleLetterLayout}>
{letterElement}
</View>
) : (
letterElement
)
})}
{alphabet.map((letter) => (
<Text
key={letter}
fontSize='$6'
textAlign='center'
color='$neutral'
height={'$1'}
userSelect='none'
>
{letter}
</Text>
))}
</YStack>
</GestureDetector>
@@ -179,6 +179,7 @@ export default function AZScroller({
width: getToken('$13'),
height: getToken('$13'),
justifyContent: 'center',
alignItems: 'center',
backgroundColor: theme.background.val,
borderRadius: getToken('$4'),
borderWidth: getToken('$1'),
@@ -193,7 +194,6 @@ export default function AZScroller({
textAlign: 'center',
fontFamily: 'Figtree-Bold',
color: theme.primary.val,
marginHorizontal: 'auto',
}}
>
{overlayLetter}
@@ -206,15 +206,20 @@ export default function AZScroller({
export const alphabeticalSelectorCallback = async (
letter: string,
pageParams: RefObject<Set<string>>,
{
hasNextPage,
fetchNextPage,
isPending,
}: UseInfiniteQueryResult<BaseItemDto[] | (string | number | BaseItemDto)[], Error>,
infiniteQuery: UseInfiniteQueryResult<BaseItemDto[] | (string | number | BaseItemDto)[], Error>,
) => {
while (!pageParams.current.has(letter.toUpperCase()) && hasNextPage) {
const target = letter.toUpperCase()
let safety = 0
const MAX_PAGES = 100
while (!pageParams.current.has(target) && infiniteQuery.hasNextPage && safety < MAX_PAGES) {
console.debug(`Fetching next page for alphabet selection`)
await fetchNextPage()
await infiniteQuery.fetchNextPage()
safety += 1
}
if (!pageParams.current.has(target) && safety >= MAX_PAGES) {
console.warn(
`Alphabetical Selector: reached page fetch cap (${MAX_PAGES}) without finding ${target}`,
)
}
console.debug(`Alphabetical Selector Callback: ${letter} complete`)
}