请添加图片描述

项目开源地址:https://atomgit.com/nutpi/rn_for_openharmony_element

标签这东西,看着简单,用处可大了。文章分类、商品属性、技能标签、筛选条件……到处都能看到它。今天就来聊聊怎么封装一个好用的 Tag 组件。

标签和徽章有啥区别

有人可能会问,上一篇不是刚写了 Badge 吗,这俩不是差不多吗?

还真不一样。Badge 是用来显示数量或状态的,通常贴在别的元素上,比如图标右上角的小红点。Tag 是用来分类和标记的,是独立的元素,可以有文字、可以点击、可以删除。

举个例子,掘金文章下面的"前端"、“React”、"JavaScript"这些就是 Tag。而消息图标上的"99+"就是 Badge。

需求整理

我在项目里用标签的场景挺多的:

文章标签,显示文章属于哪个分类。这种标签只是展示,不需要交互。

筛选标签,用户选中某个标签来筛选内容。这种标签要能点击,点击后可能要变个样式表示选中。

可删除标签,比如搜索历史、已选条件。用户可以点 x 把它删掉。

带图标标签,比如"🔥 热门"、“✨ 新品”,图标让标签更醒目。

不同颜色标签,不同分类用不同颜色区分,一眼就能看出来。

接口设计

interface TagProps {
  label: string;
  color?: ColorType;
  variant?: 'solid' | 'outline' | 'soft';
  size?: SizeType;
  closable?: boolean;
  onClose?: () => void;
  onPress?: () => void;
  icon?: string;
  style?: ViewStyle;
}

先说 label,这是唯一的必填项。标签嘛,总得有个文字告诉用户这是啥。不像 Badge 可以只显示一个点,Tag 没有文字就没有意义了。

color 用的是统一的 ColorType,和其他组件保持一致。primary、success、warning、danger 这些语义化的颜色,用起来不用记色值,代码可读性也好。

variant 有三种样式可选。solid 是实心的,背景填满颜色,最醒目。outline 是描边的,只有边框没有背景,比较轻量。soft 是柔和的,背景是很淡的颜色,文字是深色,这个是默认值。为什么 soft 是默认?因为页面上标签多的时候,solid 会显得很乱很吵,soft 就舒服多了。

size 还是老三样 sm/md/lg。标签一般都比较小,所以即使是 lg 也不会很大。

closable 和 onClose 是一对。closable 控制要不要显示那个 × 按钮,onClose 是点击 × 时的回调。为什么要分开?因为有时候你可能想显示 × 但暂时禁用删除功能,这时候可以只传 closable 不传 onClose。

onPress 让整个标签可点击。注意这个和 onClose 不冲突,点 × 触发 onClose,点标签其他地方触发 onPress。

icon 用字符串类型,直接传 emoji 就行。简单场景够用了,复杂的可以改成 ReactNode。

尺寸这块的考量

const sizeMap: Record<SizeType, { height: number; fontSize: number; paddingH: number }> = {
  sm: { height: 20, fontSize: 10, paddingH: 6 },
  md: { height: 26, fontSize: 12, paddingH: 10 },
  lg: { height: 32, fontSize: 14, paddingH: 14 },
};

这个配置对象定义了三种尺寸下标签的高度、字号和水平内边距。

sm 的高度是 20px,字号 10px,内边距 6px。这个尺寸很小,适合在空间紧张的地方用,比如表格单元格里、列表项的角落。10px 的字已经是能看清的最小字号了,再小就费眼睛。

md 是默认尺寸,高度 26px,字号 12px,内边距 10px。这个大小在大多数场景都合适,不会太大占地方,也不会太小看不清。12px 是移动端很常用的辅助文字字号。

lg 高度 32px,字号 14px,内边距 14px。当标签需要突出显示的时候用,比如页面顶部的分类导航。14px 已经是正文字号了,标签用这么大说明它很重要。

为什么要定义 paddingH(水平内边距)?因为标签的宽度是由内容决定的,内边距控制了文字和边缘的距离。内边距太小文字会贴着边,太大标签会显得很空。这几个数值是反复调出来的,和高度、字号搭配起来比较协调。

三种样式的实现细节

const getStyles = (): { container: ViewStyle; textColor: string } => {
  switch (variant) {
    case 'solid':
      return { container: { backgroundColor: colorValue }, textColor: UITheme.colors.white };
    case 'outline':
      return { container: { borderWidth: 1, borderColor: colorValue, backgroundColor: 'transparent' }, textColor: colorValue };
    case 'soft':
      return { container: { backgroundColor: `${colorValue}20` }, textColor: colorValue };
    default:
      return { container: {}, textColor: colorValue };
  }
};

这个函数根据 variant 返回容器样式和文字颜色。为什么要把这两个放一起返回?因为它们是配套的,solid 的白色文字配深色背景,outline 和 soft 的深色文字配浅色或透明背景。放一起返回可以保证它们始终匹配。

solid 样式最直接,背景色就是传入的颜色值,文字用白色。这里有个隐含的假设:我们的主题色都是中等或偏深的颜色,白色文字在上面有足够的对比度。如果你的主题色里有浅色(比如浅黄色),可能需要额外处理文字颜色。

outline 样式设置了 1px 的边框,边框颜色是主题色,背景透明。文字也用主题色,和边框呼应。这种样式很轻量,适合不需要太强调的标签。borderWidth 用 1 而不是 1.5 或 2,是因为标签本身就小,粗边框会显得很笨重。

soft 样式最有意思。${colorValue}20 这个写法是在十六进制颜色后面加透明度。20 是十六进制,换算成十进制是 32,除以 255 约等于 12.5% 的不透明度。也就是说背景色是主题色的 12.5% 透明度版本,非常淡。文字用主题色,在淡色背景上很清晰。这种配色方案在现代 UI 设计里很流行,既有颜色区分又不会太刺眼。

default 分支理论上不会走到,但 TypeScript 要求 switch 要有兜底。返回空对象和默认文字颜色,不会出错。

标签内容的渲染

const content = (
  <View
    style={[
      styles.container,
      { height: sizeMap[size].height, paddingHorizontal: sizeMap[size].paddingH },
      variantStyle,
      style,
    ]}
  >
    {icon && <Text style={{ marginRight: 4 }}>{icon}</Text>}
    <Text style={[styles.label, { fontSize: sizeMap[size].fontSize, color: textColor }]}>{label}</Text>
    {closable && (
      <TouchableOpacity onPress={onClose} style={styles.closeBtn}>
        <Text style={[styles.closeIcon, { color: textColor }]}>×</Text>
      </TouchableOpacity>
    )}
  </View>
);

这段代码构建了标签的内容结构。最外层是个 View,里面按顺序放图标、文字、关闭按钮。

样式数组的顺序很重要。styles.container 是基础样式,定义了 flex 布局和圆角。然后是尺寸相关的高度和内边距。再是 variant 相关的背景色和边框。最后是外部传入的 style,优先级最高,可以覆盖前面的任何样式。

图标部分 {icon && <Text style={{ marginRight: 4 }}>{icon}</Text>} 用了短路求值,没传 icon 就不渲染。marginRight: 4 让图标和文字之间有点间距,不会挤在一起。4px 是个很小的间距,因为标签本身就小,间距太大会显得松散。

文字部分的样式合并了基础样式(fontWeight)和动态样式(fontSize、color)。fontWeight: ‘500’ 是中等粗细,比普通文字(400)稍粗一点,让标签文字更清晰。

关闭按钮用 TouchableOpacity 包裹,让 × 可以点击。onPress={onClose} 把点击事件传出去。styles.closeBtn 设置了 marginLeft: 4,和图标的 marginRight 对称。× 字符用 Text 渲染,fontSize: 16 比标签文字大一点,方便点击。fontWeight: ‘600’ 让它更粗更明显。颜色和标签文字一致,保持视觉统一。

点击功能的处理

if (onPress) {
  return <TouchableOpacity onPress={onPress} activeOpacity={0.7}>{content}</TouchableOpacity>;
}

return content;

这段逻辑决定标签是否可点击。如果传了 onPress,就用 TouchableOpacity 把整个内容包起来;没传就直接返回内容。

为什么不直接始终用 TouchableOpacity?因为不可点击的标签不应该有点击反馈。如果始终用 TouchableOpacity,用户点击一个纯展示的标签时会看到透明度变化,会以为能点但点了没反应,体验不好。

activeOpacity={0.7} 设置点击时的透明度。默认值是 0.2,按下去会变得很透明,有点突兀。0.7 的变化更温和,用户能感知到点击了,但不会太刺眼。

这里有个细节值得注意:如果标签同时有 onPress 和 closable,点击关闭按钮会不会同时触发 onPress?答案是不会。因为关闭按钮自己是个 TouchableOpacity,点击事件会被它"吃掉",不会冒泡到外层。这是 React Native 的默认行为,不用我们额外处理,挺方便的。

基础样式定义

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    borderRadius: UITheme.borderRadius.sm,
    alignSelf: 'flex-start',
  },
  label: { fontWeight: '500' },
  closeBtn: { marginLeft: 4 },
  closeIcon: { fontSize: 16, fontWeight: '600' },
});

container 的样式有几个关键点。flexDirection: ‘row’ 让图标、文字、关闭按钮水平排列。alignItems: ‘center’ 让它们垂直居中对齐。

borderRadius 用的是主题里的 sm 值(4px)。标签比较小,圆角也要小,不然看起来像个胶囊或者药片。4px 的圆角刚好让边角有点弧度,但整体还是矩形的感觉。

alignSelf: ‘flex-start’ 这个属性很重要,容易被忽略。默认情况下,View 会撑满父容器的宽度(如果父容器是 flex 布局)。加了这个属性后,标签的宽度由内容决定,有多少字就多宽。这才是标签应有的行为。

label 只设置了 fontWeight,其他样式(fontSize、color)是动态的,在渲染时传入。

closeBtn 和 closeIcon 分开定义。closeBtn 控制按钮的位置(marginLeft),closeIcon 控制 × 的外观(fontSize、fontWeight)。分开定义让样式更清晰,也方便以后单独调整。

实际使用场景

文章标签列表

const ArticleTags = ({ tags }) => (
  <View style={styles.tagList}>
    {tags.map(tag => (
      <Tag key={tag} label={tag} size="sm" style={styles.tag} />
    ))}
  </View>
);

const styles = StyleSheet.create({
  tagList: { flexDirection: 'row', flexWrap: 'wrap' },
  tag: { marginRight: 8, marginBottom: 8 },
});

文章标签一般用小号,因为是辅助信息,不需要太显眼。tagList 设置了 flexWrap: ‘wrap’,标签多的时候会自动换行。每个标签右边和下边都有 8px 间距,换行后也能保持整齐。

这里没有传 color,会用默认的 primary。如果想根据标签内容显示不同颜色,可以维护一个映射:

const colorMap = { '前端': 'primary', '后端': 'success', '运维': 'warning' };
<Tag label={tag} color={colorMap[tag] || 'info'} />

筛选条件标签

const FilterTags = ({ filters, selected, onSelect }) => (
  <View style={styles.filterList}>
    {filters.map(filter => (
      <Tag
        key={filter.id}
        label={filter.name}
        variant={selected === filter.id ? 'solid' : 'outline'}
        onPress={() => onSelect(filter.id)}
        style={styles.filterTag}
      />
    ))}
  </View>
);

筛选标签需要区分选中和未选中状态。这里用 variant 来区分:选中的用 solid,填充颜色很醒目;未选中的用 outline,只有边框比较低调。用户一眼就能看出哪个是当前选中的。

onPress 传入选择函数,点击标签就能切换选中状态。这比用 Checkbox 或 Radio 更简洁,适合选项不多的场景。

搜索历史

const SearchHistory = () => {
  const [history, setHistory] = useState(['React Native', 'OpenHarmony', 'TypeScript']);

  const removeItem = (index) => {
    setHistory(history.filter((_, i) => i !== index));
  };

  const doSearch = (keyword) => {
    // 执行搜索
  };

  return (
    <View style={styles.historyList}>
      {history.map((item, index) => (
        <Tag
          key={item}
          label={item}
          closable
          onClose={() => removeItem(index)}
          onPress={() => doSearch(item)}
          style={styles.historyTag}
        />
      ))}
    </View>
  );
};

搜索历史的标签有两个交互:点击标签重新搜索,点击 × 删除这条历史。Tag 组件天然支持这两个功能同时存在,不用额外处理。

removeItem 用 filter 创建新数组,触发 React 重新渲染。这是 React 状态更新的标准做法,直接修改数组不会触发更新。

带图标的促销标签

const ProductTags = ({ product }) => (
  <View style={styles.tagRow}>
    {product.isNew && <Tag label="新品" icon="✨" color="primary" />}
    {product.isHot && <Tag label="热卖" icon="🔥" color="danger" />}
    {product.discount && <Tag label={`${product.discount}折`} color="warning" variant="solid" />}
  </View>
);

促销标签用图标增加视觉吸引力。新品用 ✨,热卖用 🔥,都是很直观的 emoji。折扣标签用 solid 样式,因为折扣信息很重要,要突出显示。

条件渲染 {product.isNew && ...} 让标签按需显示,没有的属性就不显示对应标签。

一些容易忽略的细节

标签组的间距

标签经常成组出现,间距处理有几种方式。

最直接的是给每个标签加 margin:

<Tag label="标签" style={{ marginRight: 8, marginBottom: 8 }} />

缺点是最后一个标签右边也有间距,可能会影响布局。

更好的方式是用 gap(React Native 0.71+):

<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
  {tags.map(tag => <Tag key={tag} label={tag} />)}
</View>

gap 只在元素之间生效,首尾没有多余间距。但要注意版本兼容性。

长文本处理

标签文字太长会撑开标签,可能破坏布局。可以限制最大宽度:

<View style={{ maxWidth: 100 }}>
  <Tag label="这是一个很长很长的标签文字" />
</View>

或者在 Tag 组件内部处理:

<Text numberOfLines={1} ellipsizeMode="tail">{label}</Text>

不过说实话,标签文字本来就应该简短。如果经常需要处理长文本,可能是产品设计有问题,应该从源头解决。

无障碍支持

给标签加上无障碍属性,让屏幕阅读器能正确朗读:

<TouchableOpacity
  onPress={onPress}
  accessible={true}
  accessibilityRole="button"
  accessibilityLabel={`${label}标签${closable ? ',可删除' : ''}`}
>

accessibilityRole=“button” 告诉屏幕阅读器这是个按钮,用户知道可以点击。accessibilityLabel 提供完整的描述,包括标签内容和是否可删除。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐