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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 28 additions & 22 deletions packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -103,29 +104,34 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
interpolatePostion(translationAnim, insets.bottom)
]}
>
<Flex
row
pointerEvents='auto'
borderTop='default'
backgroundColor='surface1'
wrap='nowrap'
justifyContent='space-evenly'
pb={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.
*/}
<GlassSurface borderEdge='top'>
<Flex
row
pointerEvents='auto'
wrap='nowrap'
justifyContent='space-evenly'
pb={insets.bottom}
>
{routes.map(({ name, key }, index) => {
const BottomTabBarButton = bottomTabBarButtons[name]

return (
<BottomTabBarButton
key={key}
routeKey={key}
isActive={index === activeIndex}
onPress={handlePress}
onLongPress={handleLongPress}
/>
)
})}
</Flex>
return (
<BottomTabBarButton
key={key}
routeKey={key}
isActive={index === activeIndex}
onPress={handlePress}
onLongPress={handleLongPress}
/>
)
})}
</Flex>
</GlassSurface>
</Animated.View>
)
}
38 changes: 38 additions & 0 deletions packages/mobile/src/components/core/BottomChin.tsx
Original file line number Diff line number Diff line change
@@ -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 <View style={{ height }} />
}
6 changes: 3 additions & 3 deletions packages/mobile/src/components/core/FlatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemT> = RNFlatList<ItemT>
Expand Down Expand Up @@ -152,10 +152,10 @@ export const FlatList = forwardRef(function FlatList<ItemT>(
const FooterComponent = ListFooterComponent ? (
<>
{ListFooterComponent}
<PlayBarChin />
<BottomChin />
</>
) : (
PlayBarChin
BottomChin
)

const flatListProps = {
Expand Down
12 changes: 0 additions & 12 deletions packages/mobile/src/components/core/PlayBarChin.tsx

This file was deleted.

159 changes: 159 additions & 0 deletions packages/mobile/src/components/core/Screen/GlassSurface.tsx
Original file line number Diff line number Diff line change
@@ -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<number>
/** 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 (
<View style={[styles.root, style]} {...other}>
<View style={StyleSheet.absoluteFill} pointerEvents='none'>
{Platform.OS === 'ios' ? (
<BlurView
blurType={isDarkMode ? 'dark' : 'light'}
blurAmount={blurAmount}
reducedTransparencyFallbackColor={backgroundSurface}
style={StyleSheet.absoluteFill}
/>
) : null}
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: backgroundSurface, opacity: tintOpacity }
]}
/>
{showBorder ? (
<Animated.View
style={[
styles.border,
borderEdge === 'top' ? styles.borderTop : styles.borderBottom,
{ backgroundColor: borderStrong },
edgeStyle
]}
/>
) : null}
</View>
{children}
</View>
)
}

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 }
}
})
12 changes: 11 additions & 1 deletion packages/mobile/src/components/core/Screen/Screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export type ScreenProps = {
variant?: ScreenVariant
as?: ComponentType<ViewProps>
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
}
Expand All @@ -76,6 +83,7 @@ export const Screen = (props: ScreenProps) => {
style,
as: RootComponent = View,
header,
headerTransparent,
onBack
} = props
const palette = useThemePalette()
Expand Down Expand Up @@ -107,6 +115,7 @@ export const Screen = (props: ScreenProps) => {
removeUndefined({
header,
headerShown: header ? true : undefined,
headerTransparent,
headerLeft: topbarLeft === undefined ? undefined : () => topbarLeft,
headerRight: topbarRight
? () => topbarRight
Expand Down Expand Up @@ -136,7 +145,8 @@ export const Screen = (props: ScreenProps) => {
headerTitleProp,
icon,
IconProps,
header
header,
headerTransparent
])

return (
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/src/components/core/Screen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './ScreenContent'
export * from './ScreenHeader'
export * from './ScreenHeaderButton'
export * from './HeaderShadow'
export * from './GlassSurface'
4 changes: 2 additions & 2 deletions packages/mobile/src/components/core/ScrollView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -15,7 +15,7 @@ export const ScrollView = forwardRef<ScrollViewElement, ScrollViewProps>(
return (
<RNScrollView {...other} ref={ref}>
{children}
<PlayBarChin />
<BottomChin />
</RNScrollView>
)
}
Expand Down
12 changes: 6 additions & 6 deletions packages/mobile/src/components/core/SectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemT> = RNSectionListProps<ItemT>
Expand Down Expand Up @@ -181,19 +181,19 @@ export const SectionList = forwardRef(function SectionList<
SectionT = DefaultSectionT
>(
props: Animated.AnimatedProps<RNSectionListProps<ItemT, SectionT>> & {
hidePlayBarChin?: boolean
hideBottomChin?: boolean
},
ref: Ref<RNSectionList<ItemT, SectionT>>
) {
const { ListFooterComponent, hidePlayBarChin, ...other } = props
const { ListFooterComponent, hideBottomChin, ...other } = props

const FooterComponent = ListFooterComponent ? (
<>
{ListFooterComponent}
{hidePlayBarChin ? null : <PlayBarChin />}
{hideBottomChin ? null : <BottomChin />}
</>
) : hidePlayBarChin ? null : (
<PlayBarChin />
) : hideBottomChin ? null : (
<BottomChin />
)

const sectionListProps = {
Expand Down
Loading
Loading