在这里插入图片描述

出国旅游、海淘购物、外贸业务,都离不开汇率换算。今天我们用 React Native 实现一个汇率换算工具,支持多种常用货币之间的互相转换,界面上用国旗图标让货币选择更直观。

汇率数据设计

在实际应用中,汇率数据通常从 API 获取。但为了避免依赖第三方服务,我们这里使用静态数据。这样做的好处是:代码可以离线运行,不需要网络权限,在 OpenHarmony 上也不用担心网络库的兼容性问题。

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

const rates: { [key: string]: { name: string; rate: number; flag: string } } = {
  CNY: { name: '人民币', rate: 1, flag: '🇨🇳' },
  USD: { name: '美元', rate: 0.14, flag: '🇺🇸' },
  EUR: { name: '欧元', rate: 0.13, flag: '🇪🇺' },
  JPY: { name: '日元', rate: 21.0, flag: '🇯🇵' },
  GBP: { name: '英镑', rate: 0.11, flag: '🇬🇧' },
  KRW: { name: '韩元', rate: 186.0, flag: '🇰🇷' },
  HKD: { name: '港币', rate: 1.09, flag: '🇭🇰' },
  TWD: { name: '台币', rate: 4.5, flag: '🇹🇼' },
};

这个数据结构设计得很巧妙。我们用人民币(CNY)作为基准货币,汇率设为 1。其他货币的汇率表示"1 人民币等于多少该货币"。比如 USD: { rate: 0.14 } 表示 1 人民币约等于 0.14 美元。

每种货币包含三个属性:

  • name:中文名称,用于显示
  • rate:相对于人民币的汇率
  • flag:国旗 emoji,让界面更直观

使用对象而不是数组的好处是:可以通过货币代码(如 rates['USD'])直接访问,不需要遍历查找。TypeScript 的类型定义 { [key: string]: ... } 表示这是一个字符串索引的对象。

鸿蒙 ArkTS 对比:数据结构

interface CurrencyInfo {
  name: string
  rate: number
  flag: string
}

const rates: Record<string, CurrencyInfo> = {
  CNY: { name: '人民币', rate: 1, flag: '🇨🇳' },
  USD: { name: '美元', rate: 0.14, flag: '🇺🇸' },
  EUR: { name: '欧元', rate: 0.13, flag: '🇪🇺' },
  JPY: { name: '日元', rate: 21.0, flag: '🇯🇵' },
  GBP: { name: '英镑', rate: 0.11, flag: '🇬🇧' },
  KRW: { name: '韩元', rate: 186.0, flag: '🇰🇷' },
  HKD: { name: '港币', rate: 1.09, flag: '🇭🇰' },
  TWD: { name: '台币', rate: 4.5, flag: '🇹🇼' },
}

ArkTS 中推荐使用 Record<string, T> 类型来定义字符串索引对象,这比 { [key: string]: T } 更简洁。同时定义一个 CurrencyInfo 接口,让代码更清晰。

数据结构在两个平台上完全一样,这是跨平台开发的优势——数据层代码可以直接复用。

状态和动画定义

export const CurrencyConverter: React.FC = () => {
  const [amount, setAmount] = useState('100');
  const [from, setFrom] = useState('CNY');
  const [to, setTo] = useState('USD');
  
  const resultAnim = useRef(new Animated.Value(1)).current;
  const rotateAnim = useRef(new Animated.Value(0)).current;
  const glowAnim = useRef(new Animated.Value(0)).current;

状态变量的设计:

  • amount:用户输入的金额,用字符串存储方便和 TextInput 绑定。默认值 ‘100’ 让用户打开就能看到换算结果
  • from:源货币代码,默认人民币
  • to:目标货币代码,默认美元

动画值的用途:

  • resultAnim:结果区域的缩放动画,每次输入变化时触发,给用户"结果更新了"的反馈
  • rotateAnim:交换按钮的旋转动画,点击时旋转 180 度
  • glowAnim:结果区域的发光动画,让界面更有活力

为什么 amount 用字符串而不是数字?因为 TextInput 的 value 属性需要字符串类型,如果用数字还需要来回转换。而且用户可能输入小数点,在输入过程中 “100.” 这样的字符串无法转成有效数字,用字符串存储更灵活。

发光动画初始化

  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(glowAnim, { toValue: 1, duration: 1500, useNativeDriver: false }),
        Animated.timing(glowAnim, { toValue: 0, duration: 1500, useNativeDriver: false }),
      ])
    ).start();
  }, []);

组件挂载时启动发光动画。这是一个无限循环的呼吸效果:1.5 秒内从 0 渐变到 1,再 1.5 秒内从 1 渐变回 0,总周期 3 秒。

useNativeDriver: false 是因为我们要用这个动画值控制 shadowOpacity,阴影相关的属性不支持原生驱动。虽然性能稍差,但发光动画的变化很平缓,不会造成卡顿。

空依赖数组 [] 表示这个 effect 只在组件挂载时执行一次,不会因为状态变化而重复执行。

结果更新动画

  useEffect(() => {
    resultAnim.setValue(0.8);
    Animated.spring(resultAnim, { toValue: 1, friction: 4, useNativeDriver: true }).start();
  }, [amount, from, to]);

每当金额、源货币或目标货币变化时,触发结果区域的弹跳动画。先把缩放值设为 0.8(稍微缩小),然后用弹簧动画恢复到 1。

这个动画的作用是给用户即时反馈:你的操作生效了,结果已经更新。没有这个动画,用户可能不确定结果是否已经重新计算。

依赖数组 [amount, from, to] 表示这三个值中任何一个变化都会触发动画。这是 React Hooks 的核心概念——声明式地描述副作用的依赖关系。

核心换算逻辑

  const convert = () => {
    const num = parseFloat(amount) || 0;
    const inCNY = num / rates[from].rate;
    return (inCNY * rates[to].rate).toFixed(2);
  };

换算逻辑分两步:

  1. 先把源货币金额转换成人民币:num / rates[from].rate
  2. 再把人民币转换成目标货币:inCNY * rates[to].rate

举个例子:把 100 美元换成日元

  • 100 美元 → 人民币:100 / 0.14 ≈ 714.29 人民币
  • 714.29 人民币 → 日元:714.29 × 21 ≈ 15000 日元

parseFloat(amount) || 0 这个写法很实用:如果 amount 无法解析成数字(比如空字符串),parseFloat 返回 NaN,而 NaN || 0 会返回 0。这样就避免了显示 NaN 的尴尬情况。

toFixed(2) 保留两位小数,这是货币金额的常见格式。

鸿蒙 ArkTS 对比:换算逻辑

convert(): string {
  let num = parseFloat(this.amount) || 0
  let inCNY = num / rates[this.from].rate
  return (inCNY * rates[this.to].rate).toFixed(2)
}

换算逻辑完全一样,只是 ArkTS 中需要用 this. 访问状态变量。这种纯计算函数是最容易跨平台复用的代码。

货币交换功能

  const swapCurrencies = () => {
    Animated.timing(rotateAnim, { toValue: 1, duration: 300, useNativeDriver: true }).start(() => {
      rotateAnim.setValue(0);
    });
    const temp = from;
    setFrom(to);
    setTo(temp);
  };

  const spin = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '180deg'] });

交换按钮的功能是把源货币和目标货币互换。动画让这个操作更有趣:按钮旋转 180 度,暗示"交换"的含义。

动画完成后的回调 () => { rotateAnim.setValue(0); } 把动画值重置为 0,这样下次点击还能再次旋转。如果不重置,动画值已经是 1,再次执行 toValue: 1 就没有效果了。

交换逻辑用了一个临时变量 temp,这是交换两个变量值的经典写法。在 JavaScript 中也可以用解构赋值 [from, to] = [to, from],但在 React 中这样写会有问题,因为 fromto 是状态值,不能直接赋值。

界面渲染:头部和输入

  return (
    <ScrollView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerIcon}>💱</Text>
        <Text style={styles.headerTitle}>汇率换算</Text>
        <Text style={styles.headerSubtitle}>实时货币转换</Text>
      </View>

      <View style={styles.inputCard}>
        <Text style={styles.inputLabel}>输入金额</Text>
        <View style={styles.inputWrapper}>
          <Text style={styles.inputFlag}>{rates[from].flag}</Text>
          <TextInput
            style={styles.input}
            value={amount}
            onChangeText={setAmount}
            keyboardType="numeric"
            placeholder="0"
            placeholderTextColor="#666"
          />
          <Text style={styles.inputCurrency}>{from}</Text>
        </View>
      </View>

输入区域的设计考虑了用户体验:

  • 左边显示当前源货币的国旗,让用户一眼就知道输入的是什么货币
  • 中间是输入框,数字键盘方便输入
  • 右边显示货币代码,进一步确认货币类型

rates[from].flag 这种写法动态获取当前源货币的国旗。当用户切换源货币时,国旗会自动更新。

货币选择网格

      <Text style={styles.sectionTitle}>从</Text>
      <View style={styles.currencyGrid}>
        {Object.entries(rates).map(([code, { name, flag }]) => (
          <TouchableOpacity
            key={code}
            style={[styles.currencyBtn, from === code && styles.currencyBtnActive]}
            onPress={() => setFrom(code)}
            activeOpacity={0.7}
          >
            <Text style={styles.currencyFlag}>{flag}</Text>
            <Text style={[styles.currencyCode, from === code && styles.currencyTextActive]}>{code}</Text>
            <Text style={[styles.currencyName, from === code && styles.currencyTextActive]}>{name}</Text>
          </TouchableOpacity>
        ))}
      </View>

货币选择用网格布局,每个货币一个按钮。Object.entries(rates) 把对象转换成 [key, value] 数组,方便用 map 遍历。

解构赋值 ([code, { name, flag }]) 同时获取货币代码和货币信息中的 nameflag 属性,代码更简洁。

选中状态通过比较 from === code 来判断,选中的按钮应用 currencyBtnActive 样式,背景变成蓝色,还有阴影效果。

这种"选择一个"的交互模式在移动端很常见,比单选按钮更直观,触摸目标也更大。

鸿蒙 ArkTS 对比:网格布局

Text('从')
  .fontSize(16)
  .fontColor('#4A90D9')
  .fontWeight(FontWeight.Bold)

Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(Object.entries(rates), ([code, info]: [string, CurrencyInfo]) => {
    Column() {
      Text(info.flag)
        .fontSize(20)
      Text(code)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.from === code ? Color.White : Color.White)
      Text(info.name)
        .fontSize(10)
        .fontColor(this.from === code ? Color.White : '#888888')
    }
    .width('23%')
    .margin('1%')
    .padding(12)
    .borderRadius(12)
    .backgroundColor(this.from === code ? '#4A90D9' : '#1a1a3e')
    .onClick(() => {
      this.from = code
    })
  })
}

ArkTS 中用 Flex 组件配合 FlexWrap.Wrap 实现网格布局。ForEach 遍历数据,每个货币用 Column 组件垂直排列国旗、代码和名称。

两种写法的结构类似,只是语法风格不同。React Native 用 JSX 和 style 数组,ArkTS 用声明式 UI 和链式调用。

交换按钮

      <TouchableOpacity style={styles.swapBtn} onPress={swapCurrencies} activeOpacity={0.7}>
        <Animated.View style={{ transform: [{ rotate: spin }] }}>
          <Text style={styles.swapIcon}>🔄</Text>
        </Animated.View>
      </TouchableOpacity>

交换按钮放在两个货币选择区域之间,位置很直观。用 Animated.View 包裹图标,应用旋转动画。

alignSelf: 'center' 让按钮在水平方向居中,不需要额外的容器。

目标货币选择

      <Text style={styles.sectionTitle}>转换为</Text>
      <View style={styles.currencyGrid}>
        {Object.entries(rates).map(([code, { name, flag }]) => (
          <TouchableOpacity
            key={code}
            style={[styles.currencyBtn, to === code && styles.currencyBtnActive]}
            onPress={() => setTo(code)}
            activeOpacity={0.7}
          >
            <Text style={styles.currencyFlag}>{flag}</Text>
            <Text style={[styles.currencyCode, to === code && styles.currencyTextActive]}>{code}</Text>
            <Text style={[styles.currencyName, to === code && styles.currencyTextActive]}>{name}</Text>
          </TouchableOpacity>
        ))}
      </View>

目标货币选择和源货币选择的代码几乎一样,只是状态变量从 from 变成了 to。这种重复代码可以提取成一个子组件,但为了保持示例的简洁性,这里直接复制。

在实际项目中,如果这种模式出现三次以上,就应该考虑抽取组件了。

结果显示

      <Animated.View style={[styles.result, {
        transform: [{ scale: resultAnim }, { perspective: 1000 }],
        shadowOpacity: glowAnim.interpolate({ inputRange: [0, 1], outputRange: [0.3, 0.6] }),
      }]}>
        <View style={styles.resultRow}>
          <Text style={styles.resultFlag}>{rates[from].flag}</Text>
          <Text style={styles.resultFrom}>{amount} {from}</Text>
        </View>
        <Text style={styles.resultEquals}>=</Text>
        <View style={styles.resultRow}>
          <Text style={styles.resultFlag}>{rates[to].flag}</Text>
          <Text style={styles.resultValue}>{convert()}</Text>
          <Text style={styles.resultCurrency}>{to}</Text>
        </View>
      </Animated.View>

结果区域是整个界面的焦点,设计上做了几个处理:

  1. 缩放动画scale: resultAnim 让结果区域在更新时有弹跳效果
  2. 透视效果perspective: 1000 为 3D 变换提供透视,虽然这里没有用到 3D 旋转,但加上透视可以让缩放动画看起来更自然
  3. 发光动画shadowOpacity 在 0.3 和 0.6 之间变化,形成呼吸效果

结果的布局分三行:

  • 第一行:源货币国旗 + 输入金额 + 源货币代码
  • 第二行:等号
  • 第三行:目标货币国旗 + 换算结果 + 目标货币代码

convert() 函数在渲染时调用,每次渲染都会重新计算。因为这是一个纯函数且计算量很小,不需要用 useMemo 优化。

免责声明

      <Text style={styles.disclaimer}>* 汇率仅供参考,非实时数据</Text>
    </ScrollView>
  );
};

底部加一行免责声明,告诉用户这不是实时汇率。这是一个好习惯,避免用户因为汇率不准确而产生误解。

样式定义:输入区域

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' },
  headerSubtitle: { fontSize: 14, color: '#888', marginTop: 4 },
  inputCard: {
    backgroundColor: '#1a1a3e',
    borderRadius: 20,
    padding: 20,
    marginBottom: 20,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  inputLabel: { color: '#888', fontSize: 14, marginBottom: 12 },
  inputWrapper: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#252550',
    borderRadius: 12,
    paddingHorizontal: 16,
  },
  inputFlag: { fontSize: 24, marginRight: 12 },
  input: { flex: 1, fontSize: 32, color: '#fff', paddingVertical: 16, fontWeight: '300' },
  inputCurrency: { color: '#4A90D9', fontSize: 18, fontWeight: '600' },

输入框的字号设为 32,比较大,方便用户查看和输入。fontWeight: '300' 使用细字重,看起来更现代。货币代码用蓝色,和主题色一致。

样式定义:货币网格

  sectionTitle: { color: '#4A90D9', fontSize: 16, fontWeight: '600', marginBottom: 12 },
  currencyGrid: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 16 },
  currencyBtn: {
    width: '23%',
    margin: '1%',
    backgroundColor: '#1a1a3e',
    padding: 12,
    borderRadius: 12,
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  currencyBtnActive: {
    backgroundColor: '#4A90D9',
    borderColor: '#4A90D9',
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.4,
    shadowRadius: 8,
  },
  currencyFlag: { fontSize: 20, marginBottom: 4 },
  currencyCode: { fontSize: 14, fontWeight: '600', color: '#fff' },
  currencyName: { fontSize: 10, color: '#888', marginTop: 2 },
  currencyTextActive: { color: '#fff' },

货币按钮用 width: '23%'margin: '1%' 实现四列布局(23% × 4 + 1% × 8 = 100%)。选中状态加蓝色阴影,让按钮看起来像是浮起来的。

货币名称用 10 号小字,因为空间有限,而且货币代码已经足够识别了。

样式定义:结果区域

  swapBtn: {
    alignSelf: 'center',
    width: 50,
    height: 50,
    borderRadius: 25,
    backgroundColor: '#252550',
    justifyContent: 'center',
    alignItems: 'center',
    marginVertical: 8,
  },
  swapIcon: { fontSize: 24 },
  result: {
    backgroundColor: '#1a1a3e',
    borderRadius: 20,
    padding: 24,
    marginTop: 8,
    borderWidth: 1,
    borderColor: '#4A90D9',
    alignItems: 'center',
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 8 },
    shadowRadius: 20,
  },
  resultRow: { flexDirection: 'row', alignItems: 'center' },
  resultFlag: { fontSize: 28, marginRight: 10 },
  resultFrom: { color: '#888', fontSize: 18 },
  resultEquals: { color: '#4A90D9', fontSize: 28, marginVertical: 12 },
  resultValue: { color: '#fff', fontSize: 42, fontWeight: '700' },
  resultCurrency: { color: '#4A90D9', fontSize: 24, marginLeft: 8 },
  disclaimer: { textAlign: 'center', color: '#666', marginTop: 20, fontSize: 12 },
});

结果区域用蓝色边框和蓝色阴影,让它成为视觉焦点。换算结果用 42 号大字,是整个界面最大的文字,突出最重要的信息。

小结

这个汇率换算工具展示了如何用 React Native 实现一个数据驱动的应用。通过合理的数据结构设计,换算逻辑变得简单清晰。国旗 emoji 让货币选择更直观,动画效果增强了交互体验。

在 OpenHarmony 平台上,这些功能都能正常工作。如果需要实时汇率,可以在联网时从 API 获取数据更新 rates 对象,换算逻辑不需要修改。


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

Logo

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

更多推荐