Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 47 additions & 27 deletions packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,34 @@ import { useCallback } from 'react'

import type { LayoutChangeEvent } from 'react-native'
import { StyleSheet, View } from 'react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import Animated, {
useAnimatedStyle,
useSharedValue
} from 'react-native-reanimated'

import { GlassCapsule } from 'app/components/core/Screen/GlassCapsule'
import { zIndex } from 'app/utils/zIndex'

import {
useChromeHiddenProgress,
useGlassHeaderInset,
useRootHeaderHeight,
useSetSubHeaderHeight,
useSubHeaderHeight
useSubHeaderHeight,
useSubHeaderScrollOffset
} from './GlassChromeContext'
import {
EDGE_FADE_TAIL,
GlassEdgeFade,
SUB_HEADER_FADE_OPACITY
} from './GlassEdgeFade'

/** Pill (32) plus 6px of glass around it. */
const PILL_BAR_HEIGHT = 44

type FloatingSubHeaderProps = {
children: ReactNode
}

/**
* Pins a screen's persistent top row (feed tabs, trending pills) beneath the
* floating root header. The pills float on their own over a fade that picks
* up where the header's leaves off, so there's no slab behind them.
* A screen's tab pills (feed tabs, trending categories) in one glass pill
* bar under the root header. It scrolls up with the list, then pins under the
* status bar once the header is gone. While pinned it slides away on scroll
* down and comes back on scroll up, with the tab bar.
*
* Owned by the screen rather than passed into `MobileRootHeader`, whose
* memoized render prop must not rebuild on tab-state changes (that remounts
Expand All @@ -39,14 +42,22 @@ export const FloatingSubHeader = (props: FloatingSubHeaderProps) => {
const subHeaderHeight = useSubHeaderHeight()
const setSubHeaderHeight = useSetSubHeaderHeight()
const hidden = useChromeHiddenProgress()
const glassHeaderInset = useGlassHeaderInset()
const scrollOffset = useSubHeaderScrollOffset()
const floating = useSharedValue(1)

// Travels by the full stack height, not just its own, so it tucks up behind
// the header rather than colliding with it on the way out.
const hideStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -glassHeaderInset * hidden.value }],
opacity: 1 - hidden.value
}))
// Glass stops rendering under a parent that fades from 0, so it hides by
// sliding fully off the top instead.
const positionStyle = useAnimatedStyle(() => {
const restingTop = headerHeight - scrollOffset.value
return {
transform: [
{
translateY:
-scrollOffset.value - hidden.value * (restingTop + subHeaderHeight)
}
]
}
})

const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
Expand All @@ -58,16 +69,16 @@ export const FloatingSubHeader = (props: FloatingSubHeaderProps) => {
return (
<Animated.View
pointerEvents='box-none'
style={[styles.root, { top: headerHeight }, hideStyle]}
style={[styles.root, { top: headerHeight }, positionStyle]}
onLayout={handleLayout}
>
<GlassEdgeFade
height={subHeaderHeight + EDGE_FADE_TAIL}
fromOpacity={SUB_HEADER_FADE_OPACITY}
toOpacity={0}
/>
<View pointerEvents='box-none' onLayout={handleLayout}>
{children}
</View>
<GlassCapsule
height={PILL_BAR_HEIGHT}
floatProgress={floating}
style={styles.pillBar}
>
<View style={styles.pills}>{children}</View>
</GlassCapsule>
</Animated.View>
)
}
Expand All @@ -77,6 +88,15 @@ const styles = StyleSheet.create({
position: 'absolute',
left: 0,
right: 0,
paddingVertical: 8,
zIndex: zIndex.HEADER_SHADOW
},
pillBar: {
alignSelf: 'flex-start'
},
pills: {
flex: 1,
justifyContent: 'center',
paddingHorizontal: 6
}
})
53 changes: 27 additions & 26 deletions packages/mobile/src/screens/app-screen/GlassChromeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
import type { SharedValue } from 'react-native-reanimated'
import {
Easing,
Extrapolation,
interpolate,
useAnimatedReaction,
useDerivedValue,
useSharedValue,
Expand Down Expand Up @@ -47,16 +45,10 @@ const ESTIMATED_HEADER_ROW_HEIGHT = 64
const DIRECTION_THRESHOLD = 6

/**
* Below this offset the chrome is always shown, so the top of a list never
* opens with the header already dismissed.
* Gap between the status bar and a pinned sub-header, so the status bar fade
* ends before the pill bar's glass starts.
*/
const ALWAYS_SHOWN_OFFSET = 40

/**
* Scroll distance over which the root header turns from flat into a pill.
* Short enough to finish before the header starts hiding.
*/
const HEADER_FLOAT_DISTANCE = 32
export const SUB_HEADER_PIN_GAP = 8

// Long enough for the tab bar's collapse to read as a morph rather than a cut.
const HIDE_TIMING = {
Expand Down Expand Up @@ -94,14 +86,16 @@ export const GlassChromeProvider = (props: { children: ReactNode }) => {
const scrollY = useSharedValue(0)
const hidden = useSharedValue(0)
const directionAnchorY = useSharedValue(0)
const headerRowHeight = headerHeight - insets.top

// Scrolling down hides the chrome and scrolling up brings it back. Near the
// top, and during rubber-band overscroll (negative offset), it stays shown.
// Scrolling down hides the chrome and scrolling up brings it back. Until the
// title row has scrolled away, and during rubber-band overscroll (negative
// offset), it stays shown.
useAnimatedReaction(
() => scrollY.value,
(current, previous) => {
if (previous === null) return
if (current < ALWAYS_SHOWN_OFFSET) {
if (current < headerRowHeight) {
hidden.value = withTiming(0, HIDE_TIMING)
directionAnchorY.value = current
return
Expand All @@ -110,7 +104,8 @@ export const GlassChromeProvider = (props: { children: ReactNode }) => {
if (Math.abs(delta) < DIRECTION_THRESHOLD) return
hidden.value = withTiming(delta > 0 ? 1 : 0, HIDE_TIMING)
directionAnchorY.value = current
}
},
[headerRowHeight]
)

// Guard against re-render loops: onLayout fires on every layout pass, and
Expand Down Expand Up @@ -198,19 +193,25 @@ export const useGlassScrollY = () => useGlassChrome().scrollY
export const useChromeHiddenProgress = () => useGlassChrome().hidden

/**
* 0 = header flat on the page (list at the top), 1 = floating pill (content
* scrolled under it).
* How far the root header has scrolled up with the list, capped once it is
* fully off screen. Negative during overscroll so it follows the rubber band.
*/
export const useHeaderFloatProgress = () => {
export const useHeaderScrollOffset = () => {
const scrollY = useGlassScrollY()
return useDerivedValue(() =>
interpolate(
scrollY.value,
[0, HEADER_FLOAT_DISTANCE],
[0, 1],
Extrapolation.CLAMP
)
)
const { headerHeight } = useGlassChrome()
return useDerivedValue(() => Math.min(scrollY.value, headerHeight))
}

/**
* How far a sub-header has scrolled up with the list. It stops just under the
* status bar and stays pinned there.
*/
export const useSubHeaderScrollOffset = () => {
const scrollY = useGlassScrollY()
const { headerHeight } = useGlassChrome()
const insets = useSafeAreaInsets()
const pinOffset = headerHeight - insets.top - SUB_HEADER_PIN_GAP
return useDerivedValue(() => Math.min(scrollY.value, pinOffset))
}

/**
Expand Down
3 changes: 0 additions & 3 deletions packages/mobile/src/screens/app-screen/GlassEdgeFade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import { useThemeColors } from 'app/utils/theme'
/** Background opacity at the very top, behind the status bar. */
export const EDGE_FADE_FROM_OPACITY = 0.94

/** Background opacity where the header's fade hands off to a sub-header's. */
export const SUB_HEADER_FADE_OPACITY = 0.7

/** How far a fade reaches past the chrome it sits behind. */
export const EDGE_FADE_TAIL = 16

Expand Down
84 changes: 27 additions & 57 deletions packages/mobile/src/screens/app-screen/MobileRootHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,38 @@ import { Text, View } from 'react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { GlassCapsule } from 'app/components/core/Screen/GlassCapsule'
import { useDrawer } from 'app/hooks/useDrawer'
import { makeStyles } from 'app/styles'

import { AppDrawerContext } from '../app-drawer-screen'

import { AccountPictureHeader } from './AccountPictureHeader'
import {
useChromeHiddenProgress,
useGlassHeaderInset,
useHeaderFloatProgress,
useRootHeaderHeight,
useHeaderScrollOffset,
useSetRootHeaderHeight
} from './GlassChromeContext'
import {
EDGE_FADE_FROM_OPACITY,
EDGE_FADE_TAIL,
GlassEdgeFade,
SUB_HEADER_FADE_OPACITY
GlassEdgeFade
} from './GlassEdgeFade'

const CAPSULE_HEIGHT = 52
const ROW_HEIGHT = 52

type MobileRootHeaderProps = {
title: string
children?: ReactNode
}

const useStyles = makeStyles(({ palette, spacing, typography }) => ({
capsule: {
marginTop: spacing(1),
marginBottom: spacing(2)
},
row: {
flex: 1,
height: ROW_HEIGHT,
marginTop: spacing(1),
marginBottom: spacing(2),
flexDirection: 'row',
alignItems: 'center',
gap: spacing(3),
paddingLeft: spacing(2),
paddingRight: spacing(2)
paddingHorizontal: spacing(4)
},
title: {
flex: 1,
Expand All @@ -59,10 +51,10 @@ const useStyles = makeStyles(({ palette, spacing, typography }) => ({
/**
* Custom header for root (bottom-bar) tab screens.
*
* [Avatar] [title] [right content], flat on the page at the top of the list.
* Once content scrolls under it, it becomes a floating glass pill matching
* the floating tab bar, with a fade behind the status bar. It reports its measured height through `GlassChromeContext` so the
* screen underneath can pad its scrollable content to start below it.
* [Avatar] [title] [right content], flat on the page. It scrolls away with
* the list, and the content passing under the status bar fades out. It
* reports its measured height through `GlassChromeContext` so the screen
* underneath can pad its scrollable content to start below it.
*/
export const MobileRootHeader = (props: MobileRootHeaderProps) => {
const { title, children } = props
Expand All @@ -71,20 +63,10 @@ export const MobileRootHeader = (props: MobileRootHeaderProps) => {
const { drawerHelpers } = useContext(AppDrawerContext)
const { isOpen: isNowPlayingDrawerOpen } = useDrawer('NowPlaying')
const setRootHeaderHeight = useSetRootHeaderHeight()
const hidden = useChromeHiddenProgress()
const floatProgress = useHeaderFloatProgress()
const headerHeight = useRootHeaderHeight()
const hasSubHeader = useGlassHeaderInset() > headerHeight

// Slide the capsule off the top as the chrome hides. Visual only: the list
// keeps its padding so content doesn't reflow.
const hideStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -headerHeight * hidden.value }]
}))
const scrollOffset = useHeaderScrollOffset()

// The fade behind the capsule goes with it; the status bar fade stays.
const fadeStyle = useAnimatedStyle(() => ({
opacity: 1 - hidden.value
const scrollStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -scrollOffset.value }]
}))

const handleOpenLeftNavDrawer = useCallback(() => {
Expand All @@ -105,36 +87,24 @@ export const MobileRootHeader = (props: MobileRootHeaderProps) => {
style={{ paddingTop: insets.top }}
onLayout={handleLayout}
>
<Animated.View pointerEvents='box-none' style={scrollStyle}>
<View style={styles.row}>
<AccountPictureHeader onPress={handleOpenLeftNavDrawer} />
<Text
accessibilityRole='header'
numberOfLines={1}
style={styles.title}
>
{title}
</Text>
{children}
</View>
</Animated.View>
<GlassEdgeFade
height={insets.top + EDGE_FADE_TAIL}
fromOpacity={EDGE_FADE_FROM_OPACITY}
toOpacity={0}
/>
<GlassEdgeFade
height={headerHeight + (hasSubHeader ? 0 : EDGE_FADE_TAIL)}
fromOpacity={EDGE_FADE_FROM_OPACITY}
toOpacity={hasSubHeader ? SUB_HEADER_FADE_OPACITY : 0}
style={fadeStyle}
/>
<Animated.View pointerEvents='box-none' style={hideStyle}>
<GlassCapsule
height={CAPSULE_HEIGHT}
floatProgress={floatProgress}
style={styles.capsule}
>
<View style={styles.row}>
<AccountPictureHeader onPress={handleOpenLeftNavDrawer} />
<Text
accessibilityRole='header'
numberOfLines={1}
style={styles.title}
>
{title}
</Text>
{children}
</View>
</GlassCapsule>
</Animated.View>
</View>
)
}
5 changes: 1 addition & 4 deletions packages/mobile/src/screens/feed-screen/FeedScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,7 @@ export const FeedScreen = () => {
return (
<Screen url='Feed' header={renderHeader} headerTransparent>
<ScreenContent>
{/* Pinned into the floating glass stack directly under the title, so
the lineups scroll behind one continuous frosted surface rather
than sliding under the header and colliding with an opaque tab
row. */}
{/* Pins under the status bar once the title scrolls away. */}
<FloatingSubHeader>
<FeedTabs currentTab={feedTab} onSelectTab={handleSelectTab} />
</FloatingSubHeader>
Expand Down
Loading
Loading