Flutter TabBar和SliverAppBar在向下滚动时隐藏

26

我正在尝试创建一个具有顶部应用程序栏和下方分页栏的应用程序。向下滚动时,该栏应通过移动到屏幕外而隐藏(但选项卡应保留),向上滚动时,应再次显示应用程序栏。您可以在WhatsApp中看到这种行为,请参见视频进行演示。(摘自Material.io)。是类似的行为,尽管应用程序栏和选项卡栏在滚动时被隐藏,因此不完全符合我的要求。

我已经能够实现自动隐藏,但是存在一些问题:

  1. 我必须将SliverAppBarsnap设置为true。如果没有这样做,当我向上滚动时,应用程序栏不会显示。

    虽然这可以工作,但这并不是我要寻求的行为。我希望应用程序栏平稳地显示(类似于WhatsApp),而不是即使您滚动很少也会出现在视图中。

    澄清一下,当我向下滚动到底部时,即使我向上滚动很少,应用程序栏也应该出现在视图中。 我不想要必须要滚动到顶部才能看到应用程序栏的情况。

  2. 当我向下滚动并更改选项卡时,会有一点内容被剪切。

    下面是显示此行为的GIF:

    演示输出的GIF

    (请参见我在listView(第1个选项卡)上向下滚动,然后返回第2个选项卡时的部分)

这是DefaultTabController的代码:

DefaultTabController(
  length: 2,
  child: new Scaffold(
    body: new NestedScrollView(
      headerSliverBuilder:
          (BuildContext context, bool innerBoxIsScrolled) {
        return <Widget>[
          new SliverAppBar(
            title: Text("Application"),
            floating: true,
            pinned: true,
            snap: true,    // <--- this is required if I want the application bar to show when I scroll up
            bottom: new TabBar(
              tabs: [ ... ],    // <-- total of 2 tabs
            ),
          ),
        ];
      },
      body: new TabBarView(
        children: [ ... ]    // <--- the array item is a ListView
      ),
    ),
  ),
),

如有需要,完整的代码可以在 GitHub 存储库 中找到。其中 main.dart这里

我还发现了一个相关问题:如何在滚动时隐藏AppBar?然而,它没有提供解决方案。同样的问题仍然存在,当您向上滚动时,SliverAppBar 不会显示。(因此需要使用 snap: true

我还在Flutter的GitHub上找到了这个问题编辑:有人评论说他们正在等待Flutter团队修复这个问题。难道没有解决方案吗?

以下是 flutter doctor -v 的输出:Pastebin。虽然发现了一些问题,但根据我的了解,它们不应该产生影响。

编辑:对于这个问题,有两个问题:


我也有这个问题。使用SliverOverlapAbsorber可以解决,但是你无法使用SliverAppBar float。有什么解决办法吗? - willy wijaya
4个回答

22

你需要使用SliverOverlapAbsorber/SliverOverlapInjector,以下代码对我有效(完整代码):

@override
  Widget build(BuildContext context) {
    return Material(
      child: Scaffold(
        body: DefaultTabController(
          length: _tabs.length, // This is the number of tabs.
          child: NestedScrollView(
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              // These are the slivers that show up in the "outer" scroll view.
              return <Widget>[
                SliverOverlapAbsorber(
                  // This widget takes the overlapping behavior of the SliverAppBar,
                  // and redirects it to the SliverOverlapInjector below. If it is
                  // missing, then it is possible for the nested "inner" scroll view
                  // below to end up under the SliverAppBar even when the inner
                  // scroll view thinks it has not been scrolled.
                  // This is not necessary if the "headerSliverBuilder" only builds
                  // widgets that do not overlap the next sliver.
                  handle:
                      NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                  sliver: SliverSafeArea(
                    top: false,
                    sliver: SliverAppBar(
                      title: const Text('Books'),
                      floating: true,
                      pinned: true,
                      snap: false,
                      primary: true,
                      forceElevated: innerBoxIsScrolled,
                      bottom: TabBar(
                        // These are the widgets to put in each tab in the tab bar.
                        tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                      ),
                    ),
                  ),
                ),
              ];
            },
            body: TabBarView(
              // These are the contents of the tab views, below the tabs.
              children: _tabs.map((String name) {
                return SafeArea(
                  top: false,
                  bottom: false,
                  child: Builder(
                    // This Builder is needed to provide a BuildContext that is "inside"
                    // the NestedScrollView, so that sliverOverlapAbsorberHandleFor() can
                    // find the NestedScrollView.
                    builder: (BuildContext context) {
                      return CustomScrollView(
                        // The "controller" and "primary" members should be left
                        // unset, so that the NestedScrollView can control this
                        // inner scroll view.
                        // If the "controller" property is set, then this scroll
                        // view will not be associated with the NestedScrollView.
                        // The PageStorageKey should be unique to this ScrollView;
                        // it allows the list to remember its scroll position when
                        // the tab view is not on the screen.
                        key: PageStorageKey<String>(name),
                        slivers: <Widget>[
                          SliverOverlapInjector(
                            // This is the flip side of the SliverOverlapAbsorber above.
                            handle:
                                NestedScrollView.sliverOverlapAbsorberHandleFor(
                                    context),
                          ),
                          SliverPadding(
                            padding: const EdgeInsets.all(8.0),
                            // In this example, the inner scroll view has
                            // fixed-height list items, hence the use of
                            // SliverFixedExtentList. However, one could use any
                            // sliver widget here, e.g. SliverList or SliverGrid.
                            sliver: SliverFixedExtentList(
                              // The items in this example are fixed to 48 pixels
                              // high. This matches the Material Design spec for
                              // ListTile widgets.
                              itemExtent: 60.0,
                              delegate: SliverChildBuilderDelegate(
                                (BuildContext context, int index) {
                                  // This builder is called for each child.
                                  // In this example, we just number each list item.
                                  return Container(
                                      color: Color((math.Random().nextDouble() *
                                                      0xFFFFFF)
                                                  .toInt() <<
                                              0)
                                          .withOpacity(1.0));
                                },
                                // The childCount of the SliverChildBuilderDelegate
                                // specifies how many children this inner list
                                // has. In this example, each tab has a list of
                                // exactly 30 items, but this is arbitrary.
                                childCount: 30,
                              ),
                            ),
                          ),
                        ],
                      );
                    },
                  ),
                );
              }).toList(),
            ),
          ),
        ),
      ),
    );
  }

这似乎是最接近解决问题的方法,但问题仍然存在,即无论您在屏幕上的哪个位置滚动,都需要 snap: true 才能显示应用栏。不幸的是,这可能是Flutter的一个错误吗? - themthem
1
我不明白snap的问题,您能否详细说明或提供一个例子? - Ismail RBOUH
1
问题在于当你向上滚动时,必须停止滚动才能显示应用栏。我希望当你开始向上滚动时,应用栏已经开始显示了。如果我的解释不够清晰,请见谅,但我想实现 Android WhatsApp 的应用栏行为。目前,行为类似于 Android Google 文档应用栏。除此之外,您的解决方案已经修复了切换选项卡时某些内容被隐藏的问题。 - themthem
使用 snap:false,这对我很有效。谢谢 @IsmailRBOUH - Tapas Pal
如何在NestedScrollView中使用SilverAppBar和SliverPersistentHeader进行处理。 - Sanjay Kumar

14

更新 - 伸缩式银色应用栏

如果你想要在有人向上滚动时即可看到展开的银色应用栏,即不需要完全滚动到顶部而只需滚动一点点,那么只需将代码中的snap: false更改为snap: true即可 :)


解决方案 [修复所有问题]

在浏览 Google、Stack Overflow、GitHub 问题和 Reddit 等网站数小时后,我终于找到了可以解决以下问题的解决方案:

  1. 标题为 Sliver App bar,在向下滚动后变得隐藏,只有选项卡栏可见。当你到达顶部时,你会再次看到标题。

  2. 主要问题:当你在选项卡 1 中滚动然后导航到选项卡 2 时,你不会看到任何重叠。选项卡 2 的内容不会被 Sliver App bar 遮挡。

  3. 列表中最顶部元素的银色填充为 0。

  4. 保留各个选项卡中的滚动位置。

以下是代码,我稍后会尝试进行解释:(DartPad 预览)

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static const String _title = 'Flutter Code Sample';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: _title,
      home: MyStatelessWidget(),
    );
  }
}

class MyStatelessWidget extends StatelessWidget {
  const MyStatelessWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final List<String> _tabs = <String>['Tab 1', 'Tab 2'];
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('Books'),
                  floating: true,
                  pinned: true,
                  snap: false,
                  forceElevated: innerBoxIsScrolled,
                  bottom: TabBar(
                    tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((String name) {
              return SafeArea(
                top: false,
                bottom: false,
                child: Builder(
                  builder: (BuildContext context) {
                    return CustomScrollView(
                      key: PageStorageKey<String>(name),
                      slivers: <Widget>[
                        SliverOverlapInjector(
                          handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                        ),
                        SliverPadding(
                          padding: const EdgeInsets.all(8.0),
                          sliver: SliverList(
                            delegate: SliverChildBuilderDelegate(
                              (BuildContext context, int index) {
                                return ListTile(
                                  title: Text('Item $index'),
                                );
                              },
                              childCount: 30,
                            ),
                          ),
                        ),
                      ],
                    );
                  },
                ),
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}

在dartpad中尽情测试,一切正常后,让我们试着理解这里发生了什么。

大部分代码来自于Flutter NestedScrollView的文档

他们在注释中非常好地提到了这点。我不是专家,所以只会强调我认为解决了大部分问题的两个关键点:

  1. SliverOverlapAbsorberSliverOverlapInjector
  2. 使用SliverList而不是ListView

通过上述两点的运用,我们主要解决了看到的额外空间或者是sliver app bar占用的空间和第一个列表项重叠的问题。

为了记住选项卡的滚动位置,他们在CustomScrollView内添加了PageStorageKey

key: PageStorageKey<String>(name),

name 只是一个字符串 -> 'Tab 1'

文档中还提到我们可以使用 SliverFixedExtentList、SliverGrid,基本上就是使用 Sliver widget。使用 Sliver widgets 应该在需要的时候进行。Flutter 官方 Youtube 视频中的其中一段中提到,ListView、GridView 等都是 Slivers 的高级实现。因此,如果您希望超级自定义滚动或外观行为,则 Slivers 是低级别的东西。

如果我遗漏了什么或者说错了什么,请在评论中让我知道。


2
谢谢您的回答!这个解决方案确实解决了重叠问题,但是一个问题是我必须滚动到顶部才能再次查看应用栏。我正在寻找类似于Android WhatsApp应用栏行为的东西。 - themthem
2
snap: true会解决你的问题 :) 我刚在Dartpad上测试过。向下滚动,然后稍微向上滚动并离开鼠标触摸板,你会看到Sliver App Bar平稳地展开 :) - krupesh Anadkat
如果这个解决方案能够解决您的问题,请接受它作为答案,这样在stackoverflow开放的问题中就会被关闭。如果不能解决,请告诉我缺少什么,我会尽力帮助 :) - krupesh Anadkat
2
谢谢,那个可行!你觉得能不能在答案中加上设置“snap to true”的部分呢?我认为如果将来有人搜索类似WhatsApp的行为,这可能会有所帮助。 - themthem
1
当然,我已经在顶部添加了它。感谢您的建议。 - krupesh Anadkat
1
这是一个惊人的答案。我在我的应用程序中完美地实现了它。 - Matthew Rideout

10

我使用带有NestedScrollView的SliverAppBar,成功制作了类似WhatsApp的浮动应用栏和选项卡。

在NestedScrollView中添加floatHeaderSlivers: true,并在SliverAppBar中添加pinned: true和floating: true。

可工作代码示例的链接

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

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: CustomSliverAppbar(),
    );
  }
}

class CustomSliverAppbar extends StatefulWidget {
  @override
  _CustomSliverAppbarState createState() => _CustomSliverAppbarState();
}

class _CustomSliverAppbarState extends State<CustomSliverAppbar>
    with SingleTickerProviderStateMixin {
  TabController _tabController;

  @override
  void initState() {
    _tabController = TabController(
      initialIndex: 0,
      length: 2,
      vsync: this,
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        floatHeaderSlivers: true,
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              title: Text(
                "WhatsApp type sliver appbar",
              ),
              centerTitle: true,
              pinned: true,
              floating: true,
              bottom: TabBar(
                  indicatorColor: Colors.black,
                  labelPadding: const EdgeInsets.only(
                    bottom: 16,
                  ),
                  controller: _tabController,
                  tabs: [
                    Text("TAB A"),
                    Text("TAB B"),
                  ]),
            ),
          ];
        },
        body: TabBarView(
          controller: _tabController,
          children: [
            TabA(),
            const Center(
              child: Text('Display Tab 2',
                  style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            ),
          ],
        ),
      ),
    );
  }

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

class TabA extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scrollbar(
      child: ListView.separated(
        separatorBuilder: (context, child) => Divider(
          height: 1,
        ),
        padding: EdgeInsets.all(0.0),
        itemCount: 30,
        itemBuilder: (context, i) {
          return Container(
            height: 100,
            width: double.infinity,
            color: Colors.primaries[Random().nextInt(Colors.primaries.length)],
          );
        },
      ),
    );
  }
}

输入图像描述


8

--- 编辑 1 --

好的,我为您快速整理了一些东西。我参考了这篇文章(由 Emily Fortuna 撰写,她是 Flutter 的主要开发人员之一),以更好地理解 Slivers。

Medium: Slivers, Demystified

然后我找到了这个 YouTube 视频,基本上使用了您的代码,所以我选择了它,而不是尝试弄清楚关于 Slivers 的每一个细节。

Youtube: Using Tab and Scroll Controllers and the NestedScrollView in Dart's Flutter Framework

结果证明,您的代码方向是正确的。您可以在 NestedScrollView 中使用 SliverAppBar(上次我尝试时还不行),但我做了一些改变。我会在我的代码之后解释:

import 'package:flutter/material.dart';

import 'dart:math';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage>  with SingleTickerProviderStateMixin /*<-- This is for the controllers*/ {
  TabController _tabController; // To control switching tabs
  ScrollController _scrollViewController; // To control scrolling

  List<String> items = [];
  List<Color> colors = [Colors.red, Colors.green, Colors.yellow, Colors.purple, Colors.blue, Colors.amber, Colors.cyan, Colors.pink];
  Random random = new Random();

  Color getRandomColor() {
    return colors.elementAt(random.nextInt(colors.length));
  }

  @override
  void initState() {
    super.initState();
    _tabController =TabController(vsync: this, length: 2);
    _scrollViewController =ScrollController();
  }

  @override
  void dispose() {
    super.dispose();
    _tabController.dispose();
    _scrollViewController.dispose();
  }

  @override
  Widget build(BuildContext context) {

 // Init the items
    for (var i = 0; i < 100; i++) {
      items.add('Item $i');
    }

    return SafeArea(
      child: NestedScrollView(
        controller: _scrollViewController,
        headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              title: Text("WhatsApp using Flutter"),
              floating: true,
              pinned: false,
              snap: true,
              bottom: TabBar(
                tabs: <Widget>[
                  Tab(
                    child: Text("Colors"),
                  ),
                  Tab(
                    child: Text("Chats"),
                  ),
                ],
                controller: _tabController,
              ),
            ),
          ];
        },
        body: TabBarView(
              controller: _tabController,
              children: <Widget>[
                ListView.builder(
                  itemBuilder: (BuildContext context, int index) {
                      Color color = getRandomColor();
                      return Container(
                        height: 150.0,
                        color: color,
                        child: Text(
                          "Row $index",
                          style: TextStyle(
                            color: Colors.white,
                          ),
                        ),
                      );
                    },
                    //physics: NeverScrollableScrollPhysics(), //This may come in handy if you have issues with scrolling in the future
                  ),

                  ListView.builder(
                    itemBuilder: (BuildContext context, int index) {
                      return Material(
                        child: ListTile(
                          leading: CircleAvatar(
                            backgroundColor: Colors.blueGrey,
                          ),
                          title: Text(
                            items.elementAt(index)
                            ),
                        ),
                      );
                    },
                    //physics: NeverScrollableScrollPhysics(),
                  ),
              ],
            ),
      ),
    );

  }
}

好的,现在开始解释。

  • 使用 StatefulWidget

    Flutter 中的大多数小部件都将是有状态的,但这取决于情况。在这种情况下,我认为最好使用一个StatefulWidget,因为您正在使用可能随用户添加或删除对话/聊天而更改的ListView

  • 使用 SafeArea 小部件。

    请在Flutter Docs:SafeArea 上阅读它。

  • 控制器

    我认为刚开始这可能是个大问题,但也许还有其他什么问题。但是,如果您正在处理 Flutter 中的自定义行为,则通常应自己创建控制器。因此,我制作了_tabController_scrollViewController(我认为我没有完全利用它们的所有功能,例如在选项卡之间跟踪滚动位置,但它们适用于基础知识)。用于TabBarTabView的选项卡控制器应该相同。

  • ListTile 前的 Material 小部件

    您可能迟早会发现这一点,但是ListTile小部件是一个Material小部件,因此根据我首次尝试呈现它时得到的输出,需要"Material祖先小部件"。所以我为您解决了一个微小的头痛。我认为这是因为我没有使用Scaffold。(在使用没有材料祖先小部件的材料小部件时,请记住这一点)

希望这能帮助你入门,如果需要任何帮助,请给我发消息或将我添加到你的Github仓库中,我会尽力帮忙。

--- 翻译 ---

我在 Reddit 上也回答了你,希望你能尽快看到其中之一。

SliverAppBar 信息

SliverAppBar 的关键属性包括:

floating: Whether the app bar should become visible as soon as the user scrolls towards the app bar.
pinned: Whether the app bar should remain visible at the start of the scroll view. (This is the one you are asking about)
snap: If snap and floating are true then the floating app bar will "snap" into view.

这些内容来自Flutter SliverAppBar文档。它们有许多不同的浮动、固定和快照组合的动画示例。

因此,对于您的应用程序,以下内容应该有效:

SliverAppBar(
            title: Text("Application"),
            floating: true, // <--- this is required if you want the appbar to come back into view when you scroll up
            pinned: false, // <--- this will make the appbar disappear on scrolling down
            snap: true,    // <--- this is required if you want the application bar to 'snap' when you scroll up (floating MUST be true as well)
            bottom: new TabBar(
              tabs: [ ... ],    // <-- total of 2 tabs
            ),
          ),

ScrollView与SliverAppBar

回答关于NestedScrollView的潜在问题。根据文档(同上),SliverAppBar是:

CustomScrollView集成的材料设计应用栏。

因此,您不能使用NestedScrollView,您需要使用CustomScrollView这是Sliver类的预期用途,但它们也可以在NestedScrollView中使用。请查看文档


所以我使用了一个CustomScrollView,并尝试将TabBarView作为SliverFillRemaining的子元素。这样做正确吗?我收到了更多的错误/警告,例如“Another exception was thrown: NoSuchMethodError: The getter 'visible' was called on null.”。你能否分享一个可行的示例? - themthem
1
你的代码可以运行,但是问题(在GIF中)似乎仍然存在。当我切换标签时,内容的顶部被隐藏了。你知道怎么解决吗?我认为我们可以通过在每次切换标签时滚动到顶部来解决这个问题。另外,不幸的是,snap: true仍然是必需的。 - themthem
1
嗯,你说得对。我一直在尝试跟随Flutter的示例使用NestedScrollView,但它破坏了将appBar“捕捉”回来的能力。我有一种感觉,你可能需要等待Flutter提供这个特定功能,而不需要深入挖掘源代码。 - Tanner Davis
1
非常感谢您的帮助。我已经创建了一个问题:#29561 - themthem

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