diff options
Diffstat (limited to 'src/view/com/util')
-rw-r--r-- | src/view/com/util/Selector.tsx | 111 | ||||
-rw-r--r-- | src/view/com/util/ViewSelector.tsx | 131 |
2 files changed, 218 insertions, 24 deletions
diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index adc393d89..95e4c66d4 100644 --- a/src/view/com/util/Selector.tsx +++ b/src/view/com/util/Selector.tsx @@ -1,37 +1,98 @@ -import React, {useState} from 'react' -import { - StyleProp, - StyleSheet, - Text, - TouchableWithoutFeedback, - View, - ViewStyle, -} from 'react-native' +import React, {createRef, useState, useMemo} from 'react' +import {StyleSheet, Text, TouchableWithoutFeedback, View} from 'react-native' +import Animated, { + SharedValue, + useAnimatedStyle, + interpolate, +} from 'react-native-reanimated' import {colors} from '../../lib/styles' +interface Layout { + x: number + width: number +} + export function Selector({ - style, + selectedIndex, items, + swipeGestureInterp, onSelect, }: { - style?: StyleProp<ViewStyle> + selectedIndex: number items: string[] + swipeGestureInterp: SharedValue<number> onSelect?: (index: number) => void }) { - const [selectedIndex, setSelectedIndex] = useState<number>(0) + const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>( + undefined, + ) + const itemRefs = useMemo( + () => Array.from({length: items.length}).map(() => createRef<View>()), + [items.length], + ) + + const currentLayouts = useMemo(() => { + const left = itemLayouts?.[selectedIndex - 1] || {x: 0, width: 0} + const middle = itemLayouts?.[selectedIndex] || {x: 0, width: 0} + const right = itemLayouts?.[selectedIndex + 1] || { + x: middle.x + 20, + width: middle.width, + } + return [left, middle, right] + }, [selectedIndex, itemLayouts]) + + const underlinePos = useAnimatedStyle(() => { + const other = + swipeGestureInterp.value === 0 + ? currentLayouts[1] + : swipeGestureInterp.value < 0 + ? currentLayouts[0] + : currentLayouts[2] + return { + left: interpolate( + Math.abs(swipeGestureInterp.value), + [0, 1], + [currentLayouts[1].x, other.x], + ), + width: interpolate( + Math.abs(swipeGestureInterp.value), + [0, 1], + [currentLayouts[1].width, other.width], + ), + } + }, [currentLayouts, swipeGestureInterp]) + + const onLayout = () => { + const promises = [] + for (let i = 0; i < items.length; i++) { + promises.push( + new Promise<Layout>(resolve => { + itemRefs[i].current?.measure( + (x: number, _y: number, width: number) => { + resolve({x, width}) + }, + ) + }), + ) + } + Promise.all(promises).then((layouts: Layout[]) => { + setItemLayouts(layouts) + }) + } + const onPressItem = (index: number) => { - setSelectedIndex(index) onSelect?.(index) } return ( - <View style={[styles.outer, style]}> + <View style={[styles.outer]} onLayout={onLayout}> + <Animated.View style={[styles.underline, underlinePos]} /> {items.map((item, i) => { const selected = i === selectedIndex return ( <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> - <View style={selected ? styles.itemSelected : styles.item}> - <Text style={selected ? styles.labelSelected : styles.label}> + <View style={styles.item} ref={itemRefs[i]}> + <Text style={selected ? styles.labelSelected : styles.itemLabel}> {item} </Text> </View> @@ -45,25 +106,27 @@ export function Selector({ const styles = StyleSheet.create({ outer: { flexDirection: 'row', + paddingTop: 8, + paddingBottom: 12, paddingHorizontal: 14, + backgroundColor: colors.white, }, item: { - paddingBottom: 12, marginRight: 20, }, - label: { + itemLabel: { fontWeight: '600', fontSize: 16, color: colors.gray5, }, - itemSelected: { - paddingBottom: 8, - marginRight: 20, - borderBottomWidth: 4, - borderBottomColor: colors.purple3, - }, labelSelected: { fontWeight: '600', fontSize: 16, }, + underline: { + position: 'absolute', + height: 4, + backgroundColor: colors.purple3, + bottom: 0, + }, }) diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx new file mode 100644 index 000000000..5dab54de7 --- /dev/null +++ b/src/view/com/util/ViewSelector.tsx @@ -0,0 +1,131 @@ +import React, {useEffect, useState, useMemo} from 'react' +import {FlatList, StyleSheet, View} from 'react-native' +import {GestureDetector, Gesture} from 'react-native-gesture-handler' +import {useSharedValue, withTiming, runOnJS} from 'react-native-reanimated' +import {Selector} from './Selector' + +const HEADER_ITEM = {_reactKey: '__header__'} +const SELECTOR_ITEM = {_reactKey: '__selector__'} +const STICKY_HEADER_INDICES = [1] +const SWIPE_GESTURE_MAX_DISTANCE = 200 +const SWIPE_GESTURE_HIT_SLOP = {left: -20, top: 0, right: 0, bottom: 0} // we ignore the left 20 pixels to avoid conflicts with the page-nav gesture + +export function ViewSelector({ + sections, + items, + refreshing, + renderHeader, + renderItem, + onSelectView, + onRefresh, + onEndReached, +}: { + sections: string[] + items: any[] + refreshing?: boolean + renderHeader?: () => JSX.Element + renderItem: (item: any) => JSX.Element + onSelectView?: (viewIndex: number) => void + onRefresh?: () => void + onEndReached?: (info: {distanceFromEnd: number}) => void +}) { + const [selectedIndex, setSelectedIndex] = useState<number>(0) + const swipeGestureInterp = useSharedValue<number>(0) + + // events + // = + + const onPressSelection = (index: number) => setSelectedIndex(index) + useEffect(() => { + onSelectView?.(selectedIndex) + }, [selectedIndex]) + + // gestures + // = + + const swipeGesture = useMemo( + () => + Gesture.Pan() + .hitSlop(SWIPE_GESTURE_HIT_SLOP) + .onUpdate(e => { + // calculate [-1, 1] range for the gesture + const clamped = Math.min(e.translationX, SWIPE_GESTURE_MAX_DISTANCE) + const reversed = clamped * -1 + const scaled = reversed / SWIPE_GESTURE_MAX_DISTANCE + swipeGestureInterp.value = scaled + }) + .onEnd(e => { + if (swipeGestureInterp.value >= 0.5) { + // swiped to next + if (selectedIndex < sections.length - 1) { + // interp to the next item's position... + swipeGestureInterp.value = withTiming(1, {duration: 100}, () => { + // ...then update the index, which triggers the useEffect() below [1] + runOnJS(setSelectedIndex)(selectedIndex + 1) + }) + } else { + swipeGestureInterp.value = withTiming(0, {duration: 100}) + } + } else if (swipeGestureInterp.value <= -0.5) { + // swiped to prev + if (selectedIndex > 0) { + // interp to the prev item's position... + swipeGestureInterp.value = withTiming(-1, {duration: 100}, () => { + // ...then update the index, which triggers the useEffect() below [1] + runOnJS(setSelectedIndex)(selectedIndex - 1) + }) + } else { + swipeGestureInterp.value = withTiming(0, {duration: 100}) + } + } else { + swipeGestureInterp.value = withTiming(0, {duration: 100}) + } + }), + [swipeGestureInterp, selectedIndex, sections.length], + ) + useEffect(() => { + // [1] completes the swipe gesture animation by resetting the interp value + // this has to be done as an effect so that it occurs *after* the selectedIndex has been updated + swipeGestureInterp.value = 0 + }, [swipeGestureInterp, selectedIndex]) + + // rendering + // = + + const renderItemInternal = ({item}: {item: any}) => { + if (item === HEADER_ITEM) { + if (renderHeader) { + return renderHeader() + } + return <View /> + } else if (item === SELECTOR_ITEM) { + return ( + <Selector + items={sections} + selectedIndex={selectedIndex} + swipeGestureInterp={swipeGestureInterp} + onSelect={onPressSelection} + /> + ) + } else { + return renderItem(item) + } + } + + const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] + return ( + <GestureDetector gesture={swipeGesture}> + <FlatList + data={data} + keyExtractor={item => item._reactKey} + renderItem={renderItemInternal} + stickyHeaderIndices={STICKY_HEADER_INDICES} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + </GestureDetector> + ) +} + +const styles = StyleSheet.create({}) |