【Flutter】两档可拉伸 BottomSheet 中嵌入 ListView

【Flutter】两档可拉伸 BottomSheet 中嵌入 ListView


我是从一位博主文章里学习的,在他的基础上增加了两档BottomSheet(我也不知道应该叫什么名字好,修改后觉得的还不错,本想直接在那篇博主文章的评论里贴代码的,但是评论区不支持Markdown,也发不了图片,就觉得还是发篇文章出来的好)

原博客链接:简书:https://www.jianshu.com/p/4f2d10750f5c
掘金:https://juejin.cn/post/6997774040236572680
GitHub:https://github.com/JeremyMing/scroll_bottom_sheet

效果图展示:

在这里插入图片描述
增加返回顶部按钮
在这里插入图片描述

【注意】本代码是在原博主代码上改的,如需学习思路推荐先看原博客,原博有详细的说明解释

动图的代码(无返回顶部按钮)

import 'dart:async';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'sliver',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final ScrollController _scrollController = ScrollController();

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

  // 列表弹起的默认高度,即一档,
  final double _initHeight = 400;
  // 二档,【注意】如果需要修改一二档数值,需要重新编译,热启动没效果
  final double _maxHeight = 650;
  // 是否是最大拉伸状态,即二档
  bool _isMaxHeight = false;

  /// 记录手指按下的位置
  double _pointerDy = 0;

  void _incrementCounter() {
    _isMaxHeight = false;
    showModalBottomSheet(
      barrierColor: Colors.black54,
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.white,
      shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
              topLeft: Radius.circular(20), topRight: Radius.circular(20))),
      builder: (ctx) {
        return StreamBuilder<double>(
          stream: _streamController.stream,
          initialData: _initHeight,
          builder: (context, snapshot) {
            double currentHeight =
                snapshot.data ?? (_isMaxHeight ? _maxHeight : _initHeight);
            return AnimatedContainer(
              duration: const Duration(milliseconds: 30),
              height: currentHeight,
              child: Listener(
                onPointerMove: (event) {
                  // 触摸事件过程 手指一直在屏幕上且发生距离滑动
                  if (_scrollController.offset != 0) {
                    // 只有列表滚动到顶部时才触发下拉动画效果
                    print("onPointerMove:${_scrollController.offset}");
                    return;
                  }
                  double distance = event.position.dy - _pointerDy;
                  if (distance != 0) {
                    // 获取手指滑动的距离,计算弹框实时高度,并发送事件
                    double _currentHeight = _isMaxHeight ? (_maxHeight - distance)
                        : (_initHeight - distance);
                    // 上划达到最大高度后,就不再响应 BottomSheet,直接 return 去响应 Listview
                    // distance < 0 => 判断是否是上划操作
                    // 这里减了100 是为了优化用户操体验,不用上划太多
                    if (distance < 0 && _currentHeight >= _maxHeight - 100) {
                      // 已经是最大高度,但状态不对,更新状态
                      if (!_isMaxHeight) {
                        _isMaxHeight = true;
                      }
                      return;
                    }
                    // 不是最大高度,响应 BottomSheet,既可上划拉伸,也可下划关闭
                    _streamController.sink.add(_currentHeight);
                  }
                },
                onPointerUp: (event) {
                  // 触摸事件结束 手指离开屏幕
                  // 这里认为滑动超过一半就认为用户要退出了,值可以根据实际体验修改
                  if (_isMaxHeight
                      ? currentHeight < (_maxHeight * 0.8)
                      : currentHeight < (_initHeight * 0.5)) {
                    Navigator.pop(context);
                  } else {
                    _streamController.sink
                        .add(_isMaxHeight ? _maxHeight : _initHeight);
                  }
                },
                onPointerDown: (event) {
                  // 触摸事件开始 手指开始接触屏幕
                  _pointerDy = event.position.dy + _scrollController.offset;
                },
                // child 这里嵌套一个 Padding,可以让 ListView 上划时有更好的融入感
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(0, 10, 0, 0),
                  child: ListView.builder(
                    controller: _scrollController,
                    // 如果上划,且已经达到 BottomSheet 的最大高度,则响应 ListView
                    physics: (currentHeight == _maxHeight)
                        ? const ClampingScrollPhysics()
                        : const NeverScrollableScrollPhysics(),
                    itemCount: 100,
                    // ListView 中的 item 批量生成
                    itemBuilder: (BuildContext context, int index) {
                      if (index % 5 == 0) {
                        return Padding(
                          padding: const EdgeInsets.fromLTRB(0, 20, 0, 10),
                          child: ListTile(
                            title: Text(
                              "标题${(index / 5).round()}",
                              style: const TextStyle(fontSize: 30),
                            ),
                          ),
                        );
                      } else {
                        return ListBody(
                          mainAxis: Axis.vertical,
                          children: [
                            SizedBox(
                              width: 150,
                              height: 150,
                              child: Image.network(
                                  "https://i2.hdslb.com/bfs/archive/1e87e11e12e28aa84ac0610fed3791eb2c3427f6.jpg@336w_190h_!web-video-rcmd-cover.webp"),
                            ),
                            Center(child: Text("图片描述 $index")),
                          ],
                        );
                      }
                    },
                  ),
                ),
              ),
            );
          },
        );
      },
    );
  }

  // 只有一个按钮的主页面
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('sliver'),
        actions: const <Widget>[
          Padding(
            padding: EdgeInsets.only(right: 10),
            child: Icon(Icons.share),
          )
        ],
      ),
      body: Builder(builder: (context) {
        return ElevatedButton(
            onPressed: () {
              _incrementCounter();
            },
            child: const Text('打开底部表'));
      }),
    );
  }
}

返回顶部按钮代码

可参考教程书的这里 https://book.flutterchina.club/chapter6/scroll_controller.html 有示例
判断上划高度是否大于一定值,即 _scrollController.offset > 1000 时,控制悬浮按钮的是否显示。
注意的是 FloatActionButtonScaffold 专属,所以要套一层 Scaffold,在里面再添加 FloatActionButton

// 上面的代码省略

  /// 列表弹起的正常高度
  final double _initHeight = 400;
  final double _maxHeight = 650;
  bool _isMaxHeight = false;
  // --------修改处-----------
  bool _showTopButton = false;
  // -------- 结束 -----------


  void _incrementCounter() {
    _isMaxHeight = false;
    showModalBottomSheet(
      barrierColor: Colors.black54,
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.white,
      shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
              topLeft: Radius.circular(20), topRight: Radius.circular(20))),
      builder: (ctx) {
        return StreamBuilder<double>(
          stream: _streamController.stream,
          initialData: _initHeight,
          builder: (context, snapshot) {
            double currentHeight =
                snapshot.data ?? (_isMaxHeight ? _maxHeight : _initHeight);
            return AnimatedContainer(
              duration: const Duration(milliseconds: 50),
              height: currentHeight,
              child: Listener(
                onPointerMove: (event) {
                  // 触摸事件过程 手指一直在屏幕上且发生距离滑动
                  if (_scrollController.offset != 0) {
                    // 只有列表滚动到顶部时才触发下拉动画效果
                    print("onPointerMove:${_scrollController.offset}");
                    // ----------------修改处-------------------
                    if (_scrollController.offset > 1000) {
                      _showTopButton = true;
                    } else if (_showTopButton) {
                      setState(() {
                        _showTopButton = false;
                      });
                    }
                    return;
                  }
                  // -----------------修改结束----------------------
                  double distance = event.position.dy - _pointerDy;
                  if (distance != 0) {
                    // 获取手指滑动的距离,计算弹框实时高度,并发送事件
                    double _currentHeight = _isMaxHeight
                        ? (_maxHeight - distance)
                        : (_initHeight - distance);
                    // 上划达到最大高度后,就不再响应 BottomSheet,直接 return 去响应 Listview
                    // distance < 0 => 判断是否是上划操作
                    // 这里减了100 是为了优化用户操体验,不用上划太多
                    if (distance < 0 && _currentHeight >= _maxHeight - 100) {
                      // 已经是最大高度,但状态不对,更新状态
                      if (!_isMaxHeight) {
                        _isMaxHeight = true;
                      }
                      // _currentHeight = _maxHeight;
                      return;
                    }
                    // 不是最大高度,响应 BottomSheet,即可上划拉伸,也可下划关闭
                    _streamController.sink.add(_currentHeight);
                  }
                },
                onPointerUp: (event) {
                  // 触摸事件结束 手指离开屏幕
                  // 这里认为滑动超过一半就认为用户要退出了,值可以根据实际体验修改
                  if (_isMaxHeight
                      ? currentHeight < (_maxHeight * 0.8)
                      : currentHeight < (_initHeight * 0.5)) {
                    Navigator.pop(context);
                  } else {
                    _streamController.sink
                        .add(_isMaxHeight ? _maxHeight : _initHeight);
                  }
                },
                onPointerDown: (event) {
                  // 触摸事件开始 手指开始接触屏幕
                  _pointerDy = event.position.dy + _scrollController.offset;
                },
                // child 这里嵌套一个 Padding,可以让 ListView 上划时有更好的融入感
                // ---------修改处,嵌套Scaffold,加入FloatActionButton---------
                child: Padding(
                  padding: EdgeInsets.fromLTRB(0, 15, 0, 0),
                  child: Scaffold(
                    body: ListView.builder(
                      controller: _scrollController,
                      // 如果上划,且已经达到 BottomSheet 的最大高度,则响应 ListView
                      physics: (currentHeight == _maxHeight)
                          ? const ClampingScrollPhysics()
                          : const NeverScrollableScrollPhysics(),
                      itemCount: 100,
                      // ListView 中的 item 批量生成
                      itemBuilder: (BuildContext context, int index) {
                        if (index % 5 == 0) {
                          return Padding(
                            padding: const EdgeInsets.fromLTRB(0, 20, 0, 10),
                            child: ListTile(
                              title: Text(
                                "标题${(index / 5).round()}",
                                style: const TextStyle(fontSize: 30),
                              ),
                            ),
                          );
                        } else {
                          return ListBody(
                            mainAxis: Axis.vertical,
                            children: [
                              SizedBox(
                                width: 150,
                                height: 150,
                                child: Image.network(
                                    "https://i2.hdslb.com/bfs/archive/1e87e11e12e28aa84ac0610fed3791eb2c3427f6.jpg@336w_190h_!web-video-rcmd-cover.webp"),
                              ),
                              Center(child: Text("图片描述 $index")),
                            ],
                          );
                        }
                      },
                    ),
                    // --------------修改处--------------
                    floatingActionButton: !_showTopButton
                        ? null
                        : FloatingActionButton(
                            child: Icon(Icons.arrow_upward),
                            onPressed: () {
                              //返回到顶部时执行动画
                              _scrollController.animateTo(
                                .0,
                                duration: Duration(milliseconds: 200),
                                curve: Curves.ease,
                              );
                              setState(
                                () {
                                  _showTopButton = false;
                                },
                              );
                            },
                          ),
                  ),
                ),
              ),
            );
          },
        );
      },
    );
  }

  
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

// 只有一个按钮的主页 代码省略。。。

增加的代码旁都有注释说明~,希望大家学习愉快。

Flutter 相关资料:

《Flutter·实战第二版》

【Flutter API】

【Flutter 中文开发文档】

【安卓IOS多尺寸图标生成】

【Flutter Material 组件官网】

一滴也没了~

Flutter提供了一个名为`BottomSheet`的小部件,用于在页面底部显示一个可滚动的内容面板。以下是一个简单的示例,演示如何使用`BottomSheet`小部件: ```dart void _showBottomSheet(BuildContext context) { showModalBottomSheet( context: context, builder: (BuildContext context) { return Container( height: 200, color: Colors.grey[200], child: Center( child: Text( 'This is a bottom sheet', style: TextStyle(fontSize: 24), ), ), ); }, ); } ``` 在上面的示例,我们使用`showModalBottomSheet`方法显示一个带有文本的容器。您可以根据需要自定义容器的内容和样式。 您还可以使用`BottomSheet`小部件自定义底部面板并将其附加到页面的底部。以下是一个示例: ```dart void _showBottomSheet(BuildContext context) { showModalBottomSheet( context: context, builder: (BuildContext context) { return BottomSheet( onClosing: () {}, builder: (BuildContext context) { return Container( height: 200, color: Colors.grey[200], child: Center( child: Text( 'This is a custom bottom sheet', style: TextStyle(fontSize: 24), ), ), ); }, ); }, ); } ``` 在上面的示例,我们使用`BottomSheet`小部件创建一个带有文本的容器,并将其附加到页面的底部。`onClosing`回调可用于在用户关闭底部面板时执行操作。您可以根据需要自定义容器的内容和样式。 需要注意的是,`showModalBottomSheet`方法返回一个`Future`对象,该对象在底部面板被关闭时解析。可以使用`Navigator.pop`方法从底部面板返回数据。例如: ```dart void _showBottomSheet(BuildContext context) async { var result = await showModalBottomSheet( context: context, builder: (BuildContext context) { return Container( height: 200, color: Colors.grey[200], child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Select an option', style: TextStyle(fontSize: 24), ), SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.of(context).pop('Option 1'), child: Text('Option 1'), ), ElevatedButton( onPressed: () => Navigator.of(context).pop('Option 2'), child: Text('Option 2'), ), ], ), ), ); }, ); print(result); } ``` 在上面的示例,我们在底部面板显示两个按钮,并使用`Navigator.pop`方法将选定的选项返回给调用方。返回的选项将解析为`result`变量。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值