flutter-制作可缩放底部弹出抽屉评论区效果

发布于:2025-03-11 ⋅ 阅读:(87) ⋅ 点赞:(0)

1. 介绍

在 Flutter 开发中,底部弹出抽屉是一种常见的交互方式,它可以为用户提供额外的操作选项或展示更多的内容。本文将详细介绍如何使用 Flutter 实现一个可缩放的底部弹出抽屉效果,用户点击特定区域后会弹出底部抽屉,抽屉的高度可以通过手指滑动进行调整。当手指滑动距离超过一定阈值时,抽屉会关闭;否则,抽屉会恢复到初始高度。

2. 效果展示

预览图

3. 步骤分析

  1. 先导入必要的库
import 'dart:async';
import 'package:flutter/material.dart';
  • dart:async:用于处理异步操作,如流(Stream)和定时器(Timer)。
  • package:flutter/material.dart:Flutter 的核心 UI 库,提供了各种 Material Design 风格的组件。
  1. 定义 ShowBottomPopupScaleBox 组件
class ShowBottomPopupScaleBox extends StatefulWidget {
  final double topH;
  final double bottomH;
  const ShowBottomPopupScaleBox(
      {Key? key, required this.topH, required this.bottomH})
      : super(key: key);

  
  ShowBottomPopupScaleBoxState createState() => ShowBottomPopupScaleBoxState();
}

ShowBottomPopupScaleBox 是一个有状态的组件,接受两个参数 topH 和 bottomH,分别表示顶部和底部的高度。并重写 createState 方法,返回 ShowBottomPopupScaleBoxState 实例。

  1. 定义 ShowBottomPopupScaleBoxState 状态类
class ShowBottomPopupScaleBoxState extends State<ShowBottomPopupScaleBox>
    with SingleTickerProviderStateMixin {
  // ...
}

ShowBottomPopupScaleBoxState 继承自 State,并混入 SingleTickerProviderStateMixin,用于管理动画控制器。

  1. 定义状态变量
final ScrollController myScrollController = ScrollController();
StreamController<double> myStreamController =
    StreamController<double>.broadcast();
StreamController<double> myBottomSheetController = StreamController<double>();
double get maxW => MediaQuery.of(context).size.width;
double drawerH = 0.0;
double touchPosition = 0.0;
late AnimationController myAnimationController;
bool allowRoll = true;
  • myScrollController:用于控制列表的滚动。
  • myStreamController 和 myBottomSheetController:分别用于发送改变弹框高度和更新 UI 的事件。
  • maxW:获取屏幕的宽度。
  • drawerH:抽屉弹起的高度。
  • touchPosition:手指触碰的位置。
  • myAnimationController:动画控制器,用于控制抽屉的弹出和关闭动画。
  • allowRoll:表示是否允许滚动。
  1. 初始化状态

void initState() {
  super.initState();
  myAnimationController = BottomSheet.createAnimationController(this);
  myAnimationController.addListener(() {
    final value = myAnimationController.value * drawerH;
    print('~~~~~~~~~~~~~更新:$value');
    myBottomSheetController.sink.add(value);
  });
}

在 initState 方法中,初始化动画控制器,并添加监听器,当动画值发生变化时,更新 UI。

  1. 构建 UI

Widget build(BuildContext context) {
  double maxH =
      MediaQuery.of(context).size.height - widget.topH - widget.bottomH - 40;
  drawerH = maxH - 300;
  print('~~~~~~~~~~~maxH:$maxH');
  print('~~~~~~~~~~~drawerH:$drawerH');
  return Scaffold(
      backgroundColor: Colors.white,
      body: SizedBox(
          width: maxW,
          height: maxH,
          child: Stack(children: [
            StreamBuilder<double>(
                stream: myBottomSheetController.stream,
                initialData: 0,
                builder: (_, snapshot) {
                  return AnimatedContainer(
                      duration: const Duration(milliseconds: 100),
                      height: maxH - snapshot.data!,
                      alignment: Alignment.center,
                      child: GestureDetector(
                          onTap: () {
                            showModalBottomSheet(
                                // ...
                            );
                          },
                          child: Image.network(
                              'https://xxxxxxxxx.png')));
                })
          ])));
}

在 build 方法中,计算屏幕高度和抽屉高度,并使用 StreamBuilder 监听 myBottomSheetController 的事件,根据事件更新 UI。
使用 GestureDetector 包裹图片,当用户点击图片时,调用 showModalBottomSheet 方法弹出底部抽屉。

  1. 处理触摸事件
onPointerMove: (event) {
  if (myScrollController.offset != 0) {
    print("onPointerMove:${myScrollController.offset}");
    return;
  }
  double distance = event.position.dy - touchPosition;
  if (distance.abs() > 0) {
    double _currentHeight = drawerH - distance;
    if (_currentHeight > drawerH) {
      return;
    }
    myStreamController.sink.add(_currentHeight);
  }
},
onPointerUp: (event) {
  if (currentHeight < (drawerH * 0.5)) {
    Navigator.pop(context);
  } else {
    myStreamController.sink.add(drawerH);
  }
},
onPointerDown: (event) {
  touchPosition = event.position.dy + myScrollController.offset;
},
  • onPointerMove:处理手指滑动事件,只有当列表滚动到顶部时才触发下拉动画效果,根据手指滑动的距离计算弹框实时高度,并发送事件。
  • onPointerUp:处理手指离开屏幕事件,根据当前抽屉高度判断是否关闭抽屉。
  • onPointerDown:处理手指开始接触屏幕事件,记录手指触碰的位置。
  1. 定义抽屉内容
Widget drawerContentWidget(double currentHeight) {
  return ListView.builder(
      controller: myScrollController,
      physics: currentHeight != drawerH
          ? const NeverScrollableScrollPhysics()
          : const ClampingScrollPhysics(),
      itemCount: 20,
      itemBuilder: (_, index) {
        return Container(
            width: double.infinity,
            height: 40,
            alignment: Alignment.center,
            child: Text('${index + 1}',
                style: const TextStyle(color: Colors.white, fontSize: 20)));
      });
}

drawerContentWidget 方法返回一个 ListView,用于显示抽屉的内容。根据当前抽屉高度设置列表的滚动属性。

4. 完整代码

  • main.dart
double topH = MediaQuery.of(context).padding.top;
double bottomH = MediaQuery.of(context).padding.bottom;

ShowBottomPopupScaleBox(topH: topH, bottomH: bottomH)
  • show_bottom_popup.dart
import 'dart:async';
import 'package:flutter/material.dart';

/// 展示底部弹出抽屉时缩放盒子
class ShowBottomPopupScaleBox extends StatefulWidget {
  final double topH;
  final double bottomH;
  const ShowBottomPopupScaleBox(
      {Key? key, required this.topH, required this.bottomH})
      : super(key: key);

  
  ShowBottomPopupScaleBoxState createState() => ShowBottomPopupScaleBoxState();
}

/// 状态
class ShowBottomPopupScaleBoxState extends State<ShowBottomPopupScaleBox>
    with SingleTickerProviderStateMixin {
  /// 滚动控制器
  final ScrollController myScrollController = ScrollController();

  /// 用来发送事件 改变弹框高度的 stream控制器
  StreamController<double> myStreamController =
      StreamController<double>.broadcast();

  /// 抽屉控制器
  StreamController<double> myBottomSheetController = StreamController<double>();

  /// 屏幕宽度
  double get maxW => MediaQuery.of(context).size.width;

  /// 抽屉弹起的高度
  double drawerH = 0.0;

  /// 手指触碰的位置
  double touchPosition = 0.0;

  /// 动画控制器
  late AnimationController myAnimationController;

  /// 允许滚动
  bool allowRoll = true;

  
  void initState() {
    super.initState();
    myAnimationController = BottomSheet.createAnimationController(this);
    myAnimationController.addListener(() {
      final value = myAnimationController.value * drawerH;

      // 更新UI
      print('~~~~~~~~~~~~~更新:$value');
      myBottomSheetController.sink.add(value);
    });
  }

  
  Widget build(BuildContext context) {
    /// 屏幕高度
    double maxH =
        MediaQuery.of(context).size.height - widget.topH - widget.bottomH - 40;
    drawerH = maxH - 300;
    print('~~~~~~~~~~~maxH:$maxH');
    print('~~~~~~~~~~~drawerH:$drawerH');
    return Scaffold(
        backgroundColor: Colors.white,
        body: SizedBox(
            width: maxW,
            height: maxH,
            child: Stack(children: [
              StreamBuilder<double>(
                  stream: myBottomSheetController.stream,
                  initialData: 0,
                  builder: (_, snapshot) {
                    return AnimatedContainer(
                        duration: const Duration(milliseconds: 100),
                        height: maxH - snapshot.data!,
                        alignment: Alignment.center,
                        child: GestureDetector(
                            onTap: () {
                              showModalBottomSheet(
                                  context: context,
                                  clipBehavior: Clip.antiAlias,
                                  barrierColor: Colors.black.withOpacity(0.3),
                                  // 设置为true时 则高度可以超过屏幕的一半
                                  isScrollControlled: true,
                                  // 圆角
                                  shape: const RoundedRectangleBorder(
                                      borderRadius: BorderRadius.only(
                                          topLeft: Radius.circular(20),
                                          topRight: Radius.circular(20))),
                                  transitionAnimationController:
                                      myAnimationController,
                                  builder: (BuildContext context) {
                                    return StreamBuilder<double>(
                                      stream: myStreamController.stream,
                                      initialData: drawerH,
                                      builder: (context, snapshot) {
                                        double currentHeight =
                                            snapshot.data ?? drawerH;
                                        return AnimatedContainer(
                                            duration: const Duration(
                                                milliseconds: 100),
                                            height: currentHeight,
                                            color: const Color(0xFF2C2C2C),
                                            child: Listener(
                                                onPointerMove: (event) {
                                                  // 触摸事件过程 手指一直在屏幕上且发生距离滑动
                                                  if (myScrollController
                                                          .offset !=
                                                      0) {
                                                    // 只有列表滚动到顶部时才触发下拉动画效果
                                                    print(
                                                        "onPointerMove:${myScrollController.offset}");
                                                    return;
                                                  }
                                                  double distance =
                                                      event.position.dy -
                                                          touchPosition;
                                                  if (distance.abs() > 0) {
                                                    // 获取手指滑动的距离,计算弹框实时高度,并发送事件
                                                    double _currentHeight =
                                                        drawerH - distance;
                                                    if (_currentHeight >
                                                        drawerH) {
                                                      return;
                                                    }
                                                    myStreamController.sink
                                                        .add(_currentHeight);
                                                  }
                                                },
                                                onPointerUp: (event) {
                                                  // 触摸事件结束 手指离开屏幕
                                                  // 这里认为滑动超过一半就认为用户要退出了,值可以根据实际体验修改
                                                  if (currentHeight <
                                                      (drawerH * 0.5)) {
                                                    Navigator.pop(context);
                                                  } else {
                                                    myStreamController.sink
                                                        .add(drawerH);
                                                  }
                                                },
                                                onPointerDown: (event) {
                                                  // 触摸事件开始 手指开始接触屏幕
                                                  touchPosition = event
                                                          .position.dy +
                                                      myScrollController.offset;
                                                },
                                                child: drawerContentWidget(
                                                    currentHeight)));
                                      },
                                    );
                                  });
                            },
                            child: Image.network(
                                'https://xxxxxxxxx.png')));
                  })
            ])));
  }

  /// 抽屉内容
  Widget drawerContentWidget(double currentHeight) {
    return ListView.builder(
        controller: myScrollController,
        physics: currentHeight != drawerH
            ? const NeverScrollableScrollPhysics()
            : const ClampingScrollPhysics(),
        itemCount: 20,
        itemBuilder: (_, index) {
          return Container(
              width: double.infinity,
              height: 40,
              alignment: Alignment.center,
              child: Text('${index + 1}',
                  style: const TextStyle(color: Colors.white, fontSize: 20)));
        });
  }
}

5. 总结

通过以上步骤,我们实现了一个可缩放的底部弹出抽屉效果。主要使用了 showModalBottomSheet 方法弹出底部抽屉,通过 StreamController 发送和接收事件,实现了抽屉高度的动态调整。同时,使用 GestureDetector 和 Listener 处理触摸事件,实现了手指滑动缩放抽屉的功能。这种实现方式可以为用户提供更加流畅和交互性强的体验。


本次分享就到这儿啦,我是鹏多多,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

往期文章

个人主页