Flutter中的标准底部弹出层

13
我在我的应用程序中很难实现“标准底部表格”,也就是指底部表格的“标题栏”可见并且可以拖动(参考:https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet)。更进一步:我找不到任何关于它的例子。我最接近期望结果的方法是在底部使用DraggableScrollableSheet作为bottomSheet,在Scaffold中实现(只有该小部件具有initialChildSize),但看起来没有办法使标题栏“粘性”,因为所有内容都是可滚动的:/。
我还发现了这个:https://flutterdoc.com/bottom-sheets-in-flutter-ec05c90453e7。好像其中关于“Persistent Bottom Sheet”的部分就是我要找的,但文章不详细,所以我无法确定如何实现它,加上那里的评论非常负面,所以我猜那并不完全正确...
有人有解决方案吗?:S
5个回答

16

您可以使用DraggableScrollableSheet来实现您在Material Design规范中看到的标准底部工作表行为。

这里我将详细解释。

步骤1:

定义您的Scaffold

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Draggable sheet demo',
      home: Scaffold(

          ///just for status bar color.
          appBar: PreferredSize(
              preferredSize: Size.fromHeight(0),
              child: AppBar(
                primary: true,
                elevation: 0,
              )),
          body: Stack(
            children: <Widget>[
              Positioned(
                left: 0.0,
                top: 0.0,
                right: 0.0,
                child: PreferredSize(
                    preferredSize: Size.fromHeight(56.0),
                    child: AppBar(
                      title: Text("Standard bottom sheet demo"),
                      elevation: 2.0,
                    )),
              ),
              DraggableSearchableListView(),
            ],
          )),
    );
  }
}

步骤2:

定义DraggableSearchableListView

 class DraggableSearchableListView extends StatefulWidget {
  const DraggableSearchableListView({
    Key key,
  }) : super(key: key);

  @override
  _DraggableSearchableListViewState createState() =>
      _DraggableSearchableListViewState();
}

class _DraggableSearchableListViewState
    extends State<DraggableSearchableListView> {
  final TextEditingController searchTextController = TextEditingController();
  final ValueNotifier<bool> searchTextCloseButtonVisibility =
      ValueNotifier<bool>(false);
  final ValueNotifier<bool> searchFieldVisibility = ValueNotifier<bool>(false);
  @override
  void dispose() {
    searchTextController.dispose();
    searchTextCloseButtonVisibility.dispose();
    searchFieldVisibility.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return NotificationListener<DraggableScrollableNotification>(
      onNotification: (notification) {
        if (notification.extent == 1.0) {
          searchFieldVisibility.value = true;
        } else {
          searchFieldVisibility.value = false;
        }
        return true;
      },
      child: DraggableScrollableActuator(
        child: Stack(
          children: <Widget>[
            DraggableScrollableSheet(
              initialChildSize: 0.30,
              minChildSize: 0.15,
              maxChildSize: 1.0,
              builder:
                  (BuildContext context, ScrollController scrollController) {
                return Container(
                  decoration: BoxDecoration(
                    color: Colors.white,
                    borderRadius: BorderRadius.only(
                      topLeft: Radius.circular(16.0),
                      topRight: Radius.circular(16.0),
                    ),
                    boxShadow: [
                      BoxShadow(
                          color: Colors.grey,
                          offset: Offset(1.0, -2.0),
                          blurRadius: 4.0,
                          spreadRadius: 2.0)
                    ],
                  ),
                  child: ListView.builder(
                    controller: scrollController,

                    ///we have 25 rows plus one header row.  
                    itemCount: 25 + 1,
                    itemBuilder: (BuildContext context, int index) {
                      if (index == 0) {
                        return Container(
                          child: Column(
                            children: <Widget>[
                              Align(
                                alignment: Alignment.centerLeft,
                                child: Padding(
                                  padding: EdgeInsets.only(
                                    top: 16.0,
                                    left: 24.0,
                                    right: 24.0,
                                  ),
                                  child: Text(
                                    "Favorites",
                                    style:
                                        Theme.of(context).textTheme.headline6,
                                  ),
                                ),
                              ),
                              SizedBox(
                                height: 8.0,
                              ),
                              Divider(color: Colors.grey),
                            ],
                          ),
                        );
                      }
                      return Padding(
                          padding: EdgeInsets.symmetric(horizontal: 16.0),
                          child: ListTile(title: Text('Item $index')));
                    },
                  ),
                );
              },
            ),
            Positioned(
              left: 0.0,
              top: 0.0,
              right: 0.0,
              child: ValueListenableBuilder<bool>(
                  valueListenable: searchFieldVisibility,
                  builder: (context, value, child) {
                    return value
                        ? PreferredSize(
                            preferredSize: Size.fromHeight(56.0),
                            child: Container(
                              decoration: BoxDecoration(
                                border: Border(
                                  bottom: BorderSide(
                                      width: 1.0,
                                      color: Theme.of(context).dividerColor),
                                ),
                                color: Theme.of(context).colorScheme.surface,
                              ),
                              child: SearchBar(
                                closeButtonVisibility:
                                    searchTextCloseButtonVisibility,
                                textEditingController: searchTextController,
                                onClose: () {
                                  searchFieldVisibility.value = false;
                                  DraggableScrollableActuator.reset(context);
                                },
                                onSearchSubmit: (String value) {
                                  ///submit search query to your business logic component
                                },
                              ),
                            ),
                          )
                        : Container();
                  }),
            ),
          ],
        ),
      ),
    );
  }
}

第三步:

定义自定义粘性搜索栏(SearchBar)

 class SearchBar extends StatelessWidget {
  final TextEditingController textEditingController;
  final ValueNotifier<bool> closeButtonVisibility;
  final ValueChanged<String> onSearchSubmit;
  final VoidCallback onClose;

  const SearchBar({
    Key key,
    @required this.textEditingController,
    @required this.closeButtonVisibility,
    @required this.onSearchSubmit,
    @required this.onClose,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return Container(
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 0),
        child: Row(
          children: <Widget>[
            SizedBox(
              height: 56.0,
              width: 56.0,
              child: Material(
                type: MaterialType.transparency,
                child: InkWell(
                  child: Icon(
                    Icons.arrow_back,
                    color: theme.textTheme.caption.color,
                  ),
                  onTap: () {
                    FocusScope.of(context).unfocus();
                    textEditingController.clear();
                    closeButtonVisibility.value = false;
                    onClose();
                  },
                ),
              ),
            ),
            SizedBox(
              width: 16.0,
            ),
            Expanded(
              child: TextFormField(
                onChanged: (value) {
                  if (value != null && value.length > 0) {
                    closeButtonVisibility.value = true;
                  } else {
                    closeButtonVisibility.value = false;
                  }
                },
                onFieldSubmitted: (value) {
                  FocusScope.of(context).unfocus();
                  onSearchSubmit(value);
                },
                keyboardType: TextInputType.text,
                textInputAction: TextInputAction.search,
                textCapitalization: TextCapitalization.none,
                textAlignVertical: TextAlignVertical.center,
                textAlign: TextAlign.left,
                maxLines: 1,
                controller: textEditingController,
                decoration: InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: "Search here",
                ),
              ),
            ),
            ValueListenableBuilder<bool>(
                valueListenable: closeButtonVisibility,
                builder: (context, value, child) {
                  return value
                      ? SizedBox(
                          width: 56.0,
                          height: 56.0,
                          child: Material(
                            type: MaterialType.transparency,
                            child: InkWell(
                              child: Icon(
                                Icons.close,
                                color: theme.textTheme.caption.color,
                              ),
                              onTap: () {
                                closeButtonVisibility.value = false;
                                textEditingController.clear();
                              },
                            ),
                          ),
                        )
                      : Container();
                })
          ],
        ),
      ),
    );
  }
}

查看最终输出的屏幕截图。

状态 1:

底部表格以其初始大小显示。

enter image description here

状态 2:

用户向上拖动了底部表格。

enter image description here

状态 3:

底部表格到达屏幕顶边缘,显示一个粘性的自定义搜索栏界面。

enter image description here


就这些了。

此处查看实时演示。


太棒了!我下周会测试它(目前正在处理Angular项目),并将您的答案标记为正确的答案,非常感谢! - pb4now
1
@Darish 哇塞,太棒了! - DolDurma

5
虽然Sergio提供了一些不错的替代方案,但是还需要更多的编码才能使其正常工作。因此,我发现了Sliding_up_panel。如果其他人正在寻找解决方案,可以在这里找到它。
但是,我发现Flutter中内置的bottomSheet小部件竟然没有提供创建“标准底部面板”的选项,这真的很奇怪。

2
如果您正在寻找Persistent Bottomsheet,请参考以下链接中的源代码: Persistent Bottomsheet 您可以参考_showBottomSheet()以满足您的需求,并进行一些更改以满足您的需求。最初的回答。

您好,先生。我看到了您发的内容并尝试了一下,但是无法理解"initialSize"和"minHeight"(当向下拖动时不完全隐藏) - 我尝试向"Container"添加"constraints: BoxConstraints"以设置minHeight,但没有效果。:S - pb4now

1
你可以使用堆栈和动画来完成它:

class HelloWorldPage extends StatefulWidget {
  @override
  _HelloWorldPageState createState() => _HelloWorldPageState();
}

class _HelloWorldPageState extends State<HelloWorldPage>
    with SingleTickerProviderStateMixin {
  final double minSize = 80;
  final double maxSize = 350;

  void initState() {
    _controller =
        AnimationController(vsync: this, duration: Duration(milliseconds: 500))
          ..addListener(() {
            setState(() {});
          });

    _animation =
        Tween<double>(begin: minSize, end: maxSize).animate(_controller);

    super.initState();
  }

  AnimationController _controller;
  Animation<double> _animation;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          Positioned(
            bottom: 0,
            height: _animation.value,
            child: GestureDetector(
              onDoubleTap: () => _onEvent(),
              onVerticalDragEnd: (event) => _onEvent(),
              child: Container(
                color: Colors.red,
                width: MediaQuery.of(context).size.width,
                height: minSize,
              ),
            ),
          ),
        ],
      ),
    );
  }

  _onEvent() {
    if (_controller.isCompleted) {
      _controller.reverse(from: maxSize);
    } else {
      _controller.forward();
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}


嗨,@Sergio - 我能问一下吗:这是否意味着在这种情况下包括在Flutter中的bottomSheet是毫无意义的,并且不能像我想要的那样进行调整? - pb4now
1
不,我认为您无法设置最小大小以保持底部表单可见。也许您需要的是背景。看看这个背景的例子:https://www.youtube.com/watch?v=LcEyi1_1bAw 然后您可以根据需要进行调整。希望有所帮助!或者查看这个更简单的例子:https://medium.com/flutter/decomposing-widgets-backdrop-b5c664fb9cf4 - Sergio Bernal
好的,完美,谢谢提供信息 - 我会检查两个想法(使用堆栈和背景):) - pb4now

0

可以轻松地通过showModalBottomSheet实现。代码:

void _presentBottomSheet(BuildContext context) {
  showModalBottomSheet(
    context: context,
    builder: (context) => Wrap(
      children: <Widget>[
        SizedBox(height: 8),
        _buildBottomSheetRow(context, Icons.share, 'Share'),
        _buildBottomSheetRow(context, Icons.link, 'Get link'),
        _buildBottomSheetRow(context, Icons.edit, 'Edit Name'),
        _buildBottomSheetRow(context, Icons.delete, 'Delete collection'),
      ],
    ),
  );
}

Widget _buildBottomSheetRow(
  BuildContext context,
  IconData icon,
  String text,
) =>
    InkWell(
      onTap: () {},
      child: Row(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(16),
            child: Icon(
              icon,
              color: Colors.grey[700],
            ),
          ),
          SizedBox(width: 8),
          Text(text),
        ],
      ),
    );

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接