Android fixes (#515)

* Fix profile screen performance on android and remove dead code

* Correctly handle android hardware back btn

* Fix EditProfile modal for android

* Fix lint
zio/stable
Paul Frazee 2023-04-22 17:14:20 -05:00 committed by GitHub
parent eb6b36be61
commit d35f7c1f1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 273 additions and 594 deletions

View File

@ -13,6 +13,7 @@ import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from './view/shell' import {Shell} from './view/shell'
import * as notifee from 'lib/notifee' import * as notifee from 'lib/notifee'
import * as analytics from 'lib/analytics' import * as analytics from 'lib/analytics'
import * as backHandler from 'lib/routes/back-handler'
import * as Toast from './view/com/util/Toast' import * as Toast from './view/com/util/Toast'
import {handleLink} from './Navigation' import {handleLink} from './Navigation'
@ -28,6 +29,7 @@ const App = observer(() => {
setRootStore(store) setRootStore(store)
analytics.init(store) analytics.init(store)
notifee.init(store) notifee.init(store)
backHandler.init(store)
SplashScreen.hideAsync() SplashScreen.hideAsync()
Linking.getInitialURL().then((url: string | null) => { Linking.getInitialURL().then((url: string | null) => {
if (url) { if (url) {

View File

@ -0,0 +1,11 @@
import {BackHandler} from 'react-native'
import {RootStoreModel} from 'state/index'
export function onBack(cb: () => boolean): () => void {
const subscription = BackHandler.addEventListener('hardwareBackPress', cb)
return () => subscription.remove()
}
export function init(store: RootStoreModel) {
onBack(() => store.shell.closeAnyActiveElement())
}

View File

@ -194,6 +194,30 @@ export class ShellUiModel {
this.minimalShellMode = v this.minimalShellMode = v
} }
/**
* returns true if something was closed
* (used by the android hardware back btn)
*/
closeAnyActiveElement(): boolean {
if (this.isLightboxActive) {
this.closeLightbox()
return true
}
if (this.isModalActive) {
this.closeModal()
return true
}
if (this.isComposerActive) {
this.closeComposer()
return true
}
if (this.isDrawerOpen) {
this.closeDrawer()
return true
}
return false
}
openDrawer() { openDrawer() {
this.isDrawerOpen = true this.isDrawerOpen = true
} }

View File

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -48,6 +47,6 @@ export const Lightbox = observer(function Lightbox() {
/> />
) )
} else { } else {
return <View /> return null
} }
}) })

View File

@ -1,13 +1,15 @@
import React, {useState} from 'react' import React, {useState, useCallback} from 'react'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import { import {
ActivityIndicator, ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet, StyleSheet,
TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
@ -24,7 +26,7 @@ import {useTheme} from 'lib/ThemeContext'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {cleanError, isNetworkError} from 'lib/strings/errors' import {cleanError, isNetworkError} from 'lib/strings/errors'
export const snapPoints = ['80%'] export const snapPoints = ['fullscreen']
export function Component({ export function Component({
profileView, profileView,
@ -61,38 +63,43 @@ export function Component({
const onPressCancel = () => { const onPressCancel = () => {
store.shell.closeModal() store.shell.closeModal()
} }
const onSelectNewAvatar = async (img: RNImage | null) => { const onSelectNewAvatar = useCallback(
track('EditProfile:AvatarSelected') async (img: RNImage | null) => {
try {
// if img is null, user selected "remove avatar"
if (!img) { if (!img) {
setNewUserAvatar(null) setNewUserAvatar(null)
setUserAvatar(null) setUserAvatar(null)
return return
} }
const finalImg = await compressIfNeeded(img, 1000000) track('EditProfile:AvatarSelected')
setNewUserAvatar(finalImg) try {
setUserAvatar(finalImg.path) const finalImg = await compressIfNeeded(img, 1000000)
} catch (e: any) { setNewUserAvatar(finalImg)
setError(cleanError(e)) setUserAvatar(finalImg.path)
} } catch (e: any) {
} setError(cleanError(e))
const onSelectNewBanner = async (img: RNImage | null) => { }
if (!img) { },
setNewUserBanner(null) [track, setNewUserAvatar, setUserAvatar, setError],
setUserBanner(null) )
return const onSelectNewBanner = useCallback(
} async (img: RNImage | null) => {
track('EditProfile:BannerSelected') if (!img) {
try { setNewUserBanner(null)
const finalImg = await compressIfNeeded(img, 1000000) setUserBanner(null)
setNewUserBanner(finalImg) return
setUserBanner(finalImg.path) }
} catch (e: any) { track('EditProfile:BannerSelected')
setError(cleanError(e)) try {
} const finalImg = await compressIfNeeded(img, 1000000)
} setNewUserBanner(finalImg)
const onPressSave = async () => { setUserBanner(finalImg.path)
} catch (e: any) {
setError(cleanError(e))
}
},
[track, setNewUserBanner, setUserBanner, setError],
)
const onPressSave = useCallback(async () => {
track('EditProfile:Save') track('EditProfile:Save')
setProcessing(true) setProcessing(true)
if (error) { if (error) {
@ -120,11 +127,23 @@ export function Component({
} }
} }
setProcessing(false) setProcessing(false)
} }, [
track,
setProcessing,
setError,
error,
profileView,
onUpdate,
store,
displayName,
description,
newUserAvatar,
newUserBanner,
])
return ( return (
<View style={[s.flex1, pal.view]} testID="editProfileModal"> <KeyboardAvoidingView behavior="height">
<ScrollView style={styles.inner}> <ScrollView style={[pal.view]} testID="editProfileModal">
<Text style={[styles.title, pal.text]}>Edit my profile</Text> <Text style={[styles.title, pal.text]}>Edit my profile</Text>
<View style={styles.photos}> <View style={styles.photos}>
<UserBanner <UserBanner
@ -144,65 +163,66 @@ export function Component({
<ErrorMessage message={error} /> <ErrorMessage message={error} />
</View> </View>
)} )}
<View> <View style={styles.form}>
<Text style={[styles.label, pal.text]}>Display Name</Text> <View>
<TextInput <Text style={[styles.label, pal.text]}>Display Name</Text>
testID="editProfileDisplayNameInput" <TextInput
style={[styles.textInput, pal.border, pal.text]} testID="editProfileDisplayNameInput"
placeholder="e.g. Alice Roberts" style={[styles.textInput, pal.border, pal.text]}
placeholderTextColor={colors.gray4} placeholder="e.g. Alice Roberts"
value={displayName} placeholderTextColor={colors.gray4}
onChangeText={v => setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))} value={displayName}
/> onChangeText={v =>
</View> setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
<View style={s.pb10}> }
<Text style={[styles.label, pal.text]}>Description</Text> />
<TextInput
testID="editProfileDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. Artist, dog-lover, and memelord."
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
/>
</View>
{isProcessing ? (
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
<ActivityIndicator />
</View> </View>
) : ( <View style={s.pb10}>
<Text style={[styles.label, pal.text]}>Description</Text>
<TextInput
testID="editProfileDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. Artist, dog-lover, and memelord."
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
value={description}
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
/>
</View>
{isProcessing ? (
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="editProfileSaveBtn"
style={s.mt10}
onPress={onPressSave}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>Save Changes</Text>
</LinearGradient>
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
testID="editProfileSaveBtn" testID="editProfileCancelBtn"
style={s.mt10} style={s.mt5}
onPress={onPressSave}> onPress={onPressCancel}>
<LinearGradient <View style={[styles.btn]}>
colors={[gradients.blueLight.start, gradients.blueLight.end]} <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
start={{x: 0, y: 0}} </View>
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>Save Changes</Text>
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
)} </View>
<TouchableOpacity
testID="editProfileCancelBtn"
style={s.mt5}
onPress={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View>
</TouchableOpacity>
</ScrollView> </ScrollView>
</View> </KeyboardAvoidingView>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
inner: {
padding: 14,
},
title: { title: {
textAlign: 'center', textAlign: 'center',
fontWeight: 'bold', fontWeight: 'bold',
@ -215,6 +235,9 @@ const styles = StyleSheet.create({
paddingBottom: 4, paddingBottom: 4,
marginTop: 20, marginTop: 20,
}, },
form: {
paddingHorizontal: 14,
},
textInput: { textInput: {
borderWidth: 1, borderWidth: 1,
borderRadius: 6, borderRadius: 6,
@ -243,7 +266,7 @@ const styles = StyleSheet.create({
avi: { avi: {
position: 'absolute', position: 'absolute',
top: 80, top: 80,
left: 10, left: 24,
width: 84, width: 84,
height: 84, height: 84,
borderWidth: 2, borderWidth: 2,

View File

@ -1,5 +1,6 @@
import React, {useRef, useEffect} from 'react' import React, {useRef, useEffect} from 'react'
import {StyleSheet} from 'react-native' import {StyleSheet} from 'react-native'
import {SafeAreaView} from 'react-native-safe-area-context'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import BottomSheet from '@gorhom/bottom-sheet' import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -92,13 +93,22 @@ export const ModalsContainer = observer(function ModalsContainer() {
return null return null
} }
if (snapPoints[0] === 'fullscreen') {
return (
<SafeAreaView style={[styles.fullscreenContainer, pal.view]}>
{element}
</SafeAreaView>
)
}
return ( return (
<BottomSheet <BottomSheet
ref={bottomSheetRef} ref={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
index={store.shell.isModalActive ? 0 : -1} index={store.shell.isModalActive ? 0 : -1}
enablePanDownToClose enablePanDownToClose
keyboardBehavior="fillParent" keyboardBehavior="extend"
keyboardBlurBehavior="restore"
backdropComponent={ backdropComponent={
store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
} }
@ -115,4 +125,11 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 10, borderTopLeftRadius: 10,
borderTopRightRadius: 10, borderTopRightRadius: 10,
}, },
fullscreenContainer: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
}) })

View File

@ -128,7 +128,7 @@ const styles = StyleSheet.create({
width: 24, width: 24,
height: 24, height: 24,
bottom: 8, bottom: 8,
right: 8, right: 24,
borderRadius: 12, borderRadius: 12,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',

View File

@ -1,12 +1,13 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import {View} from 'react-native' import {Pressable, StyleSheet, View} from 'react-native'
import {Selector} from './Selector'
import {HorzSwipe} from './gestures/HorzSwipe'
import {FlatList} from './Views' import {FlatList} from './Views'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {clamp} from 'lib/numbers' import {clamp} from 'lib/numbers'
import {s} from 'lib/styles' import {s, colors} from 'lib/styles'
import {isAndroid} from 'platform/detection'
const HEADER_ITEM = {_reactKey: '__header__'} const HEADER_ITEM = {_reactKey: '__header__'}
const SELECTOR_ITEM = {_reactKey: '__selector__'} const SELECTOR_ITEM = {_reactKey: '__selector__'}
@ -16,7 +17,6 @@ export function ViewSelector({
sections, sections,
items, items,
refreshing, refreshing,
swipeEnabled,
renderHeader, renderHeader,
renderItem, renderItem,
ListFooterComponent, ListFooterComponent,
@ -42,19 +42,12 @@ export function ViewSelector({
onEndReached?: (info: {distanceFromEnd: number}) => void onEndReached?: (info: {distanceFromEnd: number}) => void
}) { }) {
const [selectedIndex, setSelectedIndex] = useState<number>(0) const [selectedIndex, setSelectedIndex] = useState<number>(0)
const panX = useAnimatedValue(0)
// events // events
// = // =
const onSwipeEnd = React.useCallback( const keyExtractor = React.useCallback(item => item._reactKey, [])
(dx: number) => {
if (dx !== 0) {
setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length))
}
},
[setSelectedIndex, selectedIndex, sections],
)
const onPressSelection = React.useCallback( const onPressSelection = React.useCallback(
(index: number) => setSelectedIndex(clamp(index, 0, sections.length)), (index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
[setSelectedIndex, sections], [setSelectedIndex, sections],
@ -77,7 +70,6 @@ export function ViewSelector({
return ( return (
<Selector <Selector
items={sections} items={sections}
panX={panX}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelect={onPressSelection} onSelect={onPressSelection}
/> />
@ -86,7 +78,7 @@ export function ViewSelector({
return renderItem(item) return renderItem(item)
} }
}, },
[sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem], [sections, selectedIndex, onPressSelection, renderHeader, renderItem],
) )
const data = React.useMemo( const data = React.useMemo(
@ -94,28 +86,98 @@ export function ViewSelector({
[items], [items],
) )
return ( return (
<HorzSwipe <FlatList
hasPriority data={data}
panX={panX} keyExtractor={keyExtractor}
swipeEnabled={swipeEnabled || false} renderItem={renderItemInternal}
canSwipeLeft={selectedIndex > 0} ListFooterComponent={ListFooterComponent}
canSwipeRight={selectedIndex < sections.length - 1} // NOTE sticky header disabled on android due to major performance issues -prf
onSwipeEnd={onSwipeEnd}> stickyHeaderIndices={isAndroid ? undefined : STICKY_HEADER_INDICES}
<FlatList refreshing={refreshing}
data={data} onScroll={onScroll}
keyExtractor={item => item._reactKey} onRefresh={onRefresh}
renderItem={renderItemInternal} onEndReached={onEndReached}
ListFooterComponent={ListFooterComponent} onEndReachedThreshold={0.6}
stickyHeaderIndices={STICKY_HEADER_INDICES} contentContainerStyle={s.contentContainer}
refreshing={refreshing} removeClippedSubviews={true}
onScroll={onScroll} scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464
onRefresh={onRefresh} />
onEndReached={onEndReached}
onEndReachedThreshold={0.6}
contentContainerStyle={s.contentContainer}
removeClippedSubviews={true}
scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464
/>
</HorzSwipe>
) )
} }
export function Selector({
selectedIndex,
items,
onSelect,
}: {
selectedIndex: number
items: string[]
onSelect?: (index: number) => void
}) {
const pal = usePalette('default')
const borderColor = useColorSchemeStyle(
{borderColor: colors.black},
{borderColor: colors.white},
)
const onPressItem = (index: number) => {
onSelect?.(index)
}
return (
<View style={[pal.view, styles.outer]}>
{items.map((item, i) => {
const selected = i === selectedIndex
return (
<Pressable
testID={`selector-${i}`}
key={item}
onPress={() => onPressItem(i)}>
<View
style={[
styles.item,
selected && styles.itemSelected,
borderColor,
]}>
<Text
style={
selected
? [styles.labelSelected, pal.text]
: [styles.label, pal.textLight]
}>
{item}
</Text>
</View>
</Pressable>
)
})}
</View>
)
}
const styles = StyleSheet.create({
outer: {
flexDirection: 'row',
paddingHorizontal: 14,
},
item: {
marginRight: 14,
paddingHorizontal: 10,
paddingTop: 8,
paddingBottom: 12,
},
itemSelected: {
borderBottomWidth: 3,
},
label: {
fontWeight: '600',
},
labelSelected: {
fontWeight: '600',
},
underline: {
position: 'absolute',
height: 4,
bottom: 0,
},
})

View File

@ -1,157 +0,0 @@
import React, {useState} from 'react'
import {
Animated,
GestureResponderEvent,
I18nManager,
PanResponder,
PanResponderGestureState,
useWindowDimensions,
View,
} from 'react-native'
import {clamp} from 'lodash'
import {s} from 'lib/styles'
interface Props {
panX: Animated.Value
canSwipeLeft?: boolean
canSwipeRight?: boolean
swipeEnabled?: boolean
hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture
distThresholdDivisor?: number
useNativeDriver?: boolean
onSwipeStart?: () => void
onSwipeStartDirection?: (dx: number) => void
onSwipeEnd?: (dx: number) => void
children: React.ReactNode
}
export function HorzSwipe({
panX,
canSwipeLeft = false,
canSwipeRight = false,
swipeEnabled = true,
hasPriority = false,
distThresholdDivisor = 1.75,
useNativeDriver = false,
onSwipeStart,
onSwipeStartDirection,
onSwipeEnd,
children,
}: Props) {
const winDim = useWindowDimensions()
const [dir, setDir] = useState<number>(0)
const swipeVelocityThreshold = 35
const swipeDistanceThreshold = winDim.width / distThresholdDivisor
const isMovingHorizontally = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
return (
Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) &&
Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25)
)
}
const canMoveScreen = (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
if (swipeEnabled === false) {
return false
}
const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
const willHandle =
isMovingHorizontally(event, gestureState) &&
((diffX > 0 && canSwipeLeft) || (diffX < 0 && canSwipeRight))
return willHandle
}
const startGesture = () => {
setDir(0)
onSwipeStart?.()
panX.stopAnimation()
// @ts-expect-error: _value is private, but docs use it as well
panX.setOffset(panX._value)
}
const respondToGesture = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
if (
// swiping left
(diffX > 0 && !canSwipeLeft) ||
// swiping right
(diffX < 0 && !canSwipeRight)
) {
panX.setValue(0)
return
}
panX.setValue(clamp(diffX / swipeDistanceThreshold, -1, 1) * -1)
const newDir = diffX > 0 ? -1 : diffX < 0 ? 1 : 0
if (newDir !== dir) {
setDir(newDir)
onSwipeStartDirection?.(newDir)
}
}
const finishGesture = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
if (
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) &&
Math.abs(gestureState.vx) > Math.abs(gestureState.vy) &&
(Math.abs(gestureState.dx) > swipeDistanceThreshold / 4 ||
Math.abs(gestureState.vx) > swipeVelocityThreshold)
) {
const final = Math.floor(
(gestureState.dx / Math.abs(gestureState.dx)) * -1,
)
Animated.timing(panX, {
toValue: final,
duration: 100,
useNativeDriver,
isInteraction: false,
}).start(() => {
onSwipeEnd?.(final)
panX.flattenOffset()
panX.setValue(0)
})
} else {
onSwipeEnd?.(0)
Animated.timing(panX, {
toValue: 0,
duration: 100,
useNativeDriver,
isInteraction: false,
}).start(() => {
panX.flattenOffset()
panX.setValue(0)
})
}
}
const panResponder = PanResponder.create({
onMoveShouldSetPanResponder: canMoveScreen,
onPanResponderGrant: startGesture,
onPanResponderMove: respondToGesture,
onPanResponderTerminate: finishGesture,
onPanResponderRelease: finishGesture,
onPanResponderTerminationRequest: () => !hasPriority,
})
return (
<View {...panResponder.panHandlers} style={s.h100pct}>
{children}
</View>
)
}

View File

@ -1,302 +0,0 @@
import React, {useState} from 'react'
import {
Animated,
GestureResponderEvent,
I18nManager,
PanResponder,
PanResponderGestureState,
useWindowDimensions,
View,
} from 'react-native'
import {clamp} from 'lodash'
import {s} from 'lib/styles'
export enum Dir {
None,
Up,
Down,
Left,
Right,
Zoom,
}
interface Props {
panX: Animated.Value
panY: Animated.Value
zoom: Animated.Value
canSwipeLeft?: boolean
canSwipeRight?: boolean
canSwipeUp?: boolean
canSwipeDown?: boolean
swipeEnabled?: boolean
zoomEnabled?: boolean
hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture
horzDistThresholdDivisor?: number
vertDistThresholdDivisor?: number
useNativeDriver?: boolean
onSwipeStart?: () => void
onSwipeStartDirection?: (dir: Dir) => void
onSwipeEnd?: (dir: Dir) => void
children: React.ReactNode
}
export function SwipeAndZoom({
panX,
panY,
zoom,
canSwipeLeft = false,
canSwipeRight = false,
canSwipeUp = false,
canSwipeDown = false,
swipeEnabled = false,
zoomEnabled = false,
hasPriority = false,
horzDistThresholdDivisor = 1.75,
vertDistThresholdDivisor = 1.75,
useNativeDriver = false,
onSwipeStart,
onSwipeStartDirection,
onSwipeEnd,
children,
}: Props) {
const winDim = useWindowDimensions()
const [dir, setDir] = useState<Dir>(Dir.None)
const [initialDistance, setInitialDistance] = useState<number | undefined>(
undefined,
)
const swipeVelocityThreshold = 35
const swipeHorzDistanceThreshold = winDim.width / horzDistThresholdDivisor
const swipeVertDistanceThreshold = winDim.height / vertDistThresholdDivisor
const isMovingHorizontally = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
return (
Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) &&
Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25)
)
}
const isMovingVertically = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
return (
Math.abs(gestureState.dy) > Math.abs(gestureState.dx * 1.25) &&
Math.abs(gestureState.vy) > Math.abs(gestureState.vx * 1.25)
)
}
const canDir = (d: Dir) => {
if (d === Dir.Left) {
return canSwipeLeft
}
if (d === Dir.Right) {
return canSwipeRight
}
if (d === Dir.Up) {
return canSwipeUp
}
if (d === Dir.Down) {
return canSwipeDown
}
if (d === Dir.Zoom) {
return zoomEnabled
}
return false
}
const isHorz = (d: Dir) => d === Dir.Left || d === Dir.Right
const isVert = (d: Dir) => d === Dir.Up || d === Dir.Down
const canMoveScreen = (
event: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
if (zoomEnabled && gestureState.numberActiveTouches === 2) {
return true
} else if (swipeEnabled && gestureState.numberActiveTouches === 1) {
const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
const dy = gestureState.dy
const willHandle =
(isMovingHorizontally(event, gestureState) &&
((dx > 0 && canSwipeLeft) || (dx < 0 && canSwipeRight))) ||
(isMovingVertically(event, gestureState) &&
((dy > 0 && canSwipeUp) || (dy < 0 && canSwipeDown)))
return willHandle
}
return false
}
const startGesture = () => {
setDir(Dir.None)
onSwipeStart?.()
// reset all state
panX.stopAnimation()
// @ts-expect-error: _value is private, but docs use it as well
panX.setOffset(panX._value)
panY.stopAnimation()
// @ts-expect-error: _value is private, but docs use it as well
panY.setOffset(panY._value)
zoom.stopAnimation()
// @ts-expect-error: _value is private, but docs use it as well
zoom.setOffset(zoom._value)
setInitialDistance(undefined)
}
const respondToGesture = (
e: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx
const dy = gestureState.dy
let newDir = Dir.None
if (dir === Dir.None) {
// establish if the user is swiping horz or vert, or zooming
if (gestureState.numberActiveTouches === 2) {
newDir = Dir.Zoom
} else if (Math.abs(dx) > Math.abs(dy)) {
newDir = dx > 0 ? Dir.Left : Dir.Right
} else {
newDir = dy > 0 ? Dir.Up : Dir.Down
}
} else if (isHorz(dir)) {
// direction update
newDir = dx > 0 ? Dir.Left : Dir.Right
} else if (isVert(dir)) {
// direction update
newDir = dy > 0 ? Dir.Up : Dir.Down
} else {
newDir = dir
}
if (newDir === Dir.Zoom) {
if (zoomEnabled) {
if (gestureState.numberActiveTouches === 2) {
// zoom in/out
const x0 = e.nativeEvent.touches[0].pageX
const x1 = e.nativeEvent.touches[1].pageX
const y0 = e.nativeEvent.touches[0].pageY
const y1 = e.nativeEvent.touches[1].pageY
const zoomDx = Math.abs(x0 - x1)
const zoomDy = Math.abs(y0 - y1)
const dist = Math.sqrt(zoomDx * zoomDx + zoomDy * zoomDy) / 100
if (
typeof initialDistance === 'undefined' ||
dist - initialDistance < 0
) {
setInitialDistance(dist)
} else {
zoom.setValue(dist - initialDistance)
}
} else {
// pan around after zooming
panX.setValue(clamp(dx / winDim.width, -1, 1) * -1)
panY.setValue(clamp(dy / winDim.height, -1, 1) * -1)
}
}
} else if (isHorz(newDir)) {
// swipe left/right
panX.setValue(
clamp(
dx / swipeHorzDistanceThreshold,
canSwipeRight ? -1 : 0,
canSwipeLeft ? 1 : 0,
) * -1,
)
panY.setValue(0)
} else if (isVert(newDir)) {
// swipe up/down
panY.setValue(
clamp(
dy / swipeVertDistanceThreshold,
canSwipeDown ? -1 : 0,
canSwipeUp ? 1 : 0,
) * -1,
)
panX.setValue(0)
}
if (!canDir(newDir)) {
newDir = Dir.None
}
if (newDir !== dir) {
setDir(newDir)
onSwipeStartDirection?.(newDir)
}
}
const finishGesture = (
_: GestureResponderEvent,
gestureState: PanResponderGestureState,
) => {
const finish = (finalDir: Dir) => () => {
if (finalDir !== Dir.None) {
onSwipeEnd?.(finalDir)
}
setDir(Dir.None)
panX.flattenOffset()
panX.setValue(0)
panY.flattenOffset()
panY.setValue(0)
}
if (
isHorz(dir) &&
(Math.abs(gestureState.dx) > swipeHorzDistanceThreshold / 4 ||
Math.abs(gestureState.vx) > swipeVelocityThreshold)
) {
// horizontal swipe reset
Animated.timing(panX, {
toValue: dir === Dir.Left ? -1 : 1,
duration: 100,
useNativeDriver,
}).start(finish(dir))
} else if (
isVert(dir) &&
(Math.abs(gestureState.dy) > swipeVertDistanceThreshold / 8 ||
Math.abs(gestureState.vy) > swipeVelocityThreshold)
) {
// vertical swipe reset
Animated.timing(panY, {
toValue: dir === Dir.Up ? -1 : 1,
duration: 100,
useNativeDriver,
}).start(finish(dir))
} else {
// zoom (or no direction) reset
onSwipeEnd?.(Dir.None)
Animated.timing(panX, {
toValue: 0,
duration: 100,
useNativeDriver,
}).start()
Animated.timing(panY, {
toValue: 0,
duration: 100,
useNativeDriver,
}).start()
Animated.timing(zoom, {
toValue: 0,
duration: 100,
useNativeDriver,
}).start()
}
}
const panResponder = PanResponder.create({
onMoveShouldSetPanResponder: canMoveScreen,
onPanResponderGrant: startGesture,
onPanResponderMove: respondToGesture,
onPanResponderTerminate: finishGesture,
onPanResponderRelease: finishGesture,
onPanResponderTerminationRequest: () => !hasPriority,
})
return (
<View {...panResponder.panHandlers} style={s.h100pct}>
{children}
</View>
)
}

View File

@ -18,7 +18,6 @@ import {EmptyState} from '../com/util/EmptyState'
import {Text} from '../com/util/text/Text' import {Text} from '../com/util/text/Text'
import {FAB} from '../com/util/fab/FAB' import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics' import {useAnalytics} from 'lib/analytics'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
@ -32,7 +31,6 @@ export const ProfileScreen = withAuthRequired(
screen('Profile') screen('Profile')
}, [screen]) }, [screen])
const onMainScroll = useOnMainScroll(store)
const [hasSetup, setHasSetup] = useState<boolean>(false) const [hasSetup, setHasSetup] = useState<boolean>(false)
const uiState = React.useMemo( const uiState = React.useMemo(
() => new ProfileUiModel(store, {user: route.params.name}), () => new ProfileUiModel(store, {user: route.params.name}),
@ -68,9 +66,12 @@ export const ProfileScreen = withAuthRequired(
track('ProfileScreen:PressCompose') track('ProfileScreen:PressCompose')
store.shell.openComposer({}) store.shell.openComposer({})
}, [store, track]) }, [store, track])
const onSelectView = (index: number) => { const onSelectView = React.useCallback(
uiState.setSelectedViewIndex(index) (index: number) => {
} uiState.setSelectedViewIndex(index)
},
[uiState],
)
const onRefresh = React.useCallback(() => { const onRefresh = React.useCallback(() => {
uiState uiState
.refresh() .refresh()
@ -158,7 +159,6 @@ export const ProfileScreen = withAuthRequired(
ListFooterComponent={Footer} ListFooterComponent={Footer}
refreshing={uiState.isRefreshing || false} refreshing={uiState.isRefreshing || false}
onSelectView={onSelectView} onSelectView={onSelectView}
onScroll={onMainScroll}
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
/> />