
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
🌊 一、贝塞尔曲线:从数学原理到视觉艺术
📚 1.1 贝塞尔曲线的历史渊源
贝塞尔曲线(Bézier Curve)是以法国工程师**皮埃尔·贝塞尔(Pierre Bézier)**的名字命名的。他在 1960 年代为雷诺汽车公司工作时,为了解决汽车车身设计的曲线问题而发明了这种曲线。最初,这种曲线被用于计算机辅助设计(CAD)系统,后来逐渐成为计算机图形学中最基础也是最重要的曲线类型之一。
贝塞尔曲线的魅力在于它的直观性 和可控性。设计师只需要调整几个控制点,就能得到各种优美的曲线形状。这种特性使得贝塞尔曲线在以下领域得到了广泛应用:
- 🎨 字体设计:TrueType 和 PostScript 字体都使用贝塞尔曲线描述字符轮廓
- 🖼️ 矢量图形:SVG、Adobe Illustrator 等矢量图形格式基于贝塞尔曲线
- 🎬 动画路径:游戏和动画中的物体运动轨迹
- 🚗 工业设计:汽车、飞机等产品的曲面设计
在现代 UI 设计中,几乎所有的曲线元素都离不开贝塞尔曲线的身影。
📐 1.2 贝塞尔曲线的数学定义
贝塞尔曲线是一种参数曲线,它通过一组控制点来定义曲线的形状。根据控制点的数量,贝塞尔曲线可以分为不同阶数:
| 阶数 | 控制点数 | 名称 | 特点 |
|---|---|---|---|
| 一阶 | 2 | 线性贝塞尔曲线 | ➖ 直线段 |
| 二阶 | 3 | 二次贝塞尔曲线 | 📐 一个控制点,单弯曲线 |
| 三阶 | 4 | 三次贝塞尔曲线 | 🌊 两个控制点,双弯曲线 |
| n阶 | n+1 | n次贝塞尔曲线 | 🔮 n个控制点 |
🔹 1.2.1 一阶贝塞尔曲线(线性插值)
一阶贝塞尔曲线是最简单的形式,实际上就是两点之间的直线段。其参数方程为:
B(t) = (1-t) * P0 + t * P1, t ∈ [0, 1]
其中:
- 📍 P0 是起点
- 📍 P1 是终点
- ⏱️ t 是参数
当 t 从 0 变化到 1 时,点 B(t) 从 P0 移动到 P1,形成一条直线。
🔹 1.2.2 二阶贝塞尔曲线(二次贝塞尔曲线)
二阶贝塞尔曲线由三个点定义:起点 P0、控制点 P1 和终点 P2。其参数方程为:
B(t) = (1-t)² * P0 + 2(1-t)t * P1 + t² * P2, t ∈ [0, 1]
💡 理解技巧 :这个公式可以理解为:首先在 P0P1 和 P1P2 两条线段上进行线性插值,得到两个中间点,然后在这两个中间点之间再进行线性插值。这种递归的构造方式被称为德卡斯特里奥算法(De Casteljau's Algorithm)。
🔹 1.2.3 三阶贝塞尔曲线(三次贝塞尔曲线)⭐
三阶贝塞尔曲线是我们本文的重点,它由四个点定义:
B(t) = (1-t)³ * P0 + 3(1-t)²t * P1 + 3(1-t)t² * P2 + t³ * P3, t ∈ [0, 1]
| 符号 | 含义 | 说明 |
|---|---|---|
| P0 | 起点 | 🟢 曲线的起始位置 |
| P1 | 控制点1 | 🔵 控制曲线的起始方向和弯曲程度 |
| P2 | 控制点2 | 🔵 控制曲线的结束方向和弯曲程度 |
| P3 | 终点 | 🔴 曲线的结束位置 |
三阶贝塞尔曲线具有以下重要特性:
| 特性 | 说明 |
|---|---|
| 🎯 端点插值 | 曲线通过起点 P0 和终点 P3 |
| 📏 切线性质 | 曲线在起点处的切线方向为 P0P1,在终点处的切线方向为 P2P3 |
| 📦 凸包性质 | 曲线完全包含在控制点形成的凸包内 |
| 🔄 仿射不变性 | 对控制点进行仿射变换等价于对曲线进行相同的仿射变换 |
🎯 1.3 为什么选择三阶贝塞尔曲线?
在音乐可视化应用中,三阶贝塞尔曲线是最常用的选择,原因如下:
| 优势 | 详细说明 |
|---|---|
| 🌊 灵活性 | 三阶贝塞尔曲线可以表示 S 形曲线,这是二阶贝塞尔曲线无法做到的。S 形曲线在模拟流体运动时非常重要,可以创造出更加自然的"呼吸感"效果。 |
| 🎮 可控性 | 两个控制点提供了足够的自由度,设计师可以精确控制曲线的形状,同时保持曲线的平滑性。 |
| ⚡ 计算效率 | 三阶贝塞尔曲线的计算复杂度适中,在保证视觉效果的同时不会对性能造成太大负担。 |
| 🔗 兼容性 | 大多数图形库和设计工具都原生支持三阶贝塞尔曲线,便于在不同平台间移植。 |
🔧 二、德卡斯特里奥算法:贝塞尔曲线的几何构造
🧮 2.1 算法原理
德卡斯特里奥算法是一种递归分割算法,用于计算贝塞尔曲线上的点。它的核心思想是将 n 阶贝塞尔曲线分解为两个 n-1 阶贝塞尔曲线。
对于三阶贝塞尔曲线,算法步骤如下:
步骤 1️⃣:给定四个控制点 P0、P1、P2、P3 和参数 t
↓
步骤 2️⃣:在线段 P0P1 上找到点 Q0,使得 Q0 = (1-t)P0 + tP1
↓
步骤 3️⃣:在线段 P1P2 上找到点 Q1,使得 Q1 = (1-t)P1 + tP2
↓
步骤 4️⃣:在线段 P2P3 上找到点 Q2,使得 Q2 = (1-t)P2 + tP3
↓
步骤 5️⃣:在线段 Q0Q1 上找到点 R0,使得 R0 = (1-t)Q0 + tQ1
↓
步骤 6️⃣:在线段 Q1Q2 上找到点 R1,使得 R1 = (1-t)Q1 + tQ2
↓
步骤 7️⃣:在线段 R0R1 上找到点 B,使得 B = (1-t)R0 + tR1
↓
步骤 8️⃣:点 B 就是曲线上参数为 t 的点 ✅
🎨 可视化理解:这个过程就像是在不断地"切割"线段,每次切割都使点更接近曲线上的最终位置。
💻 2.2 Dart 实现德卡斯特里奥算法
dart
import 'dart:math';
/// 二维点类
class Point2D {
final double x;
final double y;
const Point2D(this.x, this.y);
// 运算符重载
Point2D operator +(Point2D other) => Point2D(x + other.x, y + other.y);
Point2D operator -(Point2D other) => Point2D(x - other.x, y - other.y);
Point2D operator *(double scalar) => Point2D(x * scalar, y * scalar);
double get distance => sqrt(x * x + y * y);
@override
String toString() => 'Point2D($x, $y)';
}
/// 三阶贝塞尔曲线
class CubicBezierCurve {
final Point2D p0; // 🟢 起点
final Point2D p1; // 🔵 控制点1
final Point2D p2; // 🔵 控制点2
final Point2D p3; // 🔴 终点
CubicBezierCurve(this.p0, this.p1, this.p2, this.p3);
/// 使用德卡斯特里奥算法计算曲线上的点
Point2D pointAt(double t) {
// 第一层插值
final q0 = _lerp(p0, p1, t);
final q1 = _lerp(p1, p2, t);
final q2 = _lerp(p2, p3, t);
// 第二层插值
final r0 = _lerp(q0, q1, t);
final r1 = _lerp(q1, q2, t);
// 第三层插值(最终点)
return _lerp(r0, r1, t);
}
/// 线性插值
Point2D _lerp(Point2D a, Point2D b, double t) {
return a + (b - a) * t;
}
/// 计算曲线的切线向量
Point2D tangentAt(double t) {
final oneMinusT = 1 - t;
final oneMinusT2 = oneMinusT * oneMinusT;
final t2 = t * t;
return (p1 - p0) * (3 * oneMinusT2) +
(p2 - p1) * (6 * oneMinusT * t) +
(p3 - p2) * (3 * t2);
}
/// 计算曲线的法线向量
Point2D normalAt(double t) {
final tangent = tangentAt(t);
return Point2D(-tangent.y, tangent.x);
}
/// 生成曲线上的点序列
List<Point2D> generatePoints(int segmentCount) {
final points = <Point2D>[];
for (int i = 0; i <= segmentCount; i++) {
final t = i / segmentCount;
points.add(pointAt(t));
}
return points;
}
}
🎚️ 2.3 曲线细分与自适应采样
在实际应用中,我们需要将贝塞尔曲线转换为一系列线段进行绘制。为了保证绘制质量,我们需要根据曲线的曲率自适应地调整采样密度。
dart
/// 自适应贝塞尔曲线采样器
class AdaptiveBezierSampler {
final double tolerance; // 🎚️ 容差值,控制采样精度
AdaptiveBezierSampler({this.tolerance = 1.0});
/// 自适应采样
List<Point2D> sample(CubicBezierCurve curve) {
final points = <Point2D>[];
_sampleRecursive(curve, 0, 1, points);
return points;
}
/// 递归细分采样
void _sampleRecursive(
CubicBezierCurve curve,
double t0,
double t1,
List<Point2D> points,
) {
final p0 = curve.pointAt(t0);
final p1 = curve.pointAt((t0 + t1) / 2);
final p2 = curve.pointAt(t1);
// 计算中点到弦的距离
final distance = _pointToLineDistance(p1, p0, p2);
if (distance < tolerance) {
// ✅ 曲线足够平直,直接添加端点
if (points.isEmpty || points.last != p0) {
points.add(p0);
}
points.add(p2);
} else {
// 🔄 曲线弯曲,继续细分
final midT = (t0 + t1) / 2;
_sampleRecursive(curve, t0, midT, points);
_sampleRecursive(curve, midT, t1, points);
}
}
/// 计算点到线段的距离
double _pointToLineDistance(Point2D point, Point2D lineStart, Point2D lineEnd) {
final line = lineEnd - lineStart;
final lineLength = line.distance;
if (lineLength == 0) {
return (point - lineStart).distance;
}
final t = max(0, min(1, point.dot(line) / (lineLength * lineLength)));
final projection = lineStart + line * t;
return (point - projection).distance;
}
}
/// 点类的扩展方法
extension Point2DExtension on Point2D {
double dot(Point2D other) => x * other.x + y * other.y;
}
🌬️ 三、流体动画的"呼吸感"设计
💭 3.1 什么是"呼吸感"?
"呼吸感"是动画设计中一个重要的美学概念,指的是动画效果具有类似生命体呼吸的节奏感和韵律感。具有"呼吸感"的动画通常具有以下特征:
| 特征 | 说明 | 示例 |
|---|---|---|
| 🔄 周期性 | 动画按照一定的周期循环往复,就像呼吸的吸气和呼气 | 波浪起伏 |
| 📈 缓动变化 | 动画速度不是恒定的,而是有加速和减速的过程 | ease-in-out |
| 🌿 有机性 | 动画轨迹不是机械的直线或圆弧,而是具有自然弯曲的曲线 | 水流效果 |
| 🎵 响应性 | 动画能够对外部刺激(如音乐节奏)做出反应 | 音频可视化 |
🎨 设计哲学:好的"呼吸感"动画应该让用户感受到一种自然的、有生命力的节奏,而不是机械的、冰冷的重复运动。
🎛️ 3.2 音乐驱动的曲线变形
在音乐可视化中,我们可以使用音频数据来驱动贝塞尔曲线的控制点,创造出随音乐律动的流体效果。
dart
import 'dart:typed_data';
import 'dart:math';
/// 音频驱动的贝塞尔曲线
class AudioDrivenBezier {
final CubicBezierCurve baseCurve; // 📐 基础曲线
final double amplitude; // 📊 变形幅度
final double phase; // 🔄 相位偏移
AudioDrivenBezier({
required this.baseCurve,
this.amplitude = 50,
this.phase = 0,
});
/// 根据音频数据生成变形曲线
CubicBezierCurve deform(Float32List audioData, double time) {
// 从音频数据中提取特征
final energy = _calculateEnergy(audioData);
final frequency = _estimateFrequency(audioData);
// 计算控制点的偏移
final offset1 = _calculateOffset(energy, frequency, time, 0);
final offset2 = _calculateOffset(energy, frequency, time, pi / 2);
// 创建变形后的曲线
return CubicBezierCurve(
baseCurve.p0,
Point2D(baseCurve.p1.x + offset1.x, baseCurve.p1.y + offset1.y),
Point2D(baseCurve.p2.x + offset2.x, baseCurve.p2.y + offset2.y),
baseCurve.p3,
);
}
/// 计算音频能量 ⚡
double _calculateEnergy(Float32List audioData) {
double sum = 0;
for (int i = 0; i < audioData.length; i++) {
sum += audioData[i] * audioData[i];
}
return sqrt(sum / audioData.length);
}
/// 估计主频率 📻
double _estimateFrequency(Float32List audioData) {
int crossings = 0;
for (int i = 1; i < audioData.length; i++) {
if ((audioData[i - 1] >= 0 && audioData[i] < 0) ||
(audioData[i - 1] < 0 && audioData[i] >= 0)) {
crossings++;
}
}
return crossings / audioData.length;
}
/// 计算控制点偏移 📍
Point2D _calculateOffset(double energy, double frequency, double time, double phaseOffset) {
final wave = sin(time * frequency * 10 + phase + phaseOffset);
final offset = energy * amplitude * wave;
return Point2D(offset * cos(phase + phaseOffset), offset * sin(phase + phaseOffset));
}
}
🌊 3.3 多曲线组合的流体效果
单条贝塞尔曲线的效果有限,我们可以组合多条曲线,创造出更加丰富的流体效果。
dart
/// 流体曲线系统
class FluidCurveSystem {
final List<CubicBezierCurve> curves;
final List<double> phases;
final double spread; // 📐 曲线间的扩散角度
FluidCurveSystem({
required this.curves,
required this.phases,
this.spread = 0.1,
});
/// 更新曲线状态
List<CubicBezierCurve> update(Float32List audioData, double time) {
final updatedCurves = <CubicBezierCurve>[];
for (int i = 0; i < curves.length; i++) {
final curve = curves[i];
final phase = phases[i];
// 计算该曲线的音频响应
final response = _calculateResponse(audioData, i, curves.length);
// 创建变形曲线
final deformed = _deformCurve(curve, response, time, phase);
updatedCurves.add(deformed);
}
return updatedCurves;
}
/// 计算曲线的音频响应 🎵
double _calculateResponse(Float32List audioData, int index, int total) {
final segmentSize = audioData.length ~/ total;
final start = index * segmentSize;
final end = min(start + segmentSize, audioData.length);
double sum = 0;
for (int i = start; i < end; i++) {
sum += audioData[i].abs();
}
return sum / (end - start);
}
/// 变形曲线 🌊
CubicBezierCurve _deformCurve(CubicBezierCurve curve, double response, double time, double phase) {
final wave = sin(time * 2 + phase) * response * 30;
return CubicBezierCurve(
curve.p0,
Point2D(curve.p1.x + wave * cos(phase), curve.p1.y + wave * sin(phase)),
Point2D(curve.p2.x - wave * cos(phase), curve.p2.y - wave * sin(phase)),
curve.p3,
);
}
}
🎵 3.4 网络音乐播放与实时音频分析
在实际的音乐可视化应用中,我们需要播放网络 MP3 并实时提取音频数据来驱动贝塞尔曲线动画。下面介绍如何使用 just_audio_ohos 实现 OpenHarmony 平台的网络音乐播放。
📦 3.4.1 添加依赖
在 pubspec.yaml 中添加 OpenHarmony 适配的音频播放库:
yaml
dependencies:
# 音频播放(OpenHarmony 适配版本)
just_audio_ohos:
git:
url: https://atomgit.com/openharmony-sig/fluttertpc_just_audio
path: just_audio/ohos
audio_session:
git:
url: https://atomgit.com/openharmony-sig/flutter_audio_session.git
🎧 3.4.2 音乐播放控制器
dart
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:typed_data';
import 'dart:math';
/// 音乐播放控制器
class MusicPlayerController extends ChangeNotifier {
final AudioPlayer _player = AudioPlayer();
bool _isPlaying = false;
bool _isInitialized = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
Float32List? _audioData;
bool get isPlaying => _isPlaying;
bool get isInitialized => _isInitialized;
Duration get position => _position;
Duration get duration => _duration;
Float32List? get audioData => _audioData;
AudioPlayer get player => _player;
/// 初始化播放器
Future<void> initialize() async {
// 配置音频会话
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
// 监听播放状态
_player.playerStateStream.listen((state) {
_isPlaying = state.playing;
notifyListeners();
});
// 监听播放进度
_player.positionStream.listen((position) {
_position = position;
notifyListeners();
});
// 监听时长
_player.durationStream.listen((duration) {
_duration = duration ?? Duration.zero;
notifyListeners();
});
_isInitialized = true;
notifyListeners();
}
/// 加载网络 MP3
Future<void> loadNetworkAudio(String url) async {
try {
await _player.setUrl(url);
_isInitialized = true;
notifyListeners();
} catch (e) {
debugPrint('加载音频失败: $e');
}
}
/// 播放/暂停切换
Future<void> togglePlay() async {
if (_isPlaying) {
await _player.pause();
} else {
await _player.play();
}
}
/// 跳转到指定位置
Future<void> seek(Duration position) async {
await _player.seek(position);
}
/// 模拟音频数据(用于可视化)
/// 注:just_audio 不直接提供音频波形数据,这里使用模拟数据
Float32List generateSimulatedAudioData() {
final data = Float32List(128);
final random = Random();
final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
for (int i = 0; i < 128; i++) {
// 基于时间和频率生成模拟音频数据
final freq = (i / 128) * 10;
final wave1 = sin(time * freq) * 0.5;
final wave2 = sin(time * freq * 2 + pi / 4) * 0.3;
final noise = (random.nextDouble() - 0.5) * 0.2;
// 如果正在播放,数据更活跃
if (_isPlaying) {
data[i] = (wave1 + wave2 + noise) * (0.5 + random.nextDouble() * 0.5);
} else {
data[i] = wave1 * 0.1;
}
}
_audioData = data;
return data;
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
}
🎨 3.4.3 音乐播放器 UI 组件
dart
/// 音乐播放器 Widget
class MusicPlayerWidget extends StatefulWidget {
final String audioUrl;
final String title;
final String artist;
const MusicPlayerWidget({
super.key,
required this.audioUrl,
this.title = '未知曲目',
this.artist = '未知艺术家',
});
@override
State<MusicPlayerWidget> createState() => _MusicPlayerWidgetState();
}
class _MusicPlayerWidgetState extends State<MusicPlayerWidget> {
late MusicPlayerController _controller;
@override
void initState() {
super.initState();
_controller = MusicPlayerController();
_initPlayer();
}
Future<void> _initPlayer() async {
await _controller.initialize();
await _controller.loadNetworkAudio(widget.audioUrl);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 曲目信息
Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
widget.artist,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
),
const SizedBox(height: 16),
// 进度条
ListenableBuilder(
listenable: _controller,
builder: (context, child) {
final duration = _controller.duration.inMilliseconds.toDouble();
final position = _controller.position.inMilliseconds.toDouble();
return Column(
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.cyan,
inactiveTrackColor: Colors.grey,
thumbColor: Colors.cyan,
),
child: Slider(
value: duration > 0 ? position.clamp(0, duration) : 0,
max: duration > 0 ? duration : 1,
onChanged: (value) {
_controller.seek(Duration(milliseconds: value.toInt()));
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_controller.position),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
Text(
_formatDuration(_controller.duration),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
],
);
},
),
const SizedBox(height: 16),
// 控制按钮
ListenableBuilder(
listenable: _controller,
builder: (context, child) {
return IconButton(
icon: Icon(
_controller.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 48,
),
onPressed: _controller.togglePlay,
);
},
),
],
),
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
🌊 3.4.4 音乐驱动的贝塞尔流体动画完整示例
dart
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:typed_data';
import 'dart:math';
void main() {
runApp(const MusicBezierApp());
}
class MusicBezierApp extends StatelessWidget {
const MusicBezierApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '音乐驱动贝塞尔流体',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.cyan,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const MusicBezierPage(),
debugShowCheckedModeBanner: false,
);
}
}
class MusicBezierPage extends StatefulWidget {
const MusicBezierPage({super.key});
@override
State<MusicBezierPage> createState() => _MusicBezierPageState();
}
class _MusicBezierPageState extends State<MusicBezierPage> with TickerProviderStateMixin {
late AnimationController _animController;
late AudioPlayer _audioPlayer;
final Random _random = Random();
Float32List _audioData = Float32List(128);
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
// 示例网络 MP3 URL(可替换为实际的音乐链接)
static const String _sampleAudioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
void initState() {
super.initState();
_initAudio();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 16),
)..repeat();
_animController.addListener(_updateAudioData);
}
Future<void> _initAudio() async {
_audioPlayer = AudioPlayer();
// 配置音频会话
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
// 监听播放状态
_audioPlayer.playerStateStream.listen((state) {
setState(() {
_isPlaying = state.playing;
});
});
// 监听进度
_audioPlayer.positionStream.listen((position) {
setState(() {
_position = position;
});
});
// 监听时长
_audioPlayer.durationStream.listen((duration) {
setState(() {
_duration = duration ?? Duration.zero;
});
});
// 加载网络音频
try {
await _audioPlayer.setUrl(_sampleAudioUrl);
} catch (e) {
debugPrint('加载音频失败: $e');
}
}
void _updateAudioData() {
final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
for (int i = 0; i < 128; i++) {
if (_isPlaying) {
// 播放时生成活跃的音频数据
final freq = (i / 128) * 8 + 1;
final wave1 = sin(time * freq) * 0.4;
final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
final wave3 = sin(time * freq * 0.5 + pi / 6) * 0.2;
final noise = (_random.nextDouble() - 0.5) * 0.15;
// 低频部分更活跃(模拟低音效果)
final bassBoost = i < 32 ? 0.3 : 0;
_audioData[i] = _audioData[i] * 0.7 +
(wave1 + wave2 + wave3 + noise + bassBoost) * 0.3;
} else {
// 暂停时数据衰减
_audioData[i] = _audioData[i] * 0.95;
}
}
setState(() {});
}
@override
void dispose() {
_animController.dispose();
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 贝塞尔流体动画背景
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Color(0xFF0A1628), Colors.black],
),
),
child: CustomPaint(
painter: MusicDrivenBezierPainter(
audioData: _audioData,
time: DateTime.now().millisecondsSinceEpoch / 1000.0,
isPlaying: _isPlaying,
),
size: Size.infinite,
),
),
// 音乐播放器控制面板
Positioned(
bottom: 40,
left: 20,
right: 20,
child: _buildPlayerControls(),
),
],
),
);
}
Widget _buildPlayerControls() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Colors.cyan.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.cyan.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题
const Text(
'🎵 贝塞尔流体律动',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'SoundHelix - Song 1',
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 14,
),
),
const SizedBox(height: 16),
// 进度条
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.cyan,
inactiveTrackColor: Colors.grey.shade800,
thumbColor: Colors.cyan,
overlayColor: Colors.cyan.withOpacity(0.2),
),
child: Slider(
value: _duration.inMilliseconds > 0
? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
: 0,
max: _duration.inMilliseconds > 0
? _duration.inMilliseconds.toDouble()
: 1,
onChanged: (value) {
_audioPlayer.seek(Duration(milliseconds: value.toInt()));
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(_position),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
Text(
_formatDuration(_duration),
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
const SizedBox(height: 12),
// 控制按钮
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 后退按钮
IconButton(
icon: const Icon(Icons.replay_10, color: Colors.white70, size: 28),
onPressed: () {
final newPos = _position - const Duration(seconds: 10);
_audioPlayer.seek(newPos.isNegative ? Duration.zero : newPos);
},
),
const SizedBox(width: 20),
// 播放/暂停按钮
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan.shade400, Colors.cyan.shade700],
),
boxShadow: [
BoxShadow(
color: Colors.cyan.withOpacity(0.4),
blurRadius: 15,
spreadRadius: 2,
),
],
),
child: IconButton(
icon: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 36,
),
onPressed: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
),
),
const SizedBox(width: 20),
// 前进按钮
IconButton(
icon: const Icon(Icons.forward_10, color: Colors.white70, size: 28),
onPressed: () {
final newPos = _position + const Duration(seconds: 10);
_audioPlayer.seek(newPos > _duration ? _duration : newPos);
},
),
],
),
],
),
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
/// 音乐驱动的贝塞尔曲线绘制器
class MusicDrivenBezierPainter extends CustomPainter {
final Float32List audioData;
final double time;
final bool isPlaying;
MusicDrivenBezierPainter({
required this.audioData,
required this.time,
required this.isPlaying,
});
@override
void paint(Canvas canvas, Size size) {
final curveCount = 15;
final segmentSize = audioData.length ~/ curveCount;
// 绘制多层贝塞尔曲线
for (int i = 0; i < curveCount; i++) {
// 计算该段的音频能量
double energy = 0;
for (int j = 0; j < segmentSize; j++) {
energy += audioData[i * segmentSize + j].abs();
}
energy /= segmentSize;
// 基础 Y 位置
final baseY = size.height * (0.1 + i * 0.055);
// 波形参数
final wave1 = sin(time * 2 + i * 0.4) * 40 * energy;
final wave2 = sin(time * 3 + i * 0.3) * 30 * energy;
final wave3 = cos(time * 1.5 + i * 0.5) * 20 * energy;
// 颜色 - 基于位置和能量
final hue = (i / curveCount * 180 + time * 15) % 360;
final saturation = 0.7 + energy * 0.3;
final color = HSVColor.fromAHSV(
0.5 + energy * 0.3,
hue,
saturation,
0.9 + energy * 0.1,
).toColor();
// 创建贝塞尔曲线路径
final path = Path();
path.moveTo(-10, baseY + wave1);
// 三阶贝塞尔曲线
path.cubicTo(
size.width * 0.25, baseY - 50 * energy + wave2,
size.width * 0.75, baseY + 50 * energy + wave3,
size.width + 10, baseY + wave1,
);
// 闭合路径形成填充区域
path.lineTo(size.width + 10, size.height + 10);
path.lineTo(-10, size.height + 10);
path.close();
// 渐变填充
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color,
color.withOpacity(0.2),
color.withOpacity(0.05),
],
stops: const [0.0, 0.5, 1.0],
);
final paint = Paint()
..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
// 绘制曲线描边
final strokePaint = Paint()
..color = color.withOpacity(0.8)
..strokeWidth = 1.5 + energy
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final strokePath = Path();
strokePath.moveTo(-10, baseY + wave1);
strokePath.cubicTo(
size.width * 0.25, baseY - 50 * energy + wave2,
size.width * 0.75, baseY + 50 * energy + wave3,
size.width + 10, baseY + wave1,
);
canvas.drawPath(strokePath, strokePaint);
}
// 绘制发光粒子效果
if (isPlaying) {
_drawParticles(canvas, size);
}
}
void _drawParticles(Canvas canvas, Size size) {
final random = Random(time.toInt());
final particleCount = 30;
for (int i = 0; i < particleCount; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = random.nextDouble() * 3 + 1;
final hue = (i / particleCount * 360 + time * 30) % 360;
final color = HSVColor.fromAHSV(0.6, hue, 1, 1).toColor();
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant MusicDrivenBezierPainter oldDelegate) => true;
}
📝 3.4.5 关键实现说明
| 要点 | 说明 |
|---|---|
| 🎧 音频会话配置 | 使用 AudioSession 配置音频焦点,避免与其他应用冲突 |
| 🌐 网络音频加载 | 使用 setUrl() 方法加载网络 MP3 文件 |
| 📊 模拟音频数据 | 由于 just_audio 不直接提供波形数据,使用数学函数模拟 |
| 🔄 实时更新 | 通过 AnimationController 高频更新音频数据和动画 |
| 🎨 能量映射 | 将音频能量映射到曲线的振幅和颜色 |
💡 提示 :如需获取真实音频波形数据,可以考虑使用
flutter_sound或fftea等库进行 FFT 分析。
🎨 四、Flutter 实现贝塞尔曲线绘制
🖌️ 4.1 CustomPainter 绘制基础
Flutter 的 CustomPainter 类提供了强大的自定义绘制能力。我们可以使用 Canvas 的 drawPath 方法来绘制贝塞尔曲线。
dart
import 'package:flutter/material.dart';
/// 贝塞尔曲线绘制器
class BezierCurvePainter extends CustomPainter {
final CubicBezierCurve curve;
final Color color;
final double strokeWidth;
final bool showControlPoints;
BezierCurvePainter({
required this.curve,
required this.color,
this.strokeWidth = 2,
this.showControlPoints = false,
});
@override
void paint(Canvas canvas, Size size) {
// 创建路径
final path = Path();
path.moveTo(curve.p0.x, curve.p0.y);
// 使用 cubicTo 绘制三阶贝塞尔曲线
path.cubicTo(
curve.p1.x, curve.p1.y, // 🔵 控制点1
curve.p2.x, curve.p2.y, // 🔵 控制点2
curve.p3.x, curve.p3.y, // 🔴 终点
);
// 绘制曲线
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawPath(path, paint);
// 绘制控制点(可选)
if (showControlPoints) {
_drawControlPoints(canvas);
}
}
void _drawControlPoints(Canvas canvas) {
final pointPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
final linePaint = Paint()
..color = Colors.red.withOpacity(0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// 绘制控制点
canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 5, pointPaint..color = Colors.green);
canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), 5, pointPaint..color = Colors.blue);
canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), 5, pointPaint..color = Colors.blue);
canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 5, pointPaint..color = Colors.green);
// 绘制控制线
canvas.drawLine(
Offset(curve.p0.x, curve.p0.y),
Offset(curve.p1.x, curve.p1.y),
linePaint,
);
canvas.drawLine(
Offset(curve.p2.x, curve.p2.y),
Offset(curve.p3.x, curve.p3.y),
linePaint,
);
}
@override
bool shouldRepaint(covariant BezierCurvePainter oldDelegate) {
return curve != oldDelegate.curve || color != oldDelegate.color;
}
}
🌈 4.2 渐变填充的流体曲线
为了增强视觉效果,我们可以为流体曲线添加渐变填充效果。
dart
/// 渐变流体曲线绘制器
class GradientFluidPainter extends CustomPainter {
final List<CubicBezierCurve> curves;
final List<Color> colors;
final double animationValue;
GradientFluidPainter({
required this.curves,
required this.colors,
required this.animationValue,
});
@override
void paint(Canvas canvas, Size size) {
for (int i = 0; i < curves.length; i++) {
final curve = curves[i];
final color = colors[i % colors.length];
// 创建填充路径
final path = Path();
path.moveTo(curve.p0.x, curve.p0.y);
path.cubicTo(
curve.p1.x, curve.p1.y,
curve.p2.x, curve.p2.y,
curve.p3.x, curve.p3.y,
);
// 闭合路径(连接到底部)
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
path.close();
// 创建渐变 🌈
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withOpacity(0.8),
color.withOpacity(0.3),
color.withOpacity(0.1),
],
stops: const [0.0, 0.5, 1.0],
);
final paint = Paint()
..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
// 绘制曲线描边
final strokePaint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final strokePath = Path();
strokePath.moveTo(curve.p0.x, curve.p0.y);
strokePath.cubicTo(
curve.p1.x, curve.p1.y,
curve.p2.x, curve.p2.y,
curve.p3.x, curve.p3.y,
);
canvas.drawPath(strokePath, strokePaint);
}
}
@override
bool shouldRepaint(covariant GradientFluidPainter oldDelegate) {
return curves != oldDelegate.curves || animationValue != oldDelegate.animationValue;
}
}
📦 五、完整示例代码
以下是完整的贝塞尔流体律动示例代码,包含网络音乐播放功能:
dart
import 'package:flutter/material.dart';
import 'package:just_audio_ohos/just_audio_ohos.dart';
import 'package:audio_session/audio_session.dart';
import 'dart:math';
import 'dart:typed_data';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '贝塞尔流体律动',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.cyan,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const BezierHomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class BezierHomePage extends StatelessWidget {
const BezierHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('🌊 贝塞尔流体律动'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSectionCard(
context,
title: '基础曲线',
description: '三阶贝塞尔曲线演示',
icon: Icons.show_chart,
color: Colors.blue,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const BasicBezierDemo()),
),
),
_buildSectionCard(
context,
title: '控制点编辑',
description: '交互式曲线编辑',
icon: Icons.control_point,
color: Colors.green,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ControlPointDemo()),
),
),
_buildSectionCard(
context,
title: '流体动画',
description: '呼吸感动画效果',
icon: Icons.waves,
color: Colors.purple,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const FluidAnimationDemo()),
),
),
_buildSectionCard(
context,
title: '音频响应',
description: '音乐驱动曲线变形',
icon: Icons.music_note,
color: Colors.orange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AudioResponsiveDemo()),
),
),
_buildSectionCard(
context,
title: '综合演示',
description: '完整流体律动效果',
icon: Icons.water_drop,
color: Colors.cyan,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const FullFluidDemo()),
),
),
],
),
);
}
Widget _buildSectionCard(
BuildContext context, {
required String title,
required String description,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey[400]),
],
),
),
),
);
}
}
class Point2D {
double x;
double y;
Point2D(this.x, this.y);
Point2D operator +(Point2D other) => Point2D(x + other.x, y + other.y);
Point2D operator -(Point2D other) => Point2D(x - other.x, y - other.y);
Point2D operator *(double scalar) => Point2D(x * scalar, y * scalar);
double dot(Point2D other) => x * other.x + y * other.y;
double get distance => sqrt(x * x + y * y);
}
class CubicBezierCurve {
final Point2D p0;
final Point2D p1;
final Point2D p2;
final Point2D p3;
CubicBezierCurve(this.p0, this.p1, this.p2, this.p3);
Point2D pointAt(double t) {
final oneMinusT = 1 - t;
final oneMinusT2 = oneMinusT * oneMinusT;
final oneMinusT3 = oneMinusT2 * oneMinusT;
final t2 = t * t;
final t3 = t2 * t;
return Point2D(
oneMinusT3 * p0.x + 3 * oneMinusT2 * t * p1.x + 3 * oneMinusT * t2 * p2.x + t3 * p3.x,
oneMinusT3 * p0.y + 3 * oneMinusT2 * t * p1.y + 3 * oneMinusT * t2 * p2.y + t3 * p3.y,
);
}
Point2D tangentAt(double t) {
final oneMinusT = 1 - t;
final oneMinusT2 = oneMinusT * oneMinusT;
final t2 = t * t;
return Point2D(
3 * oneMinusT2 * (p1.x - p0.x) + 6 * oneMinusT * t * (p2.x - p1.x) + 3 * t2 * (p3.x - p2.x),
3 * oneMinusT2 * (p1.y - p0.y) + 6 * oneMinusT * t * (p2.y - p1.y) + 3 * t2 * (p3.y - p2.y),
);
}
List<Point2D> generatePoints(int count) {
return List.generate(count + 1, (i) => pointAt(i / count));
}
}
class BasicBezierDemo extends StatelessWidget {
const BasicBezierDemo({super.key});
@override
Widget build(BuildContext context) {
final curve = CubicBezierCurve(
Point2D(50, 300),
Point2D(150, 100),
Point2D(250, 500),
Point2D(350, 300),
);
return Scaffold(
appBar: AppBar(title: const Text('基础曲线')),
body: Container(
color: Colors.black,
child: CustomPaint(
painter: BasicBezierPainter(curve: curve),
size: Size.infinite,
),
),
);
}
}
class BasicBezierPainter extends CustomPainter {
final CubicBezierCurve curve;
BasicBezierPainter({required this.curve});
@override
void paint(Canvas canvas, Size size) {
final path = Path();
path.moveTo(curve.p0.x, curve.p0.y);
path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
final curvePaint = Paint()
..color = Colors.cyan
..strokeWidth = 3
..style = PaintingStyle.stroke;
canvas.drawPath(path, curvePaint);
final controlPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 6, controlPaint);
canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 6, controlPaint);
controlPaint.color = Colors.green;
canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), 4, controlPaint);
canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), 4, controlPaint);
final linePaint = Paint()
..color = Colors.green.withOpacity(0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawLine(Offset(curve.p0.x, curve.p0.y), Offset(curve.p1.x, curve.p1.y), linePaint);
canvas.drawLine(Offset(curve.p2.x, curve.p2.y), Offset(curve.p3.x, curve.p3.y), linePaint);
}
@override
bool shouldRepaint(covariant BasicBezierPainter oldDelegate) => curve != oldDelegate.curve;
}
class ControlPointDemo extends StatefulWidget {
const ControlPointDemo({super.key});
@override
State<ControlPointDemo> createState() => _ControlPointDemoState();
}
class _ControlPointDemoState extends State<ControlPointDemo> {
Point2D p0 = Point2D(50, 300);
Point2D p1 = Point2D(150, 100);
Point2D p2 = Point2D(250, 500);
Point2D p3 = Point2D(350, 300);
int? selectedPoint;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('控制点编辑')),
body: GestureDetector(
onPanStart: (details) {
final dx = details.localPosition.dx;
final dy = details.localPosition.dy;
if ((dx - p1.x).abs() < 20 && (dy - p1.y).abs() < 20) {
selectedPoint = 1;
} else if ((dx - p2.x).abs() < 20 && (dy - p2.y).abs() < 20) {
selectedPoint = 2;
}
},
onPanUpdate: (details) {
if (selectedPoint == 1) {
setState(() {
p1 = Point2D(details.localPosition.dx, details.localPosition.dy);
});
} else if (selectedPoint == 2) {
setState(() {
p2 = Point2D(details.localPosition.dx, details.localPosition.dy);
});
}
},
onPanEnd: (_) {
selectedPoint = null;
},
child: Container(
color: Colors.black,
child: CustomPaint(
painter: ControlPointPainter(
curve: CubicBezierCurve(p0, p1, p2, p3),
selectedPoint: selectedPoint,
),
size: Size.infinite,
),
),
),
);
}
}
class ControlPointPainter extends CustomPainter {
final CubicBezierCurve curve;
final int? selectedPoint;
ControlPointPainter({required this.curve, this.selectedPoint});
@override
void paint(Canvas canvas, Size size) {
final path = Path();
path.moveTo(curve.p0.x, curve.p0.y);
path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
final curvePaint = Paint()
..color = Colors.cyan
..strokeWidth = 3
..style = PaintingStyle.stroke;
canvas.drawPath(path, curvePaint);
final linePaint = Paint()
..color = Colors.green.withOpacity(0.5)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawLine(Offset(curve.p0.x, curve.p0.y), Offset(curve.p1.x, curve.p1.y), linePaint);
canvas.drawLine(Offset(curve.p2.x, curve.p2.y), Offset(curve.p3.x, curve.p3.y), linePaint);
final endpointPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(curve.p0.x, curve.p0.y), 8, endpointPaint);
canvas.drawCircle(Offset(curve.p3.x, curve.p3.y), 8, endpointPaint);
final controlPaint = Paint()
..color = selectedPoint == 1 || selectedPoint == 2 ? Colors.yellow : Colors.green
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(curve.p1.x, curve.p1.y), selectedPoint == 1 ? 12 : 8, controlPaint);
canvas.drawCircle(Offset(curve.p2.x, curve.p2.y), selectedPoint == 2 ? 12 : 8, controlPaint);
}
@override
bool shouldRepaint(covariant ControlPointPainter oldDelegate) => true;
}
class FluidAnimationDemo extends StatefulWidget {
const FluidAnimationDemo({super.key});
@override
State<FluidAnimationDemo> createState() => _FluidAnimationDemoState();
}
class _FluidAnimationDemoState extends State<FluidAnimationDemo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('流体动画')),
body: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.deepPurple],
),
),
child: CustomPaint(
painter: FluidAnimationPainter(_controller.value),
size: Size.infinite,
),
);
},
),
);
}
}
class FluidAnimationPainter extends CustomPainter {
final double animationValue;
FluidAnimationPainter(this.animationValue);
@override
void paint(Canvas canvas, Size size) {
final curves = <CubicBezierCurve>[];
final colors = <Color>[
Colors.cyan,
Colors.blue,
Colors.purple,
Colors.pink,
];
for (int i = 0; i < 4; i++) {
final phase = i * pi / 2;
final wave1 = sin(animationValue * 2 * pi + phase) * 50;
final wave2 = sin(animationValue * 2 * pi + phase + pi / 4) * 50;
final y = size.height * (0.3 + i * 0.15);
curves.add(CubicBezierCurve(
Point2D(0, y + wave1 * 0.5),
Point2D(size.width * 0.33, y - 50 + wave1),
Point2D(size.width * 0.66, y + 50 + wave2),
Point2D(size.width, y + wave2 * 0.5),
));
}
for (int i = curves.length - 1; i >= 0; i--) {
final curve = curves[i];
final color = colors[i];
final path = Path();
path.moveTo(curve.p0.x, curve.p0.y);
path.cubicTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y);
path.lineTo(size.width, size.height);
path.lineTo(0, size.height);
path.close();
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withOpacity(0.6),
color.withOpacity(0.2),
],
);
final paint = Paint()
..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant FluidAnimationPainter oldDelegate) => true;
}
class AudioResponsiveDemo extends StatefulWidget {
const AudioResponsiveDemo({super.key});
@override
State<AudioResponsiveDemo> createState() => _AudioResponsiveDemoState();
}
class _AudioResponsiveDemoState extends State<AudioResponsiveDemo> with TickerProviderStateMixin {
late AnimationController _animController;
late AudioPlayer _audioPlayer;
final Random _random = Random();
Float32List _audioData = Float32List(64);
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
static const String _audioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
void initState() {
super.initState();
_initAudio();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 16),
)..repeat();
_animController.addListener(_updateAudioData);
}
Future<void> _initAudio() async {
_audioPlayer = AudioPlayer();
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_audioPlayer.playerStateStream.listen((state) {
setState(() {
_isPlaying = state.playing;
});
});
_audioPlayer.positionStream.listen((position) {
setState(() {
_position = position;
});
});
_audioPlayer.durationStream.listen((duration) {
setState(() {
_duration = duration ?? Duration.zero;
});
});
try {
await _audioPlayer.setUrl(_audioUrl);
} catch (e) {
debugPrint('加载音频失败: $e');
}
}
void _updateAudioData() {
final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
for (int i = 0; i < 64; i++) {
if (_isPlaying) {
final freq = (i / 64) * 6 + 1;
final wave1 = sin(time * freq) * 0.4;
final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
final noise = (_random.nextDouble() - 0.5) * 0.15;
final bassBoost = i < 16 ? 0.3 : 0;
_audioData[i] = _audioData[i] * 0.7 + (wave1 + wave2 + noise + bassBoost) * 0.3;
} else {
_audioData[i] = _audioData[i] * 0.95;
}
}
setState(() {});
}
@override
void dispose() {
_animController.dispose();
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('音频响应')),
body: Stack(
children: [
Container(
color: Colors.black,
child: CustomPaint(
painter: AudioResponsivePainter(_audioData),
size: Size.infinite,
),
),
Positioned(
bottom: 30,
left: 20,
right: 20,
child: _buildPlayerControls(),
),
],
),
);
}
Widget _buildPlayerControls() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🎵 SoundHelix - Song 1',
style: TextStyle(color: Colors.white, fontSize: 14)),
const SizedBox(height: 12),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.cyan,
inactiveTrackColor: Colors.grey.shade800,
thumbColor: Colors.cyan,
),
child: Slider(
value: _duration.inMilliseconds > 0
? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
: 0,
max: _duration.inMilliseconds > 0
? _duration.inMilliseconds.toDouble()
: 1,
onChanged: (value) {
_audioPlayer.seek(Duration(milliseconds: value.toInt()));
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_formatDuration(_position),
style: const TextStyle(color: Colors.white70, fontSize: 12)),
const SizedBox(width: 20),
IconButton(
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.cyan, size: 40),
onPressed: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
),
const SizedBox(width: 20),
Text(_formatDuration(_duration),
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
),
],
),
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
class AudioResponsivePainter extends CustomPainter {
final Float32List audioData;
AudioResponsivePainter(this.audioData);
@override
void paint(Canvas canvas, Size size) {
final curveCount = 8;
final segmentSize = audioData.length ~/ curveCount;
for (int i = 0; i < curveCount; i++) {
double energy = 0;
for (int j = 0; j < segmentSize; j++) {
energy += audioData[i * segmentSize + j].abs();
}
energy /= segmentSize;
final y = size.height * (0.2 + i * 0.08);
final amplitude = energy * 100;
final hue = i / curveCount * 360;
final color = HSVColor.fromAHSV(0.7, hue, 1, 1).toColor();
final path = Path();
path.moveTo(0, y);
path.cubicTo(
size.width * 0.33, y - amplitude,
size.width * 0.66, y + amplitude,
size.width, y,
);
final strokePaint = Paint()
..color = color
..strokeWidth = 3
..style = PaintingStyle.stroke;
canvas.drawPath(path, strokePaint);
}
}
@override
bool shouldRepaint(covariant AudioResponsivePainter oldDelegate) => audioData != oldDelegate.audioData;
}
class FullFluidDemo extends StatefulWidget {
const FullFluidDemo({super.key});
@override
State<FullFluidDemo> createState() => _FullFluidDemoState();
}
class _FullFluidDemoState extends State<FullFluidDemo> with TickerProviderStateMixin {
late AnimationController _animController;
late AudioPlayer _audioPlayer;
final Random _random = Random();
Float32List _audioData = Float32List(128);
bool _isPlaying = false;
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
static const String _audioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
void initState() {
super.initState();
_initAudio();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 16),
)..repeat();
_animController.addListener(_update);
}
Future<void> _initAudio() async {
_audioPlayer = AudioPlayer();
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_audioPlayer.playerStateStream.listen((state) {
setState(() {
_isPlaying = state.playing;
});
});
_audioPlayer.positionStream.listen((position) {
setState(() {
_position = position;
});
});
_audioPlayer.durationStream.listen((duration) {
setState(() {
_duration = duration ?? Duration.zero;
});
});
try {
await _audioPlayer.setUrl(_audioUrl);
} catch (e) {
debugPrint('加载音频失败: $e');
}
}
void _update() {
final time = DateTime.now().millisecondsSinceEpoch / 1000.0;
for (int i = 0; i < 128; i++) {
if (_isPlaying) {
final freq = (i / 128) * 8 + 1;
final wave1 = sin(time * freq) * 0.4;
final wave2 = sin(time * freq * 1.5 + pi / 3) * 0.3;
final wave3 = cos(time * freq * 0.5 + pi / 6) * 0.2;
final noise = (_random.nextDouble() - 0.5) * 0.15;
final bassBoost = i < 32 ? 0.3 : 0;
_audioData[i] = _audioData[i] * 0.85 +
(wave1 + wave2 + wave3 + noise + bassBoost) * 0.15;
} else {
_audioData[i] = _audioData[i] * 0.95;
}
}
setState(() {});
}
@override
void dispose() {
_animController.dispose();
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black, Colors.indigo, Colors.black],
),
),
child: CustomPaint(
painter: FullFluidPainter(
audioData: _audioData,
time: DateTime.now().millisecondsSinceEpoch / 1000.0,
isPlaying: _isPlaying,
),
size: Size.infinite,
),
),
Positioned(
top: 40,
left: 20,
child: SafeArea(
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
),
Positioned(
bottom: 40,
left: 20,
right: 20,
child: _buildPlayerControls(),
),
],
),
);
}
Widget _buildPlayerControls() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.cyan.withOpacity(0.3), width: 1),
boxShadow: [
BoxShadow(
color: Colors.cyan.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'🎵 贝塞尔流体律动',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
const Text('SoundHelix - Song 1',
style: TextStyle(color: Colors.white70, fontSize: 14)),
const SizedBox(height: 16),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.cyan,
inactiveTrackColor: Colors.grey.shade800,
thumbColor: Colors.cyan,
overlayColor: Colors.cyan.withOpacity(0.2),
),
child: Slider(
value: _duration.inMilliseconds > 0
? _position.inMilliseconds.toDouble().clamp(0, _duration.inMilliseconds.toDouble())
: 0,
max: _duration.inMilliseconds > 0
? _duration.inMilliseconds.toDouble()
: 1,
onChanged: (value) {
_audioPlayer.seek(Duration(milliseconds: value.toInt()));
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatDuration(_position),
style: const TextStyle(color: Colors.white70, fontSize: 12)),
Text(_formatDuration(_duration),
style: const TextStyle(color: Colors.white70, fontSize: 12)),
],
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.replay_10, color: Colors.white70, size: 28),
onPressed: () {
final newPos = _position - const Duration(seconds: 10);
_audioPlayer.seek(newPos.isNegative ? Duration.zero : newPos);
},
),
const SizedBox(width: 20),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan.shade400, Colors.cyan.shade700],
),
boxShadow: [
BoxShadow(color: Colors.cyan.withOpacity(0.4), blurRadius: 15, spreadRadius: 2),
],
),
child: IconButton(
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 36),
onPressed: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
),
),
const SizedBox(width: 20),
IconButton(
icon: const Icon(Icons.forward_10, color: Colors.white70, size: 28),
onPressed: () {
final newPos = _position + const Duration(seconds: 10);
_audioPlayer.seek(newPos > _duration ? _duration : newPos);
},
),
],
),
],
),
);
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
}
class FullFluidPainter extends CustomPainter {
final Float32List audioData;
final double time;
final bool isPlaying;
FullFluidPainter({required this.audioData, required this.time, required this.isPlaying});
@override
void paint(Canvas canvas, Size size) {
final curveCount = 12;
final segmentSize = audioData.length ~/ curveCount;
for (int i = 0; i < curveCount; i++) {
double energy = 0;
for (int j = 0; j < segmentSize; j++) {
energy += audioData[i * segmentSize + j].abs();
}
energy /= segmentSize;
final baseY = size.height * (0.15 + i * 0.06);
final wave1 = sin(time * 2 + i * 0.5) * 30 * energy;
final wave2 = sin(time * 3 + i * 0.3) * 20 * energy;
final hue = (i / curveCount * 180 + time * 20) % 360;
final color = HSVColor.fromAHSV(0.6, hue, 0.8, 1).toColor();
final path = Path();
path.moveTo(-10, baseY + wave1);
path.cubicTo(
size.width * 0.25, baseY - 40 * energy + wave2,
size.width * 0.75, baseY + 40 * energy + wave1,
size.width + 10, baseY + wave2,
);
path.lineTo(size.width + 10, size.height + 10);
path.lineTo(-10, size.height + 10);
path.close();
final gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color,
color.withOpacity(0.3),
],
);
final paint = Paint()
..shader = gradient.createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
final strokePaint = Paint()
..color = color.withOpacity(0.8)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
final strokePath = Path();
strokePath.moveTo(-10, baseY + wave1);
strokePath.cubicTo(
size.width * 0.25, baseY - 40 * energy + wave2,
size.width * 0.75, baseY + 40 * energy + wave1,
size.width + 10, baseY + wave2,
);
canvas.drawPath(strokePath, strokePaint);
}
if (isPlaying) {
_drawParticles(canvas, size);
}
}
void _drawParticles(Canvas canvas, Size size) {
final random = Random(time.toInt());
final particleCount = 25;
for (int i = 0; i < particleCount; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = random.nextDouble() * 3 + 1;
final hue = (i / particleCount * 360 + time * 30) % 360;
final color = HSVColor.fromAHSV(0.6, hue, 1, 1).toColor();
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3);
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant FullFluidPainter oldDelegate) => true;
}
📝 六、总结
本篇文章深入探讨了三阶贝塞尔曲线在音乐可视化中的应用,从数学原理到代码实现,涵盖了以下核心内容:
✅ 核心知识点回顾
| 知识点 | 说明 |
|---|---|
| 📐 贝塞尔曲线数学原理 | 理解参数方程和控制点的作用 |
| 🔄 德卡斯特里奥算法 | 递归分割计算曲线点 |
| 🌬️ "呼吸感"设计 | 周期性、缓动变化、有机性、响应性 |
| 🎵 音频驱动变形 | 使用音频数据驱动控制点移动 |
| 🎨 Flutter 绘制技术 | CustomPainter 和 Canvas 的使用 |
⭐ 最佳实践要点
- ✅ 使用三阶贝塞尔曲线平衡灵活性和计算效率
- ✅ 自适应采样保证绘制质量
- ✅ 多曲线组合创造丰富效果
- ✅ 渐变填充增强视觉层次
🚀 进阶方向
- 🔮 结合着色器实现 GPU 加速
- ✨ 添加粒子效果增强动态感
- 👆 实现用户交互控制
- ⚡ 优化性能以支持更多曲线