"面包屑"这个名字来自童话故事《汉塞尔与格莱特》。两个小孩在森林里撒面包屑标记来时的路,好找到回家的方向。
在 UI 设计里,面包屑导航做的是同样的事:告诉用户"你是怎么走到这里的",以及"怎么回去"。
首页 > 商品分类 > 手机数码 > iPhone 15
看到这行字,你立刻知道自己在哪个页面,也知道点击前面的任何一项就能回到那个层级。
今天来看看这个组件怎么实现。
源码
文件位置 src/components/ui/Breadcrumb.tsx:
tsx
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { UITheme, ColorType } from './theme';
interface BreadcrumbItem {
label: string;
onPress?: () => void;
icon?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
separator?: string;
color?: ColorType;
maxItems?: number;
style?: ViewStyle;
}
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
items,
separator = '/',
color = 'primary',
maxItems,
style,
}) => {
const colorValue = UITheme.colors[color];
let displayItems = items;
if (maxItems && items.length > maxItems) {
const first = items.slice(0, 1);
const last = items.slice(-maxItems + 2);
displayItems = [...first, { label: '...' }, ...last];
}
return (
<View style={[styles.container, style]}>
{displayItems.map((item, index) => {
const isLast = index === displayItems.length - 1;
const isClickable = item.onPress && item.label !== '...';
return (
<View key={index} style={styles.itemContainer}>
{isClickable ? (
<TouchableOpacity onPress={item.onPress} style={styles.item}>
{item.icon && <Text style={styles.icon}>{item.icon}</Text>}
<Text style={[styles.label, { color: colorValue }]}>{item.label}</Text>
</TouchableOpacity>
) : (
<View style={styles.item}>
{item.icon && <Text style={styles.icon}>{item.icon}</Text>}
<Text style={[styles.label, isLast && styles.currentLabel]}>{item.label}</Text>
</View>
)}
{!isLast && <Text style={styles.separator}>{separator}</Text>}
</View>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' },
itemContainer: { flexDirection: 'row', alignItems: 'center' },
item: { flexDirection: 'row', alignItems: 'center' },
icon: { fontSize: 14, marginRight: 4 },
label: { fontSize: UITheme.fontSize.sm, color: UITheme.colors.gray[500] },
currentLabel: { color: UITheme.colors.gray[800], fontWeight: '500' },
separator: {
fontSize: UITheme.fontSize.sm,
color: UITheme.colors.gray[400],
marginHorizontal: UITheme.spacing.sm,
},
});
70 行代码,功能不复杂但细节不少。
单个面包屑项长什么样
tsx
interface BreadcrumbItem {
label: string;
onPress?: () => void;
icon?: string;
}
三个字段,很简洁。
label 是显示的文字,比如"首页"、"商品分类"。这是必填的,面包屑总得有文字吧。
onPress 是点击回调。有这个属性的项是可点击的,通常用来跳转到对应页面。最后一项一般不传 onPress,因为那就是当前页面,点了也没意义。
icon 是可选的图标。首页项经常会加个小房子图标,让用户更容易识别。
组件接收哪些参数
tsx
interface BreadcrumbProps {
items: BreadcrumbItem[];
separator?: string;
color?: ColorType;
maxItems?: number;
style?: ViewStyle;
}
items 是面包屑项的数组,按层级顺序排列。第一项是最顶层(通常是首页),最后一项是当前页面。
separator 是分隔符,默认是斜杠 /。你也可以换成 >、→、• 或者任何你喜欢的字符。
color 是可点击项的颜色。默认用主题色,让用户知道这些是可以点的。
maxItems 是最多显示几项。路径太长时,中间的会被折叠成 ...。这个后面详细讲。
默认值和颜色
tsx
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
items,
separator = '/',
color = 'primary',
maxItems,
style,
}) => {
const colorValue = UITheme.colors[color];
separator = '/' 斜杠是最常见的分隔符,Web 上的面包屑基本都用这个。
color = 'primary' 可点击的项用主题色,和普通文字区分开。
colorValue 从主题里取出具体的颜色值,后面渲染时用。
路径折叠逻辑
tsx
let displayItems = items;
if (maxItems && items.length > maxItems) {
const first = items.slice(0, 1);
const last = items.slice(-maxItems + 2);
displayItems = [...first, { label: '...' }, ...last];
}
这段代码处理路径过长的情况。
假设 maxItems = 4,items 有 6 项:首页、一级、二级、三级、四级、当前页。
直接显示 6 项太长了,需要折叠。折叠的规则是:保留第一项,保留最后几项,中间用 ... 代替。
items.slice(0, 1) 取第一项,就是"首页"。
items.slice(-maxItems + 2) 这个有点绕。-maxItems + 2 就是 -4 + 2 = -2,slice(-2) 取最后两项,就是"四级"和"当前页"。
为什么是 maxItems + 2?因为要给第一项和 ... 留位置。maxItems 是 4,减去首页占 1 个,减去 ... 占 1 个,剩下 2 个位置给最后的项。
最后拼起来:[首页, ..., 四级, 当前页],正好 4 项。
如果 items 长度不超过 maxItems,就不折叠,直接用原数组。
渲染容器
tsx
return (
<View style={[styles.container, style]}>
{displayItems.map((item, index) => {
外层容器用了 flexDirection: 'row' 让子元素水平排列,flexWrap: 'wrap' 允许换行。
为什么要允许换行?因为面包屑可能很长,在窄屏幕上一行放不下。允许换行比横向滚动更友好。
遍历 displayItems 而不是 items,因为可能经过了折叠处理。
判断每一项的状态
tsx
const isLast = index === displayItems.length - 1;
const isClickable = item.onPress && item.label !== '...';
isLast 判断是不是最后一项。最后一项是当前页面,样式和行为都不一样。
isClickable 判断是否可点击。两个条件都要满足:有 onPress 回调,而且不是 ...。
为什么 ... 不能点击?因为它只是个占位符,表示"这里省略了一些层级",点它没有意义。
可点击项的渲染
tsx
{isClickable ? (
<TouchableOpacity onPress={item.onPress} style={styles.item}>
{item.icon && <Text style={styles.icon}>{item.icon}</Text>}
<Text style={[styles.label, { color: colorValue }]}>{item.label}</Text>
</TouchableOpacity>
)
可点击的项用 TouchableOpacity 包裹,点击时触发 onPress。
图标用条件渲染,有就显示。
文字颜色用 colorValue,就是主题色,让用户知道这是可以点的链接。
不可点击项的渲染
tsx
: (
<View style={styles.item}>
{item.icon && <Text style={styles.icon}>{item.icon}</Text>}
<Text style={[styles.label, isLast && styles.currentLabel]}>{item.label}</Text>
</View>
)}
不可点击的项用普通 View,没有触摸反馈。
文字样式有个条件:如果是最后一项(当前页面),用 currentLabel 样式,颜色更深、字体更粗。这样当前页面在视觉上更突出。
如果是 ...,既不是最后一项也不可点击,就用默认的灰色样式。
分隔符渲染
tsx
{!isLast && <Text style={styles.separator}>{separator}</Text>}
除了最后一项,每项后面都加分隔符。
separator 是从 props 传进来的,默认是 /。
分隔符用浅灰色,不会太抢眼,只是起到分隔作用。
样式定义
tsx
const styles = StyleSheet.create({
container: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap' },
容器是水平 flex 布局,垂直居中,允许换行。
tsx
itemContainer: { flexDirection: 'row', alignItems: 'center' },
item: { flexDirection: 'row', alignItems: 'center' },
每个面包屑项也是水平布局,因为图标和文字要并排显示。
itemContainer 包含了面包屑项和分隔符,item 只包含图标和文字。两层嵌套是为了让分隔符和面包屑项在同一行。
tsx
icon: { fontSize: 14, marginRight: 4 },
图标小一点,和文字之间有 4px 间距。
tsx
label: { fontSize: UITheme.fontSize.sm, color: UITheme.colors.gray[500] },
currentLabel: { color: UITheme.colors.gray[800], fontWeight: '500' },
普通文字用小字号和浅灰色。当前页面的文字用深色和中等粗细,更醒目。
tsx
separator: {
fontSize: UITheme.fontSize.sm,
color: UITheme.colors.gray[400],
marginHorizontal: UITheme.spacing.sm,
},
});
分隔符用更浅的灰色,左右有间距,和面包屑项分开。
Demo 里的用法
看看 src/screens/demos/BreadcrumbDemo.tsx。
基础用法
tsx
<Breadcrumb
items={[
{ label: '首页', onPress: () => {} },
{ label: '商品列表', onPress: () => {} },
{ label: '商品详情' },
]}
/>
三级路径。前两项有 onPress 可以点击,最后一项是当前页面不能点。
渲染出来是:首页 / 商品列表 / 商品详情
首页和商品列表是主题色,商品详情是深灰色加粗。
自定义分隔符
tsx
<Breadcrumb items={items} separator=">" />
<Breadcrumb items={items} separator="→" />
<Breadcrumb items={items} separator="•" />
三种不同的分隔符风格。> 比较常见,→ 更有方向感,• 比较简洁。
选哪个看设计稿,或者看个人喜好。
带图标
tsx
<Breadcrumb
items={[
{ label: '首页', icon: '🏠', onPress: () => {} },
{ label: '用户', icon: '👤', onPress: () => {} },
{ label: '设置', icon: '⚙️' },
]}
/>
每项都有图标,视觉上更丰富。首页用房子图标是约定俗成的。
不同颜色
tsx
<Breadcrumb items={items} color="success" />
<Breadcrumb items={items} color="danger" />
可点击项用不同颜色。一般用主题色就行,特殊场景可以换。
折叠显示
tsx
<Breadcrumb
items={[
{ label: '首页', onPress: () => {} },
{ label: '一级分类', onPress: () => {} },
{ label: '二级分类', onPress: () => {} },
{ label: '三级分类', onPress: () => {} },
{ label: '商品详情' },
]}
maxItems={4}
/>
5 项路径,设置 maxItems={4},中间会被折叠。
渲染出来是:首页 / ... / 三级分类 / 商品详情
实际场景
电商商品页
tsx
const ProductPage = ({ product, category }) => {
const breadcrumbItems = [
{ label: '首页', icon: '🏠', onPress: () => navigate('Home') },
{ label: category.parent.name, onPress: () => navigate('Category', { id: category.parent.id }) },
{ label: category.name, onPress: () => navigate('Category', { id: category.id }) },
{ label: product.name },
];
return (
<View>
<Breadcrumb items={breadcrumbItems} />
<ProductDetail product={product} />
</View>
);
};
根据商品的分类信息动态生成面包屑。点击任意层级可以跳转到对应的分类页。
后台管理系统
tsx
const AdminPage = ({ route }) => {
const pathSegments = route.path.split('/').filter(Boolean);
const items = pathSegments.map((segment, index) => {
const path = '/' + pathSegments.slice(0, index + 1).join('/');
const isLast = index === pathSegments.length - 1;
return {
label: getPageTitle(segment),
onPress: isLast ? undefined : () => navigate(path),
};
});
return <Breadcrumb items={items} separator=">" />;
};
根据当前路由自动生成面包屑。这种方式不用手动维护每个页面的面包屑配置。
文件管理器
tsx
const FileExplorer = ({ currentPath }) => {
const folders = currentPath.split('/').filter(Boolean);
const items = [
{ label: '根目录', icon: '📁', onPress: () => goToPath('/') },
...folders.map((folder, index) => ({
label: folder,
icon: '📂',
onPress: index === folders.length - 1
? undefined
: () => goToPath('/' + folders.slice(0, index + 1).join('/')),
})),
];
return <Breadcrumb items={items} maxItems={5} />;
};
文件路径可能很深,用 maxItems 折叠中间的层级。
移动端的特殊考虑
面包屑在 PC 端很常见,但在移动端用得少一些。为什么?
屏幕太窄。PC 上一行能放很长的面包屑,手机上可能放不下。虽然我们支持换行,但换行后的面包屑看起来不太优雅。
有返回按钮。移动端通常有顶部导航栏和返回按钮,用户习惯用返回键一级一级往回走,不太需要面包屑的"跳级"功能。
所以移动端的面包屑通常用在这些场景:
- 层级很深,用户可能想直接跳回某一级
- 电商商品页,用户想回到分类页浏览其他商品
- 后台管理类应用,操作逻辑更接近 PC
如果你的应用层级不深,或者用户很少需要跳级,可能不需要面包屑。
小结
面包屑组件的核心功能:
- 显示当前页面的路径层级
- 可点击的项能跳转到对应层级
- 当前页面(最后一项)样式不同且不可点击
- 路径太长时可以折叠
代码实现上,主要是处理好"可点击/不可点击"和"普通项/当前项"这两组状态的区分。
面包屑虽然简单,但能显著提升用户的导航体验。特别是在层级较深的应用里,它就像一张地图,让用户随时知道自己在哪。
可以扩展的方向
这个实现已经覆盖了基本需求,如果想做得更完善,还可以考虑:
下拉菜单
点击 ... 时弹出下拉菜单,显示被折叠的层级。这样用户不用猜中间省略了什么。
tsx
// 伪代码示意
{item.label === '...' && (
<Dropdown
items={collapsedItems}
onSelect={(item) => item.onPress?.()}
/>
)}
自定义渲染
支持传入自定义的渲染函数,让使用者完全控制每一项的样式。
tsx
interface BreadcrumbProps {
renderItem?: (item: BreadcrumbItem, isLast: boolean) => React.ReactNode;
}
响应式折叠
根据容器宽度自动决定折叠多少项,而不是固定的 maxItems。这需要测量每一项的宽度,实现起来复杂一些。
动画效果
路径变化时加上过渡动画,比如新增的项从右边滑入。
这些都是锦上添花的功能,基础版本已经够用了。
和其他导航方式的配合
面包屑不是孤立存在的,它通常和其他导航元素配合使用。
和顶部导航栏
顶部导航栏显示当前页面标题和返回按钮,面包屑显示完整路径。两者功能有重叠,但面包屑能让用户跳过中间层级直接回到某一级。
和侧边栏菜单
后台系统常见的组合。侧边栏是主导航,面包屑显示当前在哪个菜单项下的哪个子页面。
和底部 Tab 栏
移动端常见的组合。Tab 栏切换主要模块,面包屑显示模块内的层级。不过这种组合比较少见,因为移动端通常层级不深。
选择哪种导航方式,要根据应用的信息架构和用户的使用习惯来决定。面包屑适合层级深、用户需要频繁跳级的场景。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
