Flutter健身APP训练项切换滑动动画实现求助
实现健身训练项的滑动切换动画方案
针对你的健身APP训练播放功能,要实现点击箭头时训练项滑动切换的效果,最直接的方案是用PageView组件替代当前直接替换内容的逻辑,它自带平滑的页面切换动画,同时也能兼容手动滑动切换的交互。以下是具体修改步骤和完整代码:
修改核心逻辑
- 引入
PageController管理页面切换,替代原有的currentExerciseIndex直接控制内容展示 - 将每个训练项封装为独立页面,放入
PageView中 - 通过
PageController.animateToPage实现点击箭头时的带动画切换 - 同步更新顶部训练计数、底部"下一个训练"提示信息
完整修改后的代码
class BlackScreen extends StatefulWidget { final List<Map<String, dynamic>> exercises; BlackScreen({Key? key, required this.exercises}) : super(key: key); @override _BlackScreenState createState() => _BlackScreenState(); } class _BlackScreenState extends State<BlackScreen> { late PageController _pageController; int currentExerciseIndex = 0; bool isPaused = false; int elapsedTime = 0; var totalTime = 0; @override void initState() { super.initState(); _pageController = PageController(initialPage: currentExerciseIndex); startTimer(); } @override void dispose() { _pageController.dispose(); super.dispose(); } void startTimer() async { while (true) { await Future.delayed(const Duration(seconds: 1)); if (isPaused) continue; if (mounted) { setState(() { elapsedTime++; }); } } } void pauseTimer() { setState(() { isPaused = !isPaused; }); } void previousExercise() { if (currentExerciseIndex > 0) { totalTime += elapsedTime; _pageController.animateToPage( currentExerciseIndex - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); setState(() { elapsedTime = 0; isPaused = false; }); } } void nextExercise() { if (currentExerciseIndex < widget.exercises.length - 1) { totalTime += elapsedTime; _pageController.animateToPage( currentExerciseIndex + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); setState(() { elapsedTime = 0; isPaused = false; }); } else { totalTime += elapsedTime; print(totalTime); Navigator.of(context).pop(); } } String formatDuration(int seconds) { final minutes = (seconds / 60).floor().toString().padLeft(2, '0'); final remainingSeconds = (seconds % 60).toString().padLeft(2, '0'); return '$minutes:$remainingSeconds'; } Widget buildButton(IconData iconData, VoidCallback? onPressed, double size) { return GestureDetector( onTap: onPressed, child: Container( height: 50, width: 50, child: iconData == Icons.play_arrow || iconData == Icons.pause ? Container( height: size, width: size, decoration: const BoxDecoration( color: Color(0xff11b779), shape: BoxShape.circle, ), child: Icon( iconData, color: Colors.black, size: size - 10, ), alignment: Alignment.center, ) : Icon( iconData, color: Colors.white, size: size, ), alignment: Alignment.center, ), ); } // 封装单个训练项页面 Widget _buildExercisePage(Map<String, dynamic> exercise) { var size = MediaQuery.of(context).size; var topPadding = MediaQuery.of(context).padding.top; return Stack(alignment: Alignment.center, children: [ Positioned( top: 0, child: Image.network( exercise['video'], height: size.height * .7)), Positioned( top: topPadding + 10, left: 10, child: Row(children: [ GestureDetector( onTap: () { Navigator.of(context).pop(); }, child: const Icon(Icons.arrow_drop_down, size: 20, color: Colors.black)), const DefaultTextStyle( style: TextStyle(fontSize: 20, color: Colors.black), child: Text('Just Lift'), ), ])), Positioned( top: topPadding + 12, right: 10, child: DefaultTextStyle( style: const TextStyle(fontSize: 20, color: Colors.black), child: Text('Exercise ' + (currentExerciseIndex + 1).toString() + '/' + (widget.exercises.length).toString()), ), ), Positioned( top: size.height * .7 + 10, child: Material( color: Colors.transparent, child: Column(children: [ DefaultTextStyle( style: const TextStyle( fontSize: 25, color: Colors.white, fontWeight: FontWeight.bold), child: Text(exercise['name'].toUpperCase()), ), const SizedBox(height: 5), Text('${formatDuration(elapsedTime)}', style: const TextStyle(color: Colors.white, fontSize: 25)), const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ buildButton( Icons.arrow_back, currentExerciseIndex == 0 ? null : previousExercise, size.height * .05, ), buildButton( isPaused ? Icons.play_arrow : Icons.pause, pauseTimer, size.height * .07, ), buildButton( Icons.arrow_forward, currentExerciseIndex == widget.exercises.length - 1 ? null : nextExercise, size.height * .05, ), ], ), ]))), ]); } @override Widget build(BuildContext context) { var size = MediaQuery.of(context).size; if (widget.exercises.length == 0) return Container(); return Container( height: size.height, width: size.width, color: Colors.black, child: Stack( children: [ PageView.builder( controller: _pageController, physics: const NeverScrollableScrollPhysics(), // 禁用手动滑动,仅保留按钮控制 itemCount: widget.exercises.length, onPageChanged: (index) { setState(() { currentExerciseIndex = index; }); }, itemBuilder: (context, index) { return _buildExercisePage(widget.exercises[index]); }, ), // 底部的下一个训练提示或完成按钮 Positioned( bottom: 0, child: currentExerciseIndex == widget.exercises.length - 1 ? SizedBox( width: size.width * .33, child: ElevatedButton( onPressed: nextExercise, style: ElevatedButton.styleFrom( primary: const Color.fromARGB(255, 17, 183, 183), padding: const EdgeInsets.all(10), tapTargetSize: MaterialTapTargetSize.shrinkWrap), child: const Text('Finish!', style: TextStyle( color: Colors.black, fontWeight: FontWeight.bold)))) : Column(children: [ const DefaultTextStyle( style: TextStyle( fontSize: 15, color: Colors.grey, fontWeight: FontWeight.bold), child: Transform.translate( offset: Offset(-3, 0), child: Text(' Up Next')), ), DefaultTextStyle( style: const TextStyle( fontSize: 15, color: Color.fromARGB(255, 209, 209, 209), fontWeight: FontWeight.bold), child: Text( widget.exercises[currentExerciseIndex + 1]['name']), ) ]), ) ], ), ); } }
关键修改点说明
- 使用
PageController的animateToPage方法实现300ms的平滑滑动动画,通过Curves.easeInOut保证过渡自然 - 将每个训练项的UI封装为
_buildExercisePage方法,作为PageView的子页面 - 禁用了
PageView的手动滑动(NeverScrollableScrollPhysics),若需要支持用户手动滑动切换训练项,移除该配置即可 - 保留原有的计时器、暂停/继续、计数展示等所有功能,仅修改页面切换逻辑
内容的提问来源于stack exchange,提问作者Cidiko Andorem
相关产品推荐
相关产品推荐

