一.效果展示
湖人的紫金配色
二.前置知识点
1.Dart基本语法知识
2.Flutter中一切皆组件
3.常用的组件和自定义组件
4.路由跳转
5.数据存储
6.Future异步调用
三.技术要点
1.登录模块
我们在主界面会有个登录检测,如果未登录会进入到登录界面,登录过则直接进入主界面,逻辑很简单,我们看看代码:
Future<bool> checkLoginStatus() async {
if (kDebugMode) {
print("checkLoginStatus start ");
}
bool isLoggedIn = await SpUtil.getBool(Const.loginFlag) ?? false;
await Future.delayed(const Duration(seconds: 1));//至少1秒钟
if (isLoggedIn) {
// 如果已登录,跳转到主页
if (kDebugMode) {
print("has login");
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const MainPage()));
} else {
// 如果未登录,跳转到登录页
if (kDebugMode) {
print("has no login");
}
Navigator.pushReplacement(
context, MaterialPageRoute(builder: (context) => const LoginPage()));
}
return isLoggedIn;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: FutureBuilder<bool>(
future: checkLoginStatus(), // 使用异步操作的结果
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
// 等待异步操作完成时显示加载指示器
print('wait----');
return const Center(child: CircularProgressIndicator());
} else {
print('end----');
// 异步操作完成后,根据结果显示相应页面
return Container(); // 不需要额外显示内容,因为页面已经跳转
}
},
),
);
}
登录部分的UI也很简单,有两个TextFormField输入框和一个ElevatedButton组成的单独的登录的页面,登录成功后会跳转到主界面,并且保存账号到SP中去,我们看下登录的逻辑代码:
void _submitForm() {
if (_formKey.currentState!.validate()) {
// 在这里处理登录逻辑,例如发送请求到服务器验证用户名和密码
print('用户名: $_username');
print('密码: $_password');
//_formKey.currentState!.save();//会保存用户名和密码并触发TextFormField.onSaved方法
if(_username=="123"&&_password=="123"){
SpUtil.setString(Const.userName, _username);
SpUtil.setString(Const.passWord, _password);
SpUtil.setBool(Const.loginFlag, true);
showToast("登录成功!");
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const MainPage()));
}else{
showToast("登陆失败!账号或密码不正确!");
}
}
}
void showToast(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: <Widget>[
TextFormField(
decoration: const InputDecoration(labelText: '用户名'),
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入用户名';
}
_username=value;
return null;
},
onSaved: (value) {
_username = value!;
print('save userName:$_username');
} ,
),
TextFormField(
decoration: const InputDecoration(labelText: '密码'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入密码';
}
_password=value;
return null;
},
onSaved: (value) => _password = value!,
),
const SizedBox(height: 16.0),
ElevatedButton(
onPressed: _submitForm,
child: const Text('登录'),
),
],
),
),
);
}
2.首页模块
1.首页-底部
使用BottomNavigation承载首页、热图、收藏、个人部分,代码展示:
class MainPageState extends StatefulWidget {
const MainPageState({super.key});
@override
State<MainPageState> createState() => _MainPageState();
}
class _MainPageState extends State<MainPageState> {
int _currentIndex = 0;
static const List<Widget> _widgetOptions = <Widget>[
HomePage(),
RankingPage(),
CollectPage(),
MinePage()
];
void _onItemTapped(int index) {
setState(() {
_currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return MyWillPopScope(
child:Scaffold(
body: Center(
child: _widgetOptions.elementAt(_currentIndex),
),
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '首页',
),
BottomNavigationBarItem(
icon: Icon(Icons.whatshot),
label: '热图',
),
BottomNavigationBarItem(
icon: Icon(Icons.favorite),
label: '收藏',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '个人',
),
],
currentIndex: _currentIndex,
selectedItemColor: Const.themeBottomColor,
unselectedItemColor: Colors.grey,
onTap: _onItemTapped,
showSelectedLabels: true,
showUnselectedLabels: true,
)),);
}
可以看到,当我们切换到首页时,会加载到HomePage中去,它其实就是Home中的热门。
2.首页-热门
那这个热门主要是有三个部分组成,即:顶部的搜索框、中间的TabBar,底部的StaggeredGridView,并且StaggeredGridView加载第一行时使用了自定义的轮播图组件,轮播图下方的使用了2列的自定义的视频组件列表,那这些数据的来源是从哪里来的呢?轮播图我采用的是网络加载的图片,而2列的自定义视频组件,我使用的是本地的json文件,我们先来看下展示部分的代码:
///圆角搜索框
class RoundedSearchBar extends StatefulWidget {
final ValueChanged<String> onChangedValue;
RoundedSearchBar(this.onChangedValue, {super.key});
@override
_RoundedSearchBarState createState() =>
_RoundedSearchBarState(onChangedValue);
}
class _RoundedSearchBarState extends State<RoundedSearchBar> {
final TextEditingController _controller = TextEditingController();
final ValueChanged<String> onChangedValue;
_RoundedSearchBarState(this.onChangedValue);
@override
Widget build(BuildContext context) {
return SizedBox(
height: 35,
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: '搜索',
contentPadding: EdgeInsets.all(10.0),//居中
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(20.0)), // 设置圆角
),
filled: true,
fillColor: Colors.white.withOpacity(0.8),
),
onChanged: onChangedValue,
onSubmitted: onChangedValue,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class _HotPageState extends State<HotPage> {
List<HomeRecommend>? data = [];
final String title;
_HotPageState(this.title);
@override
void initState() {
_loadData();
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: StaggeredGridView.countBuilder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 5, left: 5, right: 5),
crossAxisCount: 2,
// 假设我们有2列
itemCount: (data?.length)!+1,
itemBuilder: (BuildContext context, int index) {
if (index == 0) {
//第一行使用轮播图 自定义带指示器的
return Padding(
padding: EdgeInsets.only(bottom: 10),
child: CarouselWidget((value) {
if (kDebugMode) {
print('value:$value');
}
}, imageUrls: const [
"https://i2.hdslb.com/bfs/archive/d537ecb2cc1271e5098e3e79c5133e54326608c1.jpg",
"https://k.sinaimg.cn/www/dy/slidenews/2_img/2010_30/786_132505_247943.jpg/w640slw.jpg",
"https://i0.hdslb.com/bfs/archive/a12e17f2a31b5c97b8bc4c36a7170f1694c1e2c1.jpg"
], needDots: true));
} else {
return
//使用本地加载的json资源
VideoCard(model: data?[index-1]);
// Card(
// color: Colors.green,
// child: Center(child: Text('Item $index')),
// );
}
},
staggeredTileBuilder: (int index) {
return StaggeredTile.count(index == 0 ? 2 : 1,
index == 0 ? 1 : 1); //第一个参数是横轴所占的单元数,第二个参数是主轴所占的单元数
}),
));
}
_loadData() async {
var homeRecommendResp = await HomeDao.loadLocalRecommend();//本地数据
setState(() {
if (mounted) {
//合并
data = homeRecommendResp?.data;
print('_loadData:$data');
}
});
}
}
3.热图模块
这个模块也很简单,使用StraggerGridView和自定义CacheImageView加载了一些网络图片,并且按照奇数还是偶数来显示主轴方向所占行数:
class _RankingPageState extends State<RankingPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Const.themeColor,
centerTitle: true,
title: const Text('热图'),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: StaggeredGridView.countBuilder(
crossAxisCount: 2, // 假设我们有2列
itemCount: Const.rangkingData.length,
itemBuilder: (BuildContext context, int index) => Card(
color: Colors.green,
child: cachedImage(Const.rangkingData[index])
),
staggeredTileBuilder: (int index) =>
StaggeredTile.count(1, index.isEven ? 2 : 1), //奇数主轴占1格 偶数主轴占2格
),
),
);
}
}
4.个人模块
这模块有点特殊效果,如向下滚动收缩,向上滚动还原。这个是借助于NestedScrollView和SliverAppBar来实现的,然后NestedScrollView的body用ListView承载了三段,分别是Banner轮播图、专区推荐组件和扩展区组件。代码也很简单,我们来看下主线代码:
class MinePage extends StatefulWidget {
const MinePage({super.key});
@override
State<MinePage> createState() => _MinePageState();
}
class _MinePageState extends State<MinePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
// appBar: AppBar(
// backgroundColor: Const.themeColor,
// centerTitle: true,
// title: const Text('我的'),
// ),
body: ProfilePage());
}
}
///我的
class ProfilePage extends StatefulWidget {
@override
_ProfilePageState createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage>
with AutomaticKeepAliveClientMixin {
final ProfileMo _profileMo = ProfileMo.fromJson(profiles);
ScrollController _controller = ScrollController();
static const double MAX_BOTTOM = 40;
static const double MIN_BOTTOM = 10;
//滚动范围
static const MAX_OFFSET = 80;
double _dyBottom = MAX_BOTTOM;
@override
Widget build(BuildContext context) {
super.build(context);
return NestedScrollView(
controller: _controller,
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[_buildAppBar()]; //可滚动区域 呈现收缩和展开的效果
},
body: ListView(
padding: const EdgeInsets.only(top: 10),
children: [..._buildContentList()],//滚动区域下面
),
);
}
_buildHead() {
if (_profileMo == null) return Container();
return FlexibleHeader(
name: _profileMo.name, face: _profileMo.face, controller: _controller);
}
@override
bool get wantKeepAlive => true;
_buildAppBar() {
return SliverAppBar(
//扩展高度
expandedHeight: 160,
backgroundColor: Const.themeColor,
//标题栏是否固定
pinned: true,
//定义股东空间 展开时显示
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
titlePadding: EdgeInsets.only(left: 0),
title: _buildHead(),
background: Stack(
children: [
Positioned.fill(
child: cachedImage(
'https://www.devio.org/img/beauty_camera/beauty_camera4.jpg')),
const Positioned.fill(child: Blur(sigma: 20)),
Positioned(bottom: 0, left: 0, right: 0, child: _buildProfileTab())
],
),
),
);
}
_buildContentList() {
if (_profileMo == null) {
return [];
}
return [ _buildBanner(), CourseCard(courseList: _profileMo.courseList), BenefitCard(benefitList: _profileMo.benefitList), //DarkModelItem() ];
}
_buildBanner() {
List<String> bannerUrls =
_profileMo.bannerList.map((value) => value.url).toList();
return SizedBox(
height: 140,
child: CarouselWidget((value) {
if (kDebugMode) {
print('value:$value');
}
}, imageUrls: bannerUrls, needDots: true));
}
_buildProfileTab() {
if (_profileMo == null) return Container();
return Container(
padding: const EdgeInsets.only(top: 5, bottom: 5),
decoration: const BoxDecoration(color: Colors.white54),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildIconText('收藏', _profileMo.favorite),
_buildIconText('点赞', _profileMo.like),
_buildIconText('浏览', _profileMo.browsing),
_buildIconText('金币', _profileMo!.coin),
_buildIconText('粉丝', _profileMo.fans),
],
),
);
}
_buildIconText(String text, int count) {
return Column(
children: [
Text('$count',
style: const TextStyle(fontSize: 15, color: Colors.black87)),
Text(text, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
}
5.返回实现
我们知道在Flutter中一切皆组件,所以返回的实现也是个组件,我们在Main组件中外层包裹了一个 MyWillPopScope组件实现了两次返回退出的提示效果,我们来看下代码:
class MyWillPopScope extends StatefulWidget {
final Widget child;
final bool disableDoubleBackPress;
const MyWillPopScope({
Key? key,
required this.child,
this.disableDoubleBackPress = false,
}) : super(key: key);
@override
State<StatefulWidget> createState() {
return MyWillPopScopeState();
}
}
class MyWillPopScopeState extends State<MyWillPopScope> {
DateTime? _lastBackPressTime;
static const Duration _timeLimit = Duration(seconds: 2);
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: _onWillPop,
child: widget.child,
);
}
Future<bool> _onWillPop() async {
if (widget.disableDoubleBackPress) {
// 如果禁用了双重点击退出的逻辑,则直接允许退出
return Future.value(true);
} else {
DateTime now = DateTime.now();
if (_lastBackPressTime == null ||
now.difference(_lastBackPressTime!) > _timeLimit) {
// 第一次按返回键,提示用户再次按返回键退出
_lastBackPressTime = now;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('再按一次返回键退出应用'),
duration: Duration(seconds: 2),
),
);
return Future.value(false); // 阻止默认返回行为
}
// 第二次按返回键,允许退出
return Future.value(true);
}
}
}
四.总结与展望
1.登录模块
1.检测是否登录,未登录跳转登录
2.登录过直接跳转至主页
3.使用futureBuild来解决耗时卡顿问题
2.主界面模块
1.返回键得拦截与封装
2.在Scaffold 使用bottomNavigationBar构建底部导航栏
3.统一主题色到const中
3.首页模块
3.1.热门模块
- 自定义appbar中得title,并使用封装模块,支持搜索回调
- 使用tabbar和tabView支持顶部切换tab
- 自定义banner,支持循环播放,滑动切换、底部显示
- 使用第三方组件StaggeredGridView行列区分
- 自定义卡片显示
3.2.推荐模块
1.使用第三方组件StaggeredGridView瀑布是显示
4.个人模块
1.NestedScrollView和SliverAppBar结合使用
2.卡片式布局
5.数据来源
1.图片来源于网络
2.json数据本地自己组装
本次代码仅仅是作为纯练手项目,旨在掌握flutter一些组件的使用与简单的封装抽离。
下一版本准备:
1.网络模块 需封装成组件类型
2.路由模块 单独封装
3.使用数据库开发收藏模块
4.增加视频播放与弹幕功能