feat(splash): 将 fallback 动画改为帧动画以提升兼容性
- 将 Tween 动画替换为 53 帧 PNG 序列动画 - 添加 splash_frames 资源目录(15fps,约3.5秒) - 使用 gaplessPlayback 防止帧切换闪烁 - 保留帧加载失败时的静态 fallback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
After Width: | Height: | Size: 866 KiB |
|
After Width: | Height: | Size: 943 KiB |
|
After Width: | Height: | Size: 968 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 985 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 975 KiB |
|
After Width: | Height: | Size: 982 KiB |
|
After Width: | Height: | Size: 971 KiB |
|
After Width: | Height: | Size: 954 KiB |
|
After Width: | Height: | Size: 903 KiB |
|
After Width: | Height: | Size: 960 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 935 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 867 KiB |
|
After Width: | Height: | Size: 872 KiB |
|
After Width: | Height: | Size: 861 KiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 823 KiB |
|
After Width: | Height: | Size: 805 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 693 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 788 KiB |
|
After Width: | Height: | Size: 799 KiB |
|
After Width: | Height: | Size: 806 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 857 KiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 817 KiB |
|
After Width: | Height: | Size: 894 KiB |
|
After Width: | Height: | Size: 924 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 941 KiB |
|
After Width: | Height: | Size: 896 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 941 KiB |
|
After Width: | Height: | Size: 936 KiB |
|
After Width: | Height: | Size: 923 KiB |
|
After Width: | Height: | Size: 870 KiB |
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 873 KiB |
|
|
@ -16,8 +16,7 @@ class SplashPage extends ConsumerStatefulWidget {
|
||||||
ConsumerState<SplashPage> createState() => _SplashPageState();
|
ConsumerState<SplashPage> createState() => _SplashPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SplashPageState extends ConsumerState<SplashPage>
|
class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
/// 视频播放控制器
|
/// 视频播放控制器
|
||||||
VideoPlayerController? _videoController;
|
VideoPlayerController? _videoController;
|
||||||
|
|
||||||
|
|
@ -33,17 +32,17 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
/// 视频加载是否失败(使用 fallback 动画)
|
/// 视频加载是否失败(使用 fallback 动画)
|
||||||
bool _videoFailed = false;
|
bool _videoFailed = false;
|
||||||
|
|
||||||
/// Fallback 动画控制器
|
/// 帧动画总帧数
|
||||||
AnimationController? _fallbackAnimationController;
|
static const int _totalFrames = 53;
|
||||||
|
|
||||||
/// Logo 缩放动画
|
/// 帧动画帧率 (fps)
|
||||||
Animation<double>? _logoScaleAnimation;
|
static const int _frameRate = 15;
|
||||||
|
|
||||||
/// Logo 透明度动画
|
/// 当前显示的帧索引
|
||||||
Animation<double>? _logoOpacityAnimation;
|
int _currentFrameIndex = 0;
|
||||||
|
|
||||||
/// 文字透明度动画
|
/// 帧动画是否正在播放
|
||||||
Animation<double>? _textOpacityAnimation;
|
bool _isFrameAnimationPlaying = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -55,7 +54,6 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_videoController?.removeListener(_onVideoStateChanged);
|
_videoController?.removeListener(_onVideoStateChanged);
|
||||||
_videoController?.dispose();
|
_videoController?.dispose();
|
||||||
_fallbackAnimationController?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,77 +128,18 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动 fallback 动画(视频加载失败时使用)
|
/// 启动 fallback 帧动画(视频加载失败时使用)
|
||||||
void _startFallbackAnimation() {
|
void _startFallbackAnimation() {
|
||||||
debugPrint('[SplashPage] 启动 fallback 动画');
|
debugPrint('[SplashPage] 启动 fallback 帧动画,共 $_totalFrames 帧');
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_videoFailed = true;
|
_videoFailed = true;
|
||||||
|
_isFrameAnimationPlaying = true;
|
||||||
|
_currentFrameIndex = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建动画控制器,总时长 3 秒
|
// 启动帧动画播放
|
||||||
_fallbackAnimationController = AnimationController(
|
_playFrameAnimation();
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 3000),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Logo 缩放动画:0.5 -> 1.0 -> 1.05 -> 1.0(弹性效果)
|
|
||||||
_logoScaleAnimation = TweenSequence<double>([
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween<double>(begin: 0.5, end: 1.05)
|
|
||||||
.chain(CurveTween(curve: Curves.easeOutBack)),
|
|
||||||
weight: 40,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween<double>(begin: 1.05, end: 1.0)
|
|
||||||
.chain(CurveTween(curve: Curves.easeInOut)),
|
|
||||||
weight: 10,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: ConstantTween<double>(1.0),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
]).animate(_fallbackAnimationController!);
|
|
||||||
|
|
||||||
// Logo 透明度动画:0 -> 1
|
|
||||||
_logoOpacityAnimation = TweenSequence<double>([
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween<double>(begin: 0.0, end: 1.0)
|
|
||||||
.chain(CurveTween(curve: Curves.easeOut)),
|
|
||||||
weight: 30,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: ConstantTween<double>(1.0),
|
|
||||||
weight: 70,
|
|
||||||
),
|
|
||||||
]).animate(_fallbackAnimationController!);
|
|
||||||
|
|
||||||
// 文字透明度动画:延迟出现
|
|
||||||
_textOpacityAnimation = TweenSequence<double>([
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: ConstantTween<double>(0.0),
|
|
||||||
weight: 30,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween<double>(begin: 0.0, end: 1.0)
|
|
||||||
.chain(CurveTween(curve: Curves.easeOut)),
|
|
||||||
weight: 20,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: ConstantTween<double>(1.0),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
]).animate(_fallbackAnimationController!);
|
|
||||||
|
|
||||||
// 动画完成后跳转
|
|
||||||
_fallbackAnimationController!.addStatusListener((status) {
|
|
||||||
if (status == AnimationStatus.completed) {
|
|
||||||
_navigateToNextPage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 开始动画
|
|
||||||
_fallbackAnimationController!.forward();
|
|
||||||
|
|
||||||
// 1秒后显示跳过按钮
|
// 1秒后显示跳过按钮
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
|
|
@ -212,9 +151,29 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 跳过 fallback 动画
|
/// 播放帧动画
|
||||||
|
Future<void> _playFrameAnimation() async {
|
||||||
|
final frameDuration = Duration(milliseconds: 1000 ~/ _frameRate);
|
||||||
|
|
||||||
|
while (_isFrameAnimationPlaying && _currentFrameIndex < _totalFrames && mounted) {
|
||||||
|
await Future.delayed(frameDuration);
|
||||||
|
|
||||||
|
if (!mounted || !_isFrameAnimationPlaying) break;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_currentFrameIndex++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动画播放完成
|
||||||
|
if (_currentFrameIndex >= _totalFrames && mounted && _isFrameAnimationPlaying) {
|
||||||
|
_navigateToNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跳过 fallback 帧动画
|
||||||
void _skipFallbackAnimation() {
|
void _skipFallbackAnimation() {
|
||||||
_fallbackAnimationController?.stop();
|
_isFrameAnimationPlaying = false;
|
||||||
_navigateToNextPage();
|
_navigateToNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,44 +331,43 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 fallback 动画视图(视频加载失败时使用)
|
/// 构建 fallback 帧动画视图(视频加载失败时使用)
|
||||||
Widget _buildFallbackAnimationView() {
|
Widget _buildFallbackAnimationView() {
|
||||||
return AnimatedBuilder(
|
// 帧文件名格式: frame_001.png, frame_002.png, ... frame_053.png
|
||||||
animation: _fallbackAnimationController!,
|
final frameNumber = (_currentFrameIndex + 1).toString().padLeft(3, '0');
|
||||||
builder: (context, child) {
|
final framePath = 'assets/images/splash_frames/frame_$frameNumber.png';
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
return SizedBox.expand(
|
||||||
height: double.infinity,
|
child: Image.asset(
|
||||||
decoration: const BoxDecoration(
|
framePath,
|
||||||
gradient: LinearGradient(
|
fit: BoxFit.cover,
|
||||||
begin: Alignment.topCenter,
|
gaplessPlayback: true, // 关键:防止帧切换时闪烁
|
||||||
end: Alignment.bottomCenter,
|
errorBuilder: (context, error, stackTrace) {
|
||||||
colors: [
|
// 帧加载失败时显示静态 fallback
|
||||||
Color(0xFFFFF5E6),
|
debugPrint('[SplashPage] 帧加载失败: $framePath, 错误: $error');
|
||||||
Color(0xFFFFE4B5),
|
return Container(
|
||||||
],
|
width: double.infinity,
|
||||||
),
|
height: double.infinity,
|
||||||
),
|
decoration: const BoxDecoration(
|
||||||
child: Column(
|
gradient: LinearGradient(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
begin: Alignment.topCenter,
|
||||||
children: [
|
end: Alignment.bottomCenter,
|
||||||
// Logo 带缩放和透明度动画
|
colors: [
|
||||||
Opacity(
|
Color(0xFFFFF5E6),
|
||||||
opacity: _logoOpacityAnimation?.value ?? 1.0,
|
Color(0xFFFFE4B5),
|
||||||
child: Transform.scale(
|
],
|
||||||
scale: _logoScaleAnimation?.value ?? 1.0,
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/images/logo/app_icon.png',
|
|
||||||
width: 128,
|
|
||||||
height: 128,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
// 文字带透明度动画
|
child: Column(
|
||||||
Opacity(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
opacity: _textOpacityAnimation?.value ?? 1.0,
|
children: [
|
||||||
child: const Text(
|
Image.asset(
|
||||||
|
'assets/images/logo/app_icon.png',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
'榴莲皇后',
|
'榴莲皇后',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
|
|
@ -420,25 +378,11 @@ class _SplashPageState extends ConsumerState<SplashPage>
|
||||||
color: Color(0xFF4A3F35),
|
color: Color(0xFF4A3F35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
// 副标题(延迟显示)
|
);
|
||||||
Opacity(
|
},
|
||||||
opacity: _textOpacityAnimation?.value ?? 1.0,
|
),
|
||||||
child: const Text(
|
|
||||||
'认种榴莲 · 共享收益',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontFamily: 'Noto Sans SC',
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: Color(0xFF8B7355),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ flutter:
|
||||||
- assets/images/backgrounds/
|
- assets/images/backgrounds/
|
||||||
- assets/images/avatars/
|
- assets/images/avatars/
|
||||||
- assets/images/illustrations/
|
- assets/images/illustrations/
|
||||||
|
- assets/images/splash_frames/
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
- assets/icons/nav/
|
- assets/icons/nav/
|
||||||
- assets/icons/tokens/
|
- assets/icons/tokens/
|
||||||
|
|
|
||||||