Flutter实现Tinder式左滑及卡片上滑覆盖动画求助
实现Tinder风格的卡片切换动画
要实现你需要的左滑(Pass按钮)和上滑覆盖(Back按钮)动画,我们可以通过AnimationController结合SlideTransition、FadeTransition和Stack组件来完成,以下是具体实现方案:
核心思路
- 使用
Stack叠放两张卡片,底层显示待切换的卡片,顶层显示当前卡片 - 通过
AnimationController控制顶层卡片的位移和透明度动画 - 根据按钮类型(Pass/Back)切换动画的方向和参数
完整代码实现
1. 状态与动画初始化
替换你原来的状态定义,添加动画相关变量:
import 'package:flutter/material.dart'; class TinderCardScreen extends StatefulWidget { const TinderCardScreen({super.key}); @override State<TinderCardScreen> createState() => _TinderCardScreenState(); } class _TinderCardScreenState extends State<TinderCardScreen> with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation<Offset> _slideAnimation; late Animation<double> _fadeAnimation; int _currentIndex = 0; bool _isHidden = true; // 保留你原来的可见性控制变量 // 定义动画类型 enum _AnimationType { leftSlide, topSlide } _AnimationType? _currentAnimation; // 图片列表,可扩展更多图片 final List<String> _imagePaths = [ "assets/images/image.jpeg", "assets/images/imageQ.jpeg", ]; @override void initState() { super.initState(); // 初始化动画控制器,时长800ms _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); // 默认左滑动画参数 _slideAnimation = Tween<Offset>( begin: Offset.zero, end: const Offset(-1.5, 0), ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); _fadeAnimation = Tween<double>( begin: 1.0, end: 0.0, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeIn, )); } @override void dispose() { _animationController.dispose(); super.dispose(); }
2. 按钮点击与动画逻辑
替换原来的_onBackButtonTap和_onPassButtonTap方法:
void _onBackButtonTap() { if (_currentIndex == 0) return; // 已经是第一张,无需切换 // 设置上滑覆盖动画参数:从屏幕上方滑入到当前位置,透明度从0到1 _currentAnimation = _AnimationType.topSlide; _slideAnimation = Tween<Offset>( begin: const Offset(0, -1.5), end: Offset.zero, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); _fadeAnimation = Tween<double>( begin: 0.0, end: 1.0, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeIn, )); // 执行动画,结束后切换索引并重置控制器 _animationController.forward().then((_) { setState(() { _currentIndex--; }); _animationController.reset(); }); } void _onPassButtonTap() { if (_currentIndex == _imagePaths.length - 1) return; // 已经是最后一张,无需切换 // 设置左滑动画参数:从当前位置滑出到屏幕左侧,透明度从1到0 _currentAnimation = _AnimationType.leftSlide; _slideAnimation = Tween<Offset>( begin: Offset.zero, end: const Offset(-1.5, 0), ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); _fadeAnimation = Tween<double>( begin: 1.0, end: 0.0, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeIn, )); // 执行动画,结束后切换索引并重置控制器 _animationController.forward().then((_) { setState(() { _currentIndex++; }); _animationController.reset(); }); } // 保留你原来的Shoot按钮逻辑 void _onBasketballIconTap() { // 你的原有逻辑 }
3. 重构UI部分
将原来的单个卡片容器替换为Stack结构,提取卡片为单独方法:
// 构建单个卡片组件 Widget _buildCard(String imagePath) { return Container( height: context.height * 0.68, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), image: DecorationImage( image: AssetImage(imagePath), fit: BoxFit.cover, ), ), child: Align( alignment: Alignment.bottomLeft, child: Container( padding: const EdgeInsets.only(left: 16, bottom: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // User name Visibility( visible: _isHidden, child: const Text("Jessica, 24", style: TextStyle( fontSize: 30, color: Colors.white ),), ), SizedBox(height: context.height * 0.02,), // Location Visibility( visible: _isHidden, child: ClipRRect( borderRadius: BorderRadius.circular(12), child: Material( elevation: 4, child: Container( padding: const EdgeInsets.all(8), width: context.width * 0.37, decoration: BoxDecoration( color: const Color(0xFF485354), borderRadius: BorderRadius.circular(12), ), child: const Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Icon( Icons.location_on, color: Colors.white, size: 30, ), Text( 'New York', style: TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, ), ), ], ), ), ), ), ), // Action buttons row Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ // Pass button InkWell( onTap: _onPassButtonTap, child: const CircleAvatar( radius: 50, backgroundColor: Colors.white, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text("👋", style: TextStyle(fontSize: 29)), Text("Pass", style: TextStyle(color: Colors.black)) ], ), ), ), // Shoot button InkWell( onTap: _onBasketballIconTap, child: Container( decoration: const BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ Color(0xFF2A64E5), Color(0xFF3264E4), Color(0xFF453CCA), Color(0xFF3A43AF), Color(0xFF4634B1), ], ) ), child: const CircleAvatar( radius: 60, backgroundColor: Color(0xFF6814CE), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text("🏀", style: TextStyle(fontSize: 42, fontFamily: "Roboto")), Text("Shoot") ], ), ), ) ), // Back button InkWell( onTap: _onBackButtonTap, child: const CircleAvatar( radius: 50, backgroundColor: Colors.white, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text("👈", style: TextStyle(fontSize: 29)), Text("Back", style: TextStyle(color: Colors.black)) ], ), ), ), ], ), ], ), ), ), ); } @override Widget build(BuildContext context) { return Scaffold( body: Padding( padding: const EdgeInsets.all(8.0), child: SizedBox( height: context.height * 0.68, child: Stack( children: [ // 底层卡片:显示待切换的卡片(当前索引的前/后一张) _buildCard(_currentIndex == 0 ? _imagePaths[1] : _imagePaths[0]), // 顶层卡片:当前显示的卡片,应用动画 SlideTransition( position: _slideAnimation, child: FadeTransition( opacity: _fadeAnimation, child: _buildCard(_imagePaths[_currentIndex]), ), ), ], ), ), ), ); } }
关键说明
SingleTickerProviderStateMixin:提供动画所需的帧同步SlideTransition:控制卡片的位移动画,Offset参数控制方向(左滑用x轴负方向,上滑用y轴负方向)FadeTransition:配合位移添加透明度变化,增强动画层次感Stack:实现卡片叠放效果,确保切换时底层卡片可见
内容的提问来源于stack exchange,提问作者Muhammad Talha
相关产品推荐
相关产品推荐

