在这里插入图片描述

今天我们用 React Native 实现一个抽签工具,支持自定义选项列表,带有旋转、缩放、发光等多重动画效果。

状态设计

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

export const LotteryPicker: React.FC = () => {
  const [options, setOptions] = useState('选项1\n选项2\n选项3\n选项4\n选项5');
  const [result, setResult] = useState<string | null>(null);
  const [isAnimating, setIsAnimating] = useState(false);
  
  const scaleAnim = useRef(new Animated.Value(1)).current;
  const rotateAnim = useRef(new Animated.Value(0)).current;
  const glowAnim = useRef(new Animated.Value(0)).current;
  const buttonAnim = useRef(new Animated.Value(1)).current;

状态设计包含选项列表、抽签结果、动画状态。

选项列表options 是多行文本,每行一个选项。默认值是 5 个选项,用换行符 \n 分隔。用户可以编辑这个文本,添加或删除选项。

抽签结果result 是当前显示的结果。初始值是 null,显示"点击抽签"提示。抽签过程中会快速切换不同的选项,最后停在随机选中的选项上。

动画状态isAnimating 标记是否正在抽签。抽签过程中禁用按钮,防止重复点击。

四个动画值

  • scaleAnim:结果框的缩放动画,抽签过程中快速缩放,最终结果放大后缩回
  • rotateAnim:结果框的旋转动画,抽签时旋转 720 度(两圈)
  • glowAnim:结果框的发光动画,阴影透明度循环变化,营造"呼吸"效果
  • buttonAnim:按钮的缩放动画,点击时缩小再弹回

发光动画循环

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

组件加载时启动发光动画的无限循环。

循环动画Animated.loop 让动画无限重复。内部是一个序列动画,先从 0 到 1(1.5 秒),再从 1 到 0(1.5 秒),总共 3 秒一个周期。

为什么不用 useNativeDriver?因为发光动画控制的是 shadowOpacity(阴影透明度),这是一个样式属性,不是变换属性。useNativeDriver 只支持 transformopacity,不支持 shadowOpacity。如果强制使用 useNativeDriver: true,会报错。

发光效果glowAnim 的值在 0 和 1 之间循环变化,后面会用插值映射到阴影透明度 0.3-0.6。阴影透明度变化让结果框看起来像在"呼吸",吸引用户注意。

为什么用 1500ms?因为 1.5 秒的节奏不快不慢,既能让用户注意到动画,又不会太频繁导致视觉疲劳。3 秒一个完整周期,符合人的呼吸节奏。

抽签核心逻辑

  const pick = () => {
    const items = options.split('\n').filter(s => s.trim());
    if (items.length === 0) return;
    
    setIsAnimating(true);
    
    // 按钮动画
    Animated.sequence([
      Animated.timing(buttonAnim, { toValue: 0.9, duration: 100, useNativeDriver: true }),
      Animated.spring(buttonAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
    ]).start();
    
    // 旋转动画
    rotateAnim.setValue(0);
    Animated.timing(rotateAnim, { toValue: 1, duration: 1500, useNativeDriver: true }).start();

抽签函数包含选项解析、动画启动、随机选择三个部分。

选项解析options.split('\n') 按换行符分割,得到选项数组。filter(s => s.trim()) 过滤掉空行和只有空格的行。trim() 去除首尾空格,如果结果是空字符串,filter 会过滤掉。

空选项检查:如果没有有效选项,直接返回,不执行抽签。这避免了空数组导致的错误。

设置动画状态setIsAnimating(true) 标记开始抽签,禁用按钮。

按钮动画:点击按钮时,按钮缩小到 90%(100ms),再弹回到 100%。friction: 3 让弹簧有明显的回弹效果,给用户"按下去"的触感反馈。

旋转动画rotateAnim.setValue(0) 重置旋转角度为 0,确保每次抽签都从 0 度开始旋转。Animated.timing 让旋转值从 0 到 1(1.5 秒),后面会用插值映射到 0-720 度。

快速切换动画

    let count = 0;
    const interval = setInterval(() => {
      setResult(items[Math.floor(Math.random() * items.length)]);
      
      // 缩放动画
      Animated.sequence([
        Animated.timing(scaleAnim, { toValue: 1.1, duration: 50, useNativeDriver: true }),
        Animated.timing(scaleAnim, { toValue: 1, duration: 50, useNativeDriver: true }),
      ]).start();
      
      count++;
      if (count > 15) {
        clearInterval(interval);
        setIsAnimating(false);
        setResult(items[Math.floor(Math.random() * items.length)]);
        
        // 最终结果动画
        Animated.spring(scaleAnim, { toValue: 1.2, friction: 3, useNativeDriver: true }).start(() => {
          Animated.spring(scaleAnim, { toValue: 1, friction: 4, useNativeDriver: true }).start();
        });
      }
    }, 100);
  };

快速切换动画营造"抽签"的紧张感。

定时器setInterval 每 100ms 执行一次,快速切换显示的选项。

随机选择Math.floor(Math.random() * items.length) 生成 0 到 items.length - 1 的随机整数,作为数组索引。每次都随机选择一个选项显示。

缩放动画:每次切换选项时,结果框放大到 110%(50ms),再缩回到 100%(50ms)。总共 100ms,和定时器间隔一致。这让每次切换都有视觉反馈,用户能清楚看到选项在变化。

计数器count 记录切换次数。切换 15 次后停止(1.5 秒),和旋转动画的时长一致。

停止动画

  • clearInterval(interval) 停止定时器
  • setIsAnimating(false) 解除动画状态,启用按钮
  • 再次随机选择一个选项作为最终结果

最终结果动画:结果框放大到 120%,再缩回到 100%。两次弹簧动画串联,第一次放大用 friction: 3(弹性强),第二次缩回用 friction: 4(弹性弱),形成"弹出 → 稳定"的效果。

为什么切换 15 次?因为 15 次 × 100ms = 1500ms,和旋转动画的时长一致。旋转和切换同时结束,视觉上更协调。如果切换次数太少(比如 5 次),抽签过程太短,用户感觉不到紧张感;如果太多(比如 30 次),抽签过程太长,用户会不耐烦。

快速模板

  const presets = [
    { name: '今天吃什么', icon: '🍜', options: '火锅\n烧烤\n麻辣烫\n炒菜\n面条\n饺子\n汉堡\n披萨' },
    { name: '做还是不做', icon: '🤔', options: '做!\n不做\n再想想\n明天再说' },
    { name: '谁来干活', icon: '👷', options: '张三\n李四\n王五\n赵六' },
  ];

预设模板让用户快速开始,不需要手动输入选项。

模板结构:每个模板包含名称、图标、选项列表。选项列表是多行文本,和 options 状态的格式一致。

模板内容

  • “今天吃什么”:8 种常见食物,解决选择困难症
  • “做还是不做”:4 种决策选项,帮助做决定
  • “谁来干活”:4 个人名,随机分配任务

为什么选择这 3 个模板?因为它们覆盖了抽签工具的 3 种典型场景:选择(吃什么)、决策(做不做)、分配(谁来做)。用户可以直接使用,也可以在模板基础上修改。

旋转插值

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

插值把动画值 0-1 映射到旋转角度 0-720 度。

为什么旋转 720 度?因为 720 度是两圈,视觉效果明显,但又不会太多导致眩晕。一圈(360 度)太少,感觉不够刺激;三圈(1080 度)太多,用户会觉得晕。

为什么用字符串?因为 rotate 变换需要带单位的字符串,比如 '45deg'。如果用数字 45,React Native 会报错。

界面渲染:头部和结果框

  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>

      <Animated.View style={[styles.resultBox, {
        transform: [{ scale: scaleAnim }, { rotate: spin }],
        shadowOpacity: glowAnim.interpolate({ inputRange: [0, 1], outputRange: [0.3, 0.6] }),
      }]}>
        <Text style={[styles.result, isAnimating && styles.resultAnimating]}>
          {result || '点击抽签'}
        </Text>
      </Animated.View>

头部区域包含图标、标题、副标题。🎰 老虎机图标表示"随机抽取"的概念。

结果框动画

  • transform: [{ scale: scaleAnim }, { rotate: spin }]:同时应用缩放和旋转变换
  • shadowOpacity:用插值把 glowAnim 的 0-1 映射到阴影透明度 0.3-0.6。阴影透明度循环变化,营造"呼吸"效果

结果文本

  • result || '点击抽签':如果没有结果,显示提示文字
  • isAnimating && styles.resultAnimating:抽签过程中应用 resultAnimating 样式,降低透明度到 0.7,让用户知道结果还在变化

为什么阴影透明度是 0.3-0.6?因为 0.3 是最小值,阴影可见但不明显;0.6 是最大值,阴影明显但不过分。如果最小值是 0,阴影会完全消失,视觉上不连续;如果最大值是 1,阴影太重,会遮挡内容。

抽签按钮

      <Animated.View style={{ transform: [{ scale: buttonAnim }] }}>
        <TouchableOpacity
          style={[styles.btn, isAnimating && styles.btnDisabled]}
          onPress={pick}
          disabled={isAnimating}
          activeOpacity={0.8}
        >
          <Text style={styles.btnIcon}>{isAnimating ? '🎲' : '🎯'}</Text>
          <Text style={styles.btnText}>{isAnimating ? '抽签中...' : '开始抽签'}</Text>
        </TouchableOpacity>
      </Animated.View>

按钮用 Animated.View 包裹,应用缩放动画。

按钮状态

  • disabled={isAnimating}:抽签过程中禁用按钮,防止重复点击
  • isAnimating && styles.btnDisabled:抽签过程中应用 btnDisabled 样式,降低透明度到 0.7

按钮内容

  • 图标:抽签中显示 🎲(骰子),空闲时显示 🎯(靶心)
  • 文字:抽签中显示"抽签中…“,空闲时显示"开始抽签”

为什么用两个图标?因为图标变化能让用户立即感知状态变化。🎲 骰子表示"正在随机",🎯 靶心表示"准备就绪"。

选项输入和模板

      <View style={styles.inputCard}>
        <Text style={styles.label}>📝 选项列表(每行一个)</Text>
        <TextInput
          style={styles.input}
          value={options}
          onChangeText={setOptions}
          multiline
          placeholderTextColor="#666"
        />
      </View>

      <View style={styles.presetsSection}>
        <Text style={styles.label}>⚡ 快速模板</Text>
        <View style={styles.presets}>
          {presets.map(p => (
            <TouchableOpacity
              key={p.name}
              style={styles.preset}
              onPress={() => setOptions(p.options)}
              activeOpacity={0.7}
            >
              <Text style={styles.presetIcon}>{p.icon}</Text>
              <Text style={styles.presetText}>{p.name}</Text>
            </TouchableOpacity>
          ))}
        </View>
      </View>
    </ScrollView>
  );
};

选项输入框

  • multiline:支持多行输入
  • 标签提示"每行一个",告诉用户输入格式
  • 最小高度 150 像素,确保有足够的空间

模板按钮

  • 遍历 presets 数组,渲染 3 个模板按钮
  • 点击模板按钮时,用模板的选项列表替换当前的 options
  • 每个按钮显示图标和名称

为什么用 flexWrap: 'wrap'?因为模板按钮的宽度不固定(取决于文字长度),用 flexWrap 让按钮自动换行。如果一行放不下,自动换到下一行。

鸿蒙 ArkTS 对比:抽签逻辑

@State options: string = '选项1\n选项2\n选项3\n选项4\n选项5'
@State result: string = '点击抽签'
@State isAnimating: boolean = false

private scaleAnim: number = 1
private rotateAnim: number = 0

pick() {
  const items = this.options.split('\n').filter(s => s.trim())
  if (items.length === 0) return
  
  this.isAnimating = true
  
  // 启动旋转动画
  animateTo({ duration: 1500 }, () => {
    this.rotateAnim = 720
  })
  
  let count = 0
  const interval = setInterval(() => {
    this.result = items[Math.floor(Math.random() * items.length)]
    
    // 缩放动画
    animateTo({ duration: 50 }, () => { this.scaleAnim = 1.1 })
    setTimeout(() => {
      animateTo({ duration: 50 }, () => { this.scaleAnim = 1 })
    }, 50)
    
    count++
    if (count > 15) {
      clearInterval(interval)
      this.isAnimating = false
      this.result = items[Math.floor(Math.random() * items.length)]
      
      // 最终结果动画
      animateTo({ duration: 300, curve: Curve.Spring }, () => {
        this.scaleAnim = 1.2
      })
      setTimeout(() => {
        animateTo({ duration: 300, curve: Curve.Spring }, () => {
          this.scaleAnim = 1
        })
      }, 300)
    }
  }, 100)
}

ArkTS 中的抽签逻辑完全一样,核心是定时器 + 随机选择 + 动画。区别在于动画 API:React Native 用 Animated API,ArkTS 用 animateTo 函数。animateTo 更简洁,不需要创建动画值对象,直接修改状态变量就能触发动画。

样式定义:结果框和按钮

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 },
  resultBox: {
    backgroundColor: '#1a1a3e',
    padding: 50,
    borderRadius: 20,
    marginBottom: 24,
    alignItems: 'center',
    borderWidth: 2,
    borderColor: '#4A90D9',
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 8 },
    shadowRadius: 20,
  },
  result: { fontSize: 36, color: '#fff', fontWeight: '700' },
  resultAnimating: { opacity: 0.7 },

结果框是页面的视觉焦点,用大内边距(50)、大圆角(20)、蓝色边框、蓝色阴影营造"舞台"效果。

阴影设置

  • shadowColor: '#4A90D9':蓝色阴影,和边框颜色一致
  • shadowOffset: { width: 0, height: 8 }:阴影向下偏移 8 像素,模拟"悬浮"效果
  • shadowRadius: 20:阴影模糊半径 20,让阴影柔和扩散

结果文字:字号 36,白色粗体,醒目。抽签过程中透明度降低到 0.7,让用户知道结果还在变化。

样式定义:按钮和输入框

  btn: {
    backgroundColor: '#e74c3c',
    padding: 18,
    borderRadius: 16,
    alignItems: 'center',
    marginBottom: 24,
    flexDirection: 'row',
    justifyContent: 'center',
    shadowColor: '#e74c3c',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.4,
    shadowRadius: 15,
  },
  btnDisabled: { opacity: 0.7 },
  btnIcon: { fontSize: 24, marginRight: 10 },
  btnText: { color: '#fff', fontSize: 20, fontWeight: '700' },
  inputCard: {
    backgroundColor: '#1a1a3e',
    borderRadius: 16,
    padding: 16,
    marginBottom: 20,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  label: { color: '#888', fontSize: 14, marginBottom: 12 },
  input: {
    backgroundColor: '#252550',
    padding: 14,
    borderRadius: 12,
    minHeight: 150,
    color: '#fff',
    fontSize: 16,
  },

按钮用红色背景(#e74c3c),和蓝色的结果框形成对比,是页面的第二个视觉焦点。红色阴影和红色背景一致,增强立体感。

按钮布局

  • flexDirection: 'row':图标和文字水平排列
  • justifyContent: 'center':内容居中对齐
  • 图标字号 24,文字字号 20,图标稍大,更醒目

输入框用深蓝色背景,最小高度 150 像素,确保能显示多行选项。

样式定义:模板按钮

  presetsSection: { marginBottom: 20 },
  presets: { flexDirection: 'row', flexWrap: 'wrap' },
  preset: {
    backgroundColor: '#1a1a3e',
    paddingVertical: 12,
    paddingHorizontal: 16,
    borderRadius: 12,
    marginRight: 10,
    marginBottom: 10,
    flexDirection: 'row',
    alignItems: 'center',
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  presetIcon: { fontSize: 18, marginRight: 8 },
  presetText: { color: '#4A90D9', fontSize: 14 },
});

模板按钮用 flexWrap: 'wrap' 自动换行。每个按钮包含图标和文字,水平排列。

按钮样式

  • 背景色和输入卡片一致,保持视觉统一
  • 圆角 12,比卡片的圆角 16 小,形成层次
  • 右边距和下边距各 10,让按钮之间有间隙
  • 文字用蓝色,和结果框的边框颜色一致

为什么模板按钮用蓝色文字?因为蓝色是页面的主题色(结果框边框、阴影都是蓝色),用蓝色文字保持视觉一致性。红色只用在主按钮上,突出主要操作。

小结

这个抽签工具展示了多重动画的协调配合。旋转、缩放、发光三个动画同时运行,营造丰富的视觉效果。定时器 + 随机选择实现快速切换,给用户紧张感。最终结果用弹簧动画强调,让用户清楚看到选中的选项。


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

Logo

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

更多推荐