diff --git a/packages/mobile/src/components/core/Screen/GlassCapsule.tsx b/packages/mobile/src/components/core/Screen/GlassCapsule.tsx index c8e9ddc12de..a0a25eb3dd9 100644 --- a/packages/mobile/src/components/core/Screen/GlassCapsule.tsx +++ b/packages/mobile/src/components/core/Screen/GlassCapsule.tsx @@ -33,6 +33,11 @@ type GlassCapsuleProps = { * time under a page-colored cover that fades out. */ floatProgress: SharedValue + /** + * Tints the iOS 26 glass, for pills that sit over busy content where the + * clear material leaves text hard to read. + */ + tintColor?: string } /** @@ -41,7 +46,7 @@ type GlassCapsuleProps = { * `GlassSurface` clipped to the same shape with a soft shadow. */ export const GlassCapsule = (props: GlassCapsuleProps) => { - const { children, height, style, floatProgress } = props + const { children, height, style, floatProgress, tintColor } = props const isDarkMode = isDarkTheme(useThemeVariant()) const { background, borderDefault } = useThemeColors() const shape = { height, borderRadius: height / 2 } @@ -86,6 +91,7 @@ export const GlassCapsule = (props: GlassCapsuleProps) => { diff --git a/packages/mobile/src/components/top-tab-bar/TopTabNavigator.tsx b/packages/mobile/src/components/top-tab-bar/TopTabNavigator.tsx index 57527d4118d..93d98f8e134 100644 --- a/packages/mobile/src/components/top-tab-bar/TopTabNavigator.tsx +++ b/packages/mobile/src/components/top-tab-bar/TopTabNavigator.tsx @@ -1,6 +1,9 @@ import type { ComponentType, ReactNode } from 'react' -import type { MaterialTopTabNavigationOptions } from '@react-navigation/material-top-tabs' +import type { + MaterialTopTabBarProps, + MaterialTopTabNavigationOptions +} from '@react-navigation/material-top-tabs' import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs' import type { SvgProps } from 'react-native-svg' @@ -12,14 +15,25 @@ type TabNavigatorProps = { initialScreenName?: string children: ReactNode screenOptions?: MaterialTopTabNavigationOptions + /** Replaces the default tab bar, e.g. to float it over the content. */ + tabBar?: (props: MaterialTopTabBarProps) => ReactNode } +const renderDefaultTabBar = (props: MaterialTopTabBarProps) => ( + +) + export const TabNavigator = (props: TabNavigatorProps) => { - const { initialScreenName, children, screenOptions } = props + const { + initialScreenName, + children, + screenOptions, + tabBar = renderDefaultTabBar + } = props return ( } + tabBar={tabBar} screenOptions={screenOptions} > {children} @@ -59,14 +73,16 @@ type TopTabsProps = { initialScreenName?: string screens?: ScreenConfig[] screenOptions?: MaterialTopTabNavigationOptions + tabBar?: (props: MaterialTopTabBarProps) => ReactNode } export const TopTabNavigator = (props: TopTabsProps) => { - const { initialScreenName, screens, screenOptions } = props + const { initialScreenName, screens, screenOptions, tabBar } = props return ( {screens?.map((screen) => tabScreen({ key: screen.name, ...screen }))} diff --git a/packages/mobile/src/screens/app-screen/CompactHeader.tsx b/packages/mobile/src/screens/app-screen/CompactHeader.tsx new file mode 100644 index 00000000000..33523d1f9e8 --- /dev/null +++ b/packages/mobile/src/screens/app-screen/CompactHeader.tsx @@ -0,0 +1,163 @@ +import type { ReactNode } from 'react' + +import { StyleSheet, Text, View } from 'react-native' +import type { SharedValue } from 'react-native-reanimated' +import Animated, { + useAnimatedStyle, + useSharedValue +} from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +import { GlassCapsule } from 'app/components/core/Screen/GlassCapsule' +import { makeStyles } from 'app/styles' +import { useThemeColors } from 'app/utils/theme' +import { zIndex } from 'app/utils/zIndex' + +/** Height of the compact title pill. */ +export const COMPACT_TITLE_HEIGHT = 36 +/** Height of the glass bar holding a screen's compact controls. */ +const COMPACT_CONTROLS_HEIGHT = 36 +/** Gap between the status bar and the title pill. */ +const COMPACT_TOP_GAP = 4 +/** Gap between the title pill and the controls under it. */ +const COMPACT_STACK_GAP = 6 + +/** Page-colored tint so pill text stays readable over the list. */ +const useCompactTint = () => `${useThemeColors().background.slice(0, 7)}B3` + +/** + * Glass can't fade in from transparent, so compact chrome slides down from + * above the screen as it forms instead of fading. + */ +const useSlideInStyle = (progress: SharedValue, distance: number) => + useAnimatedStyle(() => ({ + transform: [ + { translateY: -distance * (1 - progress.value) }, + { scale: 0.9 + 0.1 * progress.value } + ] + })) + +const useStyles = makeStyles(({ palette, spacing, typography }) => ({ + titleContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing(2), + paddingHorizontal: spacing(4) + }, + title: { + fontSize: 15, + lineHeight: 20, + fontFamily: typography.fontByWeight.bold, + color: palette.neutral + }, + controlsContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing(1.5) + } +})) + +type CompactTitlePillProps = { + title: string + progress: SharedValue + /** Shown before the title, e.g. a search icon. */ + leading?: ReactNode +} + +/** + * The small glass pill a root screen's header contracts to once its title row + * has scrolled away. Pinned just under the status bar. + */ +export const CompactTitlePill = (props: CompactTitlePillProps) => { + const { title, progress, leading } = props + const styles = useStyles() + const insets = useSafeAreaInsets() + const floating = useSharedValue(1) + const tint = useCompactTint() + const top = insets.top + COMPACT_TOP_GAP + const slideStyle = useSlideInStyle( + progress, + top + COMPACT_TITLE_HEIGHT + COMPACT_TOP_GAP + ) + + return ( + + + + {leading} + + {title} + + + + + ) +} + +type CompactControlsProps = { + children: ReactNode + progress: SharedValue +} + +/** + * A screen's controls (tab pills, filter) in a small glass bar under the + * compact title pill. Rendered by the screen so tab state changes don't + * rebuild the navigation header. + */ +export const CompactControls = (props: CompactControlsProps) => { + const { children, progress } = props + const styles = useStyles() + const insets = useSafeAreaInsets() + const floating = useSharedValue(1) + const tint = useCompactTint() + const top = + insets.top + COMPACT_TOP_GAP + COMPACT_TITLE_HEIGHT + COMPACT_STACK_GAP + const slideStyle = useSlideInStyle( + progress, + top + COMPACT_CONTROLS_HEIGHT + COMPACT_TOP_GAP + ) + + return ( + + + {children} + + + ) +} + +const layout = StyleSheet.create({ + root: { + position: 'absolute', + left: 0, + right: 0, + alignItems: 'center', + zIndex: zIndex.HEADER_SHADOW + }, + capsule: { + alignSelf: 'center', + // GlassCapsule insets itself 16px from each edge while floating; centered + // pills size to their content instead. + marginHorizontal: 0 + } +}) diff --git a/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx index baf24f0e968..105a0644a18 100644 --- a/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx +++ b/packages/mobile/src/screens/app-screen/FloatingSubHeader.tsx @@ -2,62 +2,62 @@ import type { ReactNode } from 'react' import { useCallback } from 'react' import type { LayoutChangeEvent } from 'react-native' -import { StyleSheet, View } from 'react-native' -import Animated, { - useAnimatedStyle, - useSharedValue -} from 'react-native-reanimated' +import { StyleSheet } from 'react-native' +import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { GlassCapsule } from 'app/components/core/Screen/GlassCapsule' +import { makeStyles } from 'app/styles' import { zIndex } from 'app/utils/zIndex' +import { CompactControls } from './CompactHeader' import { - useChromeHiddenProgress, + useCompactControlsProgress, + useHeaderScrollOffset, useRootHeaderHeight, - useSetSubHeaderHeight, - useSubHeaderHeight, - useSubHeaderScrollOffset + useSetSubHeaderHeight } from './GlassChromeContext' -/** Pill (32) plus 6px of glass around it. */ -const PILL_BAR_HEIGHT = 44 - type FloatingSubHeaderProps = { + /** The in-flow row under the title (e.g. large tab pills). */ children: ReactNode + /** + * Smaller copy of the controls, shown in a glass bar under the compact + * title pill once the in-flow row has scrolled away. + */ + compact?: ReactNode + /** Pads the row to the screen gutter. Off for full-width rows. */ + padded?: boolean } +const useStyles = makeStyles(({ palette, spacing }) => ({ + row: { + backgroundColor: palette.white + }, + padded: { + paddingHorizontal: spacing(4), + paddingVertical: spacing(2) + } +})) + /** - * A screen's tab pills (feed tabs, trending categories) in one glass pill - * bar under the root header. It scrolls up with the list, then pins under the - * status bar once the header is gone. While pinned it slides away on scroll - * down and comes back on scroll up, with the tab bar. + * A screen's controls row (feed tabs, trending categories) under the root + * header. It scrolls away with the header, then its compact copy pins under + * the compact title pill. * * Owned by the screen rather than passed into `MobileRootHeader`, whose * memoized render prop must not rebuild on tab-state changes (that remounts * `AccountPictureHeader` and re-fires the profile-picture fetch). */ export const FloatingSubHeader = (props: FloatingSubHeaderProps) => { - const { children } = props + const { children, compact, padded = true } = props + const styles = useStyles() const headerHeight = useRootHeaderHeight() - const subHeaderHeight = useSubHeaderHeight() const setSubHeaderHeight = useSetSubHeaderHeight() - const hidden = useChromeHiddenProgress() - const scrollOffset = useSubHeaderScrollOffset() - const floating = useSharedValue(1) + const scrollOffset = useHeaderScrollOffset() + const compactProgress = useCompactControlsProgress() - // Glass stops rendering under a parent that fades from 0, so it hides by - // sliding fully off the top instead. - const positionStyle = useAnimatedStyle(() => { - const restingTop = headerHeight - scrollOffset.value - return { - transform: [ - { - translateY: - -scrollOffset.value - hidden.value * (restingTop + subHeaderHeight) - } - ] - } - }) + const positionStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: -scrollOffset.value }] + })) const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -67,36 +67,31 @@ export const FloatingSubHeader = (props: FloatingSubHeaderProps) => { ) return ( - - + - {children} - - + {children} + + {compact ? ( + {compact} + ) : null} + ) } -const styles = StyleSheet.create({ +const layout = StyleSheet.create({ root: { position: 'absolute', left: 0, right: 0, - paddingVertical: 8, zIndex: zIndex.HEADER_SHADOW - }, - pillBar: { - alignSelf: 'flex-start' - }, - pills: { - flex: 1, - justifyContent: 'center', - paddingHorizontal: 6 } }) diff --git a/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx index 9756fccd994..9b67a765a07 100644 --- a/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx +++ b/packages/mobile/src/screens/app-screen/GlassChromeContext.tsx @@ -34,8 +34,8 @@ type GlassChromeContextValue = { directionAnchorY: SharedValue } -/** Header capsule (52) plus its top and bottom margins. */ -const ESTIMATED_HEADER_ROW_HEIGHT = 64 +/** Avatar/title row: 32 line height plus 12 above and below. */ +const ESTIMATED_HEADER_ROW_HEIGHT = 56 /** * Distance (px) the user must move in one direction before the chrome flips @@ -44,11 +44,8 @@ const ESTIMATED_HEADER_ROW_HEIGHT = 64 */ const DIRECTION_THRESHOLD = 6 -/** - * Gap between the status bar and a pinned sub-header, so the status bar fade - * ends before the pill bar's glass starts. - */ -export const SUB_HEADER_PIN_GAP = 8 +/** Scroll distance over which a compact pill forms. */ +const COMPACT_FORM_DISTANCE = 36 // Long enough for the tab bar's collapse to read as a morph rather than a cut. const HIDE_TIMING = { @@ -193,25 +190,55 @@ export const useGlassScrollY = () => useGlassChrome().scrollY export const useChromeHiddenProgress = () => useGlassChrome().hidden /** - * How far the root header has scrolled up with the list, capped once it is - * fully off screen. Negative during overscroll so it follows the rubber band. + * How far the header stack (root header + sub-header) has scrolled up with + * the list, capped once it is fully off screen. Negative during overscroll so + * it follows the rubber band. */ export const useHeaderScrollOffset = () => { const scrollY = useGlassScrollY() - const { headerHeight } = useGlassChrome() - return useDerivedValue(() => Math.min(scrollY.value, headerHeight)) + const { headerHeight, subHeaderHeight } = useGlassChrome() + const max = headerHeight + subHeaderHeight + return useDerivedValue(() => Math.min(scrollY.value, max)) } /** - * How far a sub-header has scrolled up with the list. It stops just under the - * status bar and stays pinned there. + * 0 while the title row is on screen, 1 once it has scrolled away. Drives the + * compact title pill, which finishes forming as the row leaves. */ -export const useSubHeaderScrollOffset = () => { +export const useCompactTitleProgress = () => { const scrollY = useGlassScrollY() const { headerHeight } = useGlassChrome() const insets = useSafeAreaInsets() - const pinOffset = headerHeight - insets.top - SUB_HEADER_PIN_GAP - return useDerivedValue(() => Math.min(scrollY.value, pinOffset)) + const end = headerHeight - insets.top + return useDerivedValue(() => + Math.min( + 1, + Math.max( + 0, + (scrollY.value - (end - COMPACT_FORM_DISTANCE)) / COMPACT_FORM_DISTANCE + ) + ) + ) +} + +/** + * Same as `useCompactTitleProgress`, but for the compact controls under the + * pill, which form as the in-flow sub-header row leaves. + */ +export const useCompactControlsProgress = () => { + const scrollY = useGlassScrollY() + const { headerHeight, subHeaderHeight } = useGlassChrome() + const insets = useSafeAreaInsets() + const end = headerHeight - insets.top + subHeaderHeight + return useDerivedValue(() => + Math.min( + 1, + Math.max( + 0, + (scrollY.value - (end - COMPACT_FORM_DISTANCE)) / COMPACT_FORM_DISTANCE + ) + ) + ) } /** diff --git a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx index 7ff32691a42..646f8a57ef8 100644 --- a/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx +++ b/packages/mobile/src/screens/app-screen/MobileRootHeader.tsx @@ -2,17 +2,24 @@ import type { ReactNode } from 'react' import { useCallback, useContext } from 'react' import type { LayoutChangeEvent } from 'react-native' -import { Text, View } from 'react-native' -import Animated, { useAnimatedStyle } from 'react-native-reanimated' +import { View } from 'react-native' +import Animated, { + interpolate, + useAnimatedStyle +} from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { GradientText } from 'app/components/core' import { useDrawer } from 'app/hooks/useDrawer' import { makeStyles } from 'app/styles' import { AppDrawerContext } from '../app-drawer-screen' import { AccountPictureHeader } from './AccountPictureHeader' +import { CompactTitlePill } from './CompactHeader' import { + useCompactTitleProgress, + useGlassScrollY, useHeaderScrollOffset, useSetRootHeaderHeight } from './GlassChromeContext' @@ -22,52 +29,85 @@ import { GlassEdgeFade } from './GlassEdgeFade' -const ROW_HEIGHT = 52 +/** Scroll distance over which the status bar fade comes in. */ +const EDGE_FADE_DISTANCE = 24 type MobileRootHeaderProps = { title: string children?: ReactNode + /** Hairline under the title row. Off when a sub-header row follows it. */ + showDivider?: boolean } const useStyles = makeStyles(({ palette, spacing, typography }) => ({ + block: { + backgroundColor: palette.white + }, + // Fills the gap above the block when overscroll pulls it down. + overscrollFill: { + position: 'absolute', + left: 0, + right: 0, + bottom: '100%', + height: 1000, + backgroundColor: palette.white + }, row: { - height: ROW_HEIGHT, - marginTop: spacing(1), - marginBottom: spacing(2), flexDirection: 'row', alignItems: 'center', - gap: spacing(3), - paddingHorizontal: spacing(4) + gap: spacing(4), + paddingHorizontal: spacing(4), + paddingVertical: spacing(3) }, title: { + fontSize: 24, + lineHeight: 32, + fontFamily: typography.fontByWeight.bold + }, + titleContainer: { flex: 1, - fontSize: 20, - lineHeight: 26, - fontFamily: typography.fontByWeight.heavy, - color: palette.neutral + minWidth: 0 + }, + divider: { + height: 1, + backgroundColor: palette.neutralLight8 } })) /** - * Custom header for root (bottom-bar) tab screens. + * Header for root (bottom-bar) tab screens. + * + * At rest it is the classic flat header: [Avatar] [gradient title] [right + * content]. It scrolls away with the list, and once the title row is gone a + * small glass pill with the title takes its place under the status bar. + * Screens with controls (tab pills, filters) render their own in-flow row + * and compact copy through `FloatingSubHeader`. * - * [Avatar] [title] [right content], flat on the page. It scrolls away with - * the list, and the content passing under the status bar fades out. It - * reports its measured height through `GlassChromeContext` so the screen + * Reports its measured height through `GlassChromeContext` so the screen * underneath can pad its scrollable content to start below it. */ export const MobileRootHeader = (props: MobileRootHeaderProps) => { - const { title, children } = props + const { title, children, showDivider = true } = props const insets = useSafeAreaInsets() const styles = useStyles() const { drawerHelpers } = useContext(AppDrawerContext) const { isOpen: isNowPlayingDrawerOpen } = useDrawer('NowPlaying') const setRootHeaderHeight = useSetRootHeaderHeight() + const scrollY = useGlassScrollY() const scrollOffset = useHeaderScrollOffset() + const compactProgress = useCompactTitleProgress() const scrollStyle = useAnimatedStyle(() => ({ transform: [{ translateY: -scrollOffset.value }] })) + const fadeStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + scrollY.value, + [0, EDGE_FADE_DISTANCE], + [0, 1], + 'clamp' + ) + })) const handleOpenLeftNavDrawer = useCallback(() => { if (isNowPlayingDrawerOpen) return @@ -82,29 +122,33 @@ export const MobileRootHeader = (props: MobileRootHeaderProps) => { ) return ( - - + + + - - {title} - + + + {title} + + {children} + {showDivider ? : null} + ) } diff --git a/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx b/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx index 4626df25d2d..74e0bd3c12e 100644 --- a/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/TrendingTabScreen.tsx @@ -16,7 +16,7 @@ export type TrendingTabScreenParamList = AppTabScreenParamList & { // — the switch from native-bar positioning to custom-header positioning causes // a visible double-shift of the content. const renderTrendingHeader = () => ( - + ) diff --git a/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx b/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx index 1dc675d4f1e..c8e31369943 100644 --- a/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx +++ b/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx @@ -1,9 +1,15 @@ import React, { useEffect, useRef } from 'react' +import { View } from 'react-native' import { IOScrollView } from 'react-native-intersection-observer' import { Screen, ScreenContent } from 'app/components/core' import { useScrollToTop } from 'app/hooks/useScrollToTop' +import { + useGlassScrollHandler, + useRootHeaderHeight, + useSyncGlassScroll +} from 'app/screens/app-screen/GlassChromeContext' import { SearchResults } from '../search-screen/search-results/SearchResults' import { @@ -52,16 +58,34 @@ const SearchExploreContent = () => { const showSearch = Boolean(query || hasAnyFilter || category !== 'all') + const headerHeight = useRootHeaderHeight() + const handleGlassScroll = useGlassScrollHandler() + const syncGlassScroll = useSyncGlassScroll() + + // Search results replace the explore list, and the explore list remounts at + // the top, so the header always starts back in place. + useEffect(() => { + syncGlassScroll(0) + }, [showSearch, syncGlassScroll]) + return ( - {showSearch ? ( - + + + ) : ( - + )} + ) } diff --git a/packages/mobile/src/screens/explore-screen/components/SearchExploreHeader.tsx b/packages/mobile/src/screens/explore-screen/components/SearchExploreHeader.tsx index 119464cd515..5aad322a628 100644 --- a/packages/mobile/src/screens/explore-screen/components/SearchExploreHeader.tsx +++ b/packages/mobile/src/screens/explore-screen/components/SearchExploreHeader.tsx @@ -8,9 +8,12 @@ import React, { import { exploreMessages as messages } from '@audius/common/messages' import { useIsFocused, useNavigation } from '@react-navigation/native' -import type { ScrollView } from 'react-native' -import { Keyboard } from 'react-native' -import { useSharedValue } from 'react-native-reanimated' +import type { LayoutChangeEvent, ScrollView } from 'react-native' +import { Keyboard, StyleSheet, View } from 'react-native' +import Animated, { + interpolate, + useAnimatedStyle +} from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useDebounce } from 'react-use' @@ -23,16 +26,28 @@ import { TextInputSize, useTheme } from '@audius/harmony-native' -import { GlassCapsule } from 'app/components/core/Screen/GlassCapsule' import { useDrawer } from 'app/hooks/useDrawer' import { AppDrawerContext } from 'app/screens/app-drawer-screen' import { AccountPictureHeader } from 'app/screens/app-screen/AccountPictureHeader' +import { CompactTitlePill } from 'app/screens/app-screen/CompactHeader' +import { + useCompactTitleProgress, + useGlassScrollY, + useHeaderScrollOffset, + useSetRootHeaderHeight +} from 'app/screens/app-screen/GlassChromeContext' +import { + EDGE_FADE_FROM_OPACITY, + EDGE_FADE_TAIL, + GlassEdgeFade +} from 'app/screens/app-screen/GlassEdgeFade' import { SearchCategoriesAndFilters } from 'app/screens/search-screen/SearchCategoriesAndFilters' import { useSearchQuery } from '../../search-screen/searchState' import { useExploreRoute } from '../hooks' -const SEARCH_CAPSULE_HEIGHT = 52 +/** Scroll distance over which the status bar fade comes in. */ +const EDGE_FADE_DISTANCE = 24 type SearchExploreHeaderProps = { scrollRef?: React.RefObject @@ -40,7 +55,7 @@ type SearchExploreHeaderProps = { export const SearchExploreHeader = (props: SearchExploreHeaderProps) => { const { scrollRef } = props - const { spacing, cornerRadius } = useTheme() + const { spacing, color } = useTheme() const { params } = useExploreRoute<'SearchExplore'>() const { drawerHelpers } = useContext(AppDrawerContext) const navigation = useNavigation() @@ -71,11 +86,13 @@ export const SearchExploreHeader = (props: SearchExploreHeaderProps) => { // off-screen with autoFocus: true already on its route. useEffect(() => { if (params?.autoFocus === true && isFocused) { + // The field may have scrolled away with the header. + scrollRef?.current?.scrollTo?.({ y: 0, animated: false }) textInputRef.current?.focus() // @ts-expect-error: setParams is not typed on the generic NavigationProp, but is available on StackNavigationProp navigation.setParams?.({ autoFocus: false }) } - }, [params?.autoFocus, navigation, isFocused]) + }, [params?.autoFocus, navigation, isFocused, scrollRef]) const handleOpenLeftNavDrawer = useCallback(() => { if (isNowPlayingDrawerOpen) return @@ -100,56 +117,113 @@ export const SearchExploreHeader = (props: SearchExploreHeaderProps) => { ) const { top } = useSafeAreaInsets() - const flat = useSharedValue(0) + const setHeaderHeight = useSetRootHeaderHeight() + const scrollY = useGlassScrollY() + const scrollOffset = useHeaderScrollOffset() + const compactProgress = useCompactTitleProgress() + + const scrollStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: -scrollOffset.value }] + })) + const fadeStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + scrollY.value, + [0, EDGE_FADE_DISTANCE], + [0, 1], + 'clamp' + ) + })) + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + setHeaderHeight(event.nativeEvent.layout.height) + }, + [setHeaderHeight] + ) return ( - <> + {/* - Same capsule as the other tabs' headers, with the search field in - place of the title. Explore's content scrolls below it rather than - under it, so it stays flat and never hides. + The classic header: avatar + search field, then categories. It + scrolls away with the explore content and contracts to a title pill. + While searching the list is swapped out and the chrome scroll resets, + so it stays put. */} - - - - - - - inputValue ? ( - - ) : null - } - /> - + + + + + + + inputValue ? ( + + ) : null + } + /> - - - - - - + + + + + + + } + /> + ) } + +const styles = StyleSheet.create({ + // Fills the gap above the header when overscroll pulls it down. + overscrollFill: { + position: 'absolute', + left: 0, + right: 0, + bottom: '100%', + height: 1000 + }, + root: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 2 + } +}) diff --git a/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx b/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx index b78ec4e92b1..6883a09f3f1 100644 --- a/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedFilterButton.tsx @@ -7,7 +7,11 @@ import { Flex, IconLeading, SelectablePill } from '@audius/harmony-native' import { FEED_FILTER_MODAL } from './FeedFilterDrawer' -export const FeedFilterButton = () => { +type FeedFilterButtonProps = { + size?: 'small' | 'large' +} + +export const FeedFilterButton = ({ size = 'large' }: FeedFilterButtonProps) => { const dispatch = useDispatch() const [feedFilter] = useFeedFilter() @@ -27,7 +31,7 @@ export const FeedFilterButton = () => { { // re-firing the profile-picture image-fetch path. const renderHeader = useCallback( () => ( - + {isForYou ? null : } ), @@ -259,8 +259,17 @@ export const FeedScreen = () => { return ( - {/* Pins under the status bar once the title scrolls away. */} - + + {isForYou ? null : } + + } + > {/* Horizontal pager: swipe left/right toggles between For You and diff --git a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx index 29f777ccd07..2a3b570c6b1 100644 --- a/packages/mobile/src/screens/feed-screen/FeedTabs.tsx +++ b/packages/mobile/src/screens/feed-screen/FeedTabs.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import { FeedTab } from '@audius/common/models' import { Flex, SelectablePill } from '@audius/harmony-native' @@ -12,17 +14,24 @@ const tabs: FeedTab[] = [FeedTab.FOR_YOU, FeedTab.LATEST] type FeedTabsProps = { currentTab: FeedTab onSelectTab: (tab: FeedTab) => void + /** `small` for the compact copy under the collapsed header pill. */ + size?: 'small' | 'large' + children?: ReactNode } -/** Rendered inside `FloatingSubHeader`'s glass pill bar, which owns the fill. */ -export const FeedTabs = ({ currentTab, onSelectTab }: FeedTabsProps) => { +export const FeedTabs = ({ + currentTab, + onSelectTab, + size = 'large', + children +}: FeedTabsProps) => { return ( - + {tabs.map((tab) => ( { disableUnselectAnimation /> ))} + {children} ) } diff --git a/packages/mobile/src/screens/library-screen/AlbumsTab.tsx b/packages/mobile/src/screens/library-screen/AlbumsTab.tsx index 8e3de6d958f..dd59bcd9543 100644 --- a/packages/mobile/src/screens/library-screen/AlbumsTab.tsx +++ b/packages/mobile/src/screens/library-screen/AlbumsTab.tsx @@ -20,6 +20,7 @@ import { makeStyles } from 'app/styles' import { NoTracksPlaceholder } from './NoTracksPlaceholder' import { OfflineContentBanner } from './OfflineContentBanner' import { useLibraryCollections } from './useLibraryCollections' +import { useLibraryTabChrome } from './useLibraryTabChrome' const { getCategory } = libraryPageSelectors const { getIsReachable } = reachabilitySelectors @@ -44,6 +45,7 @@ const useStyles = makeStyles(() => ({ export const AlbumsTab = () => { const styles = useStyles() + const { headerInset, onScroll } = useLibraryTabChrome() const [filterValue, setFilterValue] = useState('') const [debouncedFilterValue, setDebouncedFilterValue] = useState('') @@ -94,24 +96,33 @@ export const AlbumsTab = () => { return ( {noItemsLoaded ? ( - !isReachable ? ( - - ) : ( - - ) + + {!isReachable ? ( + + ) : ( + + )} + ) : ( <> - - { - setFilterValue(text) - handleChangeFilterValue(text) - }} - /> + + { + setFilterValue(text) + handleChangeFilterValue(text) + }} + /> + + } + contentContainerStyle={{ paddingTop: headerInset }} + onScroll={onScroll} + scrollEventThrottle={16} collectionType='album' onEndReached={handleEndReached} onEndReachedThreshold={0.5} diff --git a/packages/mobile/src/screens/library-screen/LibraryScreen.tsx b/packages/mobile/src/screens/library-screen/LibraryScreen.tsx index 99a8cd9fc31..39beddb801e 100644 --- a/packages/mobile/src/screens/library-screen/LibraryScreen.tsx +++ b/packages/mobile/src/screens/library-screen/LibraryScreen.tsx @@ -1,13 +1,22 @@ +import { useCallback } from 'react' + import { LibraryPageTabs } from '@audius/common/store' -import { View } from 'react-native' +import type { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs' -import { IconAlbum, IconNote, IconPlaylists } from '@audius/harmony-native' +import { + Flex, + IconAlbum, + IconNote, + IconPlaylists, + SelectablePill +} from '@audius/harmony-native' import { Screen, ScreenContent } from 'app/components/core' import { ScreenPrimaryContent } from 'app/components/core/Screen/ScreenPrimaryContent' import { ScreenSecondaryContent } from 'app/components/core/Screen/ScreenSecondaryContent' import { TopTabNavigator } from 'app/components/top-tab-bar' +import { TopTabBar } from 'app/components/top-tab-bar/TopTabBar' +import { FloatingSubHeader } from 'app/screens/app-screen/FloatingSubHeader' import { MobileRootHeader } from 'app/screens/app-screen/MobileRootHeader' -import { makeStyles } from 'app/styles' import { AlbumsTab } from './AlbumsTab' import { LibraryCategorySelectionMenu } from './LibraryCategorySelectionMenu' @@ -37,33 +46,64 @@ const libraryScreens = [ } ] -const useStyles = makeStyles(({ palette }) => ({ - subHeader: { - backgroundColor: palette.white - } -})) +/** + * Category pills and the Tracks / Albums / Playlists tabs, as the header's + * scroll-away row. The tab bar floats over the tab lists, which pad their own + * content to start below it. + */ +const LibraryTabBar = (props: MaterialTopTabBarProps) => { + const { state, navigation } = props + + const compactTabs = ( + + {state.routes.map((route, index) => ( + { + if (isSelected) navigation.navigate(value) + }} + disableUnselectAnimation + /> + ))} + + ) + + return ( + + + + + ) +} + +const renderLibraryTabBar = (props: MaterialTopTabBarProps) => ( + +) export const LibraryScreen = () => { - const styles = useStyles() + const renderHeader = useCallback( + () => ( + + + + ), + [] + ) return ( - ( - - - - )} - > - - - - - + + {null} diff --git a/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx b/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx index 3791c09675b..516cb9d7bd5 100644 --- a/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx +++ b/packages/mobile/src/screens/library-screen/LibraryTrackRows.tsx @@ -25,7 +25,12 @@ type LibraryTrackRowsProps = { | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' + | 'ListHeaderComponent' + | 'ListEmptyComponent' | 'keyboardShouldPersistTaps' + | 'contentContainerStyle' + | 'onScroll' + | 'scrollEventThrottle' > const DEFAULT_PAGE_SIZE = 10 @@ -40,8 +45,8 @@ const keyExtractor = (item: Item, index: number) => /** * Library tracks as dense rows, the same row Trending and profiles use. - * The list is full-bleed: the filter above it and the page provide the - * spacing, not a card. + * The list is full-bleed: the filter in its header and the page provide + * the spacing, not a card. */ export const LibraryTrackRows = (props: LibraryTrackRowsProps) => { const { diff --git a/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx b/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx index beec7dfbf23..1e1b8b2eb4a 100644 --- a/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx +++ b/packages/mobile/src/screens/library-screen/PlaylistsTab.tsx @@ -21,6 +21,7 @@ import { makeStyles } from 'app/styles' import { NoTracksPlaceholder } from './NoTracksPlaceholder' import { OfflineContentBanner } from './OfflineContentBanner' import { useLibraryCollections } from './useLibraryCollections' +import { useLibraryTabChrome } from './useLibraryTabChrome' const { getIsReachable } = reachabilitySelectors const { getCategory } = libraryPageSelectors @@ -44,6 +45,7 @@ const useStyles = makeStyles(() => ({ export const PlaylistsTab = () => { const styles = useStyles() + const { headerInset, onScroll } = useLibraryTabChrome() const [filterValue, setFilterValue] = useState('') const [debouncedFilterValue, setDebouncedFilterValue] = useState('') @@ -92,24 +94,33 @@ export const PlaylistsTab = () => { return ( {noItemsLoaded ? ( - !isReachable ? ( - - ) : ( - - ) + + {!isReachable ? ( + + ) : ( + + )} + ) : ( <> - - { - setFilterValue(text) - handleChangeFilterValue(text) - }} - /> + + { + setFilterValue(text) + handleChangeFilterValue(text) + }} + /> + + } + contentContainerStyle={{ paddingTop: headerInset }} + onScroll={onScroll} + scrollEventThrottle={16} collectionType='playlist' onEndReached={handleEndReached} onEndReachedThreshold={0.5} diff --git a/packages/mobile/src/screens/library-screen/TracksTab.tsx b/packages/mobile/src/screens/library-screen/TracksTab.tsx index ce7dd281c4e..973ec151fa4 100644 --- a/packages/mobile/src/screens/library-screen/TracksTab.tsx +++ b/packages/mobile/src/screens/library-screen/TracksTab.tsx @@ -29,6 +29,7 @@ import { makeStyles } from 'app/styles' import { LibraryTrackRows } from './LibraryTrackRows' import { NoTracksPlaceholder } from './NoTracksPlaceholder' import { OfflineContentBanner } from './OfflineContentBanner' +import { useLibraryTabChrome } from './useLibraryTabChrome' const { getSelectedCategoryLocalTrackAdds, getCategory } = libraryPageSelectors const { getIsReachable } = reachabilitySelectors @@ -52,7 +53,6 @@ const useStyles = makeStyles(({ palette, spacing }) => ({ }, rowsContainer: { flex: 1, - marginTop: spacing(3), marginBottom: spacing(4), marginHorizontal: spacing(3), borderWidth: 1, @@ -96,6 +96,7 @@ function useTracksWithUsers(trackIds: ID[]) { export const TracksTab = () => { const dispatch = useDispatch() const styles = useStyles() + const { headerInset, onScroll } = useLibraryTabChrome() const isReachable = useSelector(getIsReachable) const { isEnabled: isTrackRowsEnabled } = useFeatureFlag( FeatureFlags.TRACK_ROWS @@ -283,27 +284,50 @@ export const TracksTab = () => { const shouldShowFilterInput = trackUids.length > 0 || filterValue || (isPending && saveCount > 0) + // Banner and filter ride in the list header so they scroll away with the + // page header. + const listHeader = ( + <> + + {shouldShowFilterInput ? ( + + ) : null} + + ) + const listChromeProps = { + ListHeaderComponent: listHeader, + contentContainerStyle: { paddingTop: headerInset }, + onScroll, + scrollEventThrottle: 16 + } + const renderBody = () => { - if (filteredTrackUids.length === 0 && !isPending) { - if (!isReachable) { - return ( + const isEmpty = filteredTrackUids.length === 0 && !isPending + // While filtering, keep the list (and its filter input) mounted with an + // empty state inside it, so the input doesn't lose focus mid-typing. + if (isEmpty && !filterValue) { + return ( + + {listHeader} - + {isReachable ? ( + + ) : ( + + )} - ) - } - return ( - - ) } - if (!showTrackSkeletonList && filteredTrackUids.length === 0) { - return null - } + const listEmpty = isEmpty ? ( + + + + ) : null if (isTrackRowsEnabled) { return ( @@ -320,6 +344,8 @@ export const TracksTab = () => { keyboardShouldPersistTaps='handled' togglePlay={togglePlay} uids={showTrackSkeletonList ? undefined : filteredTrackUids} + ListEmptyComponent={listEmpty} + {...listChromeProps} /> ) @@ -340,21 +366,12 @@ export const TracksTab = () => { togglePlay={togglePlay} trackItemAction='overflow' uids={showTrackSkeletonList ? undefined : filteredTrackUids} + ListEmptyComponent={listEmpty} + {...listChromeProps} /> ) } - return ( - - - {shouldShowFilterInput ? ( - - ) : null} - {renderBody()} - - ) + return {renderBody()} } diff --git a/packages/mobile/src/screens/library-screen/useLibraryTabChrome.ts b/packages/mobile/src/screens/library-screen/useLibraryTabChrome.ts new file mode 100644 index 00000000000..e34252d02b0 --- /dev/null +++ b/packages/mobile/src/screens/library-screen/useLibraryTabChrome.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useRef } from 'react' + +import { useIsFocused } from '@react-navigation/native' +import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native' + +import { + useGlassHeaderInset, + useGlassScrollHandler, + useSyncGlassScroll +} from 'app/screens/app-screen/GlassChromeContext' + +/** + * Hooks a Library tab's list up to the scroll-away header. Each tab keeps its + * own scroll position, so the focused tab drives the chrome and the header + * jumps to match on tab switch. + * + * Returns the top inset the list's content needs to start below the header + * and the `onScroll` handler to attach (with `scrollEventThrottle={16}`). + */ +export const useLibraryTabChrome = () => { + const headerInset = useGlassHeaderInset() + const handleGlassScroll = useGlassScrollHandler() + const syncGlassScroll = useSyncGlassScroll() + const isFocused = useIsFocused() + const isFocusedRef = useRef(isFocused) + isFocusedRef.current = isFocused + const offsetRef = useRef(0) + + useEffect(() => { + if (isFocused) syncGlassScroll(offsetRef.current) + }, [isFocused, syncGlassScroll]) + + const onScroll = useCallback( + (event: NativeSyntheticEvent) => { + offsetRef.current = event.nativeEvent.contentOffset.y + if (isFocusedRef.current) handleGlassScroll(event) + }, + [handleGlassScroll] + ) + + return { headerInset, onScroll } +} diff --git a/packages/mobile/src/screens/trending-screen/TrendingFilterButton.tsx b/packages/mobile/src/screens/trending-screen/TrendingFilterButton.tsx index 885b27a6e93..d4cf274aade 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingFilterButton.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingFilterButton.tsx @@ -7,7 +7,13 @@ import { Flex, IconLeading, SelectablePill } from '@audius/harmony-native' import { TRENDING_FILTER_MODAL } from './TrendingCombinedFilterDrawer' -export const TrendingFilterButton = () => { +type TrendingFilterButtonProps = { + size?: 'small' | 'large' +} + +export const TrendingFilterButton = ({ + size = 'large' +}: TrendingFilterButtonProps) => { const dispatch = useDispatch() const timeRange = useSelector(trendingPageSelectors.getTrendingTimeRange) const genre = useSelector(trendingPageSelectors.getTrendingGenre) @@ -30,7 +36,7 @@ export const TrendingFilterButton = () => { + /** `small` for the compact copy under the collapsed header pill. */ + size?: 'small' | 'large' + /** Extra controls after the category pills. */ + children?: ReactNode } const useStyles = makeStyles(({ palette, spacing, typography }) => ({ - // No backgroundColor: inside `FloatingSubHeader` the glass pill bar owns - // the fill. + // No backgroundColor: `FloatingSubHeader` owns the fill. root: { borderBottomWidth: 1, borderBottomColor: palette.neutralLight8, @@ -89,7 +92,9 @@ export const TrendingHeader = (props: TrendingHeaderProps) => { icon: Icon, filterModal, showTitleRow = true, - styles: stylesProp + styles: stylesProp, + size = 'large', + children } = props const styles = useStyles() const dispatch = useDispatch() @@ -148,14 +153,14 @@ export const TrendingHeader = (props: TrendingHeaderProps) => { {categories.map((cat) => ( { disableUnselectAnimation /> ))} + {children} ) diff --git a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx index 6ed4518e381..ac6a00178d9 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingScreen.tsx @@ -56,7 +56,7 @@ export const TrendingScreen = () => { // re-firing the profile-picture image-fetch path. const renderHeader = useCallback( () => ( - + {category === 'tracks' ? : null} ), @@ -79,8 +79,21 @@ export const TrendingScreen = () => { return ( - {/* Pins under the status bar once the title scrolls away. */} - + + {category === 'tracks' ? ( + + ) : null} + + } + > {trendingPills}