diff --git a/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx index 449d11bba78..baf24f0e968 100644 --- a/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx +++ b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx @@ -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 @@ -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) => { @@ -58,16 +69,16 @@ export const FloatingSubHeader = (props: FloatingSubHeaderProps) => { return ( - - - {children} - + + {children} + ) } @@ -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 } }) diff --git a/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx index df2d0908e62..9756fccd994 100644 --- a/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx +++ b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx @@ -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, @@ -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 = { @@ -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 @@ -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 @@ -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)) } /** diff --git a/packages/mobile/src/screens/app-screen/GlassEdgeFade.tsx b/packages/mobile/src/screens/app-screen/GlassEdgeFade.tsx index b22e5e90984..d4fb20b2e21 100644 --- a/packages/mobile/src/screens/app-screen/GlassEdgeFade.tsx +++ b/packages/mobile/src/screens/app-screen/GlassEdgeFade.tsx @@ -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 diff --git a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx index 5ce859e1665..7ff32691a42 100644 --- a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx +++ b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx @@ -6,7 +6,6 @@ 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' @@ -14,20 +13,16 @@ 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 @@ -35,17 +30,14 @@ type MobileRootHeaderProps = { } 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, @@ -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 @@ -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(() => { @@ -105,36 +87,24 @@ export const MobileRootHeader = (props: MobileRootHeaderProps) => { style={{ paddingTop: insets.top }} onLayout={handleLayout} > + + + + + {title} + + {children} + + - - - - - - - {title} - - {children} - - - ) } diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx index 4cc9658e609..22377e378a0 100644 --- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx @@ -259,10 +259,7 @@ export const FeedScreen = () => { return ( - {/* 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. */} diff --git a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx index 99fc4a057de..29f777ccd07 100644 --- a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx @@ -1,7 +1,6 @@ import { FeedTab } from '@audius/common/models' -import { ScrollView, View } from 'react-native' -import { Flex, SelectablePill, useTheme } from '@audius/harmony-native' +import { Flex, SelectablePill } from '@audius/harmony-native' const tabLabels: Record = { [FeedTab.FOR_YOU]: 'For You', @@ -15,36 +14,25 @@ type FeedTabsProps = { onSelectTab: (tab: FeedTab) => void } +/** Rendered inside `FloatingSubHeader`'s glass pill bar, which owns the fill. */ export const FeedTabs = ({ currentTab, onSelectTab }: FeedTabsProps) => { - const { spacing } = useTheme() return ( - // No background: this row is rendered inside the header's `GlassSurface` - // (see FloatingSubHeader), which owns the frosted fill. Painting an opaque - // white here would punch a solid band through the glass. - - - - {tabs.map((tab) => ( - { - if (!isSelected) return - onSelectTab(value as FeedTab) - }} - disableUnselectAnimation - /> - ))} - - - + + {tabs.map((tab) => ( + { + if (!isSelected) return + onSelectTab(value as FeedTab) + }} + disableUnselectAnimation + /> + ))} + ) } diff --git a/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx b/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx index 3d15479d1ca..b56943cbbab 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx @@ -9,16 +9,11 @@ import { } from '@audius/common/store' import { ALL_GENRES } from '@audius/common/utils' import type { ViewStyle } from 'react-native' -import { ScrollView, View } from 'react-native' +import { View } from 'react-native' import type { SvgProps } from 'react-native-svg' import { useDispatch, useSelector } from 'react-redux' -import { - Flex, - IconLeading, - SelectablePill, - useTheme -} from '@audius/harmony-native' +import { Flex, IconLeading, SelectablePill } from '@audius/harmony-native' import { GradientIcon, GradientText } from 'app/components/core' import type { StylesProp } from 'app/styles' import { makeStyles } from 'app/styles' @@ -45,9 +40,8 @@ type TrendingHeaderProps = { } const useStyles = makeStyles(({ palette, spacing, typography }) => ({ - // No backgroundColor: when rendered inside `FloatingSubHeader` the - // surrounding `GlassSurface` owns the frosted fill, and an opaque white here - // would punch a solid band through it. + // No backgroundColor: inside `FloatingSubHeader` the glass pill bar owns + // the fill. root: { borderBottomWidth: 1, borderBottomColor: palette.neutralLight8, @@ -98,7 +92,6 @@ export const TrendingHeader = (props: TrendingHeaderProps) => { styles: stylesProp } = props const styles = useStyles() - const { spacing } = useTheme() const dispatch = useDispatch() const category = useSelector(getTrendingCategory) ?? 'tracks' const timeRange = useSelector(trendingPageSelectors.getTrendingTimeRange) @@ -152,29 +145,25 @@ export const TrendingHeader = (props: TrendingHeaderProps) => { ) : null} ) : null} - - - {categories.map((cat) => ( - - ))} - - + {categories.map((cat) => ( + + ))} + ) } diff --git a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx index a2e8fe2fe1a..6ed4518e381 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx @@ -79,8 +79,7 @@ export const TrendingScreen = () => { return ( - {/* Pinned into the floating glass stack under the title so the - lineups scroll behind one continuous frosted surface. */} + {/* Pins under the status bar once the title scrolls away. */} {trendingPills}