// ============================================================ // 闲言APP — 交互动画扩展 // 创建时间: 2026-04-20 // 更新时间: 2026-04-20 // 作用: 统一按钮弹性、卡片按压、列表项入场、Confetti 庆祝等交互动画 // 上次更新: 集成 Confetti 庆祝效果 + LikeAnimation 增强 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:confetti/confetti.dart'; import '../../theme/app_theme.dart'; import '../../theme/app_spacing.dart'; import '../../theme/app_radius.dart'; // ============================================================ // 弹性按钮 — 按压缩放效果 (iOS 风格) // ============================================================ /// 弹性按钮 /// /// 按压时缩小到 0.95,松开弹回,模拟 iOS 按钮的弹性反馈。 class BounceButton extends StatefulWidget { const BounceButton({ super.key, required this.child, this.onTap, this.scaleDown = 0.95, this.duration = const Duration(milliseconds: 120), }); final Widget child; final VoidCallback? onTap; final double scaleDown; final Duration duration; @override State createState() => _BounceButtonState(); } class _BounceButtonState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _scaleAnimation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: widget.duration, reverseDuration: widget.duration, ); _scaleAnimation = Tween( begin: 1.0, end: widget.scaleDown, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); } @override void dispose() { _controller.dispose(); super.dispose(); } void _onTapDown(TapDownDetails _) => _controller.forward(); void _onTapUp(TapUpDetails _) => _controller.reverse(); void _onTapCancel() => _controller.reverse(); @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: _onTapDown, onTapUp: _onTapUp, onTapCancel: _onTapCancel, onTap: widget.onTap, child: ScaleTransition(scale: _scaleAnimation, child: widget.child), ); } } // ============================================================ // 卡片按压效果 // ============================================================ /// 可按压卡片 /// /// 按压时缩小 + 降低亮度,松开弹回,增强触觉反馈。 /// 2026-06-27: 新增 onLongPress 长按回调支持。 class PressableCard extends StatefulWidget { const PressableCard({ super.key, required this.child, this.onTap, this.onLongPress, this.scaleDown = 0.97, this.borderRadius, this.padding, this.margin, this.color, }); final Widget child; final VoidCallback? onTap; /// 长按回调(触发后自动回弹,不进入按压态) final VoidCallback? onLongPress; final double scaleDown; final BorderRadius? borderRadius; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; final Color? color; @override State createState() => _PressableCardState(); } class _PressableCardState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _scaleAnimation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 100), ); _scaleAnimation = Tween( begin: 1.0, end: widget.scaleDown, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); } @override void dispose() { _controller.dispose(); super.dispose(); } void _onTapDown(TapDownDetails _) => _controller.forward(); void _onTapUp(TapUpDetails _) => _controller.reverse(); void _onTapCancel() => _controller.reverse(); void _onLongPress() { // 长按触发时回弹,避免卡片停留在按压态 _controller.reverse(); widget.onLongPress?.call(); } @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); return GestureDetector( onTapDown: _onTapDown, onTapUp: _onTapUp, onTapCancel: _onTapCancel, onTap: widget.onTap, onLongPress: widget.onLongPress == null ? null : _onLongPress, child: ScaleTransition( scale: _scaleAnimation, child: Container( padding: widget.padding ?? const EdgeInsets.all(AppSpacing.md), margin: widget.margin, decoration: BoxDecoration( color: widget.color ?? ext.bgCard, borderRadius: widget.borderRadius ?? AppRadius.lgBorder, ), child: widget.child, ), ), ); } } // ============================================================ // 列表项入场动画扩展 (flutter_animate) // ============================================================ /// 列表项入场动画配置 /// /// 使用 flutter_animate 为列表项添加交错入场动画。 /// 使用方式: /// ```dart /// item.animate().fadeIn().slideX(begin: 0.2, end: 0) /// ``` class ListItemAnimation { ListItemAnimation._(); /// 入场动画时长 static const Duration duration = Duration(milliseconds: 300); /// 交错延迟 static const Duration staggerDelay = Duration(milliseconds: 50); /// 最大交错数量 static const int maxStagger = 10; /// 淡入 + 从下方滑入 (列表默认) static List> get slideUp => [ const FadeEffect(duration: duration), const SlideEffect( duration: duration, begin: Offset(0, 0.1), end: Offset.zero, curve: Curves.easeOutCubic, ), ]; /// 淡入 + 从右侧滑入 (卡片) static List> get slideRight => [ const FadeEffect(duration: duration), const SlideEffect( duration: duration, begin: Offset(0.15, 0), end: Offset.zero, curve: Curves.easeOutCubic, ), ]; /// 仅淡入 (Tab 切换内容) static List> get fadeIn => [ const FadeEffect(duration: duration), ]; /// 弹性缩放入场 (弹窗/面板) static List> get scaleIn => [ const FadeEffect(duration: duration), const ScaleEffect( duration: duration, begin: Offset(0.9, 0.9), end: Offset(1.0, 1.0), curve: Curves.easeOutBack, ), ]; } // ============================================================ // 点赞心形动画 // ============================================================ /// 点赞动画组件 /// /// 点击时心形图标弹跳放大再缩小。 /// 注意: 列表项内不使用 Confetti(避免大量粒子导致卡顿), /// 全局庆祝请使用 CelebrationOverlay。 class LikeAnimation extends StatefulWidget { const LikeAnimation({ super.key, required this.isLiked, required this.onToggle, this.size = 24, }); final bool isLiked; final ValueChanged onToggle; final double size; @override State createState() => _LikeAnimationState(); } class _LikeAnimationState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), reverseDuration: const Duration(milliseconds: 200), ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override void didUpdateWidget(covariant LikeAnimation oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isLiked != widget.isLiked && widget.isLiked) { if (mounted && !_controller.isDismissed) { _controller.forward(from: 0).then((_) { if (mounted) _controller.reverse(); }); } } } void _handleTap() { if (!mounted) return; widget.onToggle(!widget.isLiked); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: ScaleTransition( scale: TweenSequence([ TweenSequenceItem( tween: Tween(begin: 1.0, end: 1.3), weight: 0.5, ), TweenSequenceItem( tween: Tween(begin: 1.3, end: 1.0), weight: 0.5, ), ]).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), ), child: Icon( widget.isLiked ? CupertinoIcons.heart_fill : CupertinoIcons.heart, size: widget.size, color: widget.isLiked ? CupertinoColors.systemRed.color : CupertinoColors.secondaryLabel.resolveFrom(context), ), ), ); } } // ============================================================ // 收藏弹跳动画 // ============================================================ /// 收藏弹跳动画组件 /// /// 点击⭐时弹跳放大再缩小,配合收藏状态切换。 class FavoriteBounceAnimation extends StatefulWidget { const FavoriteBounceAnimation({ super.key, required this.isFavorited, required this.onToggle, this.size = 20, }); final bool isFavorited; final VoidCallback onToggle; final double size; @override State createState() => _FavoriteBounceAnimationState(); } class _FavoriteBounceAnimationState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 400), reverseDuration: const Duration(milliseconds: 250), ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _handleTap() { if (!mounted) return; final wasFavorited = widget.isFavorited; widget.onToggle(); if (!wasFavorited && mounted && !_controller.isDismissed) { _controller.forward(from: 0).then((_) { if (mounted) _controller.reverse(); }); } } @override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: ScaleTransition( scale: TweenSequence([ TweenSequenceItem( tween: Tween(begin: 1.0, end: 1.5), weight: 0.4, ), TweenSequenceItem( tween: Tween(begin: 1.5, end: 0.9), weight: 0.3, ), TweenSequenceItem( tween: Tween(begin: 0.9, end: 1.0), weight: 0.3, ), ]).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), ), child: Text( widget.isFavorited ? '⭐' : '☆', style: TextStyle( fontSize: widget.size, color: widget.isFavorited ? null : CupertinoColors.secondaryLabel.resolveFrom(context), ), ), ), ); } } // ============================================================ // 稍后读动画 // ============================================================ /// 稍后读动画组件 /// /// 点击📖时缩放反馈。 class ReadLaterAnimation extends StatefulWidget { const ReadLaterAnimation({ super.key, required this.isReadLater, required this.onToggle, this.size = 20, }); final bool isReadLater; final VoidCallback onToggle; final double size; @override State createState() => _ReadLaterAnimationState(); } class _ReadLaterAnimationState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), reverseDuration: const Duration(milliseconds: 200), ); } @override void dispose() { _controller.dispose(); super.dispose(); } void _handleTap() { if (!mounted) return; widget.onToggle(); if (mounted && !_controller.isDismissed) { _controller.forward(from: 0).then((_) { if (mounted) _controller.reverse(); }); } } @override Widget build(BuildContext context) { return GestureDetector( onTap: _handleTap, child: ScaleTransition( scale: TweenSequence([ TweenSequenceItem( tween: Tween(begin: 1.0, end: 1.25), weight: 0.5, ), TweenSequenceItem( tween: Tween(begin: 1.25, end: 1.0), weight: 0.5, ), ]).animate( CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), ), child: Text( widget.isReadLater ? '📖' : '📄', style: TextStyle(fontSize: widget.size), ), ), ); } } // ============================================================ // Confetti 庆祝覆盖层 — 全屏撒花效果 // ============================================================ /// 庆祝动画触发器 /// /// 用于创作完成、首次收藏等里程碑时刻的全屏撒花。 class CelebrationOverlay extends StatefulWidget { const CelebrationOverlay({super.key, required this.child, this.triggerKey}); final Widget child; final GlobalKey? triggerKey; static CelebrationOverlayState? of(BuildContext context) { return context.findAncestorStateOfType(); } @override State createState() => CelebrationOverlayState(); } class CelebrationOverlayState extends State { late final ConfettiController _centerController; @override void initState() { super.initState(); _centerController = ConfettiController( duration: const Duration(milliseconds: 1500), ); } @override void dispose() { _centerController.dispose(); super.dispose(); } /// 触发全屏庆祝撒花 void celebrate() { _centerController.play(); } @override Widget build(BuildContext context) { return Stack( children: [ widget.child, Positioned.fill( child: IgnorePointer( child: Center( child: ConfettiWidget( confettiController: _centerController, blastDirectionality: BlastDirectionality.explosive, emissionFrequency: 0.03, numberOfParticles: 30, maxBlastForce: 25, minBlastForce: 10, particleDrag: 0.08, gravity: 0.08, colors: const [ Color(0xFFFF6B6B), Color(0xFF4ECDC4), Color(0xFF45B7D1), Color(0xFFFFE66D), Color(0xFF9B59B6), ], ), ), ), ), ], ); } }