You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter健身APP训练项切换滑动动画实现求助

实现健身训练项的滑动切换动画方案

针对你的健身APP训练播放功能,要实现点击箭头时训练项滑动切换的效果,最直接的方案是用PageView组件替代当前直接替换内容的逻辑,它自带平滑的页面切换动画,同时也能兼容手动滑动切换的交互。以下是具体修改步骤和完整代码:

修改核心逻辑

  1. 引入PageController管理页面切换,替代原有的currentExerciseIndex直接控制内容展示
  2. 将每个训练项封装为独立页面,放入PageView中
  3. 通过PageController.animateToPage实现点击箭头时的带动画切换
  4. 同步更新顶部训练计数、底部"下一个训练"提示信息

完整修改后的代码

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 21:17:04