diff --git a/src/view/com/util/Selector.tsx b/src/view/com/util/Selector.tsx index adc393d89e..95e4c66d4a 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 + selectedIndex: number items: string[] + swipeGestureInterp: SharedValue onSelect?: (index: number) => void }) { - const [selectedIndex, setSelectedIndex] = useState(0) + const [itemLayouts, setItemLayouts] = useState( + undefined, + ) + const itemRefs = useMemo( + () => Array.from({length: items.length}).map(() => createRef()), + [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(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 ( - + + {items.map((item, i) => { const selected = i === selectedIndex return ( onPressItem(i)}> - - + + {item} @@ -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 0000000000..5dab54de7d --- /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(0) + const swipeGestureInterp = useSharedValue(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 + } else if (item === SELECTOR_ITEM) { + return ( + + ) + } else { + return renderItem(item) + } + } + + const data = [HEADER_ITEM, SELECTOR_ITEM, ...items] + return ( + + item._reactKey} + renderItem={renderItemInternal} + stickyHeaderIndices={STICKY_HEADER_INDICES} + refreshing={refreshing} + onRefresh={onRefresh} + onEndReached={onEndReached} + /> + + ) +} + +const styles = StyleSheet.create({}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 6711f7e040..9fe094af17 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) => - const renderLoadingItem = (_params: RenderItemParams) => ( - Loading... - ) - const renderErrorItem = ({item}: {item: ErrorItem}) => ( - - - - ) - const renderEmptyItem = (_params: RenderItemParams) => ( - No posts yet! - ) - const renderProfileItem = (_params: RenderItemParams) => { + const renderHeader = () => { if (!profileUiState) { return } return } - const renderSectionHeader = ({section}: {section: Section}) => { - if (section?.data?.[0] !== SECTION_HEADER_ITEM) { - return ( - - ) - } - return - } - const renderPostsItem = ({item}: {item: FeedViewItemModel | Symbol}) => { - if (item === END_ITEM || item instanceof Symbol) { - return - end of feed - - } - return - } - const renderBadgesItem = ({item}: {item: any}) => todo - - 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 = () => Loading... } 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) => ( + + + + ) } 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 - end of feed - + } + return + } } else if (profileUiState.currentView.isEmpty) { - sections.push({ - data: [EMPTY_ITEM], - renderItem: renderEmptyItem, - }) + items.push(EMPTY_ITEM) + renderItem = () => No posts yet! } } if (profileUiState.selectedViewIndex === SECTION_IDS.BADGES) { - sections.push({ - data: [{}], - renderItem: renderBadgesItem, - } as Section) + items.push(EMPTY_ITEM) + renderItem = () => TODO } } + if (!renderItem) { + renderItem = () => + } return ( - - {profileUiState && - (profileUiState.profile.hasError ? ( - - ) : ( - - ))} - + {profileUiState?.profile.hasError ? ( + + ) : ( + + )} ) }) @@ -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, diff --git a/todos.txt b/todos.txt index 0f453350c0..1dfe828900 100644 --- a/todos.txt +++ b/todos.txt @@ -21,4 +21,5 @@ Paul's todo list - Followers list - Follows list - Bugs - - Check that sub components arent reloading too much \ No newline at end of file + - Check that sub components arent reloading too much + - Profile isn't caching on nav anymore \ No newline at end of file