Files
xianyan/lib/core/utils/ui/interaction_animations.dart
2026-06-27 04:57:00 +08:00

579 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言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),
],
),
),
),
),
],
);
}
}