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 ( - +