diff --git a/packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx b/packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx
index b0ff1e07a13..ef6599ca611 100644
--- a/packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx
+++ b/packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx
@@ -9,6 +9,7 @@ import { Animated } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Flex } from '@audius/harmony-native'
+import { GlassSurface } from 'app/components/core/Screen/GlassSurface'
import { FULL_DRAWER_HEIGHT } from 'app/components/drawer'
import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer'
@@ -103,29 +104,34 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
interpolatePostion(translationAnim, insets.bottom)
]}
>
-
- {routes.map(({ name, key }, index) => {
- const BottomTabBarButton = bottomTabBarButtons[name]
+ {/*
+ Frosted rather than a solid fill so content scrolls behind the bar,
+ matching the floating header stack. The separator goes on the top edge
+ here — that's the side content passes under.
+ */}
+
+
+ {routes.map(({ name, key }, index) => {
+ const BottomTabBarButton = bottomTabBarButtons[name]
- return (
-
- )
- })}
-
+ return (
+
+ )
+ })}
+
+
)
}
diff --git a/packages/mobile/src/components/core/BottomChin.tsx b/packages/mobile/src/components/core/BottomChin.tsx
new file mode 100644
index 00000000000..5b9e746b6ca
--- /dev/null
+++ b/packages/mobile/src/components/core/BottomChin.tsx
@@ -0,0 +1,38 @@
+import { playbackSelectors } from '@audius/common/store'
+import { View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { useSelector } from 'react-redux'
+
+import { BOTTOM_BAR_HEIGHT } from '../bottom-tab-bar/constants'
+import { PLAY_BAR_HEIGHT } from '../now-playing-drawer'
+
+const { getHasTrack } = playbackSelectors
+
+/**
+ * Height a screen's scrollable content needs at its end to clear the floating
+ * bottom chrome.
+ *
+ * The tab bar is positioned absolutely (see `AppTabBar`) so content can run
+ * full-height and slide behind the glass as it scrolls. That also means the
+ * navigator no longer reserves any space for it, so without this inset the
+ * last row of every list would sit permanently under the bar — worst on short
+ * lists, which never scroll far enough for the auto-hide to uncover them.
+ *
+ * This is about the *end of the content being reachable*, not about avoiding
+ * overlap mid-scroll: content still passes under the bar while scrolling,
+ * which is the whole point of the frosted treatment.
+ *
+ * The play-bar portion stays conditional — it is 0 when nothing is playing,
+ * and unlike the tab bar the now-playing bar does not auto-hide, so its space
+ * has to be held whenever it is on screen.
+ */
+export const useBottomChinHeight = () => {
+ const hasTrack = useSelector(getHasTrack)
+ const insets = useSafeAreaInsets()
+ return BOTTOM_BAR_HEIGHT + insets.bottom + (hasTrack ? PLAY_BAR_HEIGHT : 0)
+}
+
+export const BottomChin = () => {
+ const height = useBottomChinHeight()
+ return
+}
diff --git a/packages/mobile/src/components/core/FlatList.tsx b/packages/mobile/src/components/core/FlatList.tsx
index 0901a5e1d35..777627f9e2f 100644
--- a/packages/mobile/src/components/core/FlatList.tsx
+++ b/packages/mobile/src/components/core/FlatList.tsx
@@ -13,7 +13,7 @@ import { useThemeColors } from 'app/utils/theme'
import { CollapsibleTabNavigatorContext } from '../top-tab-bar'
-import { PlayBarChin } from './PlayBarChin'
+import { BottomChin } from './BottomChin'
import { PullToRefresh, useOverflowHandlers } from './PullToRefresh'
export type FlatListT = RNFlatList
@@ -152,10 +152,10 @@ export const FlatList = forwardRef(function FlatList(
const FooterComponent = ListFooterComponent ? (
<>
{ListFooterComponent}
-
+
>
) : (
- PlayBarChin
+ BottomChin
)
const flatListProps = {
diff --git a/packages/mobile/src/components/core/PlayBarChin.tsx b/packages/mobile/src/components/core/PlayBarChin.tsx
deleted file mode 100644
index 7534d5ab499..00000000000
--- a/packages/mobile/src/components/core/PlayBarChin.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { playbackSelectors } from '@audius/common/store'
-import { View } from 'react-native'
-import { useSelector } from 'react-redux'
-
-import { PLAY_BAR_HEIGHT } from '../now-playing-drawer'
-
-const { getHasTrack } = playbackSelectors
-
-export const PlayBarChin = () => {
- const hasTrack = useSelector(getHasTrack)
- return
-}
diff --git a/packages/mobile/src/components/core/Screen/GlassSurface.tsx b/packages/mobile/src/components/core/Screen/GlassSurface.tsx
new file mode 100644
index 00000000000..6b2022ff106
--- /dev/null
+++ b/packages/mobile/src/components/core/Screen/GlassSurface.tsx
@@ -0,0 +1,159 @@
+import type { ReactNode } from 'react'
+
+import { BlurView } from '@react-native-community/blur'
+import type { ViewProps } from 'react-native'
+import { Platform, StyleSheet, View } from 'react-native'
+import type { SharedValue } from 'react-native-reanimated'
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ Extrapolation
+} from 'react-native-reanimated'
+
+import { isDarkTheme, useThemeColors, useThemeVariant } from 'app/utils/theme'
+
+/**
+ * Opacity of the tint laid over the blur. Mirrors the desktop `Frosted`
+ * surface, which sits at 65% of `--harmony-n-25` over a 10px backdrop blur.
+ * Slightly higher here because mobile blurs a much busier backdrop (album art
+ * scrolling underneath) and the header text has to stay legible at a glance.
+ */
+const IOS_TINT_OPACITY = 0.72
+
+/**
+ * Android does not get a real backdrop blur. `@react-native-community/blur`'s
+ * Android implementation is expensive enough to drop frames on a surface that
+ * is composited on every scroll frame, and the existing app-wide precedent
+ * (ProfileNavOverlay) already falls back to a solid fill there. We use a high
+ * -opacity tint instead: content still slides under the header, it just isn't
+ * blurred while it does.
+ */
+const ANDROID_TINT_OPACITY = 0.94
+
+/**
+ * Scroll distance (px) over which the bottom edge fades from invisible to
+ * fully drawn. Short enough that the edge is there by the time the first tile
+ * is meaningfully behind the glass, long enough not to snap.
+ */
+const EDGE_FADE_DISTANCE = 24
+
+type GlassSurfaceProps = ViewProps & {
+ children?: ReactNode
+ /** Draw a hairline separator along the bottom edge. */
+ showBorder?: boolean
+ /**
+ * Which edge the separator sits on. Bottom for glass that content scrolls
+ * *under* (the header stack), top for glass that content scrolls *behind*
+ * from below (the bottom tab bar).
+ */
+ borderEdge?: 'top' | 'bottom'
+ /**
+ * Scroll offset of the content behind the glass. When provided, the bottom
+ * edge (hairline + soft shadow) is absent at rest and fades in as content
+ * slides underneath — so the stack reads flush with the page until there is
+ * actually something behind it to separate from. Without it the edge is
+ * drawn statically.
+ */
+ scrollY?: SharedValue
+ /** Override the blur radius used on iOS. */
+ blurAmount?: number
+}
+
+/**
+ * A translucent "frosted glass" surface that content scrolls behind.
+ *
+ * Renders the blur/tint as an absolutely-positioned layer so the caller keeps
+ * full control of its own layout — drop it in as the first child of a
+ * position-relative container and the surface fills it.
+ *
+ * iOS gets a genuine backdrop blur; Android gets a near-opaque tint. See the
+ * opacity constants above for why.
+ */
+export const GlassSurface = (props: GlassSurfaceProps) => {
+ const {
+ children,
+ style,
+ showBorder = true,
+ borderEdge = 'bottom',
+ scrollY,
+ blurAmount = 20,
+ ...other
+ } = props
+ const isDarkMode = isDarkTheme(useThemeVariant())
+ const { backgroundSurface, borderStrong } = useThemeColors()
+
+ const tintOpacity =
+ Platform.OS === 'ios' ? IOS_TINT_OPACITY : ANDROID_TINT_OPACITY
+
+ const edgeStyle = useAnimatedStyle(() => {
+ // No scrollY (or nothing scrolled yet) → draw the edge statically, which
+ // is what non-scrolling callers want.
+ if (!scrollY) return { opacity: 1 }
+ return {
+ opacity: interpolate(
+ scrollY.value,
+ [0, EDGE_FADE_DISTANCE],
+ [0, 1],
+ Extrapolation.CLAMP
+ )
+ }
+ })
+
+ return (
+
+
+ {Platform.OS === 'ios' ? (
+
+ ) : null}
+
+ {showBorder ? (
+
+ ) : null}
+
+ {children}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: {
+ position: 'relative'
+ },
+ border: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ height: StyleSheet.hairlineWidth,
+ // Soft drop off the hairline so the glass reads as a layer above the
+ // content passing under it, not just a ruled line.
+ shadowColor: '#000',
+ shadowOpacity: 0.1,
+ shadowRadius: 3,
+ elevation: 3
+ },
+ borderBottom: {
+ bottom: 0,
+ shadowOffset: { width: 0, height: 2 }
+ },
+ borderTop: {
+ top: 0,
+ shadowOffset: { width: 0, height: -2 }
+ }
+})
diff --git a/packages/mobile/src/components/core/Screen/Screen.tsx b/packages/mobile/src/components/core/Screen/Screen.tsx
index bffff46d5ad..9ec8981355a 100644
--- a/packages/mobile/src/components/core/Screen/Screen.tsx
+++ b/packages/mobile/src/components/core/Screen/Screen.tsx
@@ -56,6 +56,13 @@ export type ScreenProps = {
variant?: ScreenVariant
as?: ComponentType
header?: () => ReactElement
+ /**
+ * Float the custom header over the screen instead of stacking content below
+ * it, so content scrolls behind a translucent header. The screen is then
+ * responsible for padding its own scrollable content by the header height
+ * (see `useRootHeaderHeight`).
+ */
+ headerTransparent?: boolean
// Callback called when user presses back button
onBack?: () => void
}
@@ -76,6 +83,7 @@ export const Screen = (props: ScreenProps) => {
style,
as: RootComponent = View,
header,
+ headerTransparent,
onBack
} = props
const palette = useThemePalette()
@@ -107,6 +115,7 @@ export const Screen = (props: ScreenProps) => {
removeUndefined({
header,
headerShown: header ? true : undefined,
+ headerTransparent,
headerLeft: topbarLeft === undefined ? undefined : () => topbarLeft,
headerRight: topbarRight
? () => topbarRight
@@ -136,7 +145,8 @@ export const Screen = (props: ScreenProps) => {
headerTitleProp,
icon,
IconProps,
- header
+ header,
+ headerTransparent
])
return (
diff --git a/packages/mobile/src/components/core/Screen/index.ts b/packages/mobile/src/components/core/Screen/index.ts
index 8da1353eb41..44591e21d4b 100644
--- a/packages/mobile/src/components/core/Screen/index.ts
+++ b/packages/mobile/src/components/core/Screen/index.ts
@@ -4,3 +4,4 @@ export * from './ScreenContent'
export * from './ScreenHeader'
export * from './ScreenHeaderButton'
export * from './HeaderShadow'
+export * from './GlassSurface'
diff --git a/packages/mobile/src/components/core/ScrollView.tsx b/packages/mobile/src/components/core/ScrollView.tsx
index 4934dbf8879..3d54eaca83c 100644
--- a/packages/mobile/src/components/core/ScrollView.tsx
+++ b/packages/mobile/src/components/core/ScrollView.tsx
@@ -3,7 +3,7 @@ import { forwardRef } from 'react'
import type { ScrollViewProps as RNScrollViewProps } from 'react-native'
import { ScrollView as RNScrollView } from 'react-native'
-import { PlayBarChin } from './PlayBarChin'
+import { BottomChin } from './BottomChin'
export type ScrollViewElement = RNScrollView
@@ -15,7 +15,7 @@ export const ScrollView = forwardRef(
return (
{children}
-
+
)
}
diff --git a/packages/mobile/src/components/core/SectionList.tsx b/packages/mobile/src/components/core/SectionList.tsx
index 3a2613ce7dc..297060f41a6 100644
--- a/packages/mobile/src/components/core/SectionList.tsx
+++ b/packages/mobile/src/components/core/SectionList.tsx
@@ -24,7 +24,7 @@ import { useThemeColors } from 'app/utils/theme'
import { CollapsibleTabNavigatorContext } from '../top-tab-bar'
-import { PlayBarChin } from './PlayBarChin'
+import { BottomChin } from './BottomChin'
import { PullToRefresh, useOverflowHandlers } from './PullToRefresh'
type CollapsibleSectionListProps = RNSectionListProps
@@ -181,19 +181,19 @@ export const SectionList = forwardRef(function SectionList<
SectionT = DefaultSectionT
>(
props: Animated.AnimatedProps> & {
- hidePlayBarChin?: boolean
+ hideBottomChin?: boolean
},
ref: Ref>
) {
- const { ListFooterComponent, hidePlayBarChin, ...other } = props
+ const { ListFooterComponent, hideBottomChin, ...other } = props
const FooterComponent = ListFooterComponent ? (
<>
{ListFooterComponent}
- {hidePlayBarChin ? null : }
+ {hideBottomChin ? null : }
>
- ) : hidePlayBarChin ? null : (
-
+ ) : hideBottomChin ? null : (
+
)
const sectionListProps = {
diff --git a/packages/mobile/src/components/core/VirtualizedKeyboardAwareScrollView.tsx b/packages/mobile/src/components/core/VirtualizedKeyboardAwareScrollView.tsx
index e92509b55ba..cf523e22fea 100644
--- a/packages/mobile/src/components/core/VirtualizedKeyboardAwareScrollView.tsx
+++ b/packages/mobile/src/components/core/VirtualizedKeyboardAwareScrollView.tsx
@@ -3,7 +3,7 @@ import type { ReactElement } from 'react'
import type { KeyboardAwareFlatListProps } from 'react-native-keyboard-aware-scroll-view'
import { KeyboardAwareFlatList } from 'react-native-keyboard-aware-scroll-view'
-import { PlayBarChin } from './PlayBarChin'
+import { BottomChin } from './BottomChin'
type BaseFlatListProps = Omit<
KeyboardAwareFlatListProps,
@@ -32,7 +32,7 @@ export const VirtualizedKeyboardAwareScrollView = (
data={null}
renderItem={() => null}
scrollIndicatorInsets={{ right: Number.MIN_VALUE }}
- ListFooterComponent={PlayBarChin}
+ ListFooterComponent={BottomChin}
{...other}
/>
)
diff --git a/packages/mobile/src/components/core/VirtualizedScrollView.tsx b/packages/mobile/src/components/core/VirtualizedScrollView.tsx
index e500aa83fee..8b9bba00754 100644
--- a/packages/mobile/src/components/core/VirtualizedScrollView.tsx
+++ b/packages/mobile/src/components/core/VirtualizedScrollView.tsx
@@ -5,7 +5,7 @@ import { FlatList } from 'react-native'
import { useScrollToTop } from 'app/hooks/useScrollToTop'
-import { PlayBarChin } from './PlayBarChin'
+import { BottomChin } from './BottomChin'
type BaseFlatListProps = Omit<
FlatListProps,
@@ -45,7 +45,7 @@ export const VirtualizedScrollView = forwardRef<
data={null}
renderItem={() => null}
scrollIndicatorInsets={{ right: Number.MIN_VALUE }}
- ListFooterComponent={PlayBarChin}
+ ListFooterComponent={BottomChin}
{...other}
/>
)
diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx
index 19518d57060..6170c7b2922 100644
--- a/packages/mobile/src/components/lineup/TrackLineup.tsx
+++ b/packages/mobile/src/components/lineup/TrackLineup.tsx
@@ -111,6 +111,14 @@ export type TrackLineupProps = {
LineupEmptyComponent?: SectionListProps['ListEmptyComponent']
ListFooterComponent?: SectionListProps['ListFooterComponent']
hideHeaderOnEmpty?: boolean
+ /**
+ * Padding/style applied to the scrollable content itself (not the list
+ * frame). Root tab screens use this to clear the floating glass header, so
+ * content starts below it but scrolls behind it.
+ */
+ contentContainerStyle?: SectionListProps['contentContainerStyle']
+ /** Scroll callback, used by root screens to drive the glass chrome. */
+ onScroll?: SectionListProps['onScroll']
itemStyles?: ViewStyle
pullToRefresh?: boolean
disableTopTabScroll?: boolean
@@ -153,6 +161,8 @@ export const TrackLineup = ({
LineupEmptyComponent,
ListFooterComponent,
hideHeaderOnEmpty,
+ contentContainerStyle,
+ onScroll,
itemStyles,
pullToRefresh,
disableTopTabScroll,
@@ -393,9 +403,12 @@ export const TrackLineup = ({
{
const { navigation, state } = props
+ const insets = useSafeAreaInsets()
+ const hidden = useTabBarHiddenProgress()
// Set handlers for the NowPlayingDrawer and BottomTabBar
// When the drawer is open, the bottom bar should hide (animated away).
// When the drawer is closed, the bottom bar should reappear (animated in).
@@ -23,14 +29,44 @@ export const AppTabBar = (props: TabBarProps) => {
}
const translationAnim = translationAnimRef.current
+ // Drop the bar past the bottom edge as the chrome hides, clearing the safe
+ // area so no sliver of glass is left floating over the home indicator.
+ // Layered over the drawer's own translation rather than folded into it.
+ const hideStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateY: (BOTTOM_BAR_HEIGHT + insets.bottom) * hidden.value }
+ ]
+ }))
+
return (
<>
-
+
+
+
>
)
}
+
+const styles = StyleSheet.create({
+ bar: {
+ // Floated out of flow so the screen container underneath is full-height.
+ // In normal flow the navigator reserves BOTTOM_BAR_HEIGHT + the bottom
+ // inset for the bar, which meant hiding it only uncovered dead
+ // background; now content actually occupies that space and slides behind
+ // the glass, and hiding the bar reveals more of it.
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ // Wrapping the bar creates a new stacking context, so the z-order it
+ // already declared for itself has to be restated out here to keep it
+ // above the now-playing drawer.
+ zIndex: 4,
+ elevation: 4
+ }
+})
diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
index 51ba9ee5c77..18d590627cf 100644
--- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
+++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx
@@ -72,6 +72,8 @@ import { ContestsScreen } from '../contests-screen'
import { FanClubSortScreen } from '../fan-club-sort-screen/FanClubSortScreen'
import { FanClubsExploreScreen } from '../fan-clubs-explore-screen/FanClubsExploreScreen'
+import { GlassChromeProvider } from './GlassChromeContext'
+import { TabBarAutoHideBridge } from './TabBarAutoHideContext'
import { useAppScreenOptions } from './useAppScreenOptions'
export type AppTabScreenParamList = {
@@ -240,119 +242,137 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => {
)
return (
-
- {baseScreen(Stack)}
-
-
-
-
-
-
-
-
-
-
+ // Publishes the floating root header's height to the screens below it, so
+ // they can pad their scrollable content and let it slide behind the glass.
+
+ {/*
+ The bottom tab bar lives outside this provider, so it can't read the
+ scroll signal directly — this hands it out while the tab is focused.
+ */}
+
+
+ {baseScreen(Stack)}
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
- // @ts-ignore hard to correctly type navigation params (PAY-1141)
- params?.chatId
- }
- options={{ ...screenOptions, fullScreenGestureEnabled: false }}
+ name='FilterButton'
+ component={FilterButtonScreen}
+ options={{ ...screenOptions, presentation: 'fullScreenModal' }}
/>
-
-
+
+
+
+
+
+ // @ts-ignore hard to correctly type navigation params (PAY-1141)
+ params?.chatId
+ }
+ options={{ ...screenOptions, fullScreenGestureEnabled: false }}
+ />
+
+
+
)
}
diff --git a/packages/mobile/src/screens/app-screen/AppTabsScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabsScreen.tsx
index f7ffb676e86..71d67ba40f1 100644
--- a/packages/mobile/src/screens/app-screen/AppTabsScreen.tsx
+++ b/packages/mobile/src/screens/app-screen/AppTabsScreen.tsx
@@ -13,6 +13,7 @@ import type { FeedTabScreenParamList } from './FeedTabScreen'
import { FeedTabScreen } from './FeedTabScreen'
import { NotificationsTabScreen } from './NotificationsTabScreen'
import type { ProfileTabScreenParamList } from './ProfileTabScreen'
+import { TabBarAutoHideProvider } from './TabBarAutoHideContext'
import type { TrendingTabScreenParamList } from './TrendingTabScreen'
import { TrendingTabScreen } from './TrendingTabScreen'
import { usePrefetchNotifications } from './usePrefetchNotifications'
@@ -35,16 +36,20 @@ export const AppTabsScreen = () => {
usePrefetchNotifications()
return (
-
-
-
-
-
-
-
+ // Sits above the navigator so the tab bar, which renders as a sibling of
+ // the screens, can still animate off the focused tab's scrolling.
+
+
+
+
+
+
+
+
+
)
}
diff --git a/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx
new file mode 100644
index 00000000000..cf81a4b02e3
--- /dev/null
+++ b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx
@@ -0,0 +1,80 @@
+import type { ReactNode } from 'react'
+import { useCallback } from 'react'
+
+import type { LayoutChangeEvent } from 'react-native'
+import { StyleSheet, View } from 'react-native'
+import Animated, { useAnimatedStyle } from 'react-native-reanimated'
+
+import { GlassSurface } from 'app/components/core/Screen/GlassSurface'
+import { zIndex } from 'app/utils/zIndex'
+
+import {
+ useChromeHiddenProgress,
+ useGlassHeaderInset,
+ useGlassScrollY,
+ useRootHeaderHeight,
+ useSetSubHeaderHeight
+} from './GlassChromeContext'
+
+type FloatingSubHeaderProps = {
+ children: ReactNode
+ /** Draw a hairline separator along the bottom edge of the glass stack. */
+ showBorder?: boolean
+}
+
+/**
+ * Pins a screen's persistent top row — feed tabs, trending pills, the library
+ * category menu — directly beneath the floating root header as a second glass
+ * layer.
+ *
+ * Without this the row would sit in normal flow and content would scroll
+ * behind the translucent header only to collide with an opaque row. Floating
+ * it keeps the whole top cluster one continuous frosted surface.
+ *
+ * The row stays owned by its screen rather than being passed into
+ * `MobileRootHeader`'s render prop on purpose: those render props are memoized
+ * so that changing tab state doesn't rebuild the header and remount
+ * `AccountPictureHeader` (which re-fires the profile-picture fetch).
+ */
+export const FloatingSubHeader = (props: FloatingSubHeaderProps) => {
+ const { children, showBorder = true } = props
+ const headerHeight = useRootHeaderHeight()
+ const setSubHeaderHeight = useSetSubHeaderHeight()
+ const scrollY = useGlassScrollY()
+ const hidden = useChromeHiddenProgress()
+ const glassHeaderInset = useGlassHeaderInset()
+
+ // 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 }]
+ }))
+
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ setSubHeaderHeight(event.nativeEvent.layout.height)
+ },
+ [setSubHeaderHeight]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ root: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ zIndex: zIndex.HEADER_SHADOW
+ }
+})
diff --git a/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx
new file mode 100644
index 00000000000..ade1f059038
--- /dev/null
+++ b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx
@@ -0,0 +1,238 @@
+import type { ReactNode } from 'react'
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState
+} from 'react'
+
+import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
+import type { SharedValue } from 'react-native-reanimated'
+import {
+ useAnimatedReaction,
+ useSharedValue,
+ withTiming
+} from 'react-native-reanimated'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+
+type GlassChromeContextValue = {
+ headerHeight: number
+ subHeaderHeight: number
+ setHeaderHeight: (height: number) => void
+ setSubHeaderHeight: (height: number) => void
+ /**
+ * Vertical scroll offset of the screen's primary list, in px, driven off the
+ * UI thread. This is the single signal the glass chrome reacts to:
+ *
+ * - today: the separator/shadow along the bottom of the glass fades in as
+ * soon as content slides underneath, so the stack sits flush at rest and
+ * only earns an edge once there is something behind it;
+ * - next: the same value (its *direction*) is what drives auto-hiding the
+ * header and bottom tab bar on scroll-down and restoring them on
+ * scroll-up.
+ */
+ scrollY: SharedValue
+}
+
+/** Avatar row (40) plus `spacing(3)` padding top and bottom. */
+const ESTIMATED_HEADER_ROW_HEIGHT = 64
+
+const GlassChromeContext = createContext(
+ undefined
+)
+
+/**
+ * Publishes the heights of the floating glass header stack to the screen
+ * rendered beneath it.
+ *
+ * Root tab screens float their header over the content
+ * (`headerTransparent`) so content scrolls behind the glass, matching the
+ * desktop client. React Navigation then stops insetting the content for us,
+ * so screens pad their own scrollable content by these heights: content
+ * *starts* below the header but slides underneath as it scrolls.
+ *
+ * Two heights rather than one because several tab screens put a persistent
+ * row directly under the title — feed tabs, trending pills, the library
+ * category menu. Those float as a second glass layer via `FloatingSubHeader`,
+ * so the list has to clear both.
+ *
+ * Heights are measured rather than computed: the safe-area inset differs per
+ * device and `OtaUpdateBanner` adds a row only when an update is pending.
+ *
+ * Mounted per tab stack in `AppTabScreen`, which wraps both the navigator's
+ * header and its screens.
+ */
+export const GlassChromeProvider = (props: { children: ReactNode }) => {
+ const insets = useSafeAreaInsets()
+
+ // Seed with the header's nominal height (avatar row + its vertical padding)
+ // so the first frame lands close to the real value. Starting at 0 would
+ // paint the list flush to the top and then jump it down once onLayout
+ // reports — a visible flash on every cold screen mount.
+ const [headerHeight, setHeaderHeightState] = useState(
+ () => insets.top + ESTIMATED_HEADER_ROW_HEIGHT
+ )
+ const [subHeaderHeight, setSubHeaderHeightState] = useState(0)
+ const scrollY = useSharedValue(0)
+
+ // Guard against re-render loops: onLayout fires on every layout pass, and
+ // sub-pixel jitter on a measured row would otherwise churn context.
+ const setHeaderHeight = useCallback((height: number) => {
+ setHeaderHeightState((current) =>
+ Math.abs(current - height) < 1 ? current : height
+ )
+ }, [])
+
+ const setSubHeaderHeight = useCallback((height: number) => {
+ setSubHeaderHeightState((current) =>
+ Math.abs(current - height) < 1 ? current : height
+ )
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ headerHeight,
+ subHeaderHeight,
+ setHeaderHeight,
+ setSubHeaderHeight,
+ scrollY
+ }),
+ [
+ headerHeight,
+ subHeaderHeight,
+ setHeaderHeight,
+ setSubHeaderHeight,
+ scrollY
+ ]
+ )
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+const useGlassChrome = () => {
+ const context = useContext(GlassChromeContext)
+ if (!context) {
+ throw new Error(
+ 'Glass chrome hooks must be used inside a '
+ )
+ }
+ return context
+}
+
+/** Height of the floating root header alone. */
+export const useRootHeaderHeight = () => useGlassChrome().headerHeight
+
+/**
+ * Total height of the floating glass stack (header + sub-header). This is the
+ * top padding a screen's scrollable content needs so it starts below the glass
+ * and scrolls behind it.
+ */
+export const useGlassHeaderInset = () => {
+ const { headerHeight, subHeaderHeight } = useGlassChrome()
+ return headerHeight + subHeaderHeight
+}
+
+/** Setter used by `MobileRootHeader` to report its measured height. */
+export const useSetRootHeaderHeight = () => useGlassChrome().setHeaderHeight
+
+/** Setter used by `FloatingSubHeader` to report its measured height. */
+export const useSetSubHeaderHeight = () => useGlassChrome().setSubHeaderHeight
+
+/** Raw scroll offset shared value, for chrome that animates off scroll. */
+export const useGlassScrollY = () => useGlassChrome().scrollY
+
+/**
+ * Snaps the chrome back to its resting state: separator hidden, header and tab
+ * bar shown.
+ *
+ * Call this whenever the list behind the glass is swapped for a different one
+ * that starts at its own offset — Feed's pager pages, Library's top tabs.
+ * Those share one provider, so without a reset the chrome keeps whatever state
+ * the *previous* tab earned: you scroll Tracks down until the header hides,
+ * flick to an unscrolled Albums tab, and the header stays gone with nothing
+ * scrolled under it. An empty tab is the worst case, since it renders no list
+ * and so never emits a scroll event to correct the stale value.
+ */
+export const useResetGlassScroll = () => {
+ const scrollY = useGlassScrollY()
+ return useCallback(() => {
+ scrollY.value = 0
+ }, [scrollY])
+}
+
+/**
+ * Distance (px) the user must move in one direction before the chrome flips
+ * between hidden and shown. Without it, sub-pixel jitter at the end of a
+ * fling would flap the header.
+ */
+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.
+ */
+const ALWAYS_SHOWN_OFFSET = 40
+
+/**
+ * Progress of the auto-hide, 0 = fully shown, 1 = fully hidden.
+ *
+ * Reads the same `scrollY` the separator uses: scrolling down past the
+ * threshold hides the chrome to give content the full screen, scrolling up
+ * brings it straight back. Near the top, and during rubber-band overscroll
+ * (negative offset), it stays shown.
+ *
+ * Consumers translate by their own measured height, so the header and the
+ * bottom tab bar can share one signal while moving opposite directions.
+ */
+export const useChromeHiddenProgress = () => {
+ const scrollY = useGlassScrollY()
+ const hidden = useSharedValue(0)
+ const lastY = useSharedValue(0)
+
+ useAnimatedReaction(
+ () => scrollY.value,
+ (current, previous) => {
+ if (previous === null) return
+ if (current < ALWAYS_SHOWN_OFFSET) {
+ hidden.value = withTiming(0, { duration: 180 })
+ lastY.value = current
+ return
+ }
+ const delta = current - lastY.value
+ if (Math.abs(delta) < DIRECTION_THRESHOLD) return
+ hidden.value = withTiming(delta > 0 ? 1 : 0, { duration: 180 })
+ lastY.value = current
+ }
+ )
+
+ return hidden
+}
+
+/**
+ * Scroll handler a root screen attaches to its primary list so the glass
+ * chrome can react to scrolling. Attach alongside `scrollEventThrottle={16}`.
+ *
+ * This is a plain JS `onScroll` rather than reanimated's
+ * `useAnimatedScrollHandler` because the shared list primitives wrap React
+ * Native's `Animated`, not reanimated, so a worklet handler would never
+ * attach. The per-event bridge hop is cheap at this throttle, and the
+ * animation itself still runs on the UI thread — the handler only writes a
+ * shared value, and `useAnimatedStyle` does the interpolation.
+ *
+ * Only one list per screen should drive this. For a screen with a horizontal
+ * pager of lineups (Feed), that means whichever page is active.
+ */
+export const useGlassScrollHandler = () => {
+ const scrollY = useGlassScrollY()
+ return useCallback(
+ (event: NativeSyntheticEvent) => {
+ scrollY.value = event.nativeEvent.contentOffset.y
+ },
+ [scrollY]
+ )
+}
diff --git a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx
index fc60b57a5de..a1c3df3da43 100644
--- a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx
+++ b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx
@@ -1,10 +1,13 @@
import type { ReactNode } from 'react'
import { useCallback, useContext } from 'react'
+import type { LayoutChangeEvent } from 'react-native'
import { View } from 'react-native'
+import Animated, { useAnimatedStyle } from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GradientText } from 'app/components/core'
+import { GlassSurface } from 'app/components/core/Screen/GlassSurface'
import { OtaUpdateBanner } from 'app/components/ota-update-banner/OtaUpdateBanner'
import { useDrawer } from 'app/hooks/useDrawer'
import { makeStyles } from 'app/styles'
@@ -12,6 +15,12 @@ import { makeStyles } from 'app/styles'
import { AppDrawerContext } from '../app-drawer-screen'
import { AccountPictureHeader } from './AccountPictureHeader'
+import {
+ useChromeHiddenProgress,
+ useGlassScrollY,
+ useRootHeaderHeight,
+ useSetRootHeaderHeight
+} from './GlassChromeContext'
type MobileRootHeaderProps = {
title: string
@@ -19,10 +28,7 @@ type MobileRootHeaderProps = {
showDivider?: boolean
}
-const useStyles = makeStyles(({ palette, spacing, typography }) => ({
- container: {
- backgroundColor: palette.white
- },
+const useStyles = makeStyles(({ spacing, typography }) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -38,10 +44,6 @@ const useStyles = makeStyles(({ palette, spacing, typography }) => ({
titleContainer: {
flex: 1,
minWidth: 0
- },
- divider: {
- height: 1,
- backgroundColor: palette.neutralLight8
}
}))
@@ -50,6 +52,11 @@ const useStyles = makeStyles(({ palette, spacing, typography }) => ({
*
* Layout (single row): [Avatar] [GradientText title] [right content]
*
+ * The header floats over its screen so content scrolls behind the frosted
+ * glass, matching the desktop client's `Frosted` surface. It reports its
+ * measured height through `GlassChromeContext` so the screen underneath
+ * can pad its scrollable content to start below the header.
+ *
* The screenshot-only Audius logo that previously lived here behind the
* Dynamic Island has moved up to AppDrawerScreen as a top-level overlay so
* it doesn't animate with the screen during stack transitions.
@@ -60,27 +67,50 @@ export const MobileRootHeader = (props: MobileRootHeaderProps) => {
const styles = useStyles()
const { drawerHelpers } = useContext(AppDrawerContext)
const { isOpen: isNowPlayingDrawerOpen } = useDrawer('NowPlaying')
+ const setRootHeaderHeight = useSetRootHeaderHeight()
+ const scrollY = useGlassScrollY()
+ const hidden = useChromeHiddenProgress()
+ const headerHeight = useRootHeaderHeight()
+
+ // Slide the whole header off the top as the chrome hides. Purely visual —
+ // the list keeps its padding, so content never reflows, it just gets more
+ // of the screen to show through.
+ const hideStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: -headerHeight * hidden.value }]
+ }))
const handleOpenLeftNavDrawer = useCallback(() => {
if (isNowPlayingDrawerOpen) return
drawerHelpers?.openDrawer()
}, [drawerHelpers, isNowPlayingDrawerOpen])
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ setRootHeaderHeight(event.nativeEvent.layout.height)
+ },
+ [setRootHeaderHeight]
+ )
+
return (
-
-
-
-
-
-
-
- {title}
-
+
+
+
+
+
+
+
+
+ {title}
+
+
+ {children}
- {children}
-
- {showDivider ? : null}
-
+
+
)
}
diff --git a/packages/mobile/src/screens/app-screen/TabBarAutoHideContext.tsx b/packages/mobile/src/screens/app-screen/TabBarAutoHideContext.tsx
new file mode 100644
index 00000000000..6dfbda0bc91
--- /dev/null
+++ b/packages/mobile/src/screens/app-screen/TabBarAutoHideContext.tsx
@@ -0,0 +1,75 @@
+import type { ReactNode } from 'react'
+import { createContext, useContext, useEffect } from 'react'
+
+import { useIsFocused } from '@react-navigation/native'
+import type { SharedValue } from 'react-native-reanimated'
+import { useAnimatedReaction, useSharedValue } from 'react-native-reanimated'
+
+import { useChromeHiddenProgress } from './GlassChromeContext'
+
+const TabBarAutoHideContext = createContext | undefined>(
+ undefined
+)
+
+/**
+ * Carries the focused tab's chrome auto-hide progress out to the bottom tab
+ * bar.
+ *
+ * `GlassChromeProvider` is mounted per tab stack, so the scroll signal it
+ * publishes is only reachable from inside a tab's own screens. The tab bar is
+ * the navigator's `tabBar` — rendered as a *sibling* of the screens, not a
+ * descendant — so no per-tab provider sits above it, and there are five of
+ * them anyway. This provider goes above the tab navigator instead and holds
+ * the single value the bar animates off; `TabBarAutoHideBridge`, mounted
+ * inside each tab stack, mirrors that tab's progress into it while it is the
+ * focused tab.
+ */
+export const TabBarAutoHideProvider = (props: { children: ReactNode }) => {
+ const hidden = useSharedValue(0)
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+/**
+ * Auto-hide progress of the bottom tab bar, 0 = fully shown, 1 = fully hidden,
+ * tracking whichever tab is focused.
+ *
+ * Outside the provider this is a value nothing ever writes, so the bar simply
+ * stays put rather than crashing a screen that renders it out of context.
+ */
+export const useTabBarHiddenProgress = () => {
+ const hidden = useContext(TabBarAutoHideContext)
+ const fallback = useSharedValue(0)
+ return hidden ?? fallback
+}
+
+/**
+ * Republishes this tab stack's auto-hide progress while the tab is focused.
+ * Mount inside the stack's `GlassChromeProvider`; renders nothing.
+ */
+export const TabBarAutoHideBridge = () => {
+ const hidden = useChromeHiddenProgress()
+ const published = useContext(TabBarAutoHideContext)
+ const isFocused = useIsFocused()
+
+ useAnimatedReaction(
+ () => hidden.value,
+ (current) => {
+ if (isFocused && published) published.value = current
+ },
+ [isFocused, published]
+ )
+
+ // A blurred tab stops scrolling, and so stops writing. The incoming tab has
+ // to hand over its progress on focus, otherwise the bar keeps whatever state
+ // the tab the user just left had it in.
+ useEffect(() => {
+ if (isFocused && published) published.value = hidden.value
+ }, [isFocused, published, hidden])
+
+ return null
+}
diff --git a/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx b/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx
index 7079bff178f..74e0bd3c12e 100644
--- a/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx
+++ b/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx
@@ -29,6 +29,11 @@ export const TrendingTabScreen =
options={{
header: renderTrendingHeader,
headerShown: true,
+ // Matches TrendingScreen's own `headerTransparent` so the very first
+ // native frame already floats the header — otherwise the pre-declared
+ // header lays out in flow and the content shifts once the screen's
+ // setOptions lands.
+ headerTransparent: true,
contentStyle: { paddingTop: 0 }
}}
/>
diff --git a/packages/mobile/src/screens/fan-clubs-explore-screen/FanClubsExploreScreen.tsx b/packages/mobile/src/screens/fan-clubs-explore-screen/FanClubsExploreScreen.tsx
index c6d8f041db7..6e0ed7cce82 100644
--- a/packages/mobile/src/screens/fan-clubs-explore-screen/FanClubsExploreScreen.tsx
+++ b/packages/mobile/src/screens/fan-clubs-explore-screen/FanClubsExploreScreen.tsx
@@ -38,7 +38,7 @@ import {
} from '@audius/harmony-native'
import imageSearchHeaderBackground from 'app/assets/images/imageCoinsBackgroundImage.webp'
import { GradientText, Screen, TokenIcon } from 'app/components/core'
-import { PlayBarChin } from 'app/components/core/PlayBarChin'
+import { BottomChin } from 'app/components/core/BottomChin'
import { UserLink } from 'app/components/user-link'
import { useNavigation } from 'app/hooks/useNavigation'
import { useStatusBarStyle } from 'app/hooks/useStatusBarStyle'
@@ -451,7 +451,7 @@ export const FanClubsExploreScreen = () => {
commonOptions={tabCommonOptions}
/>
-
+
)
}
diff --git a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
index d2c0e59ea3a..f7313230339 100644
--- a/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
+++ b/packages/mobile/src/screens/feed-screen/FeedScreen.tsx
@@ -22,11 +22,18 @@ import PagerView, {
} from 'react-native-pager-view'
import { Screen, ScreenContent } from 'app/components/core'
+import { useBottomChinHeight } from 'app/components/core/BottomChin'
import { EndOfLineupNotice } from 'app/components/lineup/EndOfLineupNotice'
import { TrackLineup } from 'app/components/lineup/TrackLineup'
import { SuggestedFollows } from 'app/components/suggested-follows'
import { useDrawer } from 'app/hooks/useDrawer'
import { AppDrawerContext } from 'app/screens/app-drawer-screen'
+import { FloatingSubHeader } from 'app/screens/app-screen/FloatingSubHeader'
+import {
+ useGlassHeaderInset,
+ useGlassScrollHandler,
+ useResetGlassScroll
+} from 'app/screens/app-screen/GlassChromeContext'
import { MobileRootHeader } from 'app/screens/app-screen/MobileRootHeader'
import { make, track } from 'app/services/analytics'
@@ -48,6 +55,10 @@ const styles = StyleSheet.create({
})
export const FeedScreen = () => {
+ const glassHeaderInset = useGlassHeaderInset()
+ const bottomChin = useBottomChinHeight()
+ const handleGlassScroll = useGlassScrollHandler()
+ const resetGlassScroll = useResetGlassScroll()
const [feedTab, setFeedTab] = useFeedTab()
const [feedFilter] = useFeedFilter()
const { data: currentUserId } = useCurrentUserId()
@@ -176,6 +187,12 @@ export const FeedScreen = () => {
}
}, [feedTabIndex])
+ // Both lineups stay mounted with their own scroll positions, so the incoming
+ // page's offset is not the one the chrome is currently showing.
+ useEffect(() => {
+ resetGlassScroll()
+ }, [feedTabIndex, resetGlassScroll])
+
// Memoized so the header isn't a new function reference on every render —
// otherwise Screen's setOptions runs each parent re-render and React
// Navigation rebuilds the header, remounting AccountPictureHeader and
@@ -189,6 +206,13 @@ export const FeedScreen = () => {
[isForYou]
)
+ // TrackLineup opts out of the shared chin (`hideBottomChin`), so the
+ // bottom inset that clears the floating tab bar has to ride along here.
+ const lineupContentStyle = useMemo(
+ () => ({ paddingTop: glassHeaderInset, paddingBottom: bottomChin }),
+ [glassHeaderInset, bottomChin]
+ )
+
const forYouLineupProps = {
// For You is intentionally track-focused: render from `trackIds` only and
// omit `lineupItems` so TrackLineup falls back to its pure-track mode,
@@ -221,9 +245,15 @@ 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. */}
+
+
+
{/* Horizontal pager: swipe left/right toggles between For You and
Latest, mirroring the tab headers above. Both lineups stay mounted
so each retains its own scroll position. The GestureDetector lets a
@@ -245,6 +275,8 @@ export const FeedScreen = () => {
ListFooterComponent={
}
+ contentContainerStyle={lineupContentStyle}
+ onScroll={isForYou ? handleGlassScroll : undefined}
{...forYouLineupProps}
/>
@@ -257,6 +289,8 @@ export const FeedScreen = () => {
ListFooterComponent={
}
+ contentContainerStyle={lineupContentStyle}
+ onScroll={isForYou ? undefined : handleGlassScroll}
{...followLineupProps}
/>
diff --git a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx
index 77ef1ad36c6..99fc4a057de 100644
--- a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx
+++ b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx
@@ -16,14 +16,12 @@ type FeedTabsProps = {
}
export const FeedTabs = ({ currentTab, onSelectTab }: FeedTabsProps) => {
- const { spacing, color } = useTheme()
+ 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.
+
{
isLoading={isPending && (collectionIds?.length ?? 0) === 0}
isLoadingMore={isFetchingNextPage && hasNextPage}
totalCount={12}
- ListFooterComponent={}
+ ListFooterComponent={}
/>
>
diff --git a/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx b/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx
index 624f69c42ef..beec7dfbf23 100644
--- a/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx
+++ b/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx
@@ -13,7 +13,7 @@ import { View } from 'react-native'
import { useSelector } from 'react-redux'
import { CollectionList } from 'app/components/collection-list'
-import { PlayBarChin } from 'app/components/core/PlayBarChin'
+import { BottomChin } from 'app/components/core/BottomChin'
import { EmptyTileCTA } from 'app/components/empty-tile-cta'
import { FilterInput } from 'app/components/filter-input'
import { makeStyles } from 'app/styles'
@@ -119,7 +119,7 @@ export const PlaylistsTab = () => {
createPlaylistSource={CreatePlaylistSource.LIBRARY_PAGE}
isLoading={isPending && (collectionIds?.length ?? 0) === 0}
totalCount={12}
- ListFooterComponent={}
+ ListFooterComponent={}
/>
>
diff --git a/packages/mobile/src/screens/library-screen/TracksTab.tsx b/packages/mobile/src/screens/library-screen/TracksTab.tsx
index 9da342b8074..39cc8cef66f 100644
--- a/packages/mobile/src/screens/library-screen/TracksTab.tsx
+++ b/packages/mobile/src/screens/library-screen/TracksTab.tsx
@@ -17,7 +17,7 @@ import { debounce } from 'lodash'
import { View } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
-import { PlayBarChin } from 'app/components/core/PlayBarChin'
+import { BottomChin } from 'app/components/core/BottomChin'
import { EmptyTileCTA } from 'app/components/empty-tile-cta'
import { FilterInput } from 'app/components/filter-input'
import { TrackList } from 'app/components/track-list'
@@ -305,7 +305,7 @@ export const TracksTab = () => {
}
onEndReached={handleMoreFetchSaves}
onEndReachedThreshold={1.5}
- ListFooterComponent={}
+ ListFooterComponent={}
togglePlay={togglePlay}
trackItemAction='overflow'
uids={showTrackSkeletonList ? undefined : filteredTrackUids}
diff --git a/packages/mobile/src/screens/notifications-screen/NotificationList.tsx b/packages/mobile/src/screens/notifications-screen/NotificationList.tsx
index fb575adf487..061ee9a388d 100644
--- a/packages/mobile/src/screens/notifications-screen/NotificationList.tsx
+++ b/packages/mobile/src/screens/notifications-screen/NotificationList.tsx
@@ -5,8 +5,14 @@ import type { Notification } from '@audius/common/store'
import { useIsFocused } from '@react-navigation/native'
import { FlashList } from '@shopify/flash-list'
import type { ViewToken } from 'react-native'
+import { View } from 'react-native'
-import { makeStyles } from 'app/styles'
+import { useBottomChinHeight } from 'app/components/core/BottomChin'
+import {
+ useGlassHeaderInset,
+ useGlassScrollHandler
+} from 'app/screens/app-screen/GlassChromeContext'
+import { spacing } from 'app/styles/spacing'
import { AppDrawerContext } from '../app-drawer-screen'
@@ -23,11 +29,7 @@ type RenderItem = Notification | LoadingItem
const isLoadingItem = (item: RenderItem): item is LoadingItem =>
'_loading' in item
-const useStyles = makeStyles(({ spacing }) => ({
- container: {
- paddingBottom: spacing(30)
- }
-}))
+const LIST_PADDING_BOTTOM = spacing(30)
/**
* Hook to handle tracking visibility for notification items, by index.
@@ -81,7 +83,9 @@ const useIsViewable = () => {
}
export const NotificationList = () => {
- const styles = useStyles()
+ const glassHeaderInset = useGlassHeaderInset()
+ const bottomChin = useBottomChinHeight()
+ const handleGlassScroll = useGlassScrollHandler()
const [isRefreshing, setIsRefreshing] = useState(false)
const { gesturesDisabled } = useContext(AppDrawerContext)
@@ -149,12 +153,25 @@ export const NotificationList = () => {
)
if (!isPending && !isError && notifications.length === 0) {
- return
+ // Not a list, so it needs the glass-header inset applied directly.
+ return (
+
+
+
+ )
}
return (
{
useFocusEffect(handleMarkAsViewed)
return (
- }>
+ }
+ headerTransparent
+ >
{null}
diff --git a/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx b/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx
index d91ce140761..3d15479d1ca 100644
--- a/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx
+++ b/packages/mobile/src/screens/trending-screen/TrendingHeader.tsx
@@ -45,8 +45,10 @@ 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.
root: {
- backgroundColor: palette.white,
borderBottomWidth: 1,
borderBottomColor: palette.neutralLight8,
borderTopWidth: 1,
diff --git a/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx b/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx
index d3a243f7640..cfce87b984a 100644
--- a/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx
+++ b/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx
@@ -29,12 +29,16 @@ const sourceFor = (timeRange: TimeRange) => {
type TrendingLineupProps = {
timeRange: TimeRange
header?: SectionListProps['ListHeaderComponent']
+ contentContainerStyle?: SectionListProps['contentContainerStyle']
+ onScroll?: SectionListProps['onScroll']
rankIconCount?: number
}
export const TrendingLineup = ({
timeRange,
header,
+ contentContainerStyle,
+ onScroll,
rankIconCount
}: TrendingLineupProps) => {
const navigation = useNavigation()
@@ -83,6 +87,8 @@ export const TrendingLineup = ({
isTrending
rankIconCount={rankIconCount}
header={header}
+ contentContainerStyle={contentContainerStyle}
+ onScroll={onScroll}
itemStyles={{ paddingTop: 16, paddingBottom: 0 }}
pullToRefresh
/>
diff --git a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx
index 22d379f466d..8e869e45e0b 100644
--- a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx
+++ b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx
@@ -5,8 +5,14 @@ import { useSelector } from 'react-redux'
import { Flex, IconTrending } from '@audius/harmony-native'
import { Screen, ScreenContent } from 'app/components/core'
+import { useBottomChinHeight } from 'app/components/core/BottomChin'
import { ScreenPrimaryContent } from 'app/components/core/Screen/ScreenPrimaryContent'
import { ScreenSecondaryContent } from 'app/components/core/Screen/ScreenSecondaryContent'
+import { FloatingSubHeader } from 'app/screens/app-screen/FloatingSubHeader'
+import {
+ useGlassHeaderInset,
+ useGlassScrollHandler
+} from 'app/screens/app-screen/GlassChromeContext'
import { MobileRootHeader } from 'app/screens/app-screen/MobileRootHeader'
import { TRENDING_FILTER_MODAL } from './TrendingCombinedFilterDrawer'
@@ -28,6 +34,16 @@ const titleByCategory = {
export const TrendingScreen = () => {
const category = useSelector(getTrendingCategory) ?? 'tracks'
+ const glassHeaderInset = useGlassHeaderInset()
+ const bottomChin = useBottomChinHeight()
+ const handleGlassScroll = useGlassScrollHandler()
+
+ // TrackLineup opts out of the shared chin, so it needs the bottom inset
+ // that clears the floating tab bar folded in here.
+ const lineupContentStyle = {
+ paddingTop: glassHeaderInset,
+ paddingBottom: bottomChin
+ }
const [winnersWeek, setWinnersWeek] = useState(null)
const [winnersSubFilter, setWinnersSubFilter] = useState<
@@ -61,23 +77,36 @@ export const TrendingScreen = () => {
)
return (
-
+
-
- {trendingPills}
-
+ {/* Pinned into the floating glass stack under the title so the
+ lineups scroll behind one continuous frosted surface. */}
+
+
+ {trendingPills}
+
+
{category === 'tracks' ? (
}>
- } />
+ }
+ contentContainerStyle={lineupContentStyle}
+ onScroll={handleGlassScroll}
+ />
) : category === 'underground' ? (
}>
-
+
) : (
}>
['ListHeaderComponent']
+ contentContainerStyle?: SectionListProps['contentContainerStyle']
+ onScroll?: SectionListProps['onScroll']
}
-export const TrendingTracksLineup = ({ header }: TrendingTracksLineupProps) => {
+export const TrendingTracksLineup = ({
+ header,
+ contentContainerStyle,
+ onScroll
+}: TrendingTracksLineupProps) => {
const timeRange = useSelector(getTrendingTimeRange) ?? TimeRange.WEEK
return (
-
+
)
}
diff --git a/packages/mobile/src/screens/trending-screen/TrendingUndergroundLineup.tsx b/packages/mobile/src/screens/trending-screen/TrendingUndergroundLineup.tsx
index 6d9af5ea508..9f89e1e98d0 100644
--- a/packages/mobile/src/screens/trending-screen/TrendingUndergroundLineup.tsx
+++ b/packages/mobile/src/screens/trending-screen/TrendingUndergroundLineup.tsx
@@ -12,10 +12,14 @@ const PAGE_SIZE = 10
type TrendingUndergroundLineupProps = {
header?: SectionListProps['ListHeaderComponent']
+ contentContainerStyle?: SectionListProps['contentContainerStyle']
+ onScroll?: SectionListProps['onScroll']
}
export const TrendingUndergroundLineup = ({
- header
+ header,
+ contentContainerStyle,
+ onScroll
}: TrendingUndergroundLineupProps) => {
const { trackIds, isPending, isFetching, hasNextPage, loadNextPage } =
useTrendingUnderground({ pageSize: PAGE_SIZE })
@@ -42,6 +46,8 @@ export const TrendingUndergroundLineup = ({
isTrending
rankIconCount={5}
header={header}
+ contentContainerStyle={contentContainerStyle}
+ onScroll={onScroll}
itemStyles={{ paddingTop: 16, paddingBottom: 0 }}
pullToRefresh
/>
diff --git a/packages/mobile/src/screens/trending-screen/TrendingWinnersView.tsx b/packages/mobile/src/screens/trending-screen/TrendingWinnersView.tsx
index 86b37455086..47e177b35c4 100644
--- a/packages/mobile/src/screens/trending-screen/TrendingWinnersView.tsx
+++ b/packages/mobile/src/screens/trending-screen/TrendingWinnersView.tsx
@@ -5,6 +5,7 @@ import {
useTrendingWinners
} from '@audius/common/api'
import { dayjs } from '@audius/common/utils'
+import type { SectionListProps } from 'react-native'
import { ScrollView, View } from 'react-native'
import {
@@ -49,6 +50,8 @@ const formatWeekLabel = (week: string | null): string => {
}
export type TrendingWinnersViewProps = {
+ contentContainerStyle?: SectionListProps['contentContainerStyle']
+ onScroll?: SectionListProps['onScroll']
week: string | null
subFilter: WinnersSubFilter
onWeekChange: (week: string | null) => void
@@ -56,6 +59,8 @@ export type TrendingWinnersViewProps = {
}
export const TrendingWinnersView = ({
+ contentContainerStyle,
+ onScroll,
week,
subFilter,
onWeekChange,
@@ -108,7 +113,11 @@ export const TrendingWinnersView = ({
if (isPending) {
return (
-
+