请添加图片描述

案例开源地址:https://atomgit.com/lqjmac/rn_openharmony_todolist

你有没有注意到,当我们在纸质待办清单上完成一项任务时,最自然的动作就是划掉它?这个简单的动作给人一种强烈的成就感——“我完成了!”。在数字化的待办应用中,我们同样需要这种仪式感。划线效果不仅仅是一个视觉装饰,它是用户完成任务后获得心理满足的重要来源。

今天我们来聊聊如何在 React Native 中实现这个看似简单却意义重大的功能。


从一个问题开始

假设你刚接手这个 TodoList 项目,产品经理跑过来说:“我们需要让用户一眼就能区分哪些任务完成了,哪些还没完成。”

你会怎么做?

最直观的方案可能是:

  • 改变背景颜色?
  • 添加一个"已完成"的标签?
  • 把完成的任务移到列表底部?

这些方案都可以,但都不如划线效果来得直接和优雅。划线是一个全球通用的符号,不需要任何文字说明,用户就能理解它的含义。


看看我们的实现

在项目代码中,完成状态的视觉反馈是这样实现的:

<Text style={[styles.taskTitle, {color: theme.text}, item.completed && styles.completedText]}>{item.title}</Text>

就这么一行代码,却包含了三层样式的叠加。让我们拆解一下。

第一层是基础样式 styles.taskTitle,定义了文字的基本外观:

taskTitle: {fontSize: 16, fontWeight: '500', marginBottom: 4},

字号 16 像素,中等粗细,底部留 4 像素的间距。这是所有任务标题共享的样式,无论完成与否。

第二层是主题颜色 {color: theme.text},根据当前是深色还是浅色模式,动态设置文字颜色。深色模式下是白色,浅色模式下是深灰色。

第三层是条件样式 item.completed && styles.completedText,只有当任务完成时才会应用:

completedText: {textDecorationLine: 'line-through', opacity: 0.5},

这里用到了一个 JavaScript 的小技巧。&& 运算符在左边为 true 时返回右边的值,为 false 时返回 false。而在样式数组中,false 会被忽略。所以当 item.completed 为 false 时,这个位置就像不存在一样。


textDecorationLine 的秘密

textDecorationLine 是 React Native 中用于文字装饰的属性,它的可选值有:

  • none - 无装饰(默认值)
  • underline - 下划线
  • line-through - 删除线(就是我们要的划线效果)
  • underline line-through - 同时有下划线和删除线

有趣的是,你可以组合使用这些值。比如 underline line-through 会同时显示下划线和删除线,虽然在待办应用中这样做没什么意义,但在其他场景可能有用。

在 Web 开发中,这个属性叫 text-decoration,而且可以设置更多选项比如装饰线的颜色和样式。React Native 简化了这个属性,只保留了最常用的功能。


为什么还要降低透明度?

你可能注意到了,除了划线,我们还把透明度降到了 0.5:

completedText: {textDecorationLine: 'line-through', opacity: 0.5},

这是一个设计上的考量。单纯的划线效果虽然能表示"完成",但视觉上已完成的任务和未完成的任务还是同样醒目。降低透明度可以让已完成的任务在视觉上"退后",让用户的注意力自然集中在还需要处理的任务上。

想象一下你的任务列表有 20 个任务,其中 15 个已完成。如果所有任务都同样醒目,你需要逐个扫描才能找到未完成的任务。但如果已完成的任务变淡了,未完成的任务就会自动"跳出来"。

这就是所谓的视觉层次(Visual Hierarchy)。通过透明度的差异,我们创造了两个层次:

  • 前景层:未完成的任务,需要用户关注
  • 背景层:已完成的任务,仅供参考

完整的状态变化链

让我们跟踪一下,当用户点击勾选框时,发生了什么:

const toggleTask = (id: string) => {
  setTasks(tasks.map(task => task.id === id ? {...task, completed: !task.completed} : task));
};
  1. 用户点击勾选框
  2. toggleTask 函数被调用,传入任务 ID
  3. tasks.map 遍历所有任务
  4. 找到匹配的任务,将其 completed 属性取反
  5. setTasks 更新状态
  6. React 检测到状态变化,触发重新渲染
  7. 在渲染过程中,item.completed && styles.completedText 的结果改变
  8. 文字样式更新,划线效果出现或消失

整个过程是即时的,用户点击的瞬间就能看到效果。这种即时反馈对用户体验至关重要。


勾选框的配合

划线效果不是孤立存在的,它需要和勾选框配合才能形成完整的视觉反馈:

<TouchableOpacity onPress={() => toggleTask(item.id)} style={styles.checkbox}>
  <View style={[styles.checkboxInner, {borderColor: theme.accent}, item.completed && {backgroundColor: theme.accent}]}>
    {item.completed && <Text style={styles.checkmark}>✓</Text>}
  </View>
</TouchableOpacity>

当任务完成时:

  • 勾选框从空心变成实心(填充主题色)
  • 勾选框内显示 ✓ 符号
  • 任务标题添加划线
  • 任务标题透明度降低

这四个变化同时发生,形成一个完整的"完成"状态。用户不需要思考,直觉就能理解发生了什么。


深色模式下的表现

我们的应用支持深色和浅色两种模式,划线效果在两种模式下都需要清晰可见:

const theme = {
  bg: darkMode ? '#0f0f23' : '#f5f5f5',
  card: darkMode ? '#1a1a2e' : '#ffffff',
  text: darkMode ? '#ffffff' : '#333333',
  subText: darkMode ? '#888888' : '#666666',
  border: darkMode ? '#2a2a4a' : '#e0e0e0',
  accent: '#6c5ce7',
};

在深色模式下,文字是白色的,划线也是白色的。在浅色模式下,文字是深灰色的,划线也是深灰色的。因为划线的颜色继承自文字颜色,所以我们不需要额外处理。

透明度的效果在两种模式下略有不同:

  • 深色模式:白色文字 50% 透明度,在深色背景上显示为灰色
  • 浅色模式:深灰色文字 50% 透明度,在浅色背景上显示为浅灰色

两种情况下,已完成的任务都会明显变淡,达到了我们想要的效果。


一个常见的坑

在实现划线效果时,有一个容易踩的坑:在某些字体下,划线可能不会穿过文字中心,而是偏上或偏下。

这是因为不同字体的基线(baseline)位置不同,而划线的位置是相对于基线计算的。在大多数情况下,系统默认字体的表现是正常的,但如果你使用了自定义字体,可能需要测试一下。

如果遇到这个问题,一个解决方案是不使用 textDecorationLine,而是用一个绝对定位的 View 来模拟划线:

const StrikethroughText = ({children, strikethrough}) => (
  <View style={{position: 'relative'}}>
    <Text>{children}</Text>
    {strikethrough && (
      <View style={{
        position: 'absolute',
        left: 0,
        right: 0,
        top: '50%',
        height: 1,
        backgroundColor: 'currentColor',
      }} />
    )}
  </View>
);

不过在我们的项目中,使用系统默认字体,textDecorationLine 工作得很好,不需要这种 workaround。


动画的可能性

当前的实现是即时切换的,没有动画过渡。如果想要更精致的效果,可以考虑添加动画。

一种思路是让划线从左到右"画"出来:

const AnimatedStrikethrough = ({completed, children}) => {
  const widthAnim = useRef(new Animated.Value(completed ? 1 : 0)).current;
  
  useEffect(() => {
    Animated.timing(widthAnim, {
      toValue: completed ? 1 : 0,
      duration: 300,
      useNativeDriver: false,
    }).start();
  }, [completed]);
  
  return (
    <View style={{position: 'relative'}}>
      <Text>{children}</Text>
      <Animated.View style={{
        position: 'absolute',
        left: 0,
        top: '50%',
        height: 2,
        backgroundColor: '#666',
        width: widthAnim.interpolate({
          inputRange: [0, 1],
          outputRange: ['0%', '100%'],
        }),
      }} />
    </View>
  );
};

这个动画会让划线从左边开始,逐渐延伸到右边,就像真的在用笔划掉一样。

不过要注意,动画虽然好看,但也会增加复杂度和性能开销。对于一个任务列表来说,用户可能会频繁地勾选和取消勾选,过多的动画反而会让人觉得"慢"。所以在实际项目中,我们选择了简单直接的即时切换。


与其他元素的协调

任务卡片上不只有标题,还有分类标签、截止日期、备注等信息:

<View style={styles.taskInfo}>
  <Text style={[styles.taskTitle, {color: theme.text}, item.completed && styles.completedText]}>{item.title}</Text>
  <View style={styles.taskMeta}>
    <View style={[styles.categoryTag, {backgroundColor: theme.accent + '30'}]}>
      <Text style={[styles.categoryText, {color: theme.accent}]}>{item.category}</Text>
    </View>
    <Text style={[styles.dueDate, {color: theme.subText}]}>📅 {item.dueDate}</Text>
  </View>
  {item.note ? <Text style={[styles.noteText, {color: theme.subText}]} numberOfLines={1}>💬 {item.note}</Text> : null}
</View>

你可能注意到,我们只给标题添加了划线效果,而没有给分类、日期、备注添加。这是有意为之的。

标题是任务的核心信息,划掉标题就足以表达"这个任务完成了"的含义。如果把所有文字都划掉,反而会显得杂乱,而且会影响这些辅助信息的可读性。

不过,透明度的降低是应用到整个卡片的吗?让我们看看:

<Animated.View style={[styles.taskCard, {backgroundColor: theme.card, borderColor: theme.border, opacity: itemAnim, ...}]}>

实际上,卡片的透明度是由入场动画控制的,不是由完成状态控制的。完成状态只影响标题的样式。这意味着分类标签、日期、备注在任务完成后仍然保持原来的清晰度。

如果你想让整个卡片在完成后都变淡,可以这样修改:

<Animated.View style={[
  styles.taskCard, 
  {backgroundColor: theme.card, borderColor: theme.border},
  {opacity: item.completed ? 0.6 : 1}
]}>

但这样做的话,勾选框和删除按钮也会变淡,可能影响操作。所以当前的设计——只让标题变淡——是一个平衡的选择。


样式数组的工作原理

React Native 的样式系统支持数组形式,这是实现条件样式的关键:

style={[styles.taskTitle, {color: theme.text}, item.completed && styles.completedText]}

当 React Native 处理这个样式数组时,它会:

  1. 从左到右遍历数组中的每个元素
  2. 忽略 nullundefinedfalse 等假值
  3. 将所有有效的样式对象合并成一个
  4. 后面的属性会覆盖前面的同名属性

所以如果 item.completed 为 true,最终的样式是:

{
  fontSize: 16,
  fontWeight: '500',
  marginBottom: 4,
  color: '#ffffff',  // 或 '#333333',取决于主题
  textDecorationLine: 'line-through',
  opacity: 0.5,
}

如果 item.completed 为 false,styles.completedText 不会被应用,最终样式是:

{
  fontSize: 16,
  fontWeight: '500',
  marginBottom: 4,
  color: '#ffffff',  // 或 '#333333'
}

这种模式非常灵活,你可以根据任意条件组合任意数量的样式。


测试你的理解

在继续之前,试着回答这几个问题:

  1. 如果想让划线变成红色而不是继承文字颜色,应该怎么做?

  2. 如果想让已完成任务的透明度是 0.3 而不是 0.5,需要修改哪里?

  3. 如果想给分类标签也添加划线效果,代码应该怎么写?

答案在文章最后。


小结

划线效果看起来简单,但它背后涉及到:

  • CSS 文字装饰属性在 React Native 中的应用
  • 条件样式的实现技巧
  • 视觉层次的设计理念
  • 状态变化到视觉更新的完整链路
  • 深色/浅色模式的适配
  • 与其他 UI 元素的协调

一个小小的功能,却能体现出开发者对用户体验的关注。当用户划掉一个任务时,那一瞬间的满足感,就是我们努力的意义。


问题答案

  1. 在 React Native 中,textDecorationLine 的颜色继承自 color 属性,无法单独设置。如果需要不同颜色的划线,需要使用自定义 View 来模拟。

  2. 修改 completedText 样式中的 opacity 值:

completedText: {textDecorationLine: 'line-through', opacity: 0.3},
  1. 给分类标签的 Text 添加条件样式:
<Text style={[styles.categoryText, {color: theme.accent}, item.completed && styles.completedText]}>{item.category}</Text>

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

Logo

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

更多推荐