Show tab bar on desktop web (#2998)

* Show tabbar on desktop

* Make bottom border always 1px

* Don't hide/show navbar when switching tabs

* two rows WIP

* Top bar tweaks

* Make scroll adjustement native-only

* Add new web scroll behavior
zio/stable
dan 2024-02-27 22:55:25 +00:00 committed by GitHub
parent 978bcc1ba9
commit ac726497a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 134 additions and 88 deletions

View File

@ -1,30 +1,24 @@
import React from 'react' import React from 'react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {MainScrollProvider} from '../util/MainScrollProvider' import {MainScrollProvider} from '../util/MainScrollProvider'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {colors, s} from 'lib/styles' import {s} from 'lib/styles'
import {View, useWindowDimensions} from 'react-native' import {View, useWindowDimensions} from 'react-native'
import {ListMethods} from '../util/List' import {ListMethods} from '../util/List'
import {Feed} from '../posts/Feed' import {Feed} from '../posts/Feed'
import {TextLink} from '../util/Link'
import {FAB} from '../util/fab/FAB' import {FAB} from '../util/fab/FAB'
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset, emitSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers' import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
@ -47,10 +41,8 @@ export function FeedPage({
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
}) { }) {
const {hasSession} = useSession() const {hasSession} = useSession()
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation() const navigation = useNavigation()
const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const [isScrolledDown, setIsScrolledDown] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false)
@ -99,63 +91,6 @@ export function FeedPage({
setHasNew(false) setHasNew(false)
}, [scrollToTop, feed, queryClient, setHasNew]) }, [scrollToTop, feed, queryClient, setHasNew])
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
Bluesky{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={emitSoftReset}
/>
{hasSession && (
<TextLink
type="title-lg"
href="/settings/following-feed"
style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
)}
</View>
)
}
return <></>
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
return ( return (
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>
<MainScrollProvider> <MainScrollProvider>
@ -171,7 +106,6 @@ export function FeedPage({
onHasNew={setHasNew} onHasNew={setHasNew}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed} renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset} headerOffset={headerOffset}
/> />
</MainScrollProvider> </MainScrollProvider>

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {HomeHeaderLayout} from './HomeHeaderLayout' import {HomeHeaderLayout} from './HomeHeaderLayout'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePinnedFeedsInfos} from '#/state/queries/feed' import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
@ -11,16 +10,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
export function HomeHeader( export function HomeHeader(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isDesktop} = useWebMediaQueries()
if (isDesktop) {
return null
}
return <HomeHeaderInner {...props} />
}
export function HomeHeaderInner(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) { ) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()

View File

@ -1,11 +1,20 @@
import React from 'react' import React from 'react'
import {StyleSheet} from 'react-native' import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout' import {useShellLayout} from '#/state/shell/shell-layout'
import {Logo} from '#/view/icons/Logo'
import {Link, TextLink} from '../util/Link'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {CogIcon} from '#/lib/icons'
export function HomeHeaderLayout({children}: {children: React.ReactNode}) { export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
const pal = usePalette('default') const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout() const {headerHeight} = useShellLayout()
const {_} = useLingui()
return ( return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
onLayout={e => { onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height headerHeight.value = e.nativeEvent.layout.height
}}> }}>
<View style={[pal.view, styles.topBar]}>
<TextLink
type="title-lg"
href="/settings/following-feed"
accessibilityLabel={_(msg`Following Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
<Logo width={28} />
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit Saved Feeds`)}
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
</View>
{children} {children}
</Animated.View> </Animated.View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 8,
marginTop: 8,
width: '100%',
},
tabBar: { tabBar: {
// @ts-ignore Web only // @ts-ignore Web only
position: 'sticky', position: 'sticky',
@ -42,7 +84,7 @@ const styles = StyleSheet.create({
left: 'calc(50% - 300px)', left: 'calc(50% - 300px)',
width: 600, width: 600,
top: 0, top: 0,
flexDirection: 'row', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
borderLeftWidth: 1, borderLeftWidth: 1,
borderRightWidth: 1, borderRightWidth: 1,

View File

@ -103,7 +103,6 @@ const styles = StyleSheet.create({
right: 0, right: 0,
top: 0, top: 0,
flexDirection: 'column', flexDirection: 'column',
borderBottomWidth: 1,
}, },
topBar: { topBar: {
flexDirection: 'row', flexDirection: 'row',

View File

@ -5,6 +5,7 @@ import {PressableWithHover} from '../util/PressableWithHover'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {DraggableScrollView} from './DraggableScrollView' import {DraggableScrollView} from './DraggableScrollView'
import {isNative} from '#/platform/detection'
export interface TabBarProps { export interface TabBarProps {
testID?: string testID?: string
@ -15,6 +16,10 @@ export interface TabBarProps {
onPressSelected?: (index: number) => void onPressSelected?: (index: number) => void
} }
// How much of the previous/next item we're showing
// to give the user a hint there's more to scroll.
const OFFSCREEN_ITEM_WIDTH = 20
export function TabBar({ export function TabBar({
testID, testID,
selectedPage, selectedPage,
@ -25,6 +30,7 @@ export function TabBar({
}: TabBarProps) { }: TabBarProps) {
const pal = usePalette('default') const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null) const scrollElRef = useRef<ScrollView>(null)
const itemRefs = useRef<Array<Element>>([])
const [itemXs, setItemXs] = useState<number[]>([]) const [itemXs, setItemXs] = useState<number[]>([])
const indicatorStyle = useMemo( const indicatorStyle = useMemo(
() => ({borderBottomColor: indicatorColor || pal.colors.link}), () => ({borderBottomColor: indicatorColor || pal.colors.link}),
@ -33,12 +39,58 @@ export function TabBar({
const {isDesktop, isTablet} = useWebMediaQueries() const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
// scrolls to the selected item when the page changes
useEffect(() => { useEffect(() => {
scrollElRef.current?.scrollTo({ if (isNative) {
x: // On native, the primary interaction is swiping.
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal, // We adjust the scroll little by little on every tab change.
// Scroll into view but keep the end of the previous item visible.
let x = itemXs[selectedPage] || 0
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
scrollElRef.current?.scrollTo({x})
} else {
// On the web, the primary interaction is tapping.
// Scrolling under tap feels disorienting so only adjust the scroll offset
// when tapping on an item out of view--and we adjust by almost an entire page.
const parent = scrollElRef?.current?.getScrollableNode?.()
if (!parent) {
return
}
const parentRect = parent.getBoundingClientRect()
if (!parentRect) {
return
}
const {
left: parentLeft,
right: parentRight,
width: parentWidth,
} = parentRect
const child = itemRefs.current[selectedPage]
if (!child) {
return
}
const childRect = child.getBoundingClientRect?.()
if (!childRect) {
return
}
const {left: childLeft, right: childRight, width: childWidth} = childRect
let dx = 0
if (childRight >= parentRight) {
dx += childRight - parentRight
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
} else if (childLeft <= parentLeft) {
dx -= parentLeft - childLeft
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
}
let x = parent.scrollLeft + dx
x = Math.max(0, x)
x = Math.min(x, parent.scrollWidth - parentWidth)
if (dx !== 0) {
parent.scroll({
left: x,
behavior: 'smooth',
}) })
}
}
}, [scrollElRef, itemXs, selectedPage, styles]) }, [scrollElRef, itemXs, selectedPage, styles])
const onPressItem = useCallback( const onPressItem = useCallback(
@ -78,6 +130,7 @@ export function TabBar({
<PressableWithHover <PressableWithHover
testID={`${testID}-selector-${i}`} testID={`${testID}-selector-${i}`}
key={`${item}-${i}`} key={`${item}-${i}`}
ref={node => (itemRefs.current[i] = node)}
onLayout={e => onItemLayout(e, i)} onLayout={e => onItemLayout(e, i)}
style={styles.item} style={styles.item}
hoverStyle={pal.viewLight} hoverStyle={pal.viewLight}
@ -94,6 +147,7 @@ export function TabBar({
) )
})} })}
</DraggableScrollView> </DraggableScrollView>
<View style={[pal.border, styles.outerBottomBorder]} />
</View> </View>
) )
} }
@ -117,6 +171,13 @@ const desktopStyles = StyleSheet.create({
borderBottomWidth: 3, borderBottomWidth: 3,
borderBottomColor: 'transparent', borderBottomColor: 'transparent',
}, },
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
}) })
const mobileStyles = StyleSheet.create({ const mobileStyles = StyleSheet.create({
@ -137,4 +198,11 @@ const mobileStyles = StyleSheet.create({
borderBottomWidth: 3, borderBottomWidth: 3,
borderBottomColor: 'transparent', borderBottomColor: 'transparent',
}, },
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
}) })

View File

@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const setMode = useSetMinimalShellMode() const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null) const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null) const startMode = useSharedValue<number | null>(null)
const didJustRestoreScroll = useSharedValue<boolean>(false)
useEffect(() => { useEffect(() => {
if (isWeb) { if (isWeb) {
return listenToForcedWindowScroll(() => { return listenToForcedWindowScroll(() => {
startDragOffset.value = null startDragOffset.value = null
startMode.value = null startMode.value = null
didJustRestoreScroll.value = true
}) })
} }
}) })
@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
mode.value = newValue mode.value = newValue
} }
} else { } else {
if (didJustRestoreScroll.value) {
didJustRestoreScroll.value = false
// Don't hide/show navbar based on scroll restoratoin.
return
}
// On the web, we don't try to follow the drag because we don't know when it ends. // On the web, we don't try to follow the drag because we don't know when it ends.
// Instead, show/hide immediately based on whether we're scrolling up or down. // Instead, show/hide immediately based on whether we're scrolling up or down.
const dy = e.contentOffset.y - (startDragOffset.value ?? 0) const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
@ -98,7 +105,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
} }
} }
}, },
[headerHeight, mode, setMode, startDragOffset, startMode], [
headerHeight,
mode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
],
) )
return ( return (