|
|
@@ -0,0 +1,286 @@
|
|
|
+import 'dart:math' as math;
|
|
|
+import 'package:flutter/material.dart';
|
|
|
+import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
|
+import 'package:photo_manager/photo_manager.dart';
|
|
|
+import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
|
|
+
|
|
|
+class PhotoSwipeCard extends StatefulWidget {
|
|
|
+ final List<AssetEntity> assets;
|
|
|
+ final Function(int)? onIndexChanged;
|
|
|
+ final Function(AssetEntity, bool)?
|
|
|
+ onActionSelected; // true for keep, false for delete
|
|
|
+ final Function()? onEnd;
|
|
|
+ final bool enableVerticalDrag;
|
|
|
+ final bool loop;
|
|
|
+ final double threshold;
|
|
|
+
|
|
|
+ const PhotoSwipeCard({
|
|
|
+ super.key,
|
|
|
+ required this.assets,
|
|
|
+ this.onIndexChanged,
|
|
|
+ this.onActionSelected,
|
|
|
+ this.onEnd,
|
|
|
+ this.enableVerticalDrag = false,
|
|
|
+ this.loop = false,
|
|
|
+ this.threshold = 0.5,
|
|
|
+ });
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<PhotoSwipeCard> createState() => _PhotoSwipeCardState();
|
|
|
+}
|
|
|
+
|
|
|
+class _PhotoSwipeCardState extends State<PhotoSwipeCard>
|
|
|
+ with SingleTickerProviderStateMixin {
|
|
|
+ late AnimationController _controller;
|
|
|
+ late Animation<Offset> _animation;
|
|
|
+ late Animation<double> _rotation;
|
|
|
+ Offset _dragOffset = Offset.zero;
|
|
|
+ int currentIndex = 0;
|
|
|
+
|
|
|
+ // 新增:弧形动画相关计算
|
|
|
+ double get _rotationAngle =>
|
|
|
+ (_dragOffset.dx / MediaQuery.of(context).size.width) * 0.3;
|
|
|
+
|
|
|
+ double get _verticalOffset => -math.sin(_rotationAngle * 2) * 100;
|
|
|
+
|
|
|
+ double get _nextCardScale {
|
|
|
+ final double dragPercent =
|
|
|
+ _dragOffset.dx.abs() / (MediaQuery.of(context).size.width * 0.3);
|
|
|
+ return 0.9 + (0.1 * math.min(1.0, dragPercent));
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ super.initState();
|
|
|
+ _controller = AnimationController(
|
|
|
+ duration: const Duration(milliseconds: 300),
|
|
|
+ vsync: this,
|
|
|
+ );
|
|
|
+ _initializeAnimations();
|
|
|
+ }
|
|
|
+
|
|
|
+ void _initializeAnimations() {
|
|
|
+ _animation = Tween<Offset>(begin: Offset.zero, end: Offset.zero)
|
|
|
+ .animate(CurvedAnimation(
|
|
|
+ parent: _controller,
|
|
|
+ curve: Curves.easeOut,
|
|
|
+ ));
|
|
|
+ _rotation = Tween<double>(begin: 0, end: 0).animate(CurvedAnimation(
|
|
|
+ parent: _controller,
|
|
|
+ curve: Curves.easeOut,
|
|
|
+ ));
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onPanStart(DragStartDetails details) {
|
|
|
+ _controller.reset();
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onPanUpdate(DragUpdateDetails details) {
|
|
|
+ setState(() {
|
|
|
+ if (widget.enableVerticalDrag) {
|
|
|
+ _dragOffset += details.delta;
|
|
|
+ } else {
|
|
|
+ _dragOffset += Offset(details.delta.dx, 0);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onPanCancel() {
|
|
|
+ _returnToCenter();
|
|
|
+ }
|
|
|
+
|
|
|
+ void _returnToCenter() {
|
|
|
+ setState(() {
|
|
|
+ _dragOffset = Offset.zero; // 重置偏移量
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onPanEnd(DragEndDetails details) {
|
|
|
+ final double threshold =
|
|
|
+ widget.threshold * MediaQuery.of(context).size.width;
|
|
|
+ final velocity = details.velocity.pixelsPerSecond.dx;
|
|
|
+ final isFlick = velocity.abs() > 1000;
|
|
|
+
|
|
|
+ if (_dragOffset.dx.abs() > threshold || isFlick) {
|
|
|
+ final bool isKeepAction = _dragOffset.dx > 0;
|
|
|
+ final bool isLastCard = currentIndex >= widget.assets.length - 1;
|
|
|
+
|
|
|
+ // 添加弧形退出动画
|
|
|
+ final endRotation = _dragOffset.dx > 0 ? 0.3 : -0.3;
|
|
|
+ final endVerticalOffset = -math.sin(endRotation * 2) * 100;
|
|
|
+
|
|
|
+ _animation = Tween<Offset>(
|
|
|
+ begin: _dragOffset,
|
|
|
+ end: Offset(
|
|
|
+ _dragOffset.dx > 0
|
|
|
+ ? MediaQuery.of(context).size.width * 1.5
|
|
|
+ : -MediaQuery.of(context).size.width * 1.5,
|
|
|
+ endVerticalOffset,
|
|
|
+ ),
|
|
|
+ ).animate(CurvedAnimation(
|
|
|
+ parent: _controller,
|
|
|
+ curve: Curves.easeOut,
|
|
|
+ ));
|
|
|
+
|
|
|
+ _controller.forward().then((_) {
|
|
|
+ widget.onActionSelected
|
|
|
+ ?.call(widget.assets[currentIndex], isKeepAction);
|
|
|
+
|
|
|
+ setState(() {
|
|
|
+ if (isLastCard) {
|
|
|
+ if (widget.loop) {
|
|
|
+ currentIndex = 0;
|
|
|
+ _controller.addListener(_onLoopStarted);
|
|
|
+ } else {
|
|
|
+ currentIndex = widget.assets.length;
|
|
|
+ widget.onEnd?.call();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ currentIndex++;
|
|
|
+ if (widget.loop && currentIndex == widget.assets.length) {
|
|
|
+ currentIndex = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _dragOffset = Offset.zero;
|
|
|
+ widget.onIndexChanged?.call(currentIndex);
|
|
|
+ });
|
|
|
+
|
|
|
+ _controller.reset();
|
|
|
+ _initializeAnimations();
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ _returnToCenter();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onLoopStarted() {
|
|
|
+ if (currentIndex == 0) {
|
|
|
+ widget.onEnd?.call();
|
|
|
+ _controller.removeListener(_onLoopStarted);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) {
|
|
|
+ return Stack(
|
|
|
+ children: [
|
|
|
+ // 下一张卡片
|
|
|
+ if (_hasNextCard())
|
|
|
+ Positioned.fill(
|
|
|
+ child: AnimatedBuilder(
|
|
|
+ animation: _controller,
|
|
|
+ builder: (context, child) {
|
|
|
+ return Transform.scale(
|
|
|
+ scale: _nextCardScale,
|
|
|
+ child: Card(
|
|
|
+ elevation: 4,
|
|
|
+ shape: RoundedRectangleBorder(
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ ),
|
|
|
+ child: ClipRRect(
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ child: AssetEntityImage(
|
|
|
+ widget.assets[_getNextIndex()],
|
|
|
+ width: 314.w,
|
|
|
+ height: 392.h,
|
|
|
+ fit: BoxFit.cover,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ // 当前卡片
|
|
|
+ if (currentIndex < widget.assets.length)
|
|
|
+ AnimatedBuilder(
|
|
|
+ animation: _controller,
|
|
|
+ builder: (context, child) {
|
|
|
+ final offset = _animation.value + _dragOffset;
|
|
|
+ return Transform(
|
|
|
+ transform: Matrix4.identity()
|
|
|
+ ..translate(offset.dx, _verticalOffset + offset.dy)
|
|
|
+ ..rotateZ(_rotationAngle)
|
|
|
+ ..setEntry(3, 2, 0.001), // 添加透视效果
|
|
|
+ alignment: Alignment.center,
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ Card(
|
|
|
+ elevation: 8,
|
|
|
+ shape: RoundedRectangleBorder(
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ ),
|
|
|
+ child: GestureDetector(
|
|
|
+ onPanStart: _onPanStart,
|
|
|
+ onPanUpdate: _onPanUpdate,
|
|
|
+ onPanEnd: _onPanEnd,
|
|
|
+ onPanCancel: _onPanCancel,
|
|
|
+ child: ClipRRect(
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ child: Stack(
|
|
|
+ children: [
|
|
|
+ AssetEntityImage(
|
|
|
+ widget.assets[currentIndex],
|
|
|
+ width: 314.w,
|
|
|
+ height: 392.h,
|
|
|
+ fit: BoxFit.cover,
|
|
|
+ ),
|
|
|
+ // 添加操作提示UI
|
|
|
+ if (_dragOffset.dx != 0)
|
|
|
+ Positioned(
|
|
|
+ top: 20,
|
|
|
+ right: _dragOffset.dx < 0 ? 20 : null,
|
|
|
+ left: _dragOffset.dx > 0 ? 20 : null,
|
|
|
+ child: Container(
|
|
|
+ padding: EdgeInsets.symmetric(
|
|
|
+ horizontal: 20.w, vertical: 10.h),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ color: _dragOffset.dx > 0
|
|
|
+ ? Colors.blue.withOpacity(0.8)
|
|
|
+ : Colors.red.withOpacity(0.8),
|
|
|
+ borderRadius: BorderRadius.circular(20),
|
|
|
+ ),
|
|
|
+ child: Text(
|
|
|
+ _dragOffset.dx > 0 ? 'Keep' : 'Delete',
|
|
|
+ style: TextStyle(
|
|
|
+ color: Colors.white,
|
|
|
+ fontSize: 16.sp,
|
|
|
+ fontWeight: FontWeight.bold,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ bool _hasNextCard() {
|
|
|
+ if (widget.loop) {
|
|
|
+ return widget.assets.length > 1;
|
|
|
+ }
|
|
|
+ return currentIndex < widget.assets.length - 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ int _getNextIndex() {
|
|
|
+ if (widget.loop) {
|
|
|
+ return (currentIndex + 1) % widget.assets.length;
|
|
|
+ }
|
|
|
+ return currentIndex + 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ _controller.dispose();
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+}
|