about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/view/com/util/Selector.tsx111
-rw-r--r--src/view/com/util/ViewSelector.tsx131
-rw-r--r--src/view/screens/Profile.tsx173
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,