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内部右侧有一部分区域没有被背景图片或背景色覆盖的情况。
浙公网安备 33010602011771号