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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions packages/mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,34 @@ PODS:
- hermes-engine/Pre-built (0.81.6)
- JWT (3.0.0-beta.14):
- Base64 (~> 1.1.2)
- LiquidGlass (0.8.2):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- lottie-ios (4.5.0)
- lottie-react-native (7.3.4):
- boost
Expand Down Expand Up @@ -3559,6 +3587,7 @@ DEPENDENCIES:
- glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`)
- GoogleUtilities
- hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- "LiquidGlass (from `../../../node_modules/@callstack/liquid-glass`)"
- lottie-react-native (from `../node_modules/lottie-react-native`)
- nanopb
- nSure
Expand Down Expand Up @@ -3721,6 +3750,8 @@ EXTERNAL SOURCES:
hermes-engine:
:podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782
LiquidGlass:
:path: "../../../node_modules/@callstack/liquid-glass"
lottie-react-native:
:path: "../node_modules/lottie-react-native"
RCT-Folly:
Expand Down Expand Up @@ -3958,6 +3989,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749
JWT: ef71dfb03e1f842081e64dc42eef0e164f35d251
LiquidGlass: 2a04e029574165bc7a4a8f5c4bed53386cec8e1b
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
lottie-react-native: d849be4292d467c0e13fd4ca5042bb352b7d1a61
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
Expand Down
1 change: 1 addition & 0 deletions packages/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@audius/harmony": "*",
"@audius/sdk": "*",
"@bravemobile/react-native-code-push": "^12.3.2",
"@callstack/liquid-glass": "0.8.2",
"@coinflowlabs/react-native": "4.5.2",
"@dr.pogodin/react-native-fs": "2.32.1",
"@ebay/nice-modal-react": "^1.2.13",
Expand Down
167 changes: 151 additions & 16 deletions packages/mobile/src/components/bottom-tab-bar/BottomTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import { useCallback } from 'react'
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'

import type {
BottomTabNavigationEventMap,
BottomTabBarProps as RNBottomTabBarProps
} from '@react-navigation/bottom-tabs'
import type { NavigationHelpers, ParamListBase } from '@react-navigation/native'
import { Animated } from 'react-native'
import type { LayoutChangeEvent } from 'react-native'
import { Animated, StyleSheet, View } from 'react-native'
import Reanimated, {
useAnimatedStyle,
useSharedValue,
withSpring
} from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { Flex } from '@audius/harmony-native'
import { GlassSurface } from 'app/components/core/Screen/GlassSurface'
import { LiquidGlassView } from 'app/components/core/Screen/LiquidGlass'
import { FULL_DRAWER_HEIGHT } from 'app/components/drawer'
import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer'
import { isDarkTheme, useThemeVariant } from 'app/utils/theme'

import { ExploreButton } from './bottom-tab-bar-buttons/ExploreButton'
import { FeedButton } from './bottom-tab-bar-buttons/FeedButton'
import { LibraryButton } from './bottom-tab-bar-buttons/LibraryButton'
import { NotificationsButton } from './bottom-tab-bar-buttons/NotificationsButton'
import { TrendingButton } from './bottom-tab-bar-buttons/TrendingButton'
import { BOTTOM_BAR_HEIGHT } from './constants'
import {
BOTTOM_BAR_HEIGHT,
FLOATING_BAR_HEIGHT,
FLOATING_BAR_INDICATOR_INSET,
FLOATING_BAR_SIDE_MARGIN
} from './constants'
import { useFloatingTabBar } from './useFloatingTabBar'

export const bottomTabBarButtons = {
feed: FeedButton,
Expand Down Expand Up @@ -70,6 +85,7 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
const { translationAnim, navigation, state } = props
const { routes, index: activeIndex } = state
const insets = useSafeAreaInsets()
const floating = useFloatingTabBar()

const handlePress = useCallback(
(isFocused: boolean, routeName: string, routeKey: string) => {
Expand Down Expand Up @@ -97,6 +113,42 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
})
}, [navigation])

const buttons = routes.map(({ name, key }, index) => {
const BottomTabBarButton = bottomTabBarButtons[name]

return (
<BottomTabBarButton
key={key}
routeKey={key}
isActive={index === activeIndex}
onPress={handlePress}
onLongPress={handleLongPress}
floating={!!floating}
/>
)
})

if (floating) {
return (
<Animated.View
pointerEvents='box-none'
style={[
styles.floatingRoot,
{ height: BOTTOM_BAR_HEIGHT + insets.bottom },
interpolatePostion(translationAnim, insets.bottom)
]}
>
<FloatingPill
bottom={floating.pillBottom}
activeIndex={activeIndex}
tabCount={routes.length}
>
{buttons}
</FloatingPill>
</Animated.View>
)
}

return (
<Animated.View
style={[
Expand All @@ -117,21 +169,104 @@ export const BottomTabBar = (props: BottomTabBarProps) => {
justifyContent='space-evenly'
pb={insets.bottom}
>
{routes.map(({ name, key }, index) => {
const BottomTabBarButton = bottomTabBarButtons[name]

return (
<BottomTabBarButton
key={key}
routeKey={key}
isActive={index === activeIndex}
onPress={handlePress}
onLongPress={handleLongPress}
/>
)
})}
{buttons}
</Flex>
</GlassSurface>
</Animated.View>
)
}

type FloatingPillProps = {
bottom: number
activeIndex: number
tabCount: number
children: ReactNode
}

/**
* iOS 26 style tab bar: a capsule of liquid glass floating over the content,
* with a highlight that springs to the active tab.
*/
const FloatingPill = (props: FloatingPillProps) => {
const { bottom, activeIndex, tabCount, children } = props
const isDarkMode = isDarkTheme(useThemeVariant())
const pillWidth = useSharedValue(0)
const index = useSharedValue(activeIndex)

useEffect(() => {
index.value = withSpring(activeIndex, {
damping: 18,
stiffness: 220,
mass: 0.8
})
}, [activeIndex, index])

const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
pillWidth.value = e.nativeEvent.layout.width
},
[pillWidth]
)

const indicatorStyle = useAnimatedStyle(() => {
const tabWidth = pillWidth.value / tabCount
return {
opacity: pillWidth.value > 0 ? 1 : 0,
width: tabWidth - FLOATING_BAR_INDICATOR_INSET * 2,
transform: [
{
translateX: index.value * tabWidth + FLOATING_BAR_INDICATOR_INSET
}
]
}
})

return (
<LiquidGlassView
effect='regular'
interactive
colorScheme={isDarkMode ? 'dark' : 'light'}
onLayout={handleLayout}
style={[styles.pill, { bottom }]}
>
<Reanimated.View
pointerEvents='none'
style={[
styles.indicator,
{
backgroundColor: isDarkMode
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.06)'
},
indicatorStyle
]}
/>
<View style={styles.pillRow}>{children}</View>
</LiquidGlassView>
)
}

const styles = StyleSheet.create({
floatingRoot: {
zIndex: 4,
elevation: 4
},
pill: {
position: 'absolute',
left: FLOATING_BAR_SIDE_MARGIN,
right: FLOATING_BAR_SIDE_MARGIN,
height: FLOATING_BAR_HEIGHT,
borderRadius: FLOATING_BAR_HEIGHT / 2
},
pillRow: {
flex: 1,
flexDirection: 'row'
},
indicator: {
position: 'absolute',
left: 0,
top: FLOATING_BAR_INDICATOR_INSET,
bottom: FLOATING_BAR_INDICATOR_INSET,
borderRadius: FLOATING_BAR_HEIGHT / 2
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type BaseBottomTabBarButtonProps = {
onPress: (isActive: boolean, routeName: string, routeKey: string) => void
onLongPress: () => void
routeKey: string
/** Laid out inside the floating glass pill rather than the classic bar. */
floating?: boolean
}

export type BottomTabBarButtonProps = BaseBottomTabBarButtonProps & {
Expand All @@ -31,6 +33,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingTop: BOTTOM_BAR_TOP_PADDING
},
floatingRoot: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
iconWrapper: {
width: 28,
height: BOTTOM_BAR_BUTTON_HEIGHT
Expand All @@ -49,6 +56,7 @@ export const BottomTabBarButton = (props: BottomTabBarButtonProps) => {
isActive,
onPress,
onLongPress,
floating,
children,
colorKeypaths: keypaths,
...lottieProps
Expand Down Expand Up @@ -101,12 +109,13 @@ export const BottomTabBarButton = (props: BottomTabBarButtonProps) => {
onPress={handlePress}
onLongPress={handleLongPress}
pointerEvents='box-only'
style={styles.root}
style={floating ? styles.floatingRoot : styles.root}
>
{({ pressed }) => {
return (
<>
{pressed ? (
{/* The glass gives its own press feedback when floating. */}
{pressed && !floating ? (
<LinearGradient
style={styles.underlay}
colors={[color.neutral.n100, color.neutral.n25]}
Expand Down
11 changes: 11 additions & 0 deletions packages/mobile/src/components/bottom-tab-bar/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,14 @@ export const BOTTOM_BAR_HEIGHT = 41
// see: https://github.com/facebook/react-native/issues/23994
export const BOTTOM_BAR_BUTTON_HEIGHT = 32
export const BOTTOM_BAR_TOP_PADDING = 8

// Floating glass pill (iOS 26). It takes up the same space as the classic bar
// (BOTTOM_BAR_HEIGHT plus the bottom inset), so screens, BottomChin and the
// now-playing drawer don't need to know which one is showing. The pill drops
// into the home indicator area to make room.
export const FLOATING_BAR_HEIGHT = 54
export const FLOATING_BAR_SIDE_MARGIN = 16
// Gap above the pill, between it and the play bar pill when a track is loaded.
export const FLOATING_BAR_TOP_GAP = 6
// Space between the edge of the pill and the highlight behind the active tab.
export const FLOATING_BAR_INDICATOR_INSET = 4
1 change: 1 addition & 0 deletions packages/mobile/src/components/bottom-tab-bar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export type { BottomTabBarProps } from './BottomTabBar'
export { BottomTabBar } from './BottomTabBar'

export * from './constants'
export { useFloatingTabBar } from './useFloatingTabBar'
25 changes: 25 additions & 0 deletions packages/mobile/src/components/bottom-tab-bar/useFloatingTabBar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { isLiquidGlassAvailable } from 'app/components/core/Screen/LiquidGlass'

import {
BOTTOM_BAR_HEIGHT,
FLOATING_BAR_HEIGHT,
FLOATING_BAR_TOP_GAP
} from './constants'

/**
* Where the floating pill sits, or null when the classic full-width bar should
* render instead.
*
* The pill only floats on devices with a home indicator. Home-button devices
* have no bottom inset to sink it into, so it would need more room than
* BOTTOM_BAR_HEIGHT gives it.
*/
export const useFloatingTabBar = () => {
const { bottom } = useSafeAreaInsets()
const pillBottom =
BOTTOM_BAR_HEIGHT + bottom - FLOATING_BAR_HEIGHT - FLOATING_BAR_TOP_GAP
if (!isLiquidGlassAvailable || pillBottom < 8) return null
return { pillBottom }
}
Loading
Loading