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

Posted

技术标签:

【中文标题】向下滚动时隐藏的 Flutter TabBar 和 SliverAppBar【英文标题】:Flutter TabBar and SliverAppBar that hides when you scroll down 【发布时间】:2019-08-06 19:30:01 【问题描述】:

我正在尝试创建一个带有顶部应用程序栏和下方标签栏的应用程序。当您向下滚动时,该栏应通过移出屏幕来隐藏(但选项卡应保留),当您向上滚动时,应用程序栏应再次显示。这种行为可以在 WhatsApp 中看到。请参阅this 视频进行演示。 (取自Material.io)。 This 是一个类似的行为,虽然应用栏和标签栏在滚动时是隐藏的,所以这不是我正在寻找的行为。

我已经能够实现自动隐藏,但是有几个问题:

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

    虽然这是可行的,但这不是我正在寻找的行为。我希望应用程序栏能够流畅地显示(类似于 WhatsApp),而不是即使滚动很少也会出现。

    澄清一下,当我一直向下滚动时,即使我向上滚动很少,应用栏也应该会出现。我不想一直向上滚动才能看到应用栏。

    当我向下滚动并更改选项卡时,一小部分内容被剪掉了。

    下面是显示行为的 GIF:

    (当我在listView(tab1)上向下滚动时查看该部分,然后移回tab2)

这是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 repository。 main.dart 是 here。

我还发现了这个相关问题:Hide Appbar on Scroll Flutter?。但是,它没有提供解决方案。同样的问题仍然存在,当您向上滚动时,SliverAppBar 将不会显示。 (所以需要snap: true

我还在 Flutter 的 GitHub 上找到了this issue。 (编辑:有人评论说他们正在等待Flutter团队解决这个问题。有没有可能没有解决方案?)

这是flutter doctor -v:Pastebin 的输出。发现了一些问题,但据我所知,它们应该不会产生影响。

编辑:这有两个问题:

https://github.com/flutter/flutter/issues/29561(已关闭) https://github.com/flutter/flutter/issues/17518

【问题讨论】:

我也有这个问题。使用 SliverOverlapAbsorber 是可行的,但是你不能使用 sliverappbar 浮动。有什么解决办法吗? 【参考方案1】:

您需要使用SliverOverlapAbsorber/SliverOverlapInjector,以下代码适用于我(Full Code):

@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 的错误吗? 我不明白snap的问题,能否详细说明或记录一个例子? 问题在于,当您向上滚动时,您必须停止滚动才能显示应用栏。我希望当您开始向上滚动时,应用栏应该已经开始显示。如果这是一个糟糕的解释,我很抱歉,但我想实现 android WhatsApp 的应用栏行为。目前,该行为类似于 Android Google Docs 应用栏。除此之外,您的解决方案还修复了更改选项卡时隐藏某些内容的问题 使用snap:false,它对我有用。谢谢@IsmailRBOUH NestedScrollView中使用SilverAppbar、SliverPersistentHeader如何处理。【参考方案2】:

更新 - Sliver 应用栏扩展

如果您想看到 Sliver 应用栏在有人向上滚动时立即展开,即不一直滚动到顶部,而只是一点点,那么只需在代码中将 snap: false 更改为 snap: true :)


解决方案[修复所有点]

在浏览 google、***、github 问题、reddit 几个小时后。我终于可以想出一个解决以下问题的解决方案:

    Sliver 应用栏的标题被隐藏,向下滚动后只有标签栏可见。当您到达顶部时,您会再次看到标题。

    MAJOR :当您在 Tab 1 中滚动然后导航到 Tab 2 时,您不会看到任何重叠。 Tab 2的内容不会被Sliver App bar挡住。

    List 中最顶部元素的 Sliver Padding 为 0。

    在单个选项卡中保留滚动位置

下面是代码,我会尝试解释一下(dartpad preview):

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 documentation of NestedScrollView

他们在 cmets 中提到的非常好。我不是专家,所以我只想强调一下我认为解决了大多数问题的方法。

我认为这里有两件事很重要:

    SliverOverlapAbsorber & SliverOverlapInjector 使用SliverList 代替ListView

无论我们看到什么额外的空间,或者sliver app bar消耗的空间和第一个列表项重叠的空间,主要是通过使用以上两点来解决的。

为了记住标签的滚动位置,他们在CustomScrollView 中添加了PageStorageKey

key: PageStorageKey<String>(name),

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

他们还在文档中提到我们可以使用 SliverFixedExtentList、SliverGrid,基本上是 Sliver 小部件。现在应该在需要时使用 Sliver 小部件。在 Flutter Youtube 视频之一(官方频道)中,他们提到 ListView、GridView 都是 Slivers 的高级实现。因此,如果您希望超级自定义滚动或外观行为,Slivers 是低级的东西。

如果我遗漏了什么或说错了,请在 cmets 中告诉我。

【讨论】:

感谢您的回答!此解决方案确实解决了重叠问题,但问题是我必须一直向上滚动才能再次查看应用栏。我正在寻找类似 Android WhatsApp 的应用栏行为 snap: true 将解决您的问题 :) 我刚刚在 dartpad 中对其进行了测试。向下滚动然后向上滚动一点并离开鼠标触摸板,您将看到 Sliver App bar 平滑扩展:) 如果它解决了问题,那么请接受这个作为答案,所以它将在***开放问题中关闭。如果不让我知道缺少什么,我会尽力帮助:) 谢谢,这行得通!您认为您可以将关于将 snap 设置为 true 的部分添加到答案中吗?我认为如果他们正在搜索像 WhatsApp 这样的行为,它可能对未来的人们有所帮助 当然,我已经在顶部添加了它。感谢您的建议。【参考方案3】:

通过使用带有 NestedScrollView 的 SliverAppbar,我能够制作类似于 WhatsApp 的带有 Tabbar 的浮动 Appbar。

在 NestedScrollView 中添加 floatHeaderSliv​​ers: true 和

固定:真,浮动:真,在 SliverAppBar 中

Link to working code sample

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)],
          );
        ,
      ),
    );
  

【讨论】:

干净整洁。真棒 +1 :)【参考方案4】:

--- 编辑 1 --

好的,所以我快速为你整理了一些东西。我关注了这篇文章(由 Flutter 的主要开发者之一 Emily Fortuna 撰写)以更好地理解 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 中的大多数小部件都是有状态的,但这取决于具体情况。我认为在这种情况下更好,因为您使用的是ListView,它可能会随着用户添加或删除对话/聊天而改变。

    SafeArea 因为这个小部件很棒。

    去Flutter Docs: SafeArea阅读它

    控制器

    一开始我认为这是个大问题,但也许是其他问题。但是,如果您在 Flutter 中处理自定义行为,您通常应该制作自己的控制器。所以我制作了_tabController_scrollViewController(我认为我没有从它们中获得所有功能,即跟踪标签之间的滚动位置,但它们适用于基础知识)。您用于TabBarTabView 的选项卡控制器应该相同。

    ListTile 之前的 Material 小部件

    您可能迟早会发现这一点,但ListTile 小部件是一个材质小部件,因此根据我最初尝试渲染它时得到的输出,它需要一个“材质祖先小部件”。所以我为你省去了一点麻烦。我认为这是因为我没有使用Scaffold。 (当您使用没有 Material 祖先小部件的 Material 小部件时,请记住这一点)

希望这可以帮助您入门,如果您需要任何帮助,请给我发消息或将我添加到您的 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 Docs。他们有很多动画示例,具有浮动、固定和捕捉的不同组合。

所以对你来说,以下应该可以工作:

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
            ),
          ),

带有 SliverAppBar 的 ScrollView

回答NestedScrollView 的基本问题。根据文档(与上述相同),SliverAppBar 是:

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

因此你不能使用NestedScrollView,你需要使用CustomScrollView这是Sliver类的预期用途,但它们可以在NestedScrollView中使用查看docs.

【讨论】:

所以我使用了CustomScrollView,并且我试图将TabBarView 作为一个孩子作为SliverFillRemaining。它是否正确?我收到更多错误/警告,例如Another exception was thrown: NoSuchMethodError: The getter 'visible' was called on null.。您可以分享一个工作示例吗? 您的代码有效,但问题(在 GIF 中)似乎仍然存在。当我更改标签时,内容的顶部是隐藏的。你知道我怎么能解决这个问题吗?我认为我们可以通过滚动到每个选项卡更改的顶部来解决这个问题。另外,很遗憾,snap: true 仍然是必需的 嗯,你是对的。我一直在尝试关注Flutter's example 使用NestedScrollView,但它破坏了将appBar '捕捉'回来的能力。我有一种感觉,你可能不得不等待 Flutter 提供这个特定的功能,而无需深入挖掘它的源代码。 感谢您的所有帮助。我创建了一个问题:#29561

以上是关于向下滚动时隐藏的 Flutter TabBar 和 SliverAppBar的主要内容,如果未能解决你的问题,请参考以下文章

滚动时如何隐藏 BottomNavigationBar - Flutter

Flutter:使用 Sliver 小部件时如何在滚动时隐藏 BottomAppBar?

在 Flutter 中的 Scroll 上隐藏底部导航栏

在 iOS 中的 navigationController 堆栈中隐藏 tabBar 时,视图在屏幕上显示后向下移动

Flutter - 隐藏 FloatingActionButton

Android:向上滚动时显示工具栏(向上拖动),向下滚动时隐藏(向下拖动)