Flutter 实战:random_color 随机颜色生成器的动画过渡、历史色板与鸿蒙适配解析

前言

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

随机颜色生成器是一个非常适合拆解 Flutter 动画、颜色和交互状态的小项目。它看起来只是“点一下换个颜色”,但源码里实际覆盖了 随机 RGB 生成背景色动画过渡HEX/RGB 文本展示对比色计算历史色板手势触发浮动按钮刷新 等多个知识点。

random_color 的实现集中在 lib/main.dart 中:页面启动后生成初始随机色,用户点击页面、上滑页面或点击刷新按钮时都会生成新颜色;背景通过 AnimatedBuilderColor.lerp 做 500ms 过渡;最近生成的 10 个颜色会进入历史列表,用户可以点击历史色块回到某个颜色。

颜色类工具的体验重点不只在生成随机值,还在于过渡是否自然、文字是否可读、色值是否清晰、历史状态是否方便回看。

在这里插入图片描述

图示说明:上图展示 Flutter 页面在移动端的布局组织方式。random_color 的实际界面由全屏颜色背景、色值卡片、历史色板和刷新按钮组成。

一、项目定位与功能边界

1.1 应用定位

random_color 是一个随机颜色生成与展示工具,适合用于 UI 配色灵感、Flutter 动画学习、颜色格式展示和手势交互练习。它不依赖网络接口,也没有复杂三方插件,核心能力全部由 Dart 和 Flutter Widget 实现。

项目当前支持:

  • 随机生成 RGB 颜色。
  • 背景色在新旧颜色之间平滑过渡。
  • 展示 HEX 色值和 RGB 色值。
  • 根据背景亮度自动切换黑白前景色。
  • 点击任意位置生成新颜色。
  • 上滑生成新颜色。
  • 点击刷新按钮生成新颜色。
  • 保留最近 10 个历史颜色。
  • 点击历史色块恢复对应颜色。

1.2 功能模块

功能模块 页面表现 源码实现
随机颜色 每次生成新的背景色 math.Random() + Color.fromARGB
动画过渡 背景色平滑变化 AnimationController + Color.lerp
色值展示 HEX 与 RGB 文本 _colorToHex()_colorToRgb()
对比色 深色背景白字,浅色背景黑字 computeLuminance()
手势触发 点击或上滑生成颜色 GestureDetector
历史色板 横向最近颜色列表 ListView.builder
刷新按钮 右下角生成颜色按钮 FloatingActionButton

1.3 技术栈

技术点 使用位置 价值
Flutter 页面、卡片、手势、动画 快速构建交互界面
Dart 随机数、字符串转换、扩展方法 处理颜色逻辑
Material 3 应用主题和基础控件 useMaterial3: true
StatefulWidget 当前色、历史色和动画状态 管理交互变化
AnimationController 控制颜色过渡进度 让刷新更自然

二、工程结构与运行环境

2.1 工程结构

random_color 是标准 Flutter 工程,核心逻辑集中在 lib/main.dart

文件或目录 作用
lib/main.dart 应用入口、随机颜色、动画、历史色板和 UI
pubspec.yaml Flutter SDK 与测试依赖声明
test/widget_test.dart Widget 测试入口
ohos/ 鸿蒙平台工程目录
analysis_options.yaml Dart 静态分析规则

2.2 运行命令

flutter doctor
flutter pub get
flutter run

项目依赖很轻,主要使用 Flutter SDK 和 Dart 标准库能力。dart:math 用于生成随机数,Flutter 动画系统用于颜色过渡。

2.3 依赖声明

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

这种依赖结构对跨端验证很友好:核心逻辑在 Dart 层,鸿蒙侧主要关注动画、手势、颜色渲染、字体和透明度表现。

三、应用入口与主题配置

3.1 main 函数

Flutter 应用从 main() 进入:

import 'package:flutter/material.dart';
import 'dart:math' as math;

void main() {
  runApp(const RandomColorApp());
}

dart:math 被命名为 math,后续通过 math.Random() 生成 RGB 随机值。

3.2 根组件

class RandomColorApp extends StatelessWidget {
  const RandomColorApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Random Color',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink),
        useMaterial3: true,
      ),
      home: const RandomColorHomePage(title: 'Random Color'),
    );
  }
}

根组件负责配置应用标题、主题和首页。由于随机色、历史色和动画进度都在页面内部变化,所以根组件保持无状态即可。

3.3 主题色

colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)

应用主题使用粉色作为种子色。页面启动后实际 AppBar 和按钮颜色会跟随当前随机色变化,因此主题色更多用于初始风格和 Material 组件默认状态。

四、StatefulWidget 与动画混入

4.1 首页组件

class RandomColorHomePage extends StatefulWidget {
  const RandomColorHomePage({super.key, required this.title});
  final String title;

  
  State<RandomColorHomePage> createState() => _RandomColorHomePageState();
}

首页需要响应用户手势、颜色变化和动画播放,因此使用 StatefulWidget

4.2 SingleTickerProviderStateMixin

class _RandomColorHomePageState extends State<RandomColorHomePage>
    with SingleTickerProviderStateMixin {
  // ...
}

SingleTickerProviderStateMixinAnimationController 提供 vsync。这个页面只有一个动画控制器,用单 ticker 就足够。

4.3 核心状态字段

Color _currentColor = Colors.pink;
Color _previousColor = Colors.white;
final List<Color> _history = [];
late AnimationController _controller;
late Animation<double> _animation;
字段 类型 作用
_currentColor Color 当前展示颜色
_previousColor Color 动画起始颜色
_history List<Color> 最近生成的颜色列表
_controller AnimationController 控制动画时间
_animation Animation<double> 输出 0 到 1 的插值进度

五、动画初始化与生命周期

5.1 initState


void initState() {
  super.initState();
  _controller = AnimationController(
    duration: const Duration(milliseconds: 500),
    vsync: this,
  );
  _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
  _generateColor();
}

页面创建后先初始化动画控制器,再创建曲线动画,最后生成初始随机色。

5.2 动画时长

duration: const Duration(milliseconds: 500)

500ms 的过渡不算突兀,也不会拖慢操作反馈,适合颜色变化这种轻量视觉动画。

5.3 dispose


void dispose() {
  _controller.dispose();
  super.dispose();
}

AnimationController 持有 ticker 资源,页面销毁时必须释放。这个生命周期处理对动画类页面非常关键。

六、随机颜色生成逻辑

6.1 生成 RGB 值

final random = math.Random();
_currentColor = Color.fromARGB(
  255,
  random.nextInt(256),
  random.nextInt(256),
  random.nextInt(256),
);

nextInt(256) 会生成 0 到 255 之间的整数,刚好对应 RGB 每个通道的取值范围。Alpha 固定为 255,表示完全不透明。

6.2 保存前一个颜色

_previousColor = _currentColor;

在生成新颜色前,先把当前颜色保存为 _previousColor。后续动画会从旧颜色插值到新颜色。

6.3 完整生成方法

void _generateColor() {
  final random = math.Random();
  setState(() {
    _previousColor = _currentColor;
    _currentColor = Color.fromARGB(
      255,
      random.nextInt(256),
      random.nextInt(256),
      random.nextInt(256),
    );
    _history.insert(0, _currentColor);
    if (_history.length > 10) {
      _history.removeLast();
    }
  });
  _controller.reset();
  _controller.forward();
}

这个方法同时完成颜色生成、历史写入、长度限制和动画播放。

七、历史色板设计

7.1 插入最新颜色

_history.insert(0, _currentColor);

新颜色插入到列表最前面,用户看到的第一个色块就是最新颜色。

7.2 限制历史数量

if (_history.length > 10) {
  _history.removeLast();
}

历史列表最多保留 10 个颜色,避免页面横向列表无限增长。

7.3 历史色块点击

onTap: () {
  setState(() {
    _previousColor = _currentColor;
    _currentColor = _history[index];
  });
}

点击历史色块会把当前颜色切换到对应历史颜色。源码中这一步没有重新播放动画控制器,因此恢复历史颜色时更接近即时切换。

八、颜色格式化:HEX 与 RGB

8.1 RGB 文本

String _colorToRgb(Color color) {
  return 'RGB(${color.r.toInt()}, ${color.g.toInt()}, ${color.b.toInt()})';
}

RGB 文本直接读取 Color 的 r、g、b 通道,并转换为整数展示。

8.2 Color 扩展方法

extension ColorExtension on Color {
  String toHexString() {
    return '${r.toInt().toRadixString(16).padLeft(2, '0')}'
        '${g.toInt().toRadixString(16).padLeft(2, '0')}'
        '${b.toInt().toRadixString(16).padLeft(2, '0')}';
  }
}

扩展方法把 RGB 三个通道转成 16 进制字符串,每个通道补齐两位。

8.3 HEX 文本细节

String _colorToHex(Color color) {
  return '#${color.toHexString().substring(2).toUpperCase()}';
}

这里有一个源码细节:toHexString() 已经返回 6 位 RGB 字符串,再执行 substring(2) 会截掉前两位。也就是说当前展示会更接近 #GGBB 形式,而不是完整 #RRGGBB。如果希望展示完整 HEX,可以直接使用 color.toHexString().toUpperCase()

String formatFullHex(Color color) {
  return '#${color.toHexString().toUpperCase()}';
}

文章按当前源码真实表现说明这个点,方便读者理解显示结果和颜色通道之间的关系。

九、对比色算法

9.1 computeLuminance

Color _getContrastColor(Color color) {
  final luminance = color.computeLuminance();
  return luminance > 0.5 ? Colors.black : Colors.white;
}

computeLuminance() 会计算颜色亮度。亮度高时使用黑色文字,亮度低时使用白色文字。

9.2 应用位置

对比色用于 AppBar、图标和提示文字。

appBar: AppBar(
  title: Text(widget.title),
  backgroundColor: _currentColor,
  foregroundColor: _getContrastColor(_currentColor),
)

这样无论当前背景是浅色还是深色,标题和按钮都能保持可读。

9.3 对比色表

背景亮度 前景色 目的
大于 0.5 黑色 浅色背景提高对比
小于等于 0.5 白色 深色背景提高对比

随机颜色不可控,所以文字颜色不能写死。对比色算法是颜色生成器的可用性基础。

十、动画过渡实现

10.1 AnimatedBuilder

AnimatedBuilder(
  animation: _animation,
  builder: (context, child) {
    return Container(
      decoration: BoxDecoration(
        color: Color.lerp(
          _previousColor,
          _currentColor,
          _animation.value,
        ),
      ),
      child: SafeArea(child: Column()),
    );
  },
)

AnimatedBuilder 会在动画值变化时重新构建背景容器。

10.2 Color.lerp

Color.lerp(_previousColor, _currentColor, _animation.value)

Color.lerp 根据动画进度在旧颜色和新颜色之间插值。_animation.value 从 0 变化到 1,背景色也从旧颜色过渡到新颜色。

10.3 动画播放流程

生成新颜色
保存 previousColor
写入 currentColor
reset 动画控制器
forward 播放动画
AnimatedBuilder 重建背景
Color.lerp 输出过渡颜色

这个流程是 Flutter 中处理颜色过渡的经典写法。

十一、手势交互设计

11.1 点击任意位置

GestureDetector(
  onTap: _generateColor,
  child: AnimatedBuilder(
    animation: _animation,
    builder: (context, child) {
      return Container();
    },
  ),
)

整个页面都可以点击生成颜色,交互成本很低。

11.2 上滑生成颜色

onVerticalDragEnd: (details) {
  if (details.primaryVelocity! < 0) {
    _generateColor();
  }
}

primaryVelocity < 0 表示向上滑动结束。这个手势让页面除了点击外,还有一种更接近浏览色卡的交互方式。

11.3 浮动按钮刷新

floatingActionButton: FloatingActionButton(
  onPressed: _generateColor,
  backgroundColor: _currentColor,
  foregroundColor: _getContrastColor(_currentColor),
  child: const Icon(Icons.refresh),
)

刷新按钮提供明确的操作入口,也和全屏点击形成互补。

十二、页面布局结构

12.1 Scaffold 骨架

return Scaffold(
  appBar: AppBar(
    title: Text(widget.title),
    backgroundColor: _currentColor,
    foregroundColor: _getContrastColor(_currentColor),
  ),
  body: GestureDetector(
    onTap: _generateColor,
    child: AnimatedBuilder(...),
  ),
  floatingActionButton: FloatingActionButton(...),
);

页面由 AppBar、全屏可点击背景和右下角按钮组成。

12.2 SafeArea 与 Column

SafeArea(
  child: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Icon(Icons.colorize),
      Text('Tap anywhere'),
      Card(...),
      Text('Swipe up for new color'),
      SizedBox(child: ListView.builder(...)),
    ],
  ),
)

SafeArea 避免内容被状态栏、刘海屏或系统手势区域遮挡。

12.3 色值卡片

Card(
  margin: const EdgeInsets.symmetric(horizontal: 32),
  color: Colors.white.withValues(alpha: 0.9),
  child: Padding(
    padding: const EdgeInsets.all(24),
    child: Column(
      children: [
        Text(_colorToHex(_currentColor)),
        Text(_colorToRgb(_currentColor)),
      ],
    ),
  ),
)

色值卡片使用接近白色的半透明背景,让文字在大多数随机背景上都比较清楚。

十三、历史色板 UI

13.1 横向列表

SizedBox(
  height: 50,
  child: ListView.builder(
    scrollDirection: Axis.horizontal,
    itemCount: _history.length,
    itemBuilder: (context, index) {
      return GestureDetector(
        onTap: () {
          setState(() {
            _previousColor = _currentColor;
            _currentColor = _history[index];
          });
        },
        child: Container(),
      );
    },
  ),
)

历史色板使用横向列表,适合展示一组小色块。

13.2 色块样式

Container(
  width: 50,
  height: 50,
  margin: const EdgeInsets.symmetric(horizontal: 4),
  decoration: BoxDecoration(
    color: _history[index],
    borderRadius: BorderRadius.circular(8),
    border: Border.all(color: Colors.white, width: 2),
  ),
)

固定尺寸色块可以避免列表跳动,白色边框让色块在深色背景上更容易辨认。

13.3 历史列表价值

历史色板让随机生成不再是一次性结果。用户可以快速回看刚才出现过的颜色,也可以对比多个颜色之间的视觉差异。

十四、边界场景与真实限制

14.1 随机性

源码每次调用 _generateColor() 都创建新的 math.Random()。这适合轻量工具使用,但不能保证颜色不重复。随机颜色出现重复是正常现象。

14.2 历史数量

历史只保留 10 个颜色,超过后删除最后一个。这让 UI 保持紧凑,但也意味着更早的颜色不会长期保留。

14.3 HEX 展示

当前 _colorToHex() 会截取 toHexString() 的后四位。RGB 文本仍能完整展示三个通道,HEX 文本则需要按源码真实表现理解。

14.4 透明度 API

源码使用 withValues(alpha: 0.7)withValues(alpha: 0.9) 控制透明度。跨端验证时需要观察目标 Flutter 版本和平台渲染效果。

十五、Widget 测试设计

15.1 基础渲染测试

import 'package:flutter_test/flutter_test.dart';
import '../lib/main.dart';

void main() {
  testWidgets('random color renders home page', (tester) async {
    await tester.pumpWidget(const RandomColorApp());

    expect(find.text('Random Color'), findsWidgets);
    expect(find.text('Tap anywhere'), findsOneWidget);
    expect(find.text('Swipe up for new color'), findsOneWidget);
  });
}

这个测试验证根组件、提示文本和首页基础结构。

15.2 点击生成测试

testWidgets('tap page keeps hex and rgb card visible', (tester) async {
  await tester.pumpWidget(const RandomColorApp());

  await tester.tap(find.text('Tap anywhere'));
  await tester.pump();

  expect(find.textContaining('RGB('), findsOneWidget);
});

由于颜色是随机的,测试不应断言具体色值,而应验证关键 UI 仍然存在。

15.3 刷新按钮测试

testWidgets('refresh button triggers animation flow', (tester) async {
  await tester.pumpWidget(const RandomColorApp());

  await tester.tap(find.byIcon(Icons.refresh));
  await tester.pump(const Duration(milliseconds: 500));

  expect(find.byIcon(Icons.colorize), findsOneWidget);
});

这个测试覆盖浮动按钮和动画推进。

15.4 测试命令

flutter test

保持测试里的根组件名称与源码一致,可以避免默认模板测试遗留造成编译失败。

十六、鸿蒙适配观察

16.1 适配优势

random_color 的核心逻辑全部在 Dart 和 Flutter Widget 层完成,没有复杂原生插件依赖,因此鸿蒙侧重点是渲染、动画和手势。

维度 当前项目情况 鸿蒙侧关注点
随机颜色 Dart math.Random 多端逻辑一致
动画过渡 AnimationController 帧率和颜色插值
手势 GestureDetector 点击和上滑识别
透明色 withValues 透明度渲染效果
历史色板 横向 ListView 滚动和色块边界

16.2 构建命令参考

flutter clean
flutter pub get
flutter build hap

具体命令取决于所使用的鸿蒙 Flutter 适配环境。对这个项目而言,主要验证页面启动、颜色动画、手势触发和色值展示。

16.3 运行验证要点

  1. 应用可以正常启动到随机颜色页面。
  2. 点击页面能生成新颜色。
  3. 上滑手势能生成新颜色。
  4. 颜色过渡动画没有明显卡顿。
  5. AppBar 前景色在深浅背景下保持可读。
  6. 历史色板能横向展示并点击恢复颜色。

鸿蒙适配中,随机色逻辑通常不是风险点,颜色渲染、透明度、动画流畅度、手势识别和文字可读性更值得仔细验证。

十七、性能与可维护性

17.1 性能特征

项目计算量很小,性能主要取决于动画和重绘。

维度 当前表现
随机生成 常量级
历史列表 最多 10 个
动画时长 500ms
页面结构 单屏展示
重绘方式 动画驱动背景重建

17.2 当前结构优点

  • 状态字段少,含义明确。
  • 颜色生成逻辑集中在 _generateColor()
  • 对比色逻辑单独封装,便于复用。
  • 历史数量受控,页面不会无限增长。
  • 动画控制器生命周期处理完整。

17.3 可演进方向

如果项目继续扩展,可以把颜色格式化能力独立出来。

String rgbToHex(Color color) {
  final r = color.r.toInt().toRadixString(16).padLeft(2, '0');
  final g = color.g.toInt().toRadixString(16).padLeft(2, '0');
  final b = color.b.toInt().toRadixString(16).padLeft(2, '0');
  return '#${(r + g + b).toUpperCase()}';
}

抽离后可以单独测试 HEX 输出、RGB 输出和对比色逻辑。

十八、常见问题与优化建议

18.1 为什么使用 SingleTickerProviderStateMixin

页面只有一个动画控制器,需要一个 ticker 驱动动画帧。SingleTickerProviderStateMixin 正好满足这个需求。

18.2 为什么要保存 _previousColor

动画需要知道起点和终点。_previousColor 是过渡起点,_currentColor 是过渡终点,两者配合 Color.lerp 才能生成平滑变化。

18.3 为什么要计算对比色

随机背景可能非常亮,也可能非常暗。固定使用白字或黑字都会遇到不可读情况,因此需要根据亮度自动选择前景色。

18.4 为什么历史色板只保留 10 个

历史数量太多会占用横向空间,也会让用户难以浏览。10 个颜色足够回看最近结果,同时保持 UI 简洁。

18.5 为什么点击历史色块没有播放同样的动画

源码点击历史色块只更新 _currentColor,没有调用动画控制器的 reset()forward()。因此历史恢复更接近即时切换,这是当前实现的真实表现。

18.6 为什么适合做鸿蒙适配示例

它同时覆盖颜色渲染、动画、手势、透明度、历史列表和对比色,这些都属于 Flutter 视觉类应用在鸿蒙侧需要重点观察的能力。

总结

random_color 用一个单页 Flutter 应用完成了随机颜色生成器的核心闭环:通过 math.Random() 生成 RGB 颜色,通过 AnimationControllerColor.lerp 做颜色过渡,通过 computeLuminance() 保证文字可读,通过历史色板保留最近颜色。

从工程角度看,这个项目的亮点是交互轻、结构清楚、动画逻辑完整。它不仅能展示颜色,还能让用户通过点击、上滑和刷新按钮快速生成新结果,并通过历史色块回看最近颜色。

从鸿蒙适配角度看,项目没有复杂原生依赖,重点在颜色渲染、动画流畅度、手势识别、透明度表现和字体可读性。把这些细节验证好,就能获得比较稳定的跨端视觉体验。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐