flutter第四篇:布局下 Scaffold

在build()方法中,如果不返回Scaffold,那么整个屏幕的背景色都会是黑色的。Scaffold有很多属性,如appBar、drawer、endDrawer、body、bottomSheet、persistentFooterButtons、bottomNavigationBar、floatingActionButton等。

Scaffold也可以放到Stack中使用,此时,Stack的其他组件都是从屏幕左上角开始布局。

20、Scaffold的appBar属性。AppBar、TabBar、TabBarView组件

appBar是应用栏,用于状态栏、顶部导航栏的布局,值是个PreferredSizeWidget。PreferredSizeWidget是个抽象类,继承了Widget接口,有4个常用的子类,分别是AppBar、TabBar、Tab、PreferredSize,其中AppBar一般用于Scaffold的appBar属性,TabBar一般用于AppBar的bottom属性,Tab一般用于TabBar的tabs属性,而PreferredSize则是在想给其他组件自定义宽高时使用。下面详细讲解。

AppBar的默认高度是56dp。在获取AppBar默认高度时,很容易把状态栏的高度也算进去,如下:

    return Scaffold(
      appBar: AppBar(
        key: key1,
      ),
      body: Center(
          child: InkWell(
        child: Container(
          decoration: BoxDecoration(border: Border.all(color: Colors.yellow)),
          child: const Text(
            "点击查看appBar高度",
            style: TextStyle(fontSize: 35),
          ),
        ),
        onTap: () {
          print("key1 size: ${key1.currentContext?.size}");
        },
      )),
    );

上例打印出的高度大于56,是因为把状态栏的高度也算进去了。状态栏即显式时间、各种类型的刘海、手机信号、wifi、电池电量的那一行区域,不同设备的状态栏高度不一样。状态栏高度获取方式是:MediaQuery.of(context).padding.top,获取系统导航栏的方式是:MediaQuery.of(context).padding.bottom。状态栏和系统导航栏合称为系统栏。其实SafeArea就是屏幕刨去系统栏后的区域。如果不想展示状态栏,则需要调用SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),immersive的意思是沉浸式。注意这个是全局的,在任一页面调用,在app内所有页面都生效(即都看不到状态栏了),若想恢复状态栏,则需要调用SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)。edgeToEdge的意思是边到边。状态栏和系统导航栏可以通过SystemChrome.setSystemUIOverlayStyle()查看,入参是一个SystemUiOverlayStyle实例,构造实例时可以指定状态栏颜色(statusBarColor)、系统导航栏颜色(systemNavigationBarColor)。

获取AppBar默认高度的正确代码如下:

  @override
  Widget build(BuildContext context) {
    final appBar = AppBar(title: const Text("Example"));
    final appBarHeight = appBar.preferredSize.height;
    print("appBarHeight:$appBarHeight");
    return Scaffold(
      appBar: appBar,
      body: Center(
        child: Text('AppBar Height:$appBarHeight'),
      ),
    );
  }

由于AppBar没有height属性,所以如果想要调整高度,需要用一个PreferredSize来包裹这个AppBar,通过设置PreferredSize的preferredSize属性进而设置AppBar的高度。但是,当调小AppBar的高度时,系统返回箭头的高度不会自动调整,AppBar高度太小时,系统返回箭头会显示不全,这个时候就需要我们用AppBar的leading占据系统返回箭头的位置了,设置leading属性为一个左箭头,点击可返回上一个页面即可。

AppBar有很多属性可以使用,利用其backgroundColor属性可以为其设置背景颜色,这样我们可以看出AppBar所占的区域,其实看到的是状态栏区域+AppBar的区域。

AppBar从左到右的属性依次是leading、title、actions,往下一行是bottom。leading属性可以是任意组件,用leadingWidth可以调整leading的宽度,默认宽度是56dp。需要注意的是,leading占的是系统自带的返回按钮的位置,如果设置了leading,那么系统自带的返回按钮就看不见了。leading右边是title,title属性也可以是任意组件。title在左边好像有一个16dp的margin一样,不管有没有leading都一样,可以用titleSpacing自定义这个宽度。利用centerTitle属性可以配置title是否居中显示。title设置为居中的话,titleSpacing就失效了。title右边是actions,是个组件集合,可以放多个组件。actions是贴着右边屏幕的,而不是与title右边界有固定间距。利用elevation属性可以设置阴影高度,通常设为0。如果body是滑动列表,当其滑到AppBar下面时,会发现AppBar的颜色有变化,如果不想让颜色变化,则需要设置AppBar的scrolledUnderElevation属性值为0,或者直接设置MaterialApp的theme属性值ThemeData的appBarTheme属性值AppBarTheme的scrolledUnderElevation值为 0,这样就可以在所有页面的AppBar上生效了。

利用其bottom属性可以在title下一行添加一些tab,实现顶部导航栏的效果。如今日头条首页的tab切换。

给AppBar的bottom属性赋值为一个TabBar,给TabBar的tabs属性赋值为一个Tab集合。Tab的text、child属性用于设置文案,最多只能设置一个。icon用于设置图标,文案和图标至少要设置一个。图标在文案上面。

TabBar必须指定controller属性,否则run时会报错。给TabBar的controller属性赋值为一个TabController。创建TabController时必须指定其length属性和vsync属性,前者就是tab的个数,后者是一个TickerProvider,我们让当前xxxState混入SingleTickerProviderStateMixin,给vsync赋值为this即可。定义TabController变量时用late修饰,然后在init方法中实例化,否则识别不出this。如此,在页面顶部就会有一排tab,用手点击某个tab时,该tab和下面的小横线会变蓝,但是tab列表不可滑动。

用TabBar的labelColor属性调整选中的tab的颜色,用unselectedLabelColor属性调整未选中的tab的颜色。

用TabBar的labelPadding属性调整tabs的间距以及tab与小横线的间距,默认是EdgeInsets.symmetric(horizontal: 16.0),即水平方向上左右都是16dp,竖直方向为0。设置bottom,会使得tab与小横线的间距变大。

用TabBar的tabAlignment属性调整tab的对齐方式,isScrollable为false时,tabAlignment默认是TabAlignment.fill,即在水平方向上占满屏幕。isScrollable为true时,tabAlignment默认是TabAlignment.startOffset,即左对齐,但在最左侧会留出52dp的空间。可选值还有TabAlignment.start,表示左对齐,TabAlignment.center,表示居中。注意,start、startOffset仅用于isScrollable为true的情况,而fill仅用于isScrollable属性为false的情况,center都适用。

TabBar默认在指示器下面紧挨着指示器的位置有一条白线,这其实是divider,设置dividerColor值为Colors.transparent,即可让diver看不见。

用TabBar的indicatorColor属性调整小横线的颜色。

用TabBar的indicatorWeight属性调整小横线的粗细,默认是2.0。实测,调大的话,如5.0,是生效的,调小的话,如0.5,是不生效的,怀疑是flutter的bug。此时只能利用indicator属性了。indicator属性是个Decoration实例,我们给其赋值为一个UnderlineTabIndicator实例,通过指定borderSide的width和color来调整indicator的宽度和颜色,这样做实测是可以把indicator调细一点的。

用TabBar的indicatorSize属性调整小横线的长度,默认是TabBarIndicatorSize.label,长度等于tab中文字的长度。值还可以是TabBarIndicatorSize.tab,长度等于tab的长度(tab按下后阴影区域的长度),会比label长一些。

其实,小横线在flutter中用indicator表示,翻译为指示器。默认情况下,tab会全部展示在屏幕中,如果tab很多,那么tab的文本就会展示不全,此时,我们可以设置TabBar的isScrollable属性值为true,这样,在屏幕中只会展示一部分tab,其他的tab我们可以左右滑动tab列表查看。

我们可以在initState()方法中调用TabController的addListener()方法,监听点击选中或者滑到的tab,如下

    tabController.addListener(() {
      if (tabController.animation?.value == tabController.index) {
        print("controller index:${tabController.index}");
      }
    });

要想实现【点击某tab,页面跟着变化】,需要指定一个TabBarView,TabBar和TabBarView共用同一个TabController实例。这样就实现了TabBar和TabBarview的协调,即手动点击某tab时,页面会自动切。左右滑页面时,tab也会自动切。需要注意的是,TabBar的点击切换tab的特性是自带的,和TabBarView没有半毛钱关系,TabBarView的左右滑动切换页面的特性也是自带的,和TabBar也没有半毛钱关系,这两个组件是可以单独存在的,只是我们一般通过让它俩共用一个TabController实例来绑定它们。

TabBar的tabs的长度和TabBarView的children的长度和TabController的length必须一致,否则run时会报错。

代码示例如下:

    return Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.red,
          leading: IconButton(
            icon: const Icon(Icons.menu),
            onPressed: () {
              print("左侧按钮图标");
              Scaffold.of(context).openDrawer();
            },
            tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
          ),
          actions: [
            IconButton(
              icon: const Icon(Icons.more_horiz),
              onPressed: () {
                print("更多");
                Scaffold.of(context).openDrawer();
              },
              tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
            )
          ],
          bottom: TabBar(
            onTap: (i) {
              print("i:$i");
            },
            tabs: const [
              Tab(child: Text("关注")),
              Tab(
                child: Text("推荐"),
              ),
              Tab(child: Text("热榜")),
              Tab(child: Text("发现")),
              Tab(
                child: Text("深圳"),
              ),
              Tab(child: Text("岛屿读书")),
            ],
            tabAlignment: TabAlignment.start,
            isScrollable: true,
            indicatorColor: Colors.yellow,
            indicatorPadding: const EdgeInsets.all(5),
            indicatorSize: TabBarIndicatorSize.tab,
            controller: tabController,
            labelColor: Colors.yellow,
            unselectedLabelColor: Colors.black,
          ),
        ),
        body: TabBarView(
          controller: tabController,
          children: const [
            Text("我是关注"),
            Text("我是推荐"),
            Text("我是热榜"),
            Text("我是发现"),
            Text("我是深圳"),
            Text("我是岛屿读书"),
          ],
        ));

上例的效果和今日头条的效果有一点不同,那就是在上例中各tab在红色区域里面,而今日头条的各tab在红色区域下面。

为此,我们把TabBar放到title中。

我们最终实现的效果是:应用一打开,就去tab页,在tab页有底部导航栏,第一个底部导航栏是首页,首页和今日头条首页一样,有顶部导航栏。

tab页:

import 'package:flutter/material.dart';

import './category.dart';
import './home.dart';
import './message.dart';
import './setting.dart';
import './user.dart';

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

  @override
  State<TabPage> createState() => _TabPageState();
}

class _TabPageState extends State<TabPage> {
  int _currentIndex = 0;

  final List<Widget> pages = const [
    HomePage(),
    CategoryPage(),
    MessagePage(),
    SettingPage(),
    UserPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Tab页"),
        backgroundColor: Colors.red,
      ),
      body: pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
          type: BottomNavigationBarType.fixed,
          selectedItemColor: Colors.red,
          onTap: (i) {
            _currentIndex = i;
            setState(() {});
          },
          currentIndex: _currentIndex,
          items: const [
            BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
            BottomNavigationBarItem(icon: Icon(Icons.category), label: "分类"),
            BottomNavigationBarItem(icon: Icon(Icons.message), label: "消息"),
            BottomNavigationBarItem(icon: Icon(Icons.settings), label: "设置"),
            BottomNavigationBarItem(icon: Icon(Icons.people), label: "用户")
          ]),
    );
  }
}

home页:

import 'package:flutter/material.dart';

import '../tool/keep_alive_wrapper.dart';

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

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

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  late TabController tabController;

  @override
  void initState() {
    super.initState();
    tabController = TabController(length: 6, vsync: this);
    tabController.addListener(() {
      if (tabController.animation?.value == tabController.index) {
        print("controller index:${tabController.index}");
      }
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: PreferredSize(
            // 自定义AppBar的高度
            preferredSize: const Size.fromHeight(56),
            child: AppBar(
              elevation: 1,
              backgroundColor: Colors.white,
              titleSpacing: 0,
              // 自定义title的高度
              title: SizedBox(
                height: 30,
                child: TabBar(
                  tabs: const [
                    Tab(child: Text("关注")),
                    Tab(
                      child: Text("推荐"),
                    ),
                    Tab(child: Text("热榜")),
                    Tab(child: Text("发现")),
                    Tab(
                      child: Text("深圳"),
                    ),
                    Tab(child: Text("岛屿读书")),
                  ],
                  isScrollable: true,
                  tabAlignment: TabAlignment.start,
                  indicatorColor: Colors.red,
                  indicatorSize: TabBarIndicatorSize.label,
                  controller: tabController,
                  labelColor: Colors.red,
                  unselectedLabelColor: Colors.black,
                ),
              ),
            )),
        body: TabBarView(
          controller: tabController,
          children: [
            KeepAliveWrapper(
              child: ListView(children: const [
                ListTile(title: Text("我关注1")),
                ListTile(title: Text("我关注2")),
                ListTile(title: Text("我关注3")),
                ListTile(title: Text("我关注4")),
                ListTile(title: Text("我关注5")),
                ListTile(title: Text("我关注6")),
                ListTile(title: Text("我关注7")),
                ListTile(title: Text("我关注8")),
                ListTile(title: Text("我关注9")),
                ListTile(title: Text("我关注10")),
                ListTile(title: Text("我关注11")),
                ListTile(title: Text("我关注12")),
                ListTile(title: Text("我关注13")),
                ListTile(title: Text("我关注14")),
                ListTile(title: Text("我关注15")),
                ListTile(title: Text("我关注16")),
                ListTile(title: Text("我关注17")),
              ]),
            ),
            const Text("我是推荐"),
            const Text("我是热榜"),
            const Text("我是发现"),
            const Text("我是深圳"),
            const Text("我是岛屿读书"),
          ],
        ));
  }
}

如上,首页和今日头条的首页不一样的一点是,我们的小横条比今日头条的粗。自己调低TabBar的indicatorWeight属性值也不管用。不知道怎么搞。页面拆解:顶部appBar、底部导航栏是tab页面的,中间的tab和内容,是home页。

17、Scaffold之bottomNavigationBar

body用于屏幕主体部分的布局,floatingActionButton用于展示一个浮动按钮,drawer、endDrawer用于左、右侧边栏的布局,bottomNavigationBar用于底部导航栏的布局,backgroundColor用于设置body的背景色。默认情况下,body是从appBar下面开始布局的,如果想让body从屏幕顶部开始布局,那么要么不设置appBar属性,要么设置extendBodyBehindAppBar属性值为true。此外,为了实现appBar不遮挡顶部的body,还需要让appBar有透明度,即把背景色设为Colors.transparent。

想要添加底部导航栏,就要用到Scaffold的bottomNavigationBar属性,值是任意Widget,可以赋值为一个BottomNavigationBar或BottomAppBar。

BottomNavigationBar常用属性有:

items,值是BottomNavigationBarItem集合,集合长度必须大于1。BottomNavigationBarItem的icon属性是底部导航栏选项展示的图标,label属性值是底部导航栏选项展示的文案。如果想只展示图标,不展示文案,则需要。反之亦然。

currentIndex,是选中的导航栏选项的索引,默认是0,表示默认选中第一个选项。

selectedItemColor,导航栏的选项被选中后,图标和文案变成的颜色。

type,当导航栏有3个以上选项时,必须设置为BottomNavigationBarType.fixed,否则只会展示第一个选项,后面的展示不出来。

onTap,导航栏选中回调函数,入参是选中的选项的索引,通过这个函数,应用可以知道是哪个选项被用户选中了,进而改变该选项的颜色,切换页面等。

BottomAppBar一般和FloatingActionButton一起使用,常用属性有:

child,和items作用一样

color,指定背景色

shape,NotchedShape实例,一般是个CircularNotchedRectangle实例,即缺口是个圆形,配合FloatingActionButton。notch是缺口的意思。

notchMargin,指定FloatingActionButton和BottomAppBar缺口的间距。

Scaffold有了底部导航栏后,其body属性值应是根据选中的导航栏选项变化的,可以赋值为pages[i],pages的值是底部导航栏对应的页面集合,i是onTap对应的回调函数的入参。底部导航栏不支持页面的左右滑动。

18、Scaffold之floatingActionButton属性。FloatingActionButton组件。

要想在底部添加一个浮动按钮,就要用到Scaffold的floatingActionButton属性。值是任意Widget,可以赋值为一个FloatingActionButton,也可以用其他组件。可以通过Scaffold的floatingActionButtonLocation属性调整floatingActionButton的位置,默认是FloatingActionButtonLocation.endFloat,在右下角,在底部导航栏的上面。如果值为FloatingActionButtonLocation.centerDocked,则在底部正中间位置,在底部导航栏的上面。如果其位置需要微调,则我们可以把floatingActionButton指定为一个Container,把FloatingActionButton放到Container里面,通过调整Container的padding和margin来微调FloatingActionButton的位置。同时,Container和FloatingActionButton都可以指定背景色,可以一样,也可以不一样,具体看需求。

下例是参考闲鱼App的底部导航栏的布局:底部导航栏一共有5个选项,正中间选项的图标被圆形的浮动按钮盖住,浮动按钮外部环绕一圈白色区域。浮动按钮在未被选中时是黄色,被选中后变为红色,且页面也要切换到对应页面。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
          type: BottomNavigationBarType.fixed,
          selectedItemColor: Colors.red,
          onTap: (i) {
            _currentIndex = i;
            setState(() {});
          },
          currentIndex: _currentIndex,
          items: const [
            BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
            BottomNavigationBarItem(icon: Icon(Icons.category), label: "分类"),
            BottomNavigationBarItem(icon: Icon(Icons.message), label: "消息"),
            BottomNavigationBarItem(icon: Icon(Icons.settings), label: "设置"),
            BottomNavigationBarItem(icon: Icon(Icons.people), label: "用户")
          ]),
      floatingActionButton: Container(
        width: 60,
        height: 60,
        decoration: const BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.white,
        ),
        padding: const EdgeInsets.all(4),
        margin: const EdgeInsets.only(top: 9),
        child: FloatingActionButton(
          backgroundColor: _currentIndex == 2 ? Colors.red : Colors.yellow,
          // 浮动按钮在未点击时,背景色是黄色,点击时变红
          shape: const CircleBorder(),
          onPressed: () {
            _currentIndex = 2;
            setState(() {});
          },
          child: const Icon(
            Icons.add,
            color: Colors.white,
          ),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }

上例中,就用Container包裹了FloatingActionButton,然后通过Container的padding、margin属性对FloatingActionButton的位置进行了微调。Container的padding属性是调整FloatingActionButton与Container四个边框的距离,如果padding大,那么FloatingActionButton所占区域就小。Container的margin属性是调整Container的位置,进而调整FloatingActionButton的位置。

19、Scaffold之drawer属性。Drawer、DrawerHeader、UserAccountsDrawerHeader组件。

要想在顶部添加侧边栏,需要用Scaffold的drawer、endDrawer属性,分别用于添加左侧边栏、右侧边栏。所谓侧边栏,即看起来是三条横线,点击后会滑出一个页面,用手从屏幕左边框往右滑,或者从屏幕右边框往左滑,也会出现这个页面。以左侧边栏为例,点击那三条横线,会从屏幕左侧滑出一个页面,用手从屏幕左边框往右滑,也可滑出此页面。如果没有设置appBar,那么侧边栏的三条横线会看不见,只能用手滑出来。drawer属性值是任意Widget,可以赋值为一个Drawer,也可以用其他组件。Drawer的child也支持任意组件。我们可以在侧边栏实现这样的效果http://oss.echowa.xndm.tech/test/op/fQ6NpA9Wetf5LcV3tnv3y8.png,即侧边栏中,顶部有个背景图,背景图上面放圆形头像、昵称、邮箱,背景图下面有两行,每一行都是左边是圆形图标,右边是一段文字。顶部其实是个DrawerHeader,我们可以给Drawer的child属性赋值为一个Column,Column第一个元素是一个DrawerHeader,第二、三个元素均是一个ListTile。见如下示例:

      drawer: const Drawer(
        child: Column(
          children: [
            UserAccountsDrawerHeader(
              accountName: Text("张三"),
              accountEmail: Text("xxx@qq.com"),
              currentAccountPicture: CircleAvatar(
                backgroundImage:
                    NetworkImage("https://www.itying.com/images/flutter/1.png"),
              ),
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: NetworkImage(
                      "https://www.itying.com/images/flutter/2.png"),
                  fit: BoxFit.cover,
                ),
              ),
            ),
            ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.people,
                ),
              ),
              title: Text("个人中心"),
            ),
            Divider(),
            ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.settings,
                ),
              ),
              title: Text("系统设置"),
            )
          ],
        ),
      ),

上例中,DrawerHeader用的是UserAccountsDrawerHeader,这是个预定义的、布局好的DrawerHeader。如果UserAccountsDrawerHeader的布局不满足我们的需求,那么我们就得用DrawerHeader,然后自定义布局,如下示例:

      drawer: Drawer(
        // 宽度默认304dp
        child: Column(
          children: [
            Row(
              children: [
                Expanded(
                  child: DrawerHeader(
                    decoration: const BoxDecoration(
                      image: DecorationImage(
                        image: NetworkImage(
                            "https://www.itying.com/images/flutter/2.png"),
                        fit: BoxFit.cover,
                      ),
                    ),
                    padding: EdgeInsets.zero,
                    child: Container(
                        padding: const EdgeInsets.only(left: 20),
                        child: const Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            CircleAvatar(
                              radius: 40,
                              backgroundImage: NetworkImage(
                                  "https://www.itying.com/images/flutter/1.png"),
                            ),
                            SizedBox(
                              height: 20,
                            ),
                            Text(
                              "张三",
                              style: TextStyle(color: Colors.white),
                            ),
                            Text(
                              "xxx@qq.com",
                              style: TextStyle(color: Colors.white),
                            ),
                          ],
                        )),
                  ),
                )
              ],
            ),
            const ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.people,
                ),
              ),
              title: Text("个人中心"),
            ),
            const Divider(),
            const ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.settings,
                ),
              ),
              title: Text("系统设置"),
            )
          ],
        ),
      ),

逻辑仍是给Drawer的child属性赋值为一个Column,里面有3个元素,第一个元素是DrawerHeader,第二、三个元素是ListTile。而之所以把DrawerHeader用Expanded包裹并放到Row中,是为了把DrawerHeader在水平方向上铺满(304dp),否则DrawerHeader的宽度是由其child的宽度和padding指定的left+right决定的,如果不人为指定等于Drawer的宽度的话,会出现Drawer内部右侧有一部分区域没有被背景图片或背景色覆盖的情况。

 

posted on 2024-09-02 17:00  koushr  阅读(125)  评论(0)    收藏  举报

导航