请添加图片描述

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

卡片是移动端 UI 中最常见的布局容器,几乎每个 App 都会用到。文章列表、商品展示、用户信息、设置项……这些场景都离不开卡片。本文将详细讲解如何封装一个灵活、易用的 Card 组件。

一、分析卡片的使用场景

在动手写代码之前,先梳理一下卡片组件需要支持哪些场景:

场景一:内容展示卡片

最基础的用法,就是把一组相关信息放在一个卡片里。比如一篇文章的标题、摘要、发布时间,放在同一个卡片里,用户一眼就能看出这是一个整体。

场景二:可点击的列表项

很多列表页面,每一项都是一个可点击的卡片。点击后跳转到详情页。这种卡片需要有点击反馈,让用户知道这是可以交互的。

场景三:带操作按钮的卡片

有些卡片右上角会有操作按钮,比如"编辑"、“删除”、“更多”。这种卡片的头部需要支持自定义内容。

场景四:不同视觉层级的卡片

页面上可能同时存在多个卡片,它们的重要程度不同。主要内容用阴影卡片突出显示,次要内容用描边卡片或填充卡片弱化显示。

基于这些场景,我们来设计组件的 API。

二、定义组件接口

interface CardProps {
  children?: React.ReactNode;
  title?: string;
  subtitle?: string;
  variant?: 'elevated' | 'outlined' | 'filled';
  onPress?: () => void;
  style?: ViewStyle;
  headerRight?: React.ReactNode;
}

children 属性

children 是卡片的主体内容,类型是 React.ReactNode,意味着你可以传入任何合法的 React 元素:文本、图片、列表、甚至嵌套的其他组件。这种设计让卡片成为一个纯粹的容器,不限制内部放什么内容。

title 和 subtitle 属性

这两个属性用于快速创建带标题的卡片。title 是主标题,字号大、字重粗;subtitle 是副标题,字号小、颜色浅。

为什么要把标题做成独立属性,而不是让用户自己在 children 里写?因为标题的样式是固定的,如果每次都要手写样式,代码会很冗余。提供 title/subtitle 属性后,用户只需要传字符串,样式由组件内部统一处理。

当然,如果用户需要完全自定义标题样式,可以不传 title/subtitle,直接在 children 里写自己的标题组件。

variant 属性

variant 定义卡片的视觉风格,提供三个选项:

  • elevated:带阴影的卡片,视觉上"浮"在页面上方,适合需要突出显示的内容
  • outlined:带边框的卡片,视觉上和页面平齐,适合普通内容
  • filled:带背景色的卡片,视觉上"嵌入"页面,适合需要弱化的内容

这三种风格形成了清晰的视觉层级:elevated > outlined > filled。在同一个页面上混合使用,可以引导用户的注意力。

onPress 属性

如果传入 onPress,整个卡片就变成可点击的。点击时会有透明度变化的反馈,让用户知道点击生效了。

这个属性是可选的。不传 onPress 时,卡片就是一个普通的容器,没有点击效果。

headerRight 属性

headerRight 用于在标题右侧放置自定义内容,通常是操作按钮。类型是 React.ReactNode,可以放按钮、图标、开关等任何组件。

为什么叫 headerRight 而不是 rightButton?因为右侧不一定是按钮,可能是一个状态标签、一个开关、或者多个按钮的组合。用 ReactNode 类型可以支持所有这些情况。

三、实现卡片样式逻辑

卡片的样式需要根据 variant 动态生成:

const getCardStyle = (): ViewStyle => {
  const base: ViewStyle = {
    borderRadius: UITheme.borderRadius.lg,
    padding: UITheme.spacing.lg,
    backgroundColor: UITheme.colors.white,
  };

  switch (variant) {
    case 'elevated':
      return { ...base, ...UITheme.shadow.md };
    case 'outlined':
      return { ...base, borderWidth: 1, borderColor: UITheme.colors.gray[200] };
    case 'filled':
      return { ...base, backgroundColor: UITheme.colors.gray[50] };
    default:
      return base;
  }
};

基础样式的设计

base 对象定义了所有变体共有的样式:

borderRadius: UITheme.borderRadius.lg 设置圆角为 12px。卡片的圆角通常比按钮大一些,因为卡片面积更大,小圆角会显得很生硬。12px 是一个比较舒适的值,既有圆润感又不会太夸张。

padding: UITheme.spacing.lg 设置内边距为 16px。内边距决定了内容和卡片边缘的距离,太小会显得拥挤,太大会浪费空间。16px 在大多数屏幕尺寸上都比较合适。

backgroundColor: UITheme.colors.white 设置背景色为白色。这是默认值,filled 变体会覆盖这个值。

elevated 变体的阴影实现

case 'elevated':
  return { ...base, ...UITheme.shadow.md };

阴影样式定义在主题配置里:

shadow: {
  md: {
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
}

这里有多个阴影属性:shadowColor 定义阴影颜色,shadowOffset 定义阴影偏移方向,shadowOpacity 定义阴影透明度,shadowRadius 定义阴影模糊半径,elevation 定义阴影高度。React Native for OpenHarmony 会根据这些属性渲染出合适的阴影效果。

shadowOffset 设为 { width: 0, height: 2 } 表示阴影向下偏移 2px,模拟光源从上方照射的效果。shadowOpacity 设为 0.1 是一个很淡的阴影,不会太突兀。shadowRadius 设为 4 让阴影边缘有一定的模糊,看起来更自然。

elevation 设为 3 是中等阴影级别。阴影是通过 elevation 属性控制的,数值越大阴影越明显,在 OpenHarmony 上同样适用。

outlined 变体的边框实现

case 'outlined':
  return { ...base, borderWidth: 1, borderColor: UITheme.colors.gray[200] };

边框宽度设为 1px,颜色用 gray[200](一个很浅的灰色)。这种淡淡的边框既能区分卡片和背景,又不会太抢眼。

为什么不用更深的边框颜色?因为边框的作用是"分隔"而不是"强调"。如果边框太深,会和内容抢夺注意力,反而影响阅读。

filled 变体的背景实现

case 'filled':
  return { ...base, backgroundColor: UITheme.colors.gray[50] };

背景色用 gray[50],这是一个非常浅的灰色,和白色背景有微妙的区别。这种设计让卡片看起来像是"嵌入"页面的,视觉权重比 elevated 和 outlined 都低。

filled 变体适合用在已经有其他视觉元素的区域,比如一个 elevated 卡片内部的子卡片,用 filled 样式可以避免阴影叠加造成的视觉混乱。

四、实现头部布局

卡片头部包含标题、副标题和右侧操作区:

{(title || subtitle || headerRight) && (
  <View style={styles.header}>
    <View style={styles.headerText}>
      {title && <Text style={styles.title}>{title}</Text>}
      {subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
    </View>
    {headerRight}
  </View>
)}

条件渲染的逻辑

(title || subtitle || headerRight) 这个条件判断确保只有在需要显示头部时才渲染头部容器。如果三个属性都没传,头部区域就不会渲染,避免产生多余的空白。

头部的 flex 布局

header: {
  flexDirection: 'row',
  justifyContent: 'space-between',
  alignItems: 'flex-start',
  marginBottom: UITheme.spacing.md,
},
headerText: { flex: 1 },

头部使用 flex 布局,flexDirection: 'row' 让标题区和操作区水平排列。justifyContent: 'space-between' 让标题靠左、操作区靠右。alignItems: 'flex-start' 让两边顶部对齐(而不是垂直居中),这样当标题有多行时,操作按钮不会跑到中间去。

headerText 设置 flex: 1 让标题区占据剩余空间。这样即使标题很长,也不会把右侧的操作按钮挤出去。

marginBottom: UITheme.spacing.md 在头部和内容之间留出 12px 的间距,让两者有清晰的分隔。

标题和副标题的样式

title: {
  fontSize: UITheme.fontSize.lg,
  fontWeight: '600',
  color: UITheme.colors.gray[800],
},
subtitle: {
  fontSize: UITheme.fontSize.sm,
  color: UITheme.colors.gray[500],
  marginTop: 2,
},

标题用 16px 字号和 600 字重,颜色用 gray[800](深灰色),视觉上比较醒目。副标题用 12px 字号和 gray[500](中灰色),视觉上明显弱于标题。

副标题的 marginTop: 2 让它和标题之间有一点点间距,但又不会太远,保持紧密的关联感。

五、实现点击交互

当传入 onPress 时,卡片需要变成可点击的:

const content = (
  <View style={[getCardStyle(), style]}>
    {/* 头部和内容 */}
  </View>
);

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

return content;

为什么用条件渲染而不是始终包裹 TouchableOpacity

如果始终用 TouchableOpacity 包裹,即使没有传 onPress,卡片也会有点击效果(透明度变化)。这会让用户误以为卡片可以点击,造成困惑。

通过条件渲染,只有真正需要点击功能时才包裹 TouchableOpacity,其他情况下就是一个普通的 View,没有任何交互效果。

activeOpacity 的选择

activeOpacity 设为 0.8,意味着按下时卡片透明度变为 80%。这个值比按钮的 0.7 高一点,因为卡片面积大,透明度变化太明显会很突兀。0.8 是一个比较温和的反馈,既能让用户感知到点击,又不会太刺眼。

content 变量的作用

把卡片内容提取到 content 变量里,是为了避免代码重复。不管是否可点击,卡片的内容都是一样的,只是外层包裹不同。这种写法比写两遍内容代码要简洁得多。

六、完整组件代码

把上面的部分组装起来,就是完整的 Card 组件:

import React from 'react';
import { View, Text, StyleSheet, ViewStyle, TouchableOpacity } from 'react-native';
import { UITheme } from './theme';

interface CardProps {
  children?: React.ReactNode;
  title?: string;
  subtitle?: string;
  variant?: 'elevated' | 'outlined' | 'filled';
  onPress?: () => void;
  style?: ViewStyle;
  headerRight?: React.ReactNode;
}

export const Card: React.FC<CardProps> = ({
  children,
  title,
  subtitle,
  variant = 'elevated',
  onPress,
  style,
  headerRight,
}) => {
  const getCardStyle = (): ViewStyle => {
    const base: ViewStyle = {
      borderRadius: UITheme.borderRadius.lg,
      padding: UITheme.spacing.lg,
      backgroundColor: UITheme.colors.white,
    };

    switch (variant) {
      case 'elevated':
        return { ...base, ...UITheme.shadow.md };
      case 'outlined':
        return { ...base, borderWidth: 1, borderColor: UITheme.colors.gray[200] };
      case 'filled':
        return { ...base, backgroundColor: UITheme.colors.gray[50] };
      default:
        return base;
    }
  };

  const content = (
    <View style={[getCardStyle(), style]}>
      {(title || subtitle || headerRight) && (
        <View style={styles.header}>
          <View style={styles.headerText}>
            {title && <Text style={styles.title}>{title}</Text>}
            {subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
          </View>
          {headerRight}
        </View>
      )}
      {children}
    </View>
  );

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

  return content;
};

const styles = StyleSheet.create({
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'flex-start',
    marginBottom: UITheme.spacing.md,
  },
  headerText: { flex: 1 },
  title: {
    fontSize: UITheme.fontSize.lg,
    fontWeight: '600',
    color: UITheme.colors.gray[800],
  },
  subtitle: {
    fontSize: UITheme.fontSize.sm,
    color: UITheme.colors.gray[500],
    marginTop: 2,
  },
});

七、各种使用场景示例

场景一:文章列表卡片

const ArticleCard = ({ article, onPress }) => (
  <Card
    variant="elevated"
    title={article.title}
    subtitle={`${article.author} · ${article.date}`}
    onPress={onPress}
  >
    <Text style={styles.summary}>{article.summary}</Text>
    <View style={styles.tags}>
      {article.tags.map(tag => (
        <Tag key={tag} label={tag} size="sm" />
      ))}
    </View>
  </Card>
);

这个例子展示了一个典型的文章卡片:标题是文章标题,副标题是作者和日期,内容区是摘要和标签。整个卡片可点击,点击后跳转到文章详情。

场景二:设置项卡片

const SettingCard = ({ title, children }) => (
  <Card variant="outlined" title={title}>
    {children}
  </Card>
);

// 使用
<SettingCard title="通知设置">
  <SettingItem label="推送通知" right={<Switch value={push} onValueChange={setPush} />} />
  <SettingItem label="邮件通知" right={<Switch value={email} onValueChange={setEmail} />} />
  <SettingItem label="短信通知" right={<Switch value={sms} onValueChange={setSms} />} />
</SettingCard>

设置页面通常用 outlined 样式,因为设置项本身就有很多交互元素(开关、选择器等),如果卡片再用阴影会显得太复杂。

场景三:带操作按钮的卡片

<Card
  variant="elevated"
  title="我的订单"
  subtitle="共 3 个待处理订单"
  headerRight={
    <Button title="查看全部" size="sm" variant="ghost" onPress={goToOrders} />
  }
>
  <OrderItem order={orders[0]} />
  <OrderItem order={orders[1]} />
  <OrderItem order={orders[2]} />
</Card>

headerRight 放置一个"查看全部"按钮,用户可以点击跳转到订单列表页。按钮用 ghost 样式,不会和卡片标题抢夺注意力。

场景四:纯内容卡片

<Card variant="elevated">
  <View style={styles.emptyState}>
    <Text style={styles.emoji}>📭</Text>
    <Text style={styles.emptyTitle}>暂无消息</Text>
    <Text style={styles.emptyDesc}>当有新消息时,会在这里显示</Text>
  </View>
</Card>

不传 title 和 subtitle,卡片就是一个纯粹的容器,内容完全由 children 决定。这种用法适合需要完全自定义布局的场景。

场景五:嵌套卡片

<Card variant="elevated" title="今日数据">
  <View style={styles.statsRow}>
    <Card variant="filled" style={styles.statCard}>
      <Text style={styles.statValue}>1,234</Text>
      <Text style={styles.statLabel}>访问量</Text>
    </Card>
    <Card variant="filled" style={styles.statCard}>
      <Text style={styles.statValue}>567</Text>
      <Text style={styles.statLabel}>新用户</Text>
    </Card>
  </View>
</Card>

外层用 elevated 卡片,内层用 filled 卡片。这种嵌套方式可以创建清晰的视觉层级,外层卡片是一个整体,内层卡片是细分的数据项。

八、样式覆盖和扩展

Card 组件支持通过 style 属性覆盖默认样式:

// 自定义圆角
<Card style={{ borderRadius: 20 }}>...</Card>

// 自定义内边距
<Card style={{ padding: 24 }}>...</Card>

// 自定义背景色
<Card style={{ backgroundColor: '#FEF3C7' }}>...</Card>

// 去掉内边距(用于图片卡片)
<Card style={{ padding: 0 }}>
  <Image source={...} style={styles.cardImage} />
  <View style={styles.cardContent}>
    <Text>图片说明</Text>
  </View>
</Card>

style 属性会和组件内部的样式合并,后者优先级更高。这意味着你可以覆盖任何默认样式,实现完全自定义的效果。

九、性能优化建议

避免在 onPress 中创建新函数

// 不好的写法:每次渲染都创建新函数
<Card onPress={() => navigation.navigate('Detail', { id: item.id })}>

// 好的写法:使用 useCallback
const handlePress = useCallback(() => {
  navigation.navigate('Detail', { id: item.id });
}, [item.id]);

<Card onPress={handlePress}>

如果 Card 在列表中使用,每次渲染都创建新函数会导致不必要的重渲染。用 useCallback 缓存函数可以避免这个问题。

长列表使用 FlatList

如果页面上有很多卡片,不要用 ScrollView + map 的方式渲染,应该用 FlatList。FlatList 会自动回收屏幕外的组件,大大减少内存占用。

<FlatList
  data={articles}
  renderItem={({ item }) => (
    <Card title={item.title} onPress={() => goToDetail(item.id)}>
      <Text>{item.summary}</Text>
    </Card>
  )}
  keyExtractor={item => item.id}
/>

十、常见问题解答

问:为什么阴影显示不正常?

阴影是通过 elevation 属性实现的,它要求组件必须有背景色。如果你的卡片背景是透明的,阴影就不会显示。确保 backgroundColor 设置了有效的颜色值,这在 OpenHarmony 平台上同样适用。

问:如何实现卡片的按下缩放效果?

可以用 Animated API 实现。在 onPressIn 时缩小到 0.98,在 onPressOut 时恢复到 1。这种微妙的缩放效果可以增强交互反馈。

问:如何让卡片内容超出时显示省略号?

在 Text 组件上设置 numberOfLines={2}ellipsizeMode="tail",内容超过两行时会自动显示省略号。


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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐