一、Flutter 布局实例
通过对 Flutter Widget的学习,掌握了基本的 Widget 的使用,接下来通过使用学习过的 Widget,实现一个小实例,巩固知识。
在平时的使用过程中,我发现用到最多的控件有:Text,Image,Contianer,Column,Row,ListView,Expand ,Stack 等。通过这些基础的 Widget 或者这些 Widget 的组合基本能实现我们的需求。
下面通过一个小实例,演示如果通过一些基础控件来实现一个完整的 Flutter 应用程序,最主要的是要熟悉控件的使用。
效果:
主要是实现了两个 tab 页,主页面点击 item 可以跳转到详情页。简单的逻辑是这样,主要看如何布局实现这样的效果。
二、主程序搭建
这里我新建了四个文件,一个是主程序架构页面,一个是主页,一个是我的,一个是详情。如下:
首先说一下项目的主体以及底部导航,这个示例里面用到了两个 tab,也就是两个页面,点击 tab 会切换界面,当然中间的图标,也就是 fab,也能相应点击事件,这里没有加。
在 Flutter 里,Scaffold 这个 Widget 代表脚手架,这里 Flutter 已经帮我们封装好了一些基本的控件,方便我们快速开发, Scaffold 里面包含了 :
- appBar:导航栏
- drawer:抽屉菜单
- bottomNavigationBar:底部导航
- fab:floatingActionButton
通过使用 Scaffold 我们就可以快速开发一个页面。
首先看一下主页的 Scaffold:
return Scaffold(
backgroundColor: Colors.white,
body: list[_index],
floatingActionButton:
Container(
width: 50,
height: 50,
child:
FloatingActionButton(
heroTag: "main_fab",
isExtended:true,
onPressed: () {
print("to do sth");
},
//图标颜色
elevation: 8,
highlightElevation: 5,
child: Icon(Icons.camera),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar:
Container(
child:BottomAppBar(
//这个 shape 是 底部导航的压缩效果
shape: CircularNotchedRectangle(),
//child: tabs(),
//阴影效果
elevation: 20,
//圆弧弧度
notchMargin: 15,
child:tabs(),
),
height: 70,
)
);
简单说一下,这里面:
body
也就是主页面,list 集合中包含我们事先定义好的页面,点击按钮,修改 _index 的值,也就相当于切换了页面。
floatingActionButton
这里 fab 图标使用了 icon,当然也能使用其他的 widget,如 image ,或者 svg 等。
floatingActionButtonLocation
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked 这个表示 Fab 的样式,centerDocked 就是表示居中陷入的样式,还有其他的样式,如右下角显示等,这里可以自行尝试。
bottomNavigationBar
bottomNavigationBar 也就是底部导航,这里我指定了一个函数 tabs(),返回底部 widget :
Row tabs() {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
new Container(
child: IconButton(
icon: Icon(Icons.home),
color:_index == 0? Colors.blue:Colors.orangeAccent,
onPressed: () {
setState(() {
_index = 0;
});
},
),
),
IconButton(
icon: Icon(Icons.person),
color:_index == 1? Colors.blue:Colors.orangeAccent,
onPressed: () {
setState(() {
_index = 1;
});
},
),
],
);
}
这里的 tabs 函数直接返回了行布局,也就是我们看到的底部的两个 tab 按钮,这里的 按钮用 Icon 来实现,当然也能用其他的 widget 来实现,如在用一个列布局,上面是图标,下面是文字等。
每个 tab 的 onPressed 中监听点击事件,当点击是修改 -index 的值,通过 setState 来刷新,这样就达到了切换页面的效果,同事通过对 _index 的判断修改点击时的颜色。
最后,完整的代码:
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_widget/home_page.dart';
import 'package:flutter_widget/my_page.dart';
void main() {
if (Platform.isAndroid) {
/**
* 设置状态栏颜色
*/
SystemUiOverlayStyle systemUiOverlayStyle = SystemUiOverlayStyle(statusBarColor: Colors.transparent);
SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);
/**
* 强制竖屏
*/
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown
]);
}
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Widget',
theme: ThemeData(
primarySwatch: Colors.blue,
primaryColor: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State {
//主页 tab 索引
int _index = 0;
Color _tabColor = Colors.blue;
@override
void initState() {
super.initState();
list..add(new HomePage())..add(new MyPage());
}
List list = new List();
@override
Widget build(BuildContext context) {
Row tabs() {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
new Container(
child: IconButton(
icon: Icon(Icons.home),
color:_index == 0? Colors.blue:Colors.orangeAccent,
onPressed: () {
setState(() {
_index = 0;
});
},
),
),
IconButton(
icon: Icon(Icons.person),
color:_index == 1? Colors.blue:Colors.orangeAccent,
onPressed: () {
setState(() {
_index = 1;
});
},
),
],
);
}
return Scaffold(
backgroundColor: Colors.white,
body: list[_index],
floatingActionButton:
Container(
width: 50,
height: 50,
child:
FloatingActionButton(
heroTag: "main_fab",
isExtended:true,
onPressed: () {
print("to do sth");
},
//图标颜色
elevation: 8,
highlightElevation: 5,
child: Icon(Icons.camera),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar:
Container(
child:BottomAppBar(
//这个 shape 是 底部导航的压缩效果
shape: CircularNotchedRectangle(),
//child: tabs(),
//阴影效果
elevation: 20,
//圆弧弧度
notchMargin: 15,
child:tabs(),
),
height: 70,
)
);
}
}
三、界面布局
主页布局
主页的的整体结构:
class HomePageState extends State {
List subjects = [];
@override
void initState() {
loadData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("当前热映电影"),
),
body: Center(
child: getBody(),
),
);
}
}
initState() 去加载数据,这里是通过 dio 去请求数据的。
body 中根据数据构造布局。
loadData
loadData() async {
String loadRUL = "https://douban.uieee.com/v2/movie/in_theaters";
try {
Response response = await Dio().get(loadRUL);
print(response);
var result = json.decode(response.toString());
setState(() {
subjects = result['subjects'];
});
} catch (e) {
print(e);
}
}
loadData() 会通过 Dio 请求数据,返回结果保存到 subjects 集合中。
getBody:
getBody() {
if (subjects.length != 0) {
return
ListView.builder(
itemCount: subjects.length,
itemBuilder: (BuildContext context, int position) {
return getItem(subjects[position]);
});
} else {
///这个是 ios 风格的加载菊花。
return CupertinoActivityIndicator();
}
}
这里面返回了 listView,通过 getItem 返回每一项布局
getItem
getItem(var subject) {
// 演员列表
var avatars = List.generate(subject['casts'].length, (int index) =>
Container(
margin: EdgeInsets.only(left: index.toDouble() == 0.0 ? 0.0 : 16.0),
child: CircleAvatar(
backgroundColor: Colors.white10,
backgroundImage: NetworkImage(
subject['casts'][index]['avatars']['small']
)
),
),
);
var row = Container(
margin: EdgeInsets.all(4.0),
child:
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4.0),
child: Image.network(
subject['images']['large'],
width: 100.0, height: 150.0,
fit: BoxFit.fill,
),
),
Expanded(
child:
Stack(
children: [
Container(
margin: EdgeInsets.only(left: 8.0),
height: 150.0,
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
subject['title'],
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
maxLines: 1,
),
Text(
"类型:${subject['genres'].join("、")}"
),
Text(
'导演:${subject['directors'][0]['name']}'
),
Container(
margin: EdgeInsets.only(top: 8.0),
child: Row(
children: [
Text('主演:'),
Row(
children: avatars,
)
],
),
)
],
),
),
Positioned(
top: 15,
right: 2,
child: new Text(
'${subject['rating']['average']} 分',
style: TextStyle(
fontFamily: 'GloriaHallelujah',
color: Colors.redAccent,
fontSize: 16.0
),
),
)
],
),
)
],
),
);
return InkWell(
child: Card(
child: row,
),
onTap: (){
Navigator.push(context,
PageRouteBuilder(
transitionDuration: Duration(microseconds: 100),
pageBuilder: (BuildContext context, Animation animation,
Animation secondaryAnimation) {
return new FadeTransition(
opacity: animation,
child: DetailPage(id: subject['id'].toString(),),
);
})
);
},
);
}
getItem 里是每一项的布局,根布局是一个 InkWell,这个 widget 有水波纹效果,同时能够响应点击事件,跳转到详情页面。跳转路由里面重写了 PageRouteBuilder,可以自定义过度动画。在跳转到 DetailPage 的时候,传递了一个参数 id 过去。
InkWell 中指定子 widget 为 row,也就是行布局,可以看到每个 item 是一个行布局,左边的封面,右边的三行文字,同时还有一个分数,这里的分数通过 Positioned 来定位,当然就需要 Stack 了。
完整代码:
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_widget/detail_page.dart';
import 'package:dio/dio.dart';
class HomePage extends StatefulWidget {
@override
State createState() => HomePageState();
}
class HomePageState extends State {
List subjects = [];
@override
void initState() {
loadData();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("当前热映电影"),
),
body: Center(
child: getBody(),
),
);
}
loadData() async {
String loadRUL = "https://douban.uieee.com/v2/movie/in_theaters";
try {
Response response = await Dio().get(loadRUL);
print(response);
var result = json.decode(response.toString());
setState(() {
subjects = result['subjects'];
});
} catch (e) {
print(e);
}
}
getItem(var subject) {
// 演员列表
var avatars = List.generate(subject['casts'].length, (int index) =>
Container(
margin: EdgeInsets.only(left: index.toDouble() == 0.0 ? 0.0 : 16.0),
child: CircleAvatar(
backgroundColor: Colors.white10,
backgroundImage: NetworkImage(
subject['casts'][index]['avatars']['small']
)
),
),
);
var row = Container(
margin: EdgeInsets.all(4.0),
child:
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4.0),
child: Image.network(
subject['images']['large'],
width: 100.0, height: 150.0,
fit: BoxFit.fill,
),
),
Expanded(
child:
Stack(
children: [
Container(
margin: EdgeInsets.only(left: 8.0),
height: 150.0,
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
subject['title'],
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
maxLines: 1,
),
Text(
"类型:${subject['genres'].join("、")}"
),
Text(
'导演:${subject['directors'][0]['name']}'
),
Container(
margin: EdgeInsets.only(top: 8.0),
child: Row(
children: [
Text('主演:'),
Row(
children: avatars,
)
],
),
)
],
),
),
Positioned(
top: 15,
right: 2,
child: new Text(
'${subject['rating']['average']} 分',
style: TextStyle(
fontFamily: 'GloriaHallelujah',
color: Colors.redAccent,
fontSize: 16.0
),
),
)
],
),
)
],
),
);
return InkWell(
child: Card(
child: row,
),
onTap: (){
Navigator.push(context,
PageRouteBuilder(
transitionDuration: Duration(microseconds: 100),
pageBuilder: (BuildContext context, Animation animation,
Animation secondaryAnimation) {
return new FadeTransition(
opacity: animation,
child: DetailPage(id: subject['id'].toString(),),
);
})
);
},
);
}
getBody() {
if (subjects.length != 0) {
return
ListView.builder(
itemCount: subjects.length,
itemBuilder: (BuildContext context, int position) {
return getItem(subjects[position]);
});
} else {
///这个是 ios 风格的加载菊花。
return CupertinoActivityIndicator();
}
}
}
详情页
详情页比较简单,代码如下:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
class DetailPage extends StatefulWidget {
String id;
DetailPage({this.id});
@override
State createState() => DetailPageState();
}
class DetailPageState extends State {
///movie id
String movieId = "";
///封面图片 url
String imageUrl = "";
///豆瓣评分
String score = "";
///简介
String summary = "";
///电影名称
String alt_titile = "";
@override
void initState() {
movieId = widget.id;
initMovieData();
}
initMovieData() async {
///电影详情地址
String movieDetail = "https://douban.uieee.com/v2/movie/$movieId";
try {
Response response2 = await Dio().get(movieDetail);
var result = json.decode(response2.toString());
score = result['rating']['average'];
alt_titile = result['alt_title'];
imageUrl = result['image'];
summary = result['summary'];
setState(() {
});
} catch (e) {
print(e);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
primary:false,
slivers: [
SliverAppBar(
automaticallyImplyLeading:false,
pinned: true,
expandedHeight:150,
flexibleSpace: FlexibleSpaceBar(
titlePadding: EdgeInsets.only(left:40,top: 0,bottom: 30),
background:
Image.network(
imageUrl, fit: BoxFit.cover,)
),
),
new SliverFixedExtentList(
itemExtent: 50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new Container(
color: Colors.white,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 25),
child: Text("$alt_titile",),
);
},
childCount: 1 //50个列表项
),
),
new SliverFixedExtentList(
itemExtent:10,
delegate: new SliverChildBuilderDelegate((BuildContext context, int index) {
return new Container(
alignment: Alignment.center,
);
},
childCount: 1
),
),
new SliverFixedExtentList(
itemExtent:50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
return new Container(
color: Colors.white,
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 25),
child: Text("豆瓣评分:$score",),
);
},
childCount: 1 //50个列表项
),
),
new SliverFixedExtentList(
itemExtent:10,
delegate: new SliverChildBuilderDelegate((BuildContext context, int index) {
return new Container(
alignment: Alignment.center,
);
},
childCount: 1
),
),
new SliverFixedExtentList(
itemExtent:250,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
return new Container(
padding: EdgeInsets.symmetric(horizontal: 25),
color: Colors.white,
child: new ListView(
children: [
Text("简介:$summary"),
],
),
);
},
childCount: 1 //50个列表项
),
),
],
),
);
}
}
详情界面里面,用到了头部SliverAppBar。
SliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollView。SliverAppBar可以结合FlexibleSpaceBar实现Material Design中头部伸缩的模型。为了使用 SliverAppBar,因此 body 根布局指定为 CustomScrollView。
我为了显示一个分割的效果,这个分割条我也用 SliverFixedExtentList 来实现。
我的界面
我的界面同详情页差不多。
import 'package:flutter/material.dart';
class MyPage extends StatefulWidget {
@override
State createState() => MyPageState();
}
class MyPageState extends State {
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
primary:false,
slivers: [
SliverAppBar(
automaticallyImplyLeading:false,
pinned: true,
expandedHeight:150,
flexibleSpace: FlexibleSpaceBar(
title: Container(
margin: EdgeInsets.only(top: 80),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
child:
ClipOval(
child: Image.asset("images/avatar.jpg",fit: BoxFit.cover,))),
Container(
child: new Text("个人中心", ),
),
],
),
),
centerTitle: true,
background: Image.asset(
"images/bg.jpg", fit: BoxFit.cover,),
),
),
new SliverFixedExtentList(
itemExtent: 50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.only(left: 25),
child: Text("简介:介绍一下自己吧~",),
);
},
childCount: 1 //50个列表项
),
),
new SliverFixedExtentList(
itemExtent:10,
delegate: new SliverChildBuilderDelegate((BuildContext context, int index) {
return new Container(
alignment: Alignment.center,
child: new Text(''),
);
},
childCount: 1
),
),
new SliverFixedExtentList(
itemExtent:50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new InkWell(
child:
new Container(
alignment: Alignment.centerLeft,
child:Row(
children: [
Expanded(
child: Row(
children: [
Container(
margin: EdgeInsets.only(left: 25),
child: Icon(Icons.settings,color: Colors.blue,),
),
Container(
margin: EdgeInsets.only(left: 10),
child: Text("设置",style: TextStyle(color: Colors.blue),),
),
],
),
flex: 1,
),
Expanded(
child: Container(
width: 20,
height:20,
alignment: Alignment.centerRight,
margin: EdgeInsets.only(right: 5),
child:Icon(Icons.arrow_forward_ios,color:Colors.blue),
),
flex: 1,
),
],
),
),
);
},
childCount: 1 //50个列表项
),
),
new SliverFixedExtentList(
itemExtent: 4,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new Container(
alignment: Alignment.center,
child: new Text(''),
);
},
childCount: 1 //50个列表项
),
),
///分享
new SliverFixedExtentList(
itemExtent: 50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new InkWell(
child: new Container(
alignment: Alignment.centerLeft,
child:Row(
children: [
Expanded(
child: Row(
children: [
Container(
margin: EdgeInsets.only(left: 25),
child: Icon(Icons.share,color: Colors.blue,),
),
Container(
margin: EdgeInsets.only(left: 10),
child: Text("分享",style: TextStyle(color: Colors.blue),),
),
],
),
flex: 1,
),
Expanded(
child: Container(
width: 20,
height:20,
alignment: Alignment.centerRight,
margin: EdgeInsets.only(right: 5),
child:Icon(Icons.arrow_forward_ios,color:Colors.blue),
),
flex: 1,
),
],
),
),
);
},
childCount: 1 //50个列表项
),
),
new SliverFixedExtentList(
itemExtent: 4,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
//创建列表项
return new Container(
alignment: Alignment.center,
child: new Text(''),
);
},
childCount: 1 //50个列表项
),
),
///关于
new SliverFixedExtentList(
itemExtent: 50,
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
return new InkWell(
child: new Container(
alignment: Alignment.centerLeft,
child:Row(
children: [
Expanded(
child: Row(
children: [
Container(
margin: EdgeInsets.only(left: 25),
child: Icon(Icons.insert_drive_file,color: Colors.blue,),
),
Container(
margin: EdgeInsets.only(left: 10),
child: Text("关于",style: TextStyle(color: Colors.blue),),
),
],
),
flex: 1,
),
Expanded(
child: Container(
width: 20,
height: 20,
alignment: Alignment.centerRight,
margin: EdgeInsets.only(right: 5),
child:Icon(Icons.arrow_forward_ios,color:Colors.blue),
),
flex: 1,
),
],
),
),
);
},
childCount: 1 //50个列表项
),
),
],
),
);
}
}
SliverAppBar 里面的头像和名字和用 title 来实现,圆形头像用 ClipOval。
四、总结
总体来说,使用 Flutter 实现界面布局还是很容易的,实现复杂布局也不是很难,主要就是 widget 的组合嵌套等,就是代码可能显着乱一些。
实例地址:github