Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[Perf] Drawer gesture perf fix + related cleanup (#8953)

* split drawer layout into own component

* don't put props in dep array

* memoize pager view

authored by samuel.fm and committed by

GitHub daed047b ee3e0839

+107 -85
+6 -6
src/state/shell/drawer-open.tsx
··· 1 - import React from 'react' 2 3 type StateContext = boolean 4 type SetContext = (v: boolean) => void 5 6 - const stateContext = React.createContext<StateContext>(false) 7 stateContext.displayName = 'DrawerOpenStateContext' 8 - const setContext = React.createContext<SetContext>((_: boolean) => {}) 9 setContext.displayName = 'DrawerOpenSetContext' 10 11 export function Provider({children}: React.PropsWithChildren<{}>) { 12 - const [state, setState] = React.useState(false) 13 14 return ( 15 <stateContext.Provider value={state}> ··· 19 } 20 21 export function useIsDrawerOpen() { 22 - return React.useContext(stateContext) 23 } 24 25 export function useSetDrawerOpen() { 26 - return React.useContext(setContext) 27 }
··· 1 + import {createContext, useContext, useState} from 'react' 2 3 type StateContext = boolean 4 type SetContext = (v: boolean) => void 5 6 + const stateContext = createContext<StateContext>(false) 7 stateContext.displayName = 'DrawerOpenStateContext' 8 + const setContext = createContext<SetContext>((_: boolean) => {}) 9 setContext.displayName = 'DrawerOpenSetContext' 10 11 export function Provider({children}: React.PropsWithChildren<{}>) { 12 + const [state, setState] = useState(false) 13 14 return ( 15 <stateContext.Provider value={state}> ··· 19 } 20 21 export function useIsDrawerOpen() { 22 + return useContext(stateContext) 23 } 24 25 export function useSetDrawerOpen() { 26 + return useContext(setContext) 27 }
+7 -7
src/view/com/home/HomeHeader.tsx
··· 1 import React from 'react' 2 import {useNavigation} from '@react-navigation/native' 3 4 - import {NavigationProp} from '#/lib/routes/types' 5 - import {FeedSourceInfo} from '#/state/queries/feed' 6 import {useSession} from '#/state/session' 7 - import {RenderTabBarFnProps} from '#/view/com/pager/Pager' 8 import {TabBar} from '../pager/TabBar' 9 import {HomeHeaderLayout} from './HomeHeaderLayout' 10 ··· 15 feeds: FeedSourceInfo[] 16 }, 17 ) { 18 - const {feeds} = props 19 const {hasSession} = useSession() 20 const navigation = useNavigation<NavigationProp>() 21 ··· 43 (index: number) => { 44 if (!hasPinnedCustom && index === items.length - 1) { 45 onPressFeedsLink() 46 - } else if (props.onSelect) { 47 - props.onSelect(index) 48 } 49 }, 50 - [items.length, onPressFeedsLink, props, hasPinnedCustom], 51 ) 52 53 return (
··· 1 import React from 'react' 2 import {useNavigation} from '@react-navigation/native' 3 4 + import {type NavigationProp} from '#/lib/routes/types' 5 + import {type FeedSourceInfo} from '#/state/queries/feed' 6 import {useSession} from '#/state/session' 7 + import {type RenderTabBarFnProps} from '#/view/com/pager/Pager' 8 import {TabBar} from '../pager/TabBar' 9 import {HomeHeaderLayout} from './HomeHeaderLayout' 10 ··· 15 feeds: FeedSourceInfo[] 16 }, 17 ) { 18 + const {feeds, onSelect: onSelectProp} = props 19 const {hasSession} = useSession() 20 const navigation = useNavigation<NavigationProp>() 21 ··· 43 (index: number) => { 44 if (!hasPinnedCustom && index === items.length - 1) { 45 onPressFeedsLink() 46 + } else if (onSelectProp) { 47 + onSelectProp(index) 48 } 49 }, 50 + [items.length, onPressFeedsLink, onSelectProp, hasPinnedCustom], 51 ) 52 53 return (
+22 -9
src/view/com/pager/Pager.tsx
··· 1 import { 2 useCallback, 3 useContext, 4 useImperativeHandle, 5 useRef, 6 useState, 7 } from 'react' ··· 56 } 57 58 const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) 59 60 export function Pager({ 61 ref, ··· 139 [parentOnPageScrollStateChanged], 140 ) 141 142 - const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web 143 - const nativeGesture = 144 - Gesture.Native().requireExternalGestureToFail(drawerGesture) 145 - 146 return ( 147 <View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}> 148 {renderTabBar({ ··· 151 dragProgress, 152 dragState, 153 })} 154 - <GestureDetector gesture={nativeGesture}> 155 - <AnimatedPagerView 156 ref={pagerView} 157 - style={[a.flex_1]} 158 initialPage={initialPage} 159 onPageScroll={handlePageScroll}> 160 {children} 161 - </AnimatedPagerView> 162 - </GestureDetector> 163 </View> 164 ) 165 } 166 167 function usePagerHandlers(
··· 1 import { 2 + memo, 3 useCallback, 4 useContext, 5 useImperativeHandle, 6 + useMemo, 7 useRef, 8 useState, 9 } from 'react' ··· 58 } 59 60 const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) 61 + const MemoizedAnimatedPagerView = memo(AnimatedPagerView) 62 63 export function Pager({ 64 ref, ··· 142 [parentOnPageScrollStateChanged], 143 ) 144 145 return ( 146 <View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}> 147 {renderTabBar({ ··· 150 dragProgress, 151 dragState, 152 })} 153 + <DrawerGestureRequireFail> 154 + <MemoizedAnimatedPagerView 155 ref={pagerView} 156 + style={a.flex_1} 157 initialPage={initialPage} 158 onPageScroll={handlePageScroll}> 159 {children} 160 + </MemoizedAnimatedPagerView> 161 + </DrawerGestureRequireFail> 162 </View> 163 ) 164 + } 165 + 166 + function DrawerGestureRequireFail({children}: {children: React.ReactNode}) { 167 + const drawerGesture = useContext(DrawerGestureContext) 168 + 169 + const nativeGesture = useMemo(() => { 170 + const gesture = Gesture.Native() 171 + if (drawerGesture) { 172 + gesture.requireExternalGestureToFail(drawerGesture) 173 + } 174 + return gesture 175 + }, [drawerGesture]) 176 + 177 + return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector> 178 } 179 180 function usePagerHandlers(
+72 -63
src/view/shell/index.tsx
··· 45 import {DrawerContent} from './Drawer' 46 47 function ShellInner() { 48 - const t = useTheme() 49 - const isDrawerOpen = useIsDrawerOpen() 50 - const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled() 51 - const setIsDrawerOpen = useSetDrawerOpen() 52 const winDim = useWindowDimensions() 53 const insets = useSafeAreaInsets() 54 const {state: policyUpdateState} = usePolicyUpdateContext() 55 56 - const renderDrawerContent = useCallback(() => <DrawerContent />, []) 57 - const onOpenDrawer = useCallback( 58 - () => setIsDrawerOpen(true), 59 - [setIsDrawerOpen], 60 - ) 61 - const onCloseDrawer = useCallback( 62 - () => setIsDrawerOpen(false), 63 - [setIsDrawerOpen], 64 - ) 65 - const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) 66 - const {hasSession} = useSession() 67 const closeAnyActiveElement = useCloseAnyActiveElement() 68 69 useNotificationsRegistration() ··· 102 } 103 }, [dedupe, navigation]) 104 105 - const swipeEnabled = !canGoBack && hasSession && !isDrawerSwipeDisabled 106 - const [trendingScrollGesture] = useState(() => Gesture.Native()) 107 return ( 108 <> 109 <View style={[a.h_full]}> 110 <ErrorBoundary 111 style={{paddingTop: insets.top, paddingBottom: insets.bottom}}> 112 - <Drawer 113 - renderDrawerContent={renderDrawerContent} 114 - drawerStyle={{width: Math.min(400, winDim.width * 0.8)}} 115 - configureGestureHandler={handler => { 116 - handler = handler.requireExternalGestureToFail( 117 - trendingScrollGesture, 118 - ) 119 - 120 - if (swipeEnabled) { 121 - if (isDrawerOpen) { 122 - return handler.activeOffsetX([-1, 1]) 123 - } else { 124 - return ( 125 - handler 126 - // Any movement to the left is a pager swipe 127 - // so fail the drawer gesture immediately. 128 - .failOffsetX(-1) 129 - // Don't rush declaring that a movement to the right 130 - // is a drawer swipe. It could be a vertical scroll. 131 - .activeOffsetX(5) 132 - ) 133 - } 134 - } else { 135 - // Fail the gesture immediately. 136 - // This seems more reliable than the `swipeEnabled` prop. 137 - // With `swipeEnabled` alone, the gesture may freeze after toggling off/on. 138 - return handler.failOffsetX([0, 0]).failOffsetY([0, 0]) 139 - } 140 - }} 141 - open={isDrawerOpen} 142 - onOpen={onOpenDrawer} 143 - onClose={onCloseDrawer} 144 - swipeEdgeWidth={winDim.width} 145 - swipeMinVelocity={100} 146 - swipeMinDistance={10} 147 - drawerType={isIOS ? 'slide' : 'front'} 148 - overlayStyle={{ 149 - backgroundColor: select(t.name, { 150 - light: 'rgba(0, 57, 117, 0.1)', 151 - dark: isAndroid 152 - ? 'rgba(16, 133, 254, 0.1)' 153 - : 'rgba(1, 82, 168, 0.1)', 154 - dim: 'rgba(10, 13, 16, 0.8)', 155 - }), 156 - }}> 157 <TabsNavigator /> 158 - </Drawer> 159 </ErrorBoundary> 160 </View> 161 ··· 179 180 <PolicyUpdateOverlayPortalOutlet /> 181 </> 182 ) 183 } 184
··· 45 import {DrawerContent} from './Drawer' 46 47 function ShellInner() { 48 const winDim = useWindowDimensions() 49 const insets = useSafeAreaInsets() 50 const {state: policyUpdateState} = usePolicyUpdateContext() 51 52 const closeAnyActiveElement = useCloseAnyActiveElement() 53 54 useNotificationsRegistration() ··· 87 } 88 }, [dedupe, navigation]) 89 90 return ( 91 <> 92 <View style={[a.h_full]}> 93 <ErrorBoundary 94 style={{paddingTop: insets.top, paddingBottom: insets.bottom}}> 95 + <DrawerLayout> 96 <TabsNavigator /> 97 + </DrawerLayout> 98 </ErrorBoundary> 99 </View> 100 ··· 118 119 <PolicyUpdateOverlayPortalOutlet /> 120 </> 121 + ) 122 + } 123 + 124 + function DrawerLayout({children}: {children: React.ReactNode}) { 125 + const t = useTheme() 126 + const isDrawerOpen = useIsDrawerOpen() 127 + const setIsDrawerOpen = useSetDrawerOpen() 128 + const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled() 129 + const winDim = useWindowDimensions() 130 + 131 + const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) 132 + const {hasSession} = useSession() 133 + 134 + const swipeEnabled = !canGoBack && hasSession && !isDrawerSwipeDisabled 135 + const [trendingScrollGesture] = useState(() => Gesture.Native()) 136 + 137 + const renderDrawerContent = useCallback(() => <DrawerContent />, []) 138 + const onOpenDrawer = useCallback( 139 + () => setIsDrawerOpen(true), 140 + [setIsDrawerOpen], 141 + ) 142 + const onCloseDrawer = useCallback( 143 + () => setIsDrawerOpen(false), 144 + [setIsDrawerOpen], 145 + ) 146 + 147 + return ( 148 + <Drawer 149 + renderDrawerContent={renderDrawerContent} 150 + drawerStyle={{width: Math.min(400, winDim.width * 0.8)}} 151 + configureGestureHandler={handler => { 152 + handler = handler.requireExternalGestureToFail(trendingScrollGesture) 153 + 154 + if (swipeEnabled) { 155 + if (isDrawerOpen) { 156 + return handler.activeOffsetX([-1, 1]) 157 + } else { 158 + return ( 159 + handler 160 + // Any movement to the left is a pager swipe 161 + // so fail the drawer gesture immediately. 162 + .failOffsetX(-1) 163 + // Don't rush declaring that a movement to the right 164 + // is a drawer swipe. It could be a vertical scroll. 165 + .activeOffsetX(5) 166 + ) 167 + } 168 + } else { 169 + // Fail the gesture immediately. 170 + // This seems more reliable than the `swipeEnabled` prop. 171 + // With `swipeEnabled` alone, the gesture may freeze after toggling off/on. 172 + return handler.failOffsetX([0, 0]).failOffsetY([0, 0]) 173 + } 174 + }} 175 + open={isDrawerOpen} 176 + onOpen={onOpenDrawer} 177 + onClose={onCloseDrawer} 178 + swipeEdgeWidth={winDim.width} 179 + swipeMinVelocity={100} 180 + swipeMinDistance={10} 181 + drawerType={isIOS ? 'slide' : 'front'} 182 + overlayStyle={{ 183 + backgroundColor: select(t.name, { 184 + light: 'rgba(0, 57, 117, 0.1)', 185 + dark: isAndroid ? 'rgba(16, 133, 254, 0.1)' : 'rgba(1, 82, 168, 0.1)', 186 + dim: 'rgba(10, 13, 16, 0.8)', 187 + }), 188 + }}> 189 + {children} 190 + </Drawer> 191 ) 192 } 193