[Flutter翻译]Flutter: 使用Overlay显示浮动widget

2,972 阅读7分钟

原文地址:medium.com/flutter/imp…

原文作者:medium.com/@perclasson

发布时间:2018年9月2日

想象一下:你设计了你的迷人的表格。

你把它发给你的产品经理,他看了看说:"那我得把整个国家的名字都打进去?你就不能在我输入的时候给我看看建议吗?"然后你就想:"嗯,他说得对!"嗯,他是对的!" 所以你决定实现一个 "typeahead",一个 "自动完成 "或任何你想叫它的东西。一个文本字段,在用户输入时显示建议。你开始工作......你知道如何获得建议,你知道如何做逻辑,你什么都知道......除了如何让建议漂浮在其他widget之上。

你想一想,为了达到这个目的,你必须把整个屏幕重新设计成一个Stack,然后计算出每个widget必须显示的确切位置。这非常麻烦,非常严格,非常容易出错,而且感觉就是不对。但还有另一种方法。

你可以使用Flutter预先提供的Stack,即 Overlay

在这篇文章中,我将解释如何使用Overlaywidget来创建浮在其他一切之上的widget,而不必重组你的整个视图。

你可以用它来创建自动完成建议,工具提示,或者基本上任何浮动的东西。

什么是Overlay widget?

官方文档对Overlay widget的定义是。

可以独立管理的条目。

叠加让独立的子widget通过插入到叠加的堆栈中,将视觉元素 "漂浮 "在其他widget之上。

这正是我们要找的。当我们创建MaterialApp时,它会自动创建一个Navigator,而Navigator又会创建一个Overlay;一个Stack widget,Navigator用它来管理视图的显示。

所以我们来看看如何使用Overlay来解决我们的问题。

注意:本文关注的是显示浮动widget,因此不会过多地介绍实现typeahead(自动完成)字段的细节。如果你对一个编码良好、高度可定制的typeahead widget感兴趣,一定要看看我的包,flutter_typeahead

初始程序

让我们从简单的形式开始。

Scaffold(
  body: Padding(
    padding: const EdgeInsets.all(50.0),
    child: Form(
      child: ListView(
        children: <Widget>[
          TextFormField(
            decoration: InputDecoration(
                labelText: 'Address'
            ),
          ),
          SizedBox(height: 16.0,),
          TextFormField(
            decoration: InputDecoration(
                labelText: 'City'
            ),
          ),
          SizedBox(height: 16.0,),
          TextFormField(
            decoration: InputDecoration(
                labelText: 'Address'
            ),
          ),
          SizedBox(height: 16.0,),
          RaisedButton(
            child: Text('SUBMIT'),
            onPressed: () {
              // submit the form
            },
          )
        ],
      ),
    ),
  ),
)
  • 它是一个简单的视图,包含三个文本字段:国家、城市和地址。

然后,我们将国家字段抽象成自己的有状态widget,我们称之为 CountriesField

class CountriesField extends StatefulWidget {
  @override
  _CountriesFieldState createState() => _CountriesFieldState();
}

class _CountriesFieldState extends State<CountriesField> {

  
  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(
        labelText: 'Country'
      ),
    );
  }
}

接下来我们要做的是,每当字段接收到焦点时就显示一个浮动列表,每当焦点丢失时就隐藏该列表。你可以根据你的用例来改变这个逻辑。你可能想只在用户输入一些字符时才显示它,而在用户点击Enter时删除它。在所有情况下,让我们来看看如何显示这个浮动widget。

class CountriesField extends StatefulWidget {
  @override
  _CountriesFieldState createState() => _CountriesFieldState();
}

class _CountriesFieldState extends State<CountriesField> {

  final FocusNode _focusNode = FocusNode();

  OverlayEntry _overlayEntry;

  @override
  void initState() {
    _focusNode.addListener(() {
      if (_focusNode.hasFocus) {

        this._overlayEntry = this._createOverlayEntry();
        Overlay.of(context).insert(this._overlayEntry);

      } else {
        this._overlayEntry.remove();
      }
    });
  }

  OverlayEntry _createOverlayEntry() {

    RenderBox renderBox = context.findRenderObject();
    var size = renderBox.size;
    var offset = renderBox.localToGlobal(Offset.zero);

    return OverlayEntry(
      builder: (context) => Positioned(
        left: offset.dx,
        top: offset.dy + size.height + 5.0,
        width: size.width,
        child: Material(
          elevation: 4.0,
          child: ListView(
            padding: EdgeInsets.zero,
            shrinkWrap: true,
            children: <Widget>[
              ListTile(
                title: Text('Syria'),
              ),
              ListTile(
                title: Text('Lebanon'),
              )
            ],
          ),
        ),
      )
    );
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
        focusNode: this._focusNode,
      decoration: InputDecoration(
        labelText: 'Country'
      ),
    );
  }
}
  • 我们为TextFormField分配一个FocusNode,并在initState中为其添加一个监听器。我们将使用这个监听器来检测字段何时获得/失去焦点。

  • 每当我们接收到焦点 (_focusNode.hasFocus == true),我们就使用 _createOverlayEntry 创建一个 OverlayEntry,并使用 Overlay.of(context).insert 将它插入到最近的 Overlay widget 中。

  • 每当我们失去焦点 (_focusNode.hasFocus == false),我们就会使用 _overlayEntry.remove 删除我们添加的覆盖条目。

  • _createOverlayEntry使用context.findRenderObject函数,查询我们widget的渲染框。这个渲染框使我们能够知道widget的位置、大小和其他渲染信息。这将帮助我们以后知道在哪里放置我们的浮动列表。

  • _createOverlayEntry使用渲染框来获取widget的大小,它还使用renderBox.localToGlobal来获取widget在屏幕中的坐标。我们为localToGlobal方法提供了Offset.zero,这意味着我们要在这个渲染框里面获取 (0,0) 坐标,并将其转换为屏幕上的对应坐标。

  • 然后我们创建一个OverlayEntry,这是一个用于显示Overlay中的widget的widget。

  • OverlayEntry的内容是一个Positioned widget。请记住,Positioned widgets只能插入Stack中,但也请记住,Overlay确实是一个Stack

  • 我们设置Positioned widget的坐标,我们给它与TextField相同的x坐标,相同的宽度,相同的y坐标,但为了不覆盖TextField,我们将其向底部移动一点。

  • Positioned里面,我们显示一个ListView,里面有我们想要的建议(我在例子中硬编码了几个条目)。请注意,我把所有的东西都放在一个Material widget里面。这有两个原因:因为Overlay默认不包含Material widget,而许多widget如果没有Material祖先就无法显示,而且Material widget提供了仰角属性,允许我们给widget一个阴影,使它看起来好像真的是浮动的。

就是这样! 我们的建议框现在漂浮在所有其他东西的上方了

奖励:跟着卷轴走!

在我们离开之前,让我们试着再学习一件事! 如果我们的视图是可以滚动的,那么我们可能会注意到一些东西。

建议框会跟着我们滚动!

建议框会粘在屏幕上的位置上。在某些情况下,这可能是我们想要的,但在这种情况下,我们不希望这样,我们希望它跟随我们的TextField

这里的关键是 "跟随 "这个词。Flutter为我们提供了两个widget:CompositedTransformFollowerCompositedTransformTarget。简单的说,如果我们把一个跟随者和一个目标链接起来,那么跟随者就会跟随目标,无论它走到哪里! 要链接一个跟随者和一个目标,我们必须为它们提供相同的LayerLink

因此,我们将用CompositedTransformFollower包装我们的建议框,用CompositedTransformTarget包装我们的TextField。然后,我们将通过为它们提供相同的LayerLink来链接它们。这将使建议框跟随TextField走到哪里,就跟到哪里。

class CountriesField extends StatefulWidget {
  @override
  _CountriesFieldState createState() => _CountriesFieldState();
}

class _CountriesFieldState extends State<CountriesField> {

  final FocusNode _focusNode = FocusNode();

  OverlayEntry _overlayEntry;

  final LayerLink _layerLink = LayerLink();

  @override
  void initState() {
    _focusNode.addListener(() {
      if (_focusNode.hasFocus) {

        this._overlayEntry = this._createOverlayEntry();
        Overlay.of(context).insert(this._overlayEntry);

      } else {
        this._overlayEntry.remove();
      }
    });
  }

  OverlayEntry _createOverlayEntry() {

    RenderBox renderBox = context.findRenderObject();
    var size = renderBox.size;

    return OverlayEntry(
      builder: (context) => Positioned(
        width: size.width,
        child: CompositedTransformFollower(
          link: this._layerLink,
          showWhenUnlinked: false,
          offset: Offset(0.0, size.height + 5.0),
          child: Material(
            elevation: 4.0,
            child: ListView(
              padding: EdgeInsets.zero,
              shrinkWrap: true,
              children: <Widget>[
                ListTile(
                  title: Text('Syria'),
                  onTap: () {
                    print('Syria Tapped');
                  },
                ),
                ListTile(
                  title: Text('Lebanon'),
                  onTap: () {
                    print('Lebanon Tapped');
                  },
                )
              ],
            ),
          ),
        ),
      )
    );
  }

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      link: this._layerLink,
      child: TextFormField(
          focusNode: this._focusNode,
        decoration: InputDecoration(
          labelText: 'Country'
        ),
      ),
    );
  }
}
  • 我们在OverlayEntry中用CompositedTransformFollower包装了我们的Material widget,用CompositedTransformTarget包装了TextFormField

  • 我们为跟随者目标提供了同一个LayerLink实例。这将导致跟随者目标具有相同的坐标空间,使其有效地跟随它。

  • 我们从 Positioned widget 中删除了 topleft 属性。这些属性不再需要了,因为在默认情况下,跟随者将拥有与目标相同的坐标。然而,我们保留了Positionedwidth属性,因为如果不对其进行约束,跟随者往往会无限延伸。

  • 我们为CompositedTransformFollower提供了一个偏移量,以禁止它覆盖TextField(和之前一样)。

  • 最后,我们将showWhenUnlinked设置为false,当TextField在屏幕上不可见时(比如当我们滚动到底部太远时),隐藏OverlayEntry

就这样,我们的OverlayEntry现在跟随了我们的TextField!

重要提示CompositedTransformFollower还是有点bug;即使当目标不再可见时,跟随者从屏幕上隐藏起来,跟随者还是会响应点击事件。我已经向Flutter团队开了一个问题。

github.com/flutter/flu…

并将在问题解决后更新帖子。


Overlay是一个强大的widget,它为我们提供了一个方便的Stack来放置我们的浮动widget。我已经成功地使用它来创建flutter_typeahead,我相信你也可以将它用于各种用例。 我希望这对你有用。让我知道你的想法


通过www.DeepL.com/Translator(免费版)翻译