请添加图片描述

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

在跨平台开发中,按钮是使用频率最高的交互组件。本文将从零开始,手把手带你封装一个支持多种样式、多种状态的 Button 组件,代码可直接用于生产环境。

一、定义组件的类型接口

首先明确 Button 需要支持哪些能力。我们通过 TypeScript 接口来定义:

interface ButtonProps {
  title: string;
  onPress?: () => void;
  variant?: 'solid' | 'outline' | 'ghost';
  color?: ColorType;
  size?: SizeType;
  disabled?: boolean;
  loading?: boolean;
  icon?: string;
  fullWidth?: boolean;
  style?: ViewStyle;
}

这里有几个关键的设计决策需要说明:

关于 variant 属性

variant 定义了三种视觉风格,每种风格有其特定的使用场景:

  • solid 是实心填充样式,视觉权重最高。当页面上有多个按钮时,solid 样式的按钮会第一时间吸引用户注意力,因此它适合用于主要操作,比如"提交"、“确认”、"立即购买"这类需要引导用户点击的按钮。

  • outline 是描边样式,只有边框没有填充背景。它的视觉权重比 solid 低一级,适合用于次要操作,比如"取消"、“返回”、“稍后再说”。当主要按钮和次要按钮并排出现时,用户能很直观地分辨出哪个是主操作。

  • ghost 是幽灵按钮,既没有背景也没有边框,只有文字。它的视觉权重最低,适合用于工具栏、列表项操作、或者需要弱化视觉干扰的场景。比如文章底部的"点赞"、“收藏”、"分享"按钮,用 ghost 样式就不会喧宾夺主。

关于 color 属性

color 使用语义化命名而非具体色值。这样做有三个好处:

第一,代码可读性更好。当你看到 color="danger" 时,立刻就知道这是一个危险操作按钮,不需要去查 #EF4444 是什么颜色。

第二,便于全局换肤。如果哪天设计师说要把主色调从紫色换成蓝色,你只需要改 theme 文件里的一个值,所有用到 primary 的地方都会自动更新。

第三,减少沟通成本。设计师说"这个按钮用成功色",你直接写 color="success",不用再问"成功色的色值是多少"。

关于 size 属性

size 提供 sm/md/lg 三档尺寸。移动端屏幕寸土寸金,按钮尺寸的选择需要权衡两个因素:太大会浪费空间,太小又不好点击(OpenHarmony 的人机交互指南建议可点击区域至少 44x44 vp)。

三档尺寸的典型应用场景:sm 用于紧凑型界面,比如表格里的操作按钮;md 是默认尺寸,适合大多数场景;lg 用于需要强调的场景,比如登录页的提交按钮。

关于 loading 和 disabled

这两个是独立的状态属性。loading 表示正在进行异步操作,按钮会显示加载指示器;disabled 表示按钮不可用,通常是因为前置条件不满足(比如表单校验不通过)。

两者可以同时存在:一个 disabled 的按钮也可能处于 loading 状态(虽然这种情况比较少见)。把它们设计成独立属性而不是一个 status 枚举,是为了保持 API 的灵活性。

二、建立统一的主题配置

在写组件逻辑之前,先把主题配置文件准备好。这是保证整个 UI 库风格统一的基础:

export const UITheme = {
  colors: {
    primary: '#6366F1',
    secondary: '#8B5CF6',
    success: '#10B981',
    warning: '#F59E0B',
    danger: '#EF4444',
    info: '#3B82F6',
    white: '#FFFFFF',
    gray: {
      50: '#F9FAFB',
      100: '#F3F4F6',
      200: '#E5E7EB',
      300: '#D1D5DB',
      400: '#9CA3AF',
      500: '#6B7280',
      600: '#4B5563',
      700: '#374151',
      800: '#1F2937',
      900: '#111827',
    },
  },
  spacing: {
    xs: 4,
    sm: 8,
    md: 12,
    lg: 16,
    xl: 24,
  },
  borderRadius: {
    sm: 4,
    md: 8,
    lg: 12,
    full: 9999,
  },
  fontSize: {
    xs: 10,
    sm: 12,
    md: 14,
    lg: 16,
    xl: 18,
  },
};

export type ColorType = 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
export type SizeType = 'sm' | 'md' | 'lg';

颜色体系的设计思路

primary 选用 #6366F1,这是一个偏紫的蓝色,视觉上比较现代,也是近几年流行的主色调。secondary 用 #8B5CF6,是比 primary 更紫一点的颜色,用于辅助强调。

success/warning/danger 分别对应绿/黄/红,这是全世界通用的颜色语义:绿色代表成功、安全、可以继续;黄色代表警告、需要注意;红色代表危险、错误、需要停止。遵循这个约定,用户不需要学习就能理解按钮的含义。

info 用蓝色,表示中性的信息提示,既不是好消息也不是坏消息,只是需要用户知道。

灰色梯度的作用

gray 定义了从 50 到 900 共 10 个灰度值。50 最浅接近白色,900 最深接近黑色。这个梯度在 UI 开发中非常有用:背景色通常用 50-100,边框用 200-300,次要文字用 400-500,主要文字用 700-800。

类型导出的意义

ColorTypeSizeType 的导出是为了让 TypeScript 能够进行类型检查。当你写 color="primar" 少打一个 y 时,IDE 会立刻标红提示错误,而不是等到运行时才发现按钮颜色不对。这种编译期检查能大大减少低级错误。

三、实现尺寸计算逻辑

不同尺寸的按钮,高度、内边距、字号都不一样。我们用一个对象来集中管理这些数值:

const sizeStyles: Record<SizeType, { 
  height: number; 
  paddingHorizontal: number; 
  fontSize: number 
}> = {
  sm: { height: 32, paddingHorizontal: 12, fontSize: 12 },
  md: { height: 40, paddingHorizontal: 16, fontSize: 14 },
  lg: { height: 48, paddingHorizontal: 24, fontSize: 16 },
};

为什么用 Record 而不是普通对象

Record<SizeType, ...> 是 TypeScript 的工具类型,它确保对象必须包含 SizeType 中定义的所有 key。如果你漏写了 lg 的配置,TypeScript 会报错:Property ‘lg’ is missing。这比运行时报 undefined 错误要好得多。

数值选择的依据

高度从 32 到 48,每档增加 8px。32px 是移动端按钮的最小建议高度,再小就不好点击了;48px 是比较大的按钮,适合需要强调的场景。

内边距从 12 到 24,控制按钮的宽度。按钮的宽度 = 文字宽度 + 左右内边距,内边距越大按钮越宽。sm 按钮用 12px 内边距,看起来比较紧凑;lg 按钮用 24px 内边距,看起来更大气。

字号从 12 到 16,保持可读性。12px 是移动端正文的最小建议字号,再小就看不清了;16px 是比较舒适的阅读字号。字号和按钮高度要匹配,小按钮配小字,大按钮配大字,比例才协调。

四、实现按钮容器样式

按钮的容器样式需要根据 variant 参数动态生成。这是整个组件最核心的逻辑:

const getButtonStyle = (): ViewStyle => {
  const colorValue = UITheme.colors[color];
  
  const base: ViewStyle = {
    height: sizeStyles[size].height,
    paddingHorizontal: sizeStyles[size].paddingHorizontal,
    borderRadius: UITheme.borderRadius.md,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    opacity: disabled ? 0.5 : 1,
  };

  if (fullWidth) base.width = '100%';

  switch (variant) {
    case 'solid':
      return { ...base, backgroundColor: colorValue };
    case 'outline':
      return { 
        ...base, 
        backgroundColor: 'transparent', 
        borderWidth: 1.5, 
        borderColor: colorValue 
      };
    case 'ghost':
      return { ...base, backgroundColor: 'transparent' };
    default:
      return base;
  }
};

基础样式的设计

base 对象包含所有变体共有的样式。flexDirection: 'row' 让图标和文字水平排列;alignItems: 'center'justifyContent: 'center' 让内容垂直和水平居中。

borderRadius: UITheme.borderRadius.md 使用主题配置的圆角值(8px),保证和其他组件的圆角一致。如果每个组件都写死自己的圆角值,后期想统一调整就很麻烦。

disabled 状态的处理

disabled 状态直接把 opacity 设为 0.5,这是一个简单但有效的方案。它的好处是对任何颜色的按钮都有效:红色按钮变成半透明的红色,蓝色按钮变成半透明的蓝色,视觉上都能明显看出是禁用状态。

另一种常见方案是把按钮变成灰色,但这需要为每种颜色单独处理,代码会复杂很多。

fullWidth 的实现

if (fullWidth) base.width = '100%' 这行代码让按钮占满父容器宽度。注意 ‘100%’ 是字符串不是数字,React Native 支持百分比字符串作为宽度值。

fullWidth 按钮常用于表单底部的提交按钮,或者弹窗里的确认按钮,让按钮更醒目、更容易点击。

variant 的样式差异

solid 样式设置 backgroundColor: colorValue,用主题色填充整个按钮。

outline 样式设置 backgroundColor: 'transparent' 让背景透明,然后用 borderWidth: 1.5borderColor: colorValue 画一个主题色的边框。边框宽度用 1.5px 而不是 1px,是因为 1px 在高清屏上太细了,看起来像是渲染问题。

ghost 样式只设置 backgroundColor: 'transparent',既没有填充也没有边框,只靠文字颜色来表达按钮的存在。

五、实现文字样式

文字颜色需要根据按钮变体来变化,确保在任何背景上都清晰可读:

const getTextStyle = (): TextStyle => {
  const colorValue = UITheme.colors[color];
  
  const base: TextStyle = {
    fontSize: sizeStyles[size].fontSize,
    fontWeight: '600',
  };

  switch (variant) {
    case 'solid':
      return { ...base, color: UITheme.colors.white };
    case 'outline':
    case 'ghost':
      return { ...base, color: colorValue };
    default:
      return base;
  }
};

fontWeight 的选择

fontWeight 设为 ‘600’ 是半粗体(semibold),介于普通字重(400/regular)和粗体(700/bold)之间。按钮文字用半粗体是业界通行的做法,因为:

  • 比普通字重更醒目,能吸引用户注意
  • 比粗体更轻盈,不会显得笨重
  • 在小字号下仍然清晰可读

颜色对比度的考量

solid 按钮用白色文字,因为主题色(primary/success/danger 等)都是中等明度的颜色,白色文字在这些背景上有足够的对比度。

outline 和 ghost 按钮用主题色文字,因为它们的背景是透明的(通常是白色或浅灰色页面背景),主题色文字在浅色背景上同样有足够的对比度。

这种设计确保了无论哪种变体,文字都清晰可读,符合 WCAG 无障碍标准。

六、组装完整的渲染逻辑

最后把所有部分组装成完整的组件:

export const Button: React.FC<ButtonProps> = ({
  title,
  onPress,
  variant = 'solid',
  color = 'primary',
  size = 'md',
  disabled = false,
  loading = false,
  icon,
  fullWidth = false,
  style,
}) => {
  const colorValue = UITheme.colors[color];

  return (
    <TouchableOpacity
      style={[getButtonStyle(), style]}
      onPress={onPress}
      disabled={disabled || loading}
      activeOpacity={0.7}
    >
      {loading ? (
        <ActivityIndicator 
          size="small" 
          color={variant === 'solid' ? UITheme.colors.white : colorValue} 
        />
      ) : (
        <>
          {icon && <Text style={{ marginRight: 6 }}>{icon}</Text>}
          <Text style={getTextStyle()}>{title}</Text>
        </>
      )}
    </TouchableOpacity>
  );
};

参数默认值的设置

所有可选参数都设置了默认值:variant 默认 ‘solid’,color 默认 ‘primary’,size 默认 ‘md’。这意味着最简单的用法只需要传 title 和 onPress:

<Button title="点击我" onPress={handleClick} />

这个按钮会是一个中等大小、主色调、实心填充的按钮,符合大多数场景的需求。

style 的合并方式

style={[getButtonStyle(), style]} 把计算出的样式和外部传入的 style 合并成数组。React Native 会按顺序应用样式,后面的会覆盖前面的。

这意味着外部传入的 style 优先级更高,可以覆盖组件内部的样式。比如你想给某个按钮加个 marginTop,直接传 style={{ marginTop: 10 }} 就行,不需要修改组件代码。

disabled 的双重判断

disabled={disabled || loading} 这行代码确保 loading 状态下按钮也不可点击。这是防止重复提交的关键:用户点击按钮后,按钮进入 loading 状态,此时再点击不会触发 onPress。

activeOpacity 的作用

activeOpacity 控制按下时的透明度。TouchableOpacity 的默认值是 0.2,按下去按钮会变得很透明,视觉上不太舒服。设为 0.7 后,按下时只是稍微变暗一点,反馈明显但不突兀。

loading 指示器的颜色适配

ActivityIndicator 的颜色需要根据按钮变体来设置:solid 按钮背景是深色的,用白色指示器;outline 和 ghost 按钮背景是透明的,用主题色指示器。这样无论哪种变体,loading 指示器都清晰可见。

图标的实现方式

用 emoji 作为图标是最简单的方案,不需要引入任何图标库。marginRight: 6 让图标和文字之间有 6px 的间距,视觉上不会太挤。

如果需要更专业的图标,可以把 icon 属性的类型从 string 改成 ReactNode,然后传入 react-native-vector-icons 或其他图标库的组件。

七、完整的使用示例

组件封装好了,来看看各种场景下怎么使用:

import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { Button } from './components/ui/Button';

const ButtonDemo = () => {
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    setLoading(true);
    await submitForm();
    setLoading(false);
  };

  return (
    <View style={styles.container}>
      {/* 主要操作和次要操作并排 */}
      <View style={styles.row}>
        <Button 
          title="确认" 
          color="primary" 
          onPress={handleConfirm} 
        />
        <Button 
          title="取消" 
          variant="outline" 
          onPress={handleCancel} 
          style={styles.ml} 
        />
      </View>

      {/* 危险操作用红色警示 */}
      <Button 
        title="删除账户" 
        icon="🗑️" 
        color="danger" 
        onPress={handleDelete} 
      />

      {/* 异步操作显示 loading */}
      <Button 
        title={loading ? "提交中..." : "提交"} 
        loading={loading} 
        fullWidth 
        onPress={handleSubmit} 
      />

      {/* 表单未填完时禁用提交按钮 */}
      <Button 
        title="下一步" 
        disabled={!formValid} 
        fullWidth 
        onPress={handleNext} 
      />

      {/* 工具栏用小号幽灵按钮 */}
      <View style={styles.toolbar}>
        <Button title="编辑" variant="ghost" size="sm" onPress={handleEdit} />
        <Button title="复制" variant="ghost" size="sm" onPress={handleCopy} />
        <Button title="分享" variant="ghost" size="sm" onPress={handleShare} />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { padding: 16, gap: 16 },
  row: { flexDirection: 'row' },
  ml: { marginLeft: 8 },
  toolbar: { flexDirection: 'row', justifyContent: 'space-around' },
});

每种场景选择不同的 variant、color、size 组合,能让界面的层次感更清晰。用户一眼就能分辨哪些是主要操作、哪些是次要操作、哪些是危险操作。

八、进阶优化方向

这个 Button 组件已经能满足大部分需求。如果想进一步完善,可以考虑以下方向:

增加按压动画:用 Animated API 实现按下时轻微缩放的效果,提升交互体验。

支持图标位置:增加 iconPosition 属性,支持图标在文字左边或右边。

支持全圆角:增加 rounded 属性,设为 true 时按钮变成胶囊形状。

支持渐变背景:用 react-native-linear-gradient 实现渐变色按钮。

支持涟漪效果:在 OpenHarmony 上可以通过自定义动画实现涟漪效果,增强用户交互体验。

这些优化都可以在现有代码基础上增量添加,不需要重构核心逻辑。


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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐