Skip to content

Commit 0cfaaca

Browse files
dylanjeffersclaude
andauthored
feat(mobile): glassmorphic root headers with auto-hiding chrome (#14575)
Gives the root tab screens a frosted header that content scrolls behind — matching the desktop client's `Frosted` surface — and lets the top and bottom chrome get out of the way as you scroll. **In scope:** Feed, Trending, Notifications. **Deliberately not:** Explore (keeps its own search header) and Library (its material top-tab bar is opaque and shared with Profile, so the glass had a visible seam — left exactly as it was). ## How it fits together - **`GlassSurface`** — the shared frosted surface. Real `BlurView` on iOS; on Android a near-opaque tint instead, matching the existing `ProfileNavOverlay` precedent, since Android blur on a surface composited every scroll frame is a real perf risk. - **`GlassChromeContext`** — owns a single `scrollY` shared value. The separator fade, the header translate and the tab bar translate all read from it, so they can't drift out of sync. - **Floating headers** — `headerTransparent`, so screens pad their own scroll content by the *measured* stack height. Measured rather than computed because the safe-area inset varies per device and `OtaUpdateBanner` adds a row conditionally. - **Floating tab bar** — now absolutely positioned. In normal flow the navigator reserved its height, so hiding it only uncovered dead background; content now occupies that space and slides behind the glass. ## `PlayBarChin` → `BottomChin` The rename carries a behaviour change worth reviewing. It previously reserved only `PLAY_BAR_HEIGHT`; with the tab bar out of flow it now covers both bars. Without that, the end of every list would sit behind the tab bar permanently — 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 glass while scrolling, which is the point. It also touches three Library files and `FanClubsExploreScreen` — import lines only, no behaviour change there. ## Testing - Verified on iOS simulator (iPhone 17 Pro, light mode): all three screens, neutral and scrolled, header + tab bar hide on scroll-down and return on scroll-up. - `tsc --noEmit` clean, `eslint` clean. - **Not yet verified: Android, and dark mode.** The Android tint fallback and the dark `blurType` branch are both unexercised paths. Dark-mode pass is in progress. ## No changeset, on purpose No SDK surface, and a `'@audius/mobile'` bump moves the OTA release-history routing key — that's what broke production OTA in August (see #14564). ⚠️ **Note this ships via OTA, which is currently not reaching production.** `production/1.5.185` (what iPhones poll) hasn't had a release since 2026-07-28. Merging this publishes to `rc` only; it needs #14564 merged **and** a manual production dispatch to reach users. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 837570c commit 0cfaaca

34 files changed

Lines changed: 1049 additions & 227 deletions

packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Animated } from 'react-native'
99
import { useSafeAreaInsets } from 'react-native-safe-area-context'
1010

1111
import { Flex } from '@audius/harmony-native'
12+
import { GlassSurface } from 'app/components/core/Screen/GlassSurface'
1213
import { FULL_DRAWER_HEIGHT } from 'app/components/drawer'
1314
import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer'
1415

@@ -103,29 +104,34 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
103104
interpolatePostion(translationAnim, insets.bottom)
104105
]}
105106
>
106-
<Flex
107-
row
108-
pointerEvents='auto'
109-
borderTop='default'
110-
backgroundColor='surface1'
111-
wrap='nowrap'
112-
justifyContent='space-evenly'
113-
pb={insets.bottom}
114-
>
115-
{routes.map(({ name, key }, index) => {
116-
const BottomTabBarButton = bottomTabBarButtons[name]
107+
{/*
108+
Frosted rather than a solid fill so content scrolls behind the bar,
109+
matching the floating header stack. The separator goes on the top edge
110+
here — that's the side content passes under.
111+
*/}
112+
<GlassSurface borderEdge='top'>
113+
<Flex
114+
row
115+
pointerEvents='auto'
116+
wrap='nowrap'
117+
justifyContent='space-evenly'
118+
pb={insets.bottom}
119+
>
120+
{routes.map(({ name, key }, index) => {
121+
const BottomTabBarButton = bottomTabBarButtons[name]
117122

118-
return (
119-
<BottomTabBarButton
120-
key={key}
121-
routeKey={key}
122-
isActive={index === activeIndex}
123-
onPress={handlePress}
124-
onLongPress={handleLongPress}
125-
/>
126-
)
127-
})}
128-
</Flex>
123+
return (
124+
<BottomTabBarButton
125+
key={key}
126+
routeKey={key}
127+
isActive={index === activeIndex}
128+
onPress={handlePress}
129+
onLongPress={handleLongPress}
130+
/>
131+
)
132+
})}
133+
</Flex>
134+
</GlassSurface>
129135
</Animated.View>
130136
)
131137
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { playbackSelectors } from '@audius/common/store'
2+
import { View } from 'react-native'
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
4+
import { useSelector } from 'react-redux'
5+
6+
import { BOTTOM_BAR_HEIGHT } from '../bottom-tab-bar/constants'
7+
import { PLAY_BAR_HEIGHT } from '../now-playing-drawer'
8+
9+
const { getHasTrack } = playbackSelectors
10+
11+
/**
12+
* Height a screen's scrollable content needs at its end to clear the floating
13+
* bottom chrome.
14+
*
15+
* The tab bar is positioned absolutely (see `AppTabBar`) so content can run
16+
* full-height and slide behind the glass as it scrolls. That also means the
17+
* navigator no longer reserves any space for it, so without this inset the
18+
* last row of every list would sit permanently under the bar — worst on short
19+
* lists, which never scroll far enough for the auto-hide to uncover them.
20+
*
21+
* This is about the *end of the content being reachable*, not about avoiding
22+
* overlap mid-scroll: content still passes under the bar while scrolling,
23+
* which is the whole point of the frosted treatment.
24+
*
25+
* The play-bar portion stays conditional — it is 0 when nothing is playing,
26+
* and unlike the tab bar the now-playing bar does not auto-hide, so its space
27+
* has to be held whenever it is on screen.
28+
*/
29+
export const useBottomChinHeight = () => {
30+
const hasTrack = useSelector(getHasTrack)
31+
const insets = useSafeAreaInsets()
32+
return BOTTOM_BAR_HEIGHT + insets.bottom + (hasTrack ? PLAY_BAR_HEIGHT : 0)
33+
}
34+
35+
export const BottomChin = () => {
36+
const height = useBottomChinHeight()
37+
return <View style={{ height }} />
38+
}

packages/mobile/src/components/core/FlatList.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useThemeColors } from 'app/utils/theme'
1313

1414
import { CollapsibleTabNavigatorContext } from '../top-tab-bar'
1515

16-
import { PlayBarChin } from './PlayBarChin'
16+
import { BottomChin } from './BottomChin'
1717
import { PullToRefresh, useOverflowHandlers } from './PullToRefresh'
1818

1919
export type FlatListT<ItemT> = RNFlatList<ItemT>
@@ -152,10 +152,10 @@ export const FlatList = forwardRef(function FlatList<ItemT>(
152152
const FooterComponent = ListFooterComponent ? (
153153
<>
154154
{ListFooterComponent}
155-
<PlayBarChin />
155+
<BottomChin />
156156
</>
157157
) : (
158-
PlayBarChin
158+
BottomChin
159159
)
160160

161161
const flatListProps = {

packages/mobile/src/components/core/PlayBarChin.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import type { ReactNode } from 'react'
2+
3+
import { BlurView } from '@react-native-community/blur'
4+
import type { ViewProps } from 'react-native'
5+
import { Platform, StyleSheet, View } from 'react-native'
6+
import type { SharedValue } from 'react-native-reanimated'
7+
import Animated, {
8+
interpolate,
9+
useAnimatedStyle,
10+
Extrapolation
11+
} from 'react-native-reanimated'
12+
13+
import { isDarkTheme, useThemeColors, useThemeVariant } from 'app/utils/theme'
14+
15+
/**
16+
* Opacity of the tint laid over the blur. Mirrors the desktop `Frosted`
17+
* surface, which sits at 65% of `--harmony-n-25` over a 10px backdrop blur.
18+
* Slightly higher here because mobile blurs a much busier backdrop (album art
19+
* scrolling underneath) and the header text has to stay legible at a glance.
20+
*/
21+
const IOS_TINT_OPACITY = 0.72
22+
23+
/**
24+
* Android does not get a real backdrop blur. `@react-native-community/blur`'s
25+
* Android implementation is expensive enough to drop frames on a surface that
26+
* is composited on every scroll frame, and the existing app-wide precedent
27+
* (ProfileNavOverlay) already falls back to a solid fill there. We use a high
28+
* -opacity tint instead: content still slides under the header, it just isn't
29+
* blurred while it does.
30+
*/
31+
const ANDROID_TINT_OPACITY = 0.94
32+
33+
/**
34+
* Scroll distance (px) over which the bottom edge fades from invisible to
35+
* fully drawn. Short enough that the edge is there by the time the first tile
36+
* is meaningfully behind the glass, long enough not to snap.
37+
*/
38+
const EDGE_FADE_DISTANCE = 24
39+
40+
type GlassSurfaceProps = ViewProps & {
41+
children?: ReactNode
42+
/** Draw a hairline separator along the bottom edge. */
43+
showBorder?: boolean
44+
/**
45+
* Which edge the separator sits on. Bottom for glass that content scrolls
46+
* *under* (the header stack), top for glass that content scrolls *behind*
47+
* from below (the bottom tab bar).
48+
*/
49+
borderEdge?: 'top' | 'bottom'
50+
/**
51+
* Scroll offset of the content behind the glass. When provided, the bottom
52+
* edge (hairline + soft shadow) is absent at rest and fades in as content
53+
* slides underneath — so the stack reads flush with the page until there is
54+
* actually something behind it to separate from. Without it the edge is
55+
* drawn statically.
56+
*/
57+
scrollY?: SharedValue<number>
58+
/** Override the blur radius used on iOS. */
59+
blurAmount?: number
60+
}
61+
62+
/**
63+
* A translucent "frosted glass" surface that content scrolls behind.
64+
*
65+
* Renders the blur/tint as an absolutely-positioned layer so the caller keeps
66+
* full control of its own layout — drop it in as the first child of a
67+
* position-relative container and the surface fills it.
68+
*
69+
* iOS gets a genuine backdrop blur; Android gets a near-opaque tint. See the
70+
* opacity constants above for why.
71+
*/
72+
export const GlassSurface = (props: GlassSurfaceProps) => {
73+
const {
74+
children,
75+
style,
76+
showBorder = true,
77+
borderEdge = 'bottom',
78+
scrollY,
79+
blurAmount = 20,
80+
...other
81+
} = props
82+
const isDarkMode = isDarkTheme(useThemeVariant())
83+
const { backgroundSurface, borderStrong } = useThemeColors()
84+
85+
const tintOpacity =
86+
Platform.OS === 'ios' ? IOS_TINT_OPACITY : ANDROID_TINT_OPACITY
87+
88+
const edgeStyle = useAnimatedStyle(() => {
89+
// No scrollY (or nothing scrolled yet) → draw the edge statically, which
90+
// is what non-scrolling callers want.
91+
if (!scrollY) return { opacity: 1 }
92+
return {
93+
opacity: interpolate(
94+
scrollY.value,
95+
[0, EDGE_FADE_DISTANCE],
96+
[0, 1],
97+
Extrapolation.CLAMP
98+
)
99+
}
100+
})
101+
102+
return (
103+
<View style={[styles.root, style]} {...other}>
104+
<View style={StyleSheet.absoluteFill} pointerEvents='none'>
105+
{Platform.OS === 'ios' ? (
106+
<BlurView
107+
blurType={isDarkMode ? 'dark' : 'light'}
108+
blurAmount={blurAmount}
109+
reducedTransparencyFallbackColor={backgroundSurface}
110+
style={StyleSheet.absoluteFill}
111+
/>
112+
) : null}
113+
<View
114+
style={[
115+
StyleSheet.absoluteFill,
116+
{ backgroundColor: backgroundSurface, opacity: tintOpacity }
117+
]}
118+
/>
119+
{showBorder ? (
120+
<Animated.View
121+
style={[
122+
styles.border,
123+
borderEdge === 'top' ? styles.borderTop : styles.borderBottom,
124+
{ backgroundColor: borderStrong },
125+
edgeStyle
126+
]}
127+
/>
128+
) : null}
129+
</View>
130+
{children}
131+
</View>
132+
)
133+
}
134+
135+
const styles = StyleSheet.create({
136+
root: {
137+
position: 'relative'
138+
},
139+
border: {
140+
position: 'absolute',
141+
left: 0,
142+
right: 0,
143+
height: StyleSheet.hairlineWidth,
144+
// Soft drop off the hairline so the glass reads as a layer above the
145+
// content passing under it, not just a ruled line.
146+
shadowColor: '#000',
147+
shadowOpacity: 0.1,
148+
shadowRadius: 3,
149+
elevation: 3
150+
},
151+
borderBottom: {
152+
bottom: 0,
153+
shadowOffset: { width: 0, height: 2 }
154+
},
155+
borderTop: {
156+
top: 0,
157+
shadowOffset: { width: 0, height: -2 }
158+
}
159+
})

packages/mobile/src/components/core/Screen/Screen.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ export type ScreenProps = {
5656
variant?: ScreenVariant
5757
as?: ComponentType<ViewProps>
5858
header?: () => ReactElement
59+
/**
60+
* Float the custom header over the screen instead of stacking content below
61+
* it, so content scrolls behind a translucent header. The screen is then
62+
* responsible for padding its own scrollable content by the header height
63+
* (see `useRootHeaderHeight`).
64+
*/
65+
headerTransparent?: boolean
5966
// Callback called when user presses back button
6067
onBack?: () => void
6168
}
@@ -76,6 +83,7 @@ export const Screen = (props: ScreenProps) => {
7683
style,
7784
as: RootComponent = View,
7885
header,
86+
headerTransparent,
7987
onBack
8088
} = props
8189
const palette = useThemePalette()
@@ -107,6 +115,7 @@ export const Screen = (props: ScreenProps) => {
107115
removeUndefined({
108116
header,
109117
headerShown: header ? true : undefined,
118+
headerTransparent,
110119
headerLeft: topbarLeft === undefined ? undefined : () => topbarLeft,
111120
headerRight: topbarRight
112121
? () => topbarRight
@@ -136,7 +145,8 @@ export const Screen = (props: ScreenProps) => {
136145
headerTitleProp,
137146
icon,
138147
IconProps,
139-
header
148+
header,
149+
headerTransparent
140150
])
141151

142152
return (

packages/mobile/src/components/core/Screen/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './ScreenContent'
44
export * from './ScreenHeader'
55
export * from './ScreenHeaderButton'
66
export * from './HeaderShadow'
7+
export * from './GlassSurface'

packages/mobile/src/components/core/ScrollView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { forwardRef } from 'react'
33
import type { ScrollViewProps as RNScrollViewProps } from 'react-native'
44
import { ScrollView as RNScrollView } from 'react-native'
55

6-
import { PlayBarChin } from './PlayBarChin'
6+
import { BottomChin } from './BottomChin'
77

88
export type ScrollViewElement = RNScrollView
99

@@ -15,7 +15,7 @@ export const ScrollView = forwardRef<ScrollViewElement, ScrollViewProps>(
1515
return (
1616
<RNScrollView {...other} ref={ref}>
1717
{children}
18-
<PlayBarChin />
18+
<BottomChin />
1919
</RNScrollView>
2020
)
2121
}

packages/mobile/src/components/core/SectionList.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { useThemeColors } from 'app/utils/theme'
2424

2525
import { CollapsibleTabNavigatorContext } from '../top-tab-bar'
2626

27-
import { PlayBarChin } from './PlayBarChin'
27+
import { BottomChin } from './BottomChin'
2828
import { PullToRefresh, useOverflowHandlers } from './PullToRefresh'
2929

3030
type CollapsibleSectionListProps<ItemT> = RNSectionListProps<ItemT>
@@ -181,19 +181,19 @@ export const SectionList = forwardRef(function SectionList<
181181
SectionT = DefaultSectionT
182182
>(
183183
props: Animated.AnimatedProps<RNSectionListProps<ItemT, SectionT>> & {
184-
hidePlayBarChin?: boolean
184+
hideBottomChin?: boolean
185185
},
186186
ref: Ref<RNSectionList<ItemT, SectionT>>
187187
) {
188-
const { ListFooterComponent, hidePlayBarChin, ...other } = props
188+
const { ListFooterComponent, hideBottomChin, ...other } = props
189189

190190
const FooterComponent = ListFooterComponent ? (
191191
<>
192192
{ListFooterComponent}
193-
{hidePlayBarChin ? null : <PlayBarChin />}
193+
{hideBottomChin ? null : <BottomChin />}
194194
</>
195-
) : hidePlayBarChin ? null : (
196-
<PlayBarChin />
195+
) : hideBottomChin ? null : (
196+
<BottomChin />
197197
)
198198

199199
const sectionListProps = {

0 commit comments

Comments
 (0)