React Native中自定义TabBar

前言

目前大部分应用的界面设计底部都有Tabbar,很多应用也会在底部tabbar整一些花活,也就是说我们在编写tabbar时需要更多的自定义空间,比如我的音乐播放器应用中,在tabbar的上面有一个简易播放器控制栏,包含正在播放的歌曲封面,歌曲名称和播放,下一曲,当前播放列表按钮,起初没有使用自定义tabbar方案,就只能将简易播放器控制栏以定位的方式定到tabbar上方,这时具体定位的位置值就需要动态计算,非常容易出现不同机型显示偏差,比如我的手机上和tabbar没有间隙,但是在模拟器上有间隙,很明显,如果它融入tabbar布局的话就不会出现这个问题:

基础方案

当前项目使用的是expo创建的rn项目,因此使用了expo-router,它提供了基础的底部tab 配置组件,如果没有特殊要求,使用非常方便:

tsx 复制代码
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import type { FC } from 'react';
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { Platform } from 'react-native';
import { useThemeConfig, useTheme } from '@/hooks/useTheme';
import type { IoniconsNameType } from '@/types';
/**
 * 底部导航栏组件
 * @returns 底部导航栏UI
 */
const list: { name: string; icon: IoniconsNameType; label: string }[] = [
    {
        name: 'index',
        icon: 'home-outline',
        label: '首页',
    },
    {
        name: 'audiobook',
        icon: 'book-outline',
        label: '听书',
    },
    {
        name: 'local',
        icon: 'archive-outline',
        label: '音乐',
    },
    {
        name: 'profile',
        icon: 'person-outline',
        label: '我的',
    },
];
const { Text, View } = Animated;
/**
 * Screen组件的options参数类型是BottomTabNavigationOptions
 * @returns 
 */
const AndroidTabs: FC = () => {
    const { Screen } = Tabs;
    const { backgroundColor } = useThemeConfig();
    const theme = useTheme();
    return (<Tabs
        screenOptions={{
            headerShown: false,
            // tabBarBackground: TabBarBackground,
            tabBarStyle: Platform.select({
                ios: {
                    // Use a transparent background on iOS to show the blur effect
                    position: 'absolute',
                },
                default: {
                    height: 90,
                    backgroundColor,
                },
            }),
        }}
    >
        {
            list.map(({ name, icon, label }) => (<Screen
                key={name}
                name={name}
                options={{
                    // title: '234',
                    // tabBarBadge: '9',
                    // tabBarShowLabel: false,
                    /**
                     * tabrBarIcon: ({ focused, color }) => ()
                     * 即使返回null仍然会占据icon的位置且没有配置项可以隐藏
                     * 但是可以设置tabBarIconStyle的height为0来隐藏icon
                     * 而且内部返回的icon无法应用Animation动画,因此将其高度设置为0
                     * 将图标渲染到tabBarLabel中,单独为其添加animation动画
                     */
                    // tabBarIcon({ focused, color }) {
                    //     return null;
                    // },
                    tabBarIconStyle: { display: 'none' },
                    /**item的样式 */
                    // tabBarItemStyle: { borderWidth: 1, borderColor: '#e44444' },
                    tabBarLabel({ focused }) {
                        const color = focused ? 'orange' : 'grey';
                        const aniStyle = useAnimatedStyle(() => ({
                            transform: [{ scale: withSpring(focused ? 1.1 : 1, { damping: 20, stiffness: 400 }) }],
                        }));
                        return <>
                            <View style={aniStyle}>
                                <Ionicons
                                    name={icon}
                                    size={19}
                                    color={color}
                                />
                            </View>
                            <Text style={{ color, fontSize: 10, marginTop: 3 }}>{label}</Text>
                        </>;
                    },
                    /**
                     * 可以为tab页面切换添加动画效果
                     * fade shift none
                     * 有些页面切换时元素会有闪烁问题
                     * 可以在tab页面监听useFocusEffect来添加动画效果
                     */
                    // animation: 'fade',
                    /**
                     * 用于配置该页面所在场景的样式,默认白色,如果页面进入时
                     * 有缩放,就会看到该背景,添加背景色设置
                     */
                    sceneStyle: { backgroundColor: name === 'profile' ? theme === 'dark' ? '#000' : '#f5f5f5' : backgroundColor }
                }}
            />))
        }
    </Tabs>);
};

export default AndroidTabs;

以上定义了基础的tabbar并且添加了点击tabbar时的动画效果,我们在map中创建动画样式,非常不灵活,而且它不允许在tab的布局中添加除了tabbar之外的额外元素,会导致显示失效,而我们的目标效果就是底部tabbar上方再添加简易播放器控制栏

自定义方案

接下来我们使用expo-router的自定义方案Tabs, TabList, TabTrigger, TabSlot组件来创建tabbar和tabbar页面布局:

tsx 复制代码
import type { FC } from 'react';
import Animated, { useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { Pressable, StyleSheet, View, Text } from 'react-native';
import { Tabs, TabList, TabTrigger, TabSlot, type TabTriggerSlotProps } from 'expo-router/ui';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import type { IoniconsNameType } from '@/types';
import MusicPlayerBar from '@/components/music/musicPro/MusicPlayerBar';
import BlurContainer from '@/components/ui/BlurContainer';
interface TabButtonProps extends TabTriggerSlotProps {
    icon?: IoniconsNameType;
    name: string
};
const list: { name: string; icon: IoniconsNameType; label: string, href: string }[] = [
    {
        name: 'index',
        href: '/',
        icon: 'home',
        label: '首页',
    },
    {
        name: 'audiobook',
        href: '/audiobook',
        icon: 'book',
        label: '听书',
    },
    {
        name: 'local',
        href: '/local',
        icon: 'archive',
        label: '音乐',
    },
    {
        name: 'profile',
        href: '/profile',
        icon: 'person',
        label: '我的',
    },
];
const { View: AnimatedView } = Animated;
/**
 * 当TabTrigger设置了asChild时将会向下转发所有属性
 * 然后可以按需定义自己的tab选项,在Press中如果想要触发额外事件处理程序
 * 注意额外调用TabTrigger传递的onPress事件以及参数,保证tab切换触发
 * @returns 
 */
const TabItem: FC<TabButtonProps> = ({ icon, children, name, isFocused, ...props }) => {
    const color = isFocused ? 'orange' : 'grey';
    const aniStyle = useAnimatedStyle(() => ({
        transform: [{ scale: withSpring(isFocused ? 1.2 : 1, { damping: 20, stiffness: 400 }) }],
    }));
    return (<Pressable
        {...props}
        style={styles.tabItem}
    >
        <AnimatedView
            style={aniStyle}
        >
            <Ionicons
                name={icon}
                size={19}
                color={color}
            />
        </AnimatedView>
        <Text
            style={{ color, fontSize: 10 }}
        >{children}</Text>
    </Pressable>)
};
/**
 * Android底部标签栏自定义组件
 * 将TabList设置display: 'none',并渲染TabTrigger
 * 组件是自定义tab的最佳实践,而不是设置TabList的asChild
 * 然后在TabList内部使用自定义组件,这在使用半透明图片作为背景时无法生效
 * @returns 
 */
const AndroidTabs: FC = () => {
    const { bottom } = useSafeAreaInsets();
    return (<Tabs>
        <TabSlot />
        <BlurContainer
            style={[styles.tabbar, { height: 90 + bottom }]}
        >
            <MusicPlayerBar
                showBlurView={false}
            />
            <View
                style={[styles.tabs, { height: bottom + 50 }]}
            >
                {
                    list.map(({ name, icon, label, href }) => (<TabTrigger
                        key={name}
                        name={name}
                        href={href as any}
                        asChild
                    >
                        <TabItem
                            name={name}
                            icon={icon}
                        >{label}</TabItem>
                    </TabTrigger>))
                }
            </View>
        </BlurContainer>
        <TabList
            style={styles.list}
        >
            {
                list.map(({ name, href }) => (<TabTrigger
                    key={name}
                    name={name}
                    href={href as any}
                />))
            }
        </TabList>
    </Tabs >);
};

const styles = StyleSheet.create({
    tabs: {
        flexDirection: 'row',
        justifyContent: 'space-around',
        alignItems: 'flex-start',
    },
    tabItem: {
        gap: 5,
        width: 50,
        height: 50,
        justifyContent: 'center',
        alignItems: 'center',
    },
    list: {
        display: 'none'
    },
    tabbar: {
        position: 'absolute',
        bottom: 0,
        width: '100%',
    },
});
export default AndroidTabs;

, 这次我们使用Tabs作为外层容器,它包含了tabbar和tabbar对应的页面,而页面则是在TabSlot中展示,tab的具体内容则是使用TabList和TabItem进行渲染,这时你可以创建一个容器包裹你要渲染的任意内容,最终它将渲染到应用底部tab上。如果你仔细看,应该发现底部的TabList并没有展示,这是因为将其隐藏但是需要它触发页面导航,否则它的样式本金无法灵活的自定义,这里我们使用了BlurView模糊化效果,而上面map循环的是我们真正看到的底部tabbar! 也就是说TabTrigger组件是用来触发导航的,TabItem是我们真正要自定义的tab内容,此时MusicPlayerBar可以优雅地放入tabbar的范围,不必考虑定位值的问题了!

当然除此之外也可以隐藏tabbar再自定义tabbar,但是它不会像当前方案,保留缓存tabbar页面,而且需要自己手动控制跳转。我的场景中想要解决tab的额外内容展示问题。具体选择哪一种看业务需求。具体可以查看 [项目地址](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放) 欢迎star交流学习

相关推荐
bluceli1 小时前
CSS自定义属性与主题切换:构建动态UI的终极方案
前端·css
默默学前端1 小时前
HTML 高频面试题 5 道|吃透基础,面试不慌(附详细解析)
前端·面试·职场和发展·html5
豆芽包1 小时前
前端性能优化-图片懒加载技术
前端·面试
bluceli1 小时前
JavaScript WeakMap与WeakSet:内存优化的秘密武器
前端·javascript
陆枫Larry2 小时前
折叠屏“窗口化”下的全屏背景图错位:一次小程序适配的排障思路与最小改动修复
前端
陆枫Larry2 小时前
Art Direction(艺术导向适配)
前端
Lee川2 小时前
从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战
前端·vue.js
与虾牵手2 小时前
Next.js 14 App Router 踩坑实录:5 个让我加班到凌晨的坑 🕳️
前端·javascript·面试
猩球中的木子2 小时前
怎么集成安装VitePlus(Vite+)并使用
前端·vite·前端工程化