579 lines
15 KiB
Dart
579 lines
15 KiB
Dart
// ============================================================
|
||
// 闲言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<BounceButton> createState() => _BounceButtonState();
|
||
}
|
||
|
||
class _BounceButtonState extends State<BounceButton>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _controller;
|
||
late final Animation<double> _scaleAnimation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: widget.duration,
|
||
reverseDuration: widget.duration,
|
||
);
|
||
_scaleAnimation = Tween<double>(
|
||
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<PressableCard> createState() => _PressableCardState();
|
||
}
|
||
|
||
class _PressableCardState extends State<PressableCard>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _controller;
|
||
late final Animation<double> _scaleAnimation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 100),
|
||
);
|
||
_scaleAnimation = Tween<double>(
|
||
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<Effect<void>> get slideUp => [
|
||
const FadeEffect(duration: duration),
|
||
const SlideEffect(
|
||
duration: duration,
|
||
begin: Offset(0, 0.1),
|
||
end: Offset.zero,
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
];
|
||
|
||
/// 淡入 + 从右侧滑入 (卡片)
|
||
static List<Effect<void>> get slideRight => [
|
||
const FadeEffect(duration: duration),
|
||
const SlideEffect(
|
||
duration: duration,
|
||
begin: Offset(0.15, 0),
|
||
end: Offset.zero,
|
||
curve: Curves.easeOutCubic,
|
||
),
|
||
];
|
||
|
||
/// 仅淡入 (Tab 切换内容)
|
||
static List<Effect<void>> get fadeIn => [
|
||
const FadeEffect(duration: duration),
|
||
];
|
||
|
||
/// 弹性缩放入场 (弹窗/面板)
|
||
static List<Effect<void>> 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<bool> onToggle;
|
||
final double size;
|
||
|
||
@override
|
||
State<LikeAnimation> createState() => _LikeAnimationState();
|
||
}
|
||
|
||
class _LikeAnimationState extends State<LikeAnimation>
|
||
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<double>([
|
||
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<FavoriteBounceAnimation> createState() =>
|
||
_FavoriteBounceAnimationState();
|
||
}
|
||
|
||
class _FavoriteBounceAnimationState extends State<FavoriteBounceAnimation>
|
||
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<double>([
|
||
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<ReadLaterAnimation> createState() => _ReadLaterAnimationState();
|
||
}
|
||
|
||
class _ReadLaterAnimationState extends State<ReadLaterAnimation>
|
||
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<double>([
|
||
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<CelebrationOverlayState>? triggerKey;
|
||
|
||
static CelebrationOverlayState? of(BuildContext context) {
|
||
return context.findAncestorStateOfType<CelebrationOverlayState>();
|
||
}
|
||
|
||
@override
|
||
State<CelebrationOverlay> createState() => CelebrationOverlayState();
|
||
}
|
||
|
||
class CelebrationOverlayState extends State<CelebrationOverlay> {
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|