diff options
author | Paul Frazee <pfrazee@gmail.com> | 2022-09-06 22:34:31 -0500 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2022-09-06 22:34:31 -0500 |
commit | 69265753bf4bb20b236401c216746fe5db43836b (patch) | |
tree | 3f2cac035a36d9ba65753491247c0fdf08aef52f /src | |
parent | 4974f97bf3a6d9f033caf1a8984676c68afd8cd5 (diff) | |
download | voidsky-69265753bf4bb20b236401c216746fe5db43836b.tar.zst |
Refactor profile to use new ViewSelector element which is reusable and now supports swipe gestures
Diffstat (limited to 'src')
-rw-r--r-- | src/view/com/util/Selector.tsx | 111 | ||||
-rw-r--r-- | src/view/com/util/ViewSelector.tsx | 131 | ||||
-rw-r--r-- | src/view/screens/Profile.tsx | 173 |
3 files changed, 277 insertions, 138 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({}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 6711f7e04..9fe094af1 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,37 +1,19 @@ -import React, {useState, useEffect} from 'react' -import {SectionList, StyleSheet, Text, View} from 'react-native' +import React, {useEffect, useState} from 'react' +import {StyleSheet, Text, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {ViewSelector} from '../com/util/ViewSelector' +import {ScreenParams} from '../routes' import {ProfileUiModel, SECTION_IDS} from '../../state/models/profile-ui' -import {FeedViewItemModel} from '../../state/models/feed-view' import {useStores} from '../../state' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedItem} from '../com/posts/FeedItem' -import {Selector} from '../com/util/Selector' import {ErrorScreen} from '../com/util/ErrorScreen' import {ErrorMessage} from '../com/util/ErrorMessage' import {s, colors} from '../lib/styles' -import {ScreenParams} from '../routes' - -const SECTION_HEADER_ITEM = Symbol('SectionHeaderItem') -const LOADING_ITEM = Symbol('LoadingItem') -const EMPTY_ITEM = Symbol('EmptyItem') -const END_ITEM = Symbol('EndItem') - -interface RenderItemParams { - item: any - index: number - section: Section -} -interface ErrorItem { - error: string -} - -interface Section { - data: any[] - keyExtractor?: (v: any) => string - renderItem: (params: RenderItemParams) => JSX.Element -} +const LOADING_ITEM = {_reactKey: '__loading__'} +const END_ITEM = {_reactKey: '__end__'} +const EMPTY_ITEM = {_reactKey: '__empty__'} export const Profile = observer(({visible, params}: ScreenParams) => { const store = useStores() @@ -62,8 +44,9 @@ export const Profile = observer(({visible, params}: ScreenParams) => { // events // = - const onSelectViewSelector = (index: number) => + const onSelectView = (index: number) => { profileUiState?.setSelectedViewIndex(index) + } const onRefresh = () => { profileUiState ?.refresh() @@ -81,107 +64,78 @@ export const Profile = observer(({visible, params}: ScreenParams) => { // rendering // = - const renderItem = (_params: RenderItemParams) => <View /> - const renderLoadingItem = (_params: RenderItemParams) => ( - <Text style={styles.loading}>Loading...</Text> - ) - const renderErrorItem = ({item}: {item: ErrorItem}) => ( - <View style={s.p5}> - <ErrorMessage message={item.error} onPressTryAgain={onPressTryAgain} /> - </View> - ) - const renderEmptyItem = (_params: RenderItemParams) => ( - <Text style={styles.loading}>No posts yet!</Text> - ) - const renderProfileItem = (_params: RenderItemParams) => { + const renderHeader = () => { if (!profileUiState) { return <View /> } return <ProfileHeader view={profileUiState.profile} /> } - const renderSectionHeader = ({section}: {section: Section}) => { - if (section?.data?.[0] !== SECTION_HEADER_ITEM) { - return ( - <Selector - items={ProfileUiModel.SELECTOR_ITEMS} - style={styles.selector} - onSelect={onSelectViewSelector} - /> - ) - } - return <View /> - } - const renderPostsItem = ({item}: {item: FeedViewItemModel | Symbol}) => { - if (item === END_ITEM || item instanceof Symbol) { - return <Text style={styles.endItem}>- end of feed -</Text> - } - return <FeedItem item={item} /> - } - const renderBadgesItem = ({item}: {item: any}) => <Text>todo</Text> - - const sections = [ - {data: [SECTION_HEADER_ITEM], renderItem: renderProfileItem}, - ] + let renderItem + let items: any[] = [] if (profileUiState) { if (profileUiState.selectedViewIndex === SECTION_IDS.POSTS) { if (profileUiState.isInitialLoading) { - sections.push({ - data: [LOADING_ITEM], - renderItem: renderLoadingItem, - } as Section) + items.push(LOADING_ITEM) + renderItem = () => <Text style={styles.loading}>Loading...</Text> } else if (profileUiState.feed.hasError) { - sections.push({ - data: [{error: profileUiState.feed.error}], - renderItem: renderErrorItem, - } as Section) + items.push({ + _reactKey: '__error__', + error: profileUiState.feed.error, + }) + renderItem = (item: any) => ( + <View style={s.p5}> + <ErrorMessage + message={item.error} + onPressTryAgain={onPressTryAgain} + /> + </View> + ) } else if (profileUiState.currentView.hasContent) { - const items: (FeedViewItemModel | Symbol)[] = - profileUiState.feed.feed.slice() + items = profileUiState.feed.feed.slice() if (profileUiState.feed.hasReachedEnd) { items.push(END_ITEM) } - sections.push({ - data: items, - renderItem: renderPostsItem, - keyExtractor: (item: FeedViewItemModel) => item._reactKey, - } as Section) + renderItem = (item: any) => { + if (item === END_ITEM) { + return <Text style={styles.endItem}>- end of feed -</Text> + } + return <FeedItem item={item} /> + } } else if (profileUiState.currentView.isEmpty) { - sections.push({ - data: [EMPTY_ITEM], - renderItem: renderEmptyItem, - }) + items.push(EMPTY_ITEM) + renderItem = () => <Text style={styles.loading}>No posts yet!</Text> } } if (profileUiState.selectedViewIndex === SECTION_IDS.BADGES) { - sections.push({ - data: [{}], - renderItem: renderBadgesItem, - } as Section) + items.push(EMPTY_ITEM) + renderItem = () => <Text>TODO</Text> } } + if (!renderItem) { + renderItem = () => <View /> + } return ( <View style={styles.container}> - <View style={styles.feed}> - {profileUiState && - (profileUiState.profile.hasError ? ( - <ErrorScreen - title="Failed to load profile" - message={`There was an issue when attempting to load ${params.name}`} - details={profileUiState.profile.error} - onPressTryAgain={onPressTryAgain} - /> - ) : ( - <SectionList - sections={sections} - renderSectionHeader={renderSectionHeader} - renderItem={renderItem} - refreshing={profileUiState.isRefreshing} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - ))} - </View> + {profileUiState?.profile.hasError ? ( + <ErrorScreen + title="Failed to load profile" + message={`There was an issue when attempting to load ${params.name}`} + details={profileUiState.profile.error} + onPressTryAgain={onPressTryAgain} + /> + ) : ( + <ViewSelector + sections={ProfileUiModel.SELECTOR_ITEMS} + items={items} + renderHeader={renderHeader} + renderItem={renderItem} + refreshing={profileUiState?.isRefreshing || false} + onSelectView={onSelectView} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + )} </View> ) }) @@ -191,15 +145,6 @@ const styles = StyleSheet.create({ flexDirection: 'column', height: '100%', }, - selector: { - paddingTop: 8, - backgroundColor: colors.white, - borderBottomWidth: 1, - borderColor: colors.gray2, - }, - feed: { - flex: 1, - }, loading: { paddingVertical: 10, paddingHorizontal: 14, |