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>
This commit is contained in:
hailin 2025-12-14 20:18:12 -08:00
parent ada8a44076
commit 11d9b57bda
55 changed files with 78 additions and 133 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

View File

@ -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),
),
),
),
],
),
);
},
); );
} }

View File

@ -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/