今天我们用 React Native 实现一个小费计算工具,支持快速选择小费比例,计算分摊金额。
请添加图片描述

状态设计

import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';

export const TipCalculator: React.FC = () => {
  const [bill, setBill] = useState('');
  const [tipPercent, setTipPercent] = useState('15');
  const [people, setPeople] = useState('1');
  
  const resultAnim = useRef(new Animated.Value(1)).current;
  const peopleAnim = useRef(new Animated.Value(1)).current;
  const pulseAnim = useRef(new Animated.Value(1)).current;

状态设计包含账单金额、小费比例、用餐人数、动画值。

账单金额bill 是字符串类型,存储用户输入的账单金额。

小费比例tipPercent 是字符串类型,存储小费百分比。默认值 '15',表示 15%。

用餐人数people 是字符串类型,存储用餐人数。默认值 '1',表示 1 人。

三个动画值

  • resultAnim:结果卡片的缩放动画
  • peopleAnim:人数的缩放动画
  • pulseAnim:每人支付的脉冲动画(循环放大缩小)

为什么小费比例默认 15%?因为 15% 是国际上最常见的小费比例。在美国、加拿大等国家,15% 是标准小费。

脉冲动画

  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(pulseAnim, { toValue: 1.02, duration: 1500, useNativeDriver: true }),
        Animated.timing(pulseAnim, { toValue: 1, duration: 1500, useNativeDriver: true }),
      ])
    ).start();
  }, []);

组件挂载时,启动每人支付的脉冲动画。

循环动画Animated.loop() 让动画无限循环。

序列动画

  1. 放大到 102%(1.5 秒)
  2. 缩回到 100%(1.5 秒)

为什么用脉冲动画?因为"每人支付"是用户最关心的信息。多人用餐时,用户想知道"我要付多少钱"。脉冲动画让这个数字更醒目。

输入变化动画

  useEffect(() => {
    Animated.sequence([
      Animated.timing(resultAnim, { toValue: 0.95, duration: 100, useNativeDriver: true }),
      Animated.spring(resultAnim, { toValue: 1, friction: 4, useNativeDriver: true }),
    ]).start();
  }, [bill, tipPercent, people]);

监听账单、小费比例、人数变化,触发结果卡片的动画。

依赖数组[bill, tipPercent, people] 表示当这三个值任意一个变化时,执行动画。

序列动画

  1. 缩小到 95%(100ms)
  2. 弹回到 100%(弹簧动画)

为什么监听三个输入?因为这三个值都会影响结果。账单变化,小费和总计都变化。小费比例变化,小费和总计变化。人数变化,每人支付变化。动画让用户感知到"结果更新了"。

人数动画

  const animatePeople = () => {
    Animated.sequence([
      Animated.timing(peopleAnim, { toValue: 1.2, duration: 100, useNativeDriver: true }),
      Animated.spring(peopleAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
    ]).start();
  };

人数变化时触发的动画函数。

序列动画

  1. 放大到 120%(100ms)
  2. 弹回到 100%(弹簧动画)

为什么人数变化要单独动画?因为人数是通过按钮修改的,不是输入框。单独动画让用户看到"人数变化了",增加交互反馈。

计算函数

  const calculate = () => {
    const b = parseFloat(bill) || 0;
    const t = parseFloat(tipPercent) || 0;
    const p = parseInt(people) || 1;
    const tip = b * t / 100;
    const total = b + tip;
    const perPerson = total / p;
    return { tip: tip.toFixed(2), total: total.toFixed(2), perPerson: perPerson.toFixed(2) };
  };

  const result = calculate();
  const tipOptions = [10, 15, 18, 20, 25];

计算小费、总计、每人支付。

解析输入

  • parseFloat(bill) || 0:账单金额,默认 0
  • parseFloat(tipPercent) || 0:小费比例,默认 0
  • parseInt(people) || 1:用餐人数,默认 1

计算小费tip = b * t / 100。比如账单 100,小费 15%,小费 100 × 15 / 100 = 15。

计算总计total = b + tip。比如账单 100,小费 15,总计 100 + 15 = 115。

计算每人支付perPerson = total / p。比如总计 115,2 人,每人支付 115 / 2 = 57.5。

保留 2 位小数toFixed(2) 让结果更易读。

小费选项[10, 15, 18, 20, 25] 是常见的小费比例。

为什么这些比例?因为这是国际上最常见的小费比例:

  • 10%:服务一般
  • 15%:标准小费
  • 18%:服务良好
  • 20%:服务优秀
  • 25%:服务非常好

界面渲染:头部和账单输入

  return (
    <ScrollView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerIcon}>💰</Text>
        <Text style={styles.headerTitle}>小费计算</Text>
      </View>

      <View style={styles.inputCard}>
        <Text style={styles.label}>💵 账单金额</Text>
        <View style={styles.inputWrapper}>
          <TextInput style={styles.input} value={bill} onChangeText={setBill} keyboardType="numeric" placeholder="0.00" placeholderTextColor="#666" />
        </View>
      </View>

头部显示标题,账单输入卡片包含标签和输入框。

头部

  • 图标:💰 钱袋
  • 标题:小费计算

账单输入卡片

  • 标签:💵 账单金额
  • 输入框:keyboardType="numeric" 弹出数字键盘,textAlign: 'center' 居中对齐,字号 28

为什么输入框字号 28?因为账单金额是重要信息,大字号让用户看得更清楚。

小费比例选择

      <View style={styles.inputCard}>
        <Text style={styles.label}>📊 小费比例</Text>
        <View style={styles.tipOptions}>
          {tipOptions.map(t => (
            <TouchableOpacity key={t} style={[styles.tipBtn, tipPercent === String(t) && styles.tipBtnActive]} onPress={() => setTipPercent(String(t))} activeOpacity={0.7}>
              <Text style={[styles.tipText, tipPercent === String(t) && styles.tipTextActive]}>{t}%</Text>
            </TouchableOpacity>
          ))}
        </View>
        <View style={styles.customInput}>
          <TextInput style={styles.inputSmall} value={tipPercent} onChangeText={setTipPercent} keyboardType="numeric" placeholder="自定义 %" placeholderTextColor="#666" />
        </View>
      </View>

小费比例卡片包含快速选择按钮和自定义输入框。

快速选择按钮

  • 遍历 tipOptions 数组,生成 5 个按钮
  • 当前选中的按钮用蓝色背景(tipBtnActive
  • 按钮文字显示小费比例,比如"15%"
  • 横向布局,每个按钮占相同宽度(flex: 1

自定义输入框

  • 在快速选择按钮下面
  • 字号 16,居中对齐
  • 占位符"自定义 %"

为什么提供自定义输入框?因为有些用户想输入特殊的小费比例,比如 12% 或 22%。快速选择按钮覆盖常见情况,自定义输入框覆盖特殊情况。

用餐人数

      <View style={styles.inputCard}>
        <Text style={styles.label}>👥 用餐人数</Text>
        <View style={styles.peopleRow}>
          <TouchableOpacity style={styles.peopleBtn} onPress={() => { animatePeople(); setPeople(String(Math.max(1, parseInt(people) - 1))); }} activeOpacity={0.7}>
            <Text style={styles.peopleBtnText}>−</Text>
          </TouchableOpacity>
          <Animated.Text style={[styles.peopleCount, { transform: [{ scale: peopleAnim }] }]}>{people}</Animated.Text>
          <TouchableOpacity style={styles.peopleBtn} onPress={() => { animatePeople(); setPeople(String(parseInt(people) + 1)); }} activeOpacity={0.7}>
            <Text style={styles.peopleBtnText}>+</Text>
          </TouchableOpacity>
        </View>
      </View>

用餐人数卡片包含减号按钮、人数显示、加号按钮。

减号按钮

  • 点击时触发人数动画
  • 人数减 1,最小值 1(Math.max(1, parseInt(people) - 1)
  • 圆形按钮,蓝色背景,白色文字

人数显示

  • 字号 40,白色,加粗
  • 应用缩放动画
  • 左右各有 40 的间距

加号按钮

  • 点击时触发人数动画
  • 人数加 1
  • 圆形按钮,蓝色背景,白色文字

为什么用按钮而不是输入框?因为人数通常是小整数(1-10),用按钮比输入框更方便。点击加减按钮比输入数字更快。

为什么最小值是 1?因为至少有 1 个人用餐。0 人没有意义。

结果显示

      <Animated.View style={[styles.result, { transform: [{ scale: resultAnim }] }]}>
        <View style={styles.resultRow}>
          <Text style={styles.resultLabel}>小费</Text>
          <Text style={styles.resultValue}>¥ {result.tip}</Text>
        </View>
        <View style={styles.resultRow}>
          <Text style={styles.resultLabel}>总计</Text>
          <Text style={styles.resultValue}>¥ {result.total}</Text>
        </View>
        <Animated.View style={[styles.resultMain, { transform: [{ scale: pulseAnim }] }]}>
          <Text style={styles.resultLabelMain}>每人支付</Text>
          <Text style={styles.resultValueMain}>¥ {result.perPerson}</Text>
        </Animated.View>
      </Animated.View>
    </ScrollView>
  );
};

结果卡片显示小费、总计、每人支付。

结果卡片动画:应用缩放动画,输入变化时缩小再弹回。

小费行

  • 标签:小费
  • 金额:白色文字,字号 20

总计行

  • 标签:总计
  • 金额:白色文字,字号 20

每人支付

  • 标签:每人支付
  • 金额:蓝色文字,字号 40,应用脉冲动画
  • 居中对齐

为什么每人支付字号最大?因为这是用户最关心的信息。多人用餐时,用户想知道"我要付多少钱"。大字号 + 蓝色 + 脉冲动画,让这个数字成为视觉焦点。

鸿蒙 ArkTS 对比:小费计算

@State bill: string = ''
@State tipPercent: string = '15'
@State people: string = '1'

calculate() {
  const b = parseFloat(this.bill) || 0
  const t = parseFloat(this.tipPercent) || 0
  const p = parseInt(this.people) || 1
  const tip = b * t / 100
  const total = b + tip
  const perPerson = total / p
  return { tip: tip.toFixed(2), total: total.toFixed(2), perPerson: perPerson.toFixed(2) }
}

build() {
  Column() {
    Text('小费计算')
      .fontSize(28)
      .fontWeight(FontWeight.Bold)
    
    Column() {
      Text('💵 账单金额')
      TextInput({ text: this.bill, placeholder: '0.00' })
        .type(InputType.Number)
        .fontSize(28)
        .textAlign(TextAlign.Center)
        .onChange((value: string) => {
          this.bill = value
        })
    }
    
    Column() {
      Text('📊 小费比例')
      Row() {
        ForEach([10, 15, 18, 20, 25], (t: number) => {
          Button(`${t}%`)
            .backgroundColor(this.tipPercent === String(t) ? '#4A90D9' : '#252550')
            .onClick(() => {
              this.tipPercent = String(t)
            })
        })
      }
      
      TextInput({ text: this.tipPercent, placeholder: '自定义 %' })
        .type(InputType.Number)
        .onChange((value: string) => {
          this.tipPercent = value
        })
    }
    
    Column() {
      Text('👥 用餐人数')
      Row() {
        Button('−')
          .onClick(() => {
            this.people = String(Math.max(1, parseInt(this.people) - 1))
          })
        Text(this.people)
          .fontSize(40)
          .fontWeight(FontWeight.Bold)
        Button('+')
          .onClick(() => {
            this.people = String(parseInt(this.people) + 1)
          })
      }
    }
    
    Column() {
      Row() {
        Text('小费')
        Text(`¥ ${this.calculate().tip}`)
      }
      Row() {
        Text('总计')
        Text(`¥ ${this.calculate().total}`)
      }
      Column() {
        Text('每人支付')
        Text(`¥ ${this.calculate().perPerson}`)
          .fontSize(40)
          .fontColor('#4A90D9')
          .fontWeight(FontWeight.Bold)
      }
    }
  }
}

ArkTS 中的小费计算逻辑完全一样。核心是公式:小费 = 账单 × 比例 / 100,总计 = 账单 + 小费,每人支付 = 总计 / 人数。parseFloat()parseInt()toFixed()Math.max() 都是标准 JavaScript API,跨平台通用。

样式定义

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 20 },
  header: { alignItems: 'center', marginBottom: 24 },
  headerIcon: { fontSize: 50, marginBottom: 8 },
  headerTitle: { fontSize: 28, fontWeight: '700', color: '#fff' },
  inputCard: { backgroundColor: '#1a1a3e', borderRadius: 16, padding: 16, marginBottom: 16, borderWidth: 1, borderColor: '#3a3a6a' },
  label: { fontSize: 14, color: '#888', marginBottom: 12 },
  inputWrapper: { backgroundColor: '#252550', borderRadius: 12 },
  input: { padding: 16, fontSize: 28, textAlign: 'center', color: '#fff' },
  inputSmall: { padding: 12, fontSize: 16, textAlign: 'center', color: '#fff' },
  customInput: { backgroundColor: '#252550', borderRadius: 10, marginTop: 10 },
  tipOptions: { flexDirection: 'row', marginBottom: 8 },
  tipBtn: { flex: 1, padding: 12, backgroundColor: '#252550', borderRadius: 10, marginHorizontal: 3, alignItems: 'center' },
  tipBtnActive: { backgroundColor: '#4A90D9' },
  tipText: { color: '#888', fontWeight: '600' },
  tipTextActive: { color: '#fff' },
  peopleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
  peopleBtn: { width: 56, height: 56, backgroundColor: '#4A90D9', borderRadius: 28, justifyContent: 'center', alignItems: 'center' },
  peopleBtnText: { color: '#fff', fontSize: 28, fontWeight: '600' },
  peopleCount: { fontSize: 40, fontWeight: '700', marginHorizontal: 40, color: '#fff' },
  result: { backgroundColor: '#1a1a3e', padding: 20, borderRadius: 20, borderWidth: 1, borderColor: '#3a3a6a' },
  resultRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: '#3a3a6a' },
  resultLabel: { fontSize: 16, color: '#888' },
  resultValue: { fontSize: 20, color: '#fff', fontWeight: '600' },
  resultMain: { paddingTop: 20, alignItems: 'center' },
  resultLabelMain: { fontSize: 14, color: '#888', marginBottom: 8 },
  resultValueMain: { fontSize: 40, color: '#4A90D9', fontWeight: '700' },
});

容器用深蓝黑色背景。输入卡片用深蓝色背景,圆角 16。小费比例按钮横向布局,每个按钮占相同宽度。人数按钮圆形,宽高 56,圆角 28。每人支付字号 40,蓝色。

小结

这个小费计算工具展示了实时计算和交互反馈的实现。用公式计算小费、总计、每人支付,监听输入变化触发动画。快速选择按钮让用户不需要手动输入小费比例。加减按钮让用户方便调整人数。


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

Logo

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

更多推荐