React Native for OpenHarmony 实战:构建通用的 Card 卡片容器组件
项目开源地址: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
更多推荐


所有评论(0)