Add a fancy 'drawer' animation to the tabs selector
parent
284c635330
commit
361789975f
|
@ -7,8 +7,10 @@ import {
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
|
SharedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
withTiming,
|
withTiming,
|
||||||
|
@ -24,12 +26,20 @@ import {LinkActionsModel} from '../../../state/models/shell-ui'
|
||||||
const TAB_HEIGHT = 42
|
const TAB_HEIGHT = 42
|
||||||
|
|
||||||
export const TabsSelector = observer(
|
export const TabsSelector = observer(
|
||||||
({active, onClose}: {active: boolean; onClose: () => void}) => {
|
({
|
||||||
|
active,
|
||||||
|
tabMenuInterp,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
tabMenuInterp: SharedValue<number>
|
||||||
|
onClose: () => void
|
||||||
|
}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
const insets = useSafeAreaInsets()
|
||||||
const [closingTabIndex, setClosingTabIndex] = useState<number | undefined>(
|
const [closingTabIndex, setClosingTabIndex] = useState<number | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
const initInterp = useSharedValue<number>(0)
|
|
||||||
const closeInterp = useSharedValue<number>(0)
|
const closeInterp = useSharedValue<number>(0)
|
||||||
const tabsRef = useRef<ScrollView>(null)
|
const tabsRef = useRef<ScrollView>(null)
|
||||||
const tabRefs = useMemo(
|
const tabRefs = useMemo(
|
||||||
|
@ -40,15 +50,10 @@ export const TabsSelector = observer(
|
||||||
[store.nav.tabs.length],
|
[store.nav.tabs.length],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
initInterp.value = withTiming(1, {duration: 150})
|
|
||||||
} else {
|
|
||||||
initInterp.value = 0
|
|
||||||
}
|
|
||||||
}, [initInterp, active])
|
|
||||||
const wrapperAnimStyle = useAnimatedStyle(() => ({
|
const wrapperAnimStyle = useAnimatedStyle(() => ({
|
||||||
bottom: interpolate(initInterp.value, [0, 1.0], [50, 75]),
|
transform: [
|
||||||
|
{translateY: interpolate(tabMenuInterp.value, [0, 1.0], [320, 0])},
|
||||||
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// events
|
// events
|
||||||
|
@ -118,124 +123,116 @@ export const TabsSelector = observer(
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Animated.View
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
style={[
|
||||||
<View style={styles.bg} />
|
styles.wrapper,
|
||||||
</TouchableWithoutFeedback>
|
{bottom: insets.bottom + 55},
|
||||||
<Animated.View style={[styles.wrapper, wrapperAnimStyle]}>
|
wrapperAnimStyle,
|
||||||
<View onLayout={onLayout}>
|
]}>
|
||||||
<View style={[s.p10, styles.section]}>
|
<View onLayout={onLayout}>
|
||||||
<View style={styles.btns}>
|
<View style={[s.p10, styles.section]}>
|
||||||
<TouchableWithoutFeedback onPress={onPressShareTab}>
|
<View style={styles.btns}>
|
||||||
<View style={[styles.btn]}>
|
<TouchableWithoutFeedback onPress={onPressShareTab}>
|
||||||
<View style={styles.btnIcon}>
|
<View style={[styles.btn]}>
|
||||||
<FontAwesomeIcon size={16} icon="share" />
|
<View style={styles.btnIcon}>
|
||||||
</View>
|
<FontAwesomeIcon size={16} icon="share" />
|
||||||
<Text style={styles.btnText}>Share</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
<Text style={styles.btnText}>Share</Text>
|
||||||
<TouchableWithoutFeedback onPress={onPressCloneTab}>
|
</View>
|
||||||
<View style={[styles.btn]}>
|
</TouchableWithoutFeedback>
|
||||||
<View style={styles.btnIcon}>
|
<TouchableWithoutFeedback onPress={onPressCloneTab}>
|
||||||
<FontAwesomeIcon size={16} icon={['far', 'clone']} />
|
<View style={[styles.btn]}>
|
||||||
</View>
|
<View style={styles.btnIcon}>
|
||||||
<Text style={styles.btnText}>Clone tab</Text>
|
<FontAwesomeIcon size={16} icon={['far', 'clone']} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
<Text style={styles.btnText}>Clone tab</Text>
|
||||||
<TouchableWithoutFeedback onPress={onPressNewTab}>
|
</View>
|
||||||
<View style={[styles.btn]}>
|
</TouchableWithoutFeedback>
|
||||||
<View style={styles.btnIcon}>
|
<TouchableWithoutFeedback onPress={onPressNewTab}>
|
||||||
<FontAwesomeIcon size={16} icon="plus" />
|
<View style={[styles.btn]}>
|
||||||
</View>
|
<View style={styles.btnIcon}>
|
||||||
<Text style={styles.btnText}>New tab</Text>
|
<FontAwesomeIcon size={16} icon="plus" />
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
<Text style={styles.btnText}>New tab</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableWithoutFeedback>
|
||||||
<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>
|
||||||
</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({
|
const styles = StyleSheet.create({
|
||||||
bg: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
backgroundColor: '#000',
|
|
||||||
opacity: 0.2,
|
|
||||||
},
|
|
||||||
wrapper: {
|
wrapper: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
// bottom: 75,
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
height: 320,
|
||||||
|
borderTopColor: colors.gray2,
|
||||||
|
borderTopWidth: 1,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 8,
|
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
|
@ -244,45 +241,6 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
sectionGrayBg: {
|
sectionGrayBg: {
|
||||||
backgroundColor: colors.gray1,
|
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: {
|
tabs: {
|
||||||
height: 240,
|
height: 240,
|
||||||
|
|
|
@ -115,6 +115,7 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
const scrollElRef = useRef<FlatList | undefined>()
|
const scrollElRef = useRef<FlatList | undefined>()
|
||||||
const winDim = useWindowDimensions()
|
const winDim = useWindowDimensions()
|
||||||
const swipeGestureInterp = useSharedValue<number>(0)
|
const swipeGestureInterp = useSharedValue<number>(0)
|
||||||
|
const tabMenuInterp = useSharedValue<number>(0)
|
||||||
const screenRenderDesc = constructScreenRenderDesc(store.nav)
|
const screenRenderDesc = constructScreenRenderDesc(store.nav)
|
||||||
|
|
||||||
const onPressHome = () => {
|
const onPressHome = () => {
|
||||||
|
@ -127,7 +128,26 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
const onPressSearch = () => store.nav.navigate('/search')
|
const onPressSearch = () => store.nav.navigate('/search')
|
||||||
const onPressMenu = () => setMainMenuActive(true)
|
const onPressMenu = () => setMainMenuActive(true)
|
||||||
const onPressNotifications = () => store.nav.navigate('/notifications')
|
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 goBack = () => store.nav.tab.goBack()
|
||||||
const swipeGesture = Gesture.Pan()
|
const swipeGesture = Gesture.Pan()
|
||||||
|
@ -159,6 +179,9 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
const swipeOpacity = useAnimatedStyle(() => ({
|
const swipeOpacity = useAnimatedStyle(() => ({
|
||||||
opacity: interpolate(swipeGestureInterp.value, [0, 1.0], [0.6, 0.0]),
|
opacity: interpolate(swipeGestureInterp.value, [0, 1.0], [0.6, 0.0]),
|
||||||
}))
|
}))
|
||||||
|
const tabMenuTransform = useAnimatedStyle(() => ({
|
||||||
|
transform: [{translateY: tabMenuInterp.value * -320}],
|
||||||
|
}))
|
||||||
|
|
||||||
if (!store.session.isAuthed) {
|
if (!store.session.isAuthed) {
|
||||||
return (
|
return (
|
||||||
|
@ -205,7 +228,9 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
style={[
|
style={[
|
||||||
s.flex1,
|
s.flex1,
|
||||||
styles.screen,
|
styles.screen,
|
||||||
current ? swipeTransform : undefined,
|
current
|
||||||
|
? [swipeTransform, tabMenuTransform]
|
||||||
|
: undefined,
|
||||||
]}>
|
]}>
|
||||||
<Com
|
<Com
|
||||||
params={params}
|
params={params}
|
||||||
|
@ -220,6 +245,11 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
<TabsSelector
|
||||||
|
active={isTabsSelectorActive}
|
||||||
|
tabMenuInterp={tabMenuInterp}
|
||||||
|
onClose={() => toggleTabsMenu(false)}
|
||||||
|
/>
|
||||||
<SafeAreaView style={styles.bottomBar}>
|
<SafeAreaView style={styles.bottomBar}>
|
||||||
<Btn icon="house" onPress={onPressHome} />
|
<Btn icon="house" onPress={onPressHome} />
|
||||||
<Btn icon="search" onPress={onPressSearch} />
|
<Btn icon="search" onPress={onPressSearch} />
|
||||||
|
@ -236,10 +266,6 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
onClose={() => setMainMenuActive(false)}
|
onClose={() => setMainMenuActive(false)}
|
||||||
/>
|
/>
|
||||||
<Modal />
|
<Modal />
|
||||||
<TabsSelector
|
|
||||||
active={isTabsSelectorActive}
|
|
||||||
onClose={() => setTabsSelectorActive(false)}
|
|
||||||
/>
|
|
||||||
<Composer
|
<Composer
|
||||||
active={store.shell.isComposerActive}
|
active={store.shell.isComposerActive}
|
||||||
onClose={() => store.shell.closeComposer()}
|
onClose={() => store.shell.closeComposer()}
|
||||||
|
|
Loading…
Reference in New Issue