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}