FlutterDemoApp实践

98 阅读5分钟

一.效果展示

湖人的紫金配色 image.png

二.前置知识点

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.热门模块

  1. 自定义appbar中得title,并使用封装模块,支持搜索回调
  2. 使用tabbar和tabView支持顶部切换tab
  3. 自定义banner,支持循环播放,滑动切换、底部显示
  4. 使用第三方组件StaggeredGridView行列区分
  5. 自定义卡片显示

3.2.推荐模块

1.使用第三方组件StaggeredGridView瀑布是显示

4.个人模块

1.NestedScrollView和SliverAppBar结合使用

2.卡片式布局

5.数据来源

1.图片来源于网络

2.json数据本地自己组装

本次代码仅仅是作为纯练手项目,旨在掌握flutter一些组件的使用与简单的封装抽离。

下一版本准备:

1.网络模块 需封装成组件类型

2.路由模块 单独封装

3.使用数据库开发收藏模块

4.增加视频播放与弹幕功能