Add a fancy 'drawer' animation to the tabs selector

zio/stable
Paul Frazee 2022-11-16 17:18:16 -06:00
parent 284c635330
commit 361789975f
2 changed files with 143 additions and 159 deletions

View File

@ -7,8 +7,10 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Animated, {
interpolate,
SharedValue,
useSharedValue,
useAnimatedStyle,
withTiming,
@ -24,12 +26,20 @@ import {LinkActionsModel} from '../../../state/models/shell-ui'
const TAB_HEIGHT = 42
export const TabsSelector = observer(
({active, onClose}: {active: boolean; onClose: () => void}) => {
({
active,
tabMenuInterp,
onClose,
}: {
active: boolean
tabMenuInterp: SharedValue<number>
onClose: () => void
}) => {
const store = useStores()
const insets = useSafeAreaInsets()
const [closingTabIndex, setClosingTabIndex] = useState<number | undefined>(
undefined,
)
const initInterp = useSharedValue<number>(0)
const closeInterp = useSharedValue<number>(0)
const tabsRef = useRef<ScrollView>(null)
const tabRefs = useMemo(
@ -40,15 +50,10 @@ export const TabsSelector = observer(
[store.nav.tabs.length],
)
useEffect(() => {
if (active) {
initInterp.value = withTiming(1, {duration: 150})
} else {
initInterp.value = 0
}
}, [initInterp, active])
const wrapperAnimStyle = useAnimatedStyle(() => ({
bottom: interpolate(initInterp.value, [0, 1.0], [50, 75]),
transform: [
{translateY: interpolate(tabMenuInterp.value, [0, 1.0], [320, 0])},
],
}))
// events
@ -118,124 +123,116 @@ export const TabsSelector = observer(
}
return (
<>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<Animated.View style={[styles.wrapper, wrapperAnimStyle]}>
<View onLayout={onLayout}>
<View style={[s.p10, styles.section]}>
<View style={styles.btns}>
<TouchableWithoutFeedback onPress={onPressShareTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="share" />
</View>
<Text style={styles.btnText}>Share</Text>
<Animated.View
style={[
styles.wrapper,
{bottom: insets.bottom + 55},
wrapperAnimStyle,
]}>
<View onLayout={onLayout}>
<View style={[s.p10, styles.section]}>
<View style={styles.btns}>
<TouchableWithoutFeedback onPress={onPressShareTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="share" />
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressCloneTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon={['far', 'clone']} />
</View>
<Text style={styles.btnText}>Clone tab</Text>
<Text style={styles.btnText}>Share</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressCloneTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon={['far', 'clone']} />
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressNewTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="plus" />
</View>
<Text style={styles.btnText}>New tab</Text>
<Text style={styles.btnText}>Clone tab</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onPressNewTab}>
<View style={[styles.btn]}>
<View style={styles.btnIcon}>
<FontAwesomeIcon size={16} icon="plus" />
</View>
</TouchableWithoutFeedback>
</View>
</View>
<View style={[s.p10, styles.section, styles.sectionGrayBg]}>
<ScrollView ref={tabsRef} style={styles.tabs}>
{store.nav.tabs.map((tab, tabIndex) => {
const {icon} = match(tab.current.url)
const isActive = tabIndex === currentTabIndex
const isClosing = closingTabIndex === tabIndex
return (
<Swipeable
key={tab.id}
renderLeftActions={renderSwipeActions}
renderRightActions={renderSwipeActions}
leftThreshold={100}
rightThreshold={100}
onSwipeableWillOpen={() => onCloseTab(tabIndex)}>
<Animated.View
style={[
styles.tabOuter,
isClosing ? closingTabAnimStyle : undefined,
]}>
<Animated.View
ref={tabRefs[tabIndex]}
style={[
styles.tab,
styles.existing,
isActive && styles.active,
]}>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<View style={styles.tabInner}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={20} icon={icon} />
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
suppressHighlighting={true}
style={[
styles.tabText,
isActive && styles.tabTextActive,
]}>
{tab.current.title || tab.current.url}
</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback
onPress={() => onCloseTab(tabIndex)}>
<View style={styles.tabClose}>
<FontAwesomeIcon
size={14}
icon="x"
style={styles.tabCloseIcon}
/>
</View>
</TouchableWithoutFeedback>
</Animated.View>
</Animated.View>
</Swipeable>
)
})}
</ScrollView>
<Text style={styles.btnText}>New tab</Text>
</View>
</TouchableWithoutFeedback>
</View>
</View>
</Animated.View>
</>
<View style={[s.p10, styles.section, styles.sectionGrayBg]}>
<ScrollView ref={tabsRef} style={styles.tabs}>
{store.nav.tabs.map((tab, tabIndex) => {
const {icon} = match(tab.current.url)
const isActive = tabIndex === currentTabIndex
const isClosing = closingTabIndex === tabIndex
return (
<Swipeable
key={tab.id}
renderLeftActions={renderSwipeActions}
renderRightActions={renderSwipeActions}
leftThreshold={100}
rightThreshold={100}
onSwipeableWillOpen={() => onCloseTab(tabIndex)}>
<Animated.View
style={[
styles.tabOuter,
isClosing ? closingTabAnimStyle : undefined,
]}>
<Animated.View
ref={tabRefs[tabIndex]}
style={[
styles.tab,
styles.existing,
isActive && styles.active,
]}>
<TouchableWithoutFeedback
onPress={() => onPressChangeTab(tabIndex)}>
<View style={styles.tabInner}>
<View style={styles.tabIcon}>
<FontAwesomeIcon size={20} icon={icon} />
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
suppressHighlighting={true}
style={[
styles.tabText,
isActive && styles.tabTextActive,
]}>
{tab.current.title || tab.current.url}
</Text>
</View>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback
onPress={() => onCloseTab(tabIndex)}>
<View style={styles.tabClose}>
<FontAwesomeIcon
size={14}
icon="x"
style={styles.tabCloseIcon}
/>
</View>
</TouchableWithoutFeedback>
</Animated.View>
</Animated.View>
</Swipeable>
)
})}
</ScrollView>
</View>
</View>
</Animated.View>
)
},
)
const styles = StyleSheet.create({
bg: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: '#000',
opacity: 0.2,
},
wrapper: {
position: 'absolute',
// bottom: 75,
width: '100%',
height: 320,
borderTopColor: colors.gray2,
borderTopWidth: 1,
backgroundColor: '#fff',
borderRadius: 8,
opacity: 1,
},
section: {
@ -244,45 +241,6 @@ const styles = StyleSheet.create({
},
sectionGrayBg: {
backgroundColor: colors.gray1,
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
fatMenuItems: {
flexDirection: 'row',
marginTop: 10,
marginBottom: 10,
},
fatMenuItem: {
width: 80,
alignItems: 'center',
marginRight: 6,
},
fatMenuItemMargin: {
marginRight: 14,
},
fatMenuItemIconWrapper: {
borderRadius: 6,
width: 60,
height: 60,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 5,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowOffset: {width: 0, height: 2},
shadowRadius: 2,
},
fatMenuItemIcon: {
color: colors.white,
},
fatMenuImage: {
borderRadius: 30,
width: 60,
height: 60,
marginBottom: 5,
},
fatMenuItemLabel: {
fontSize: 13,
},
tabs: {
height: 240,

View File

@ -115,6 +115,7 @@ export const MobileShell: React.FC = observer(() => {
const scrollElRef = useRef<FlatList | undefined>()
const winDim = useWindowDimensions()
const swipeGestureInterp = useSharedValue<number>(0)
const tabMenuInterp = useSharedValue<number>(0)
const screenRenderDesc = constructScreenRenderDesc(store.nav)
const onPressHome = () => {
@ -127,7 +128,26 @@ export const MobileShell: React.FC = observer(() => {
const onPressSearch = () => store.nav.navigate('/search')
const onPressMenu = () => setMainMenuActive(true)
const onPressNotifications = () => store.nav.navigate('/notifications')
const onPressTabs = () => setTabsSelectorActive(true)
const onPressTabs = () => toggleTabsMenu(!isTabsSelectorActive)
const closeTabsSelector = () => setTabsSelectorActive(false)
const toggleTabsMenu = (active: boolean) => {
if (active) {
// will trigger the animation below
setTabsSelectorActive(true)
} else {
tabMenuInterp.value = withTiming(0, {duration: 100}, () => {
// hide once the animation has finished
runOnJS(closeTabsSelector)()
})
}
}
useEffect(() => {
if (isTabsSelectorActive) {
// trigger the animation once the tabs selector is rendering
tabMenuInterp.value = withTiming(1, {duration: 100})
}
}, [isTabsSelectorActive])
const goBack = () => store.nav.tab.goBack()
const swipeGesture = Gesture.Pan()
@ -159,6 +179,9 @@ export const MobileShell: React.FC = observer(() => {
const swipeOpacity = useAnimatedStyle(() => ({
opacity: interpolate(swipeGestureInterp.value, [0, 1.0], [0.6, 0.0]),
}))
const tabMenuTransform = useAnimatedStyle(() => ({
transform: [{translateY: tabMenuInterp.value * -320}],
}))
if (!store.session.isAuthed) {
return (
@ -205,7 +228,9 @@ export const MobileShell: React.FC = observer(() => {
style={[
s.flex1,
styles.screen,
current ? swipeTransform : undefined,
current
? [swipeTransform, tabMenuTransform]
: undefined,
]}>
<Com
params={params}
@ -220,6 +245,11 @@ export const MobileShell: React.FC = observer(() => {
</ScreenContainer>
</GestureDetector>
</SafeAreaView>
<TabsSelector
active={isTabsSelectorActive}
tabMenuInterp={tabMenuInterp}
onClose={() => toggleTabsMenu(false)}
/>
<SafeAreaView style={styles.bottomBar}>
<Btn icon="house" onPress={onPressHome} />
<Btn icon="search" onPress={onPressSearch} />
@ -236,10 +266,6 @@ export const MobileShell: React.FC = observer(() => {
onClose={() => setMainMenuActive(false)}
/>
<Modal />
<TabsSelector
active={isTabsSelectorActive}
onClose={() => setTabsSelectorActive(false)}
/>
<Composer
active={store.shell.isComposerActive}
onClose={() => store.shell.closeComposer()}