🔥 三军未动粮草先行!上节《六、项目实战-非UI部分》带着兄弟们,把实战过程中可能会遇到的知识点进行了预研,涉及:网络恳求、Json序列化和反序列化、路由跳转、数据同享等内容。

😏所以,本节以放心 写UI (堆组件) 啦,因为是边写项目边写文章,许多地方写得不太好或许不对,但应该也会对读者的Flutter学习有所裨益。后续还会进行打磨,终究以库房 coder-pig/flutter_wanandroid 里的代码为准。😁 用到的接口源地址:玩Android API,话不多说,直接开端~

1. 图标

1.1. 自界说App图

《三、纯Flutter项目打包 & 混合开发[Android]》有提过这一点了,主张直接运用Flutter插件 flutter_launcher_icons主动处理一切渠道的图标生成和替换,需求一张至少 512×512 像素的 图标源图!!!翻开 pubspec.yaml 引证插件,并指定 源图生成的图标名

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_launcher_icons: ^0.13.1	# 图标生成插件
flutter_icons:
  image_path: "assets/images/icon.png"
  android: "ic_launcher"	# 指定生成的图标名
  ios: true	# iOS是否也生成图标

保存后,履行下述指令: 增加插件依靠生成并替换图标

flutter pub get
flutter pub run flutter_launcher_icons

即可完结图标替换,接着,顺手修正下 APP称号,定位到 android/app/src/main/AndroidManifest.xml 文件,修正 android:label 标签的值为你的运用称号,也支撑经过 strings.xml 索引字符串资源的写法:

<application
  android:name=".MainActivity"
  android:label="@string/app_name"
  android:icon="@mipmap/ic_launcher">
  ...
</application>

都修正完,运转看看作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

😄nice~

1.2. 内置字体图标

Flutter 默许内置一套 Material Design的字体图标,详细有哪些能够到 官方文档Google Fonts 中进行检索,支撑两种引证办法:Icons.xxxCode Point(码点) ,运用代码示例如下:

Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      // 经过Icons来引证
      Icon(Icons.block, size: 36),
      // 经过码点来引证
      Text('ue8b6 ue87d ue885',style: TextStyle(
        fontFamily: "MaterialIcons",
        fontSize: 28.0,
        color: Colors.blue,
      ),)
    ],
)

运转作用如下:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

Tips:字体图标对应的 码点主张以官方文档为准!!!比方 favorite_outlined 两者对应的码点值如下 (这也是为啥第二个图标是🚘不是♥):

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

1.3. 自界说字体图标

Flutter中,字体图标 比较 图片 的优势:

体积更小,矢量图(扩大不会影响清晰度)、能够运用文本款式(色彩、对齐等)、能够经过TextSpan和文本混用。

假如内置的字体图标满足不了需求,能够进行自界说,在Flutter中能够运用 ttf格局 的字体图标。iconfont.cn 上有许多字体图标资料,输入查找要害字,找到喜欢的图标,点击 购物车图标 (不要直接点下载,只要SVG、AI和PNG格局),选完所需的图标,点击 右上角的购物车图标,然后点击 下载代码,下载完解压,找到里边的 ttf文件,仿制到Flutter项目的 assets/fonts 目录下:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

修正 pubspec.yaml 文件增加字体图标:

fonts:
  - family: customIcon	# 指定字体名
    fonts:
      - asset: assets/fonts/iconfont.ttf

然后能够就经过 IconData 来引证咱们的自界说图标啦:

Icon(
  // 参数依次为:字体图标对应的16进制数字、字体名
  IconData(0xe6c2, fontFamily: 'customIcon'),
  size: 26,
  color: Colors.yellow,
)

运转作用图

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

字体图标对应的16进制值,能够翻开 iconfont.json 文件查看:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

每次运用图标都要查看这个 unicode码 还挺烦,能够塞到一个类里,将字体文件中的一切图标都界说成 静态变量,代码示例如下:

class CustomIcons {
  static const IconData xiao =  IconData(0xe6c2, fontFamily: 'customIcon');
}
// 运用
Icon(CustomIcons.xiao, size: 26,color: Colors.yellow,)

😁再安利两个图标东西站点:fluttericon.comfluttericon.cn

2. 自界说发动页

App发动Flutter榜首帧渲染结束前 需求一定的时刻,Flutter项目会默许装备一个简略的发动视图 (白色布景 + 居中的运用图标)。翻开android目录下的 styles.xml 文件,能够看到设置了一个发动主题:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

点开它指向的 launch_background.xml 文件:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

修正这两个item值即可完结自界说,需求在 不同分辨率mipmap 的文件夹下放一张发动图,还挺麻烦😒。

😜这儿直接用Flutter插件 flutter_native_splash 来快速设置,翻开 pubspec.yaml 文件引证插件,并 指定色彩及图片

dev_dependencies:
	flutter_native_splash: ^2.3.8
flutter_native_splash:
  color: "79B4EB"
  image: assets/images/icon.png
  android: true
  android_gravity: center
  ios: true
  android_12:
    icon_background_color: "79B4EB"
    image: assets/images/icon.png

保存后,履行下述指令: 增加插件依靠生成并装备发动页

flutter pub get
flutter pub run flutter_native_splash:create
# 假如想去掉自界说闪屏页,能够运用下述指令
# flutter pub run flutter_native_splash:remove

编译运转后翻开APP看看闪屏作用 (Android 12会裁剪图片中心的圆形部分):

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

静态发动页的可玩性不高,假如想搞些动效或许展现信息啥的,官方引荐在静态发动页后尽快显现一个 SplashScreen Widget,并在其间履行Flutter动画,简略代码示例如下:

// splash_screen.dart
import 'package:flutter/material.dart';
import 'dart:async';
class SplashScreen extends StatefulWidget {
  @override
  _SplashScreenState createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
  @override
  void initState() {
    super.initState();
    // 界说显现SplashScreen一段时刻之后的逻辑
    Timer(const Duration(seconds: 3), () {
      // Replace it with a function to navigate to your home screen
      // 如:Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen()));
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('Welcome to my App!', style: TextStyle(fontSize: 24.0)),
      ),
    );
  }
}

然后在 main.dart 中优先显现 SplashScreen:

// main.dart
import 'package:flutter/material.dart';
import 'splash_screen.dart'; // 保证引入了splash_screen.dart
// import 'home_screen.dart'; 假如有HomeScreen则需求引入
void main() {
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My Application',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SplashScreen(), // 设置SplashScreen为app的起始页面
      // routes: {
      //   '/home': (context) => HomeScreen(), 假如你有主页,能够界说路由
      // },
    );
  }
}

3. 主页草图

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

本文首要完结的三个页面:侧滑导航主页大众号,从左边的侧滑导航开端搞吧~

4. 侧滑导航页UI

4.1. Drawer (抽屉)

侧滑导航也叫 抽屉,Flutter 内置了一个 Drawer 组件来完结 从屏幕边际 (左右) 滑出来 展现一个导航菜单或其它内容。需求调配 Scaffold 运用,它的常用特点如下:

  • child:抽屉内容Widget,通常是一个 ListView,然后包括一个 DrawerHeader(抽屉头部)UserAccountsDrawerHeader(账户信息头部) ,及若干个 ListTile(菜单项) 拼接而成,当然,不喜欢也能够自己按需堆叠组件;
  • elevation:抽屉暗影巨细,以 z 轴高度表明;
  • shape:Drawer的边框形状,如设置圆角;
  • semanticLabel:描绘抽屉用途,无障碍用到;

简略写下UI:

import 'package:flutter/material.dart';
/// 侧滑页面
class DrawerScreen extends StatefulWidget {
  const DrawerScreen({super.key});
  @override
  State<StatefulWidget> createState() => _DrawerScreenState();
}
class _DrawerScreenState extends State<DrawerScreen> {
  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: ListView(
        padding: EdgeInsets.zero,
        children: const <Widget>[
          DrawerHeader(
            decoration: BoxDecoration(
              color: Color(0xFF5A78EA),
            ),
            child: Text(
              "Van ♂ Android",
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
              ),
            ),
          ),
          ListTile(
            leading: Icon(Icons.score),
            title: Text('我的积分'),
          ),
          ListTile(
            leading: Icon(Icons.settings),
            title: Text('体系设置'),
          ),
          ListTile(
            leading: Icon(Icons.logout),
            title: Text('退出登录'),
          ),
        ],
      ),
    );
  }
}

运转代码后,从左边划出抽屉看看作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

UI写完,接着要完善下逻辑,咱们当时的期望:

  • 翻开侧滑时:查询个人积分接口,假如处于登录改写我的积分,显现账户名和退出登录选项;
  • 假如处于未登录专题该,显现去登录文本,点击去登陆或我的积分,都跳转登录页;
  • 登陆完,回到此页面,再次查询积分接口,然后改写UI。

🤡 em…要登录,那得先写下登录页UI~

5. 登录页

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

页面比较简略,顶部一个 AppBar两个文本输入框 (用户名、暗码)、两个按钮 (登陆、去注册),AppBar前面介绍过了,这儿说下 文本输入按钮 用到的两个内置组件。

5.1. TextField (文本输入)

这儿用到 TextField 组件,它的常用特点如下:

  • controller: 操控TextField当时的文本,监听文本的更改,以及操控更杂乱的输入操作;
  • decoration: 装饰TextField外观的InputDecoration目标,能够设置边框、标签、提示文本等;
  • keyboardType: 用于设置键盘的类型,如文本、数字、电子邮件地址等;
  • textInputAction: 键盘上的操作按钮(通常是“下一步”或“完结”)的类型;
  • style: 用来界说输入文本的款式,如字体巨细、色彩、字重等;
  • textAlign: 输入文本的对齐办法,如左对齐、右对齐或居中;
  • autofocus: 是否在创立时主动获取焦点;
  • obscureText: 假如是暗码输入框,将此项设置为true能够躲藏暗码文本;
  • maxLength: 输入内容的最大长度;
  • onChange: 当文本内容改动时调用的回调函数;
  • onSubmitted: 用户在软键盘上按下“提交”按钮时调用的回调函数;
  • onEditingComplete:输入完结时调用的回调函数;
  • enabled: 界说TextField是否可编辑;
  • cursorColor: 光标的色彩;
  • cursorRadius: 光标的圆角;
  • cursorWidth: 光标的厚度;
  • minLinesmaxLines:最小/最大行数;

5.2. MaterialButton

按钮的话,用到 MaterialButton 组件,它的常用特点如下:

  • onPressed: 按钮点击时的回调函数。假如为null,则按钮会被禁用;
  • onLongPress: 长按按钮时的回调函数;
  • child: 通常是一个Widget,比方Text或Icon,显现在按钮中,它能够是恣意的Widget树;
  • elevation: 操控按钮在其下方显现的暗影巨细。通常用于指示按钮是否被按下;
  • padding: 按钮内部的空白区域,详细操控能够经过EdgeInsets类来完结;
  • color: 按钮的布景色彩;
  • disabledColor: 按钮被禁用时的布景色彩;
  • textColor: 文本色彩;
  • disabledTextColor: 按钮被禁用时的文本色彩;
  • splashColor: 水波纹作用的色彩,当用户点击按钮时显现;
  • highlightColor: 按钮按下时的布景色彩;
  • highlightElevation: 按钮被按下时的暗影巨细;

5.3. 编写登录页UI

知道组件特点后,写页面就水到渠成了,还要加点逻辑:

点击登录判断用户名和暗码是否为空,不为空才履行登录逻辑,不然弹出提示信息。

详细的完结代码如下:

import 'package:flutter/material.dart';
import 'package:flutter_wanandroid/ui/register_screen.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../res/colors.dart';
/// 登录页面
class LoginScreen extends StatefulWidget {
  const LoginScreen({super.key});
  @override
  State<StatefulWidget> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
  final TextEditingController _usernameController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  void _login() {
    // 登录校验逻辑
    final username = _usernameController.text;
    final password = _passwordController.text;
    if (username.isNotEmpty && password.isNotEmpty) {
      // 在主张登录恳求
      Fluttertoast.showToast(msg: "当时登录的用户名:$username → 暗码:$password");
    } else {
      Fluttertoast.showToast(msg: "用户名或暗码不能为空");
    }
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('登录页', style: TextStyle(color: Colors.white)),
        backgroundColor: MyColors.leiMuBlue,
        iconTheme: const IconThemeData(color: Colors.white),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            TextField(
              controller: _usernameController,
              decoration: const InputDecoration(
                labelText: '用户名',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 20.0),
            TextField(
              controller: _passwordController,
              decoration: const InputDecoration(
                labelText: '暗码',
                border: OutlineInputBorder(),
              ),
              obscureText: true,
            ),
            const SizedBox(height: 20.0),
            MaterialButton(
              onPressed: _login,
              color: MyColors.leiMuBlue,
              padding: const EdgeInsets.symmetric(vertical: 16.0),
              child: const Text('登录', style: TextStyle(color: Colors.white)),
            ),
            const SizedBox(height: 12.0),
            GestureDetector(
                child: Container(
                  alignment: Alignment.center,
                  padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 12.0),
                  child: const Text("去注册", style: TextStyle(color: Colors.grey)),
                ),
                onTap: () {
                  // 跳转注册页
                  Navigator.push(context, MaterialPageRoute(builder: (context) {
                    return const RegisterScreen();
                  }));
                })
          ],
        ),
      ),
    );
  }
}

5.4. Toast (提示)

上面咱们用 Toast(吐司提示) 来展现提示信息,但Flutter并没有内置这样的组件,这儿用到三方库 fluttertoast。直接履行 flutter pub add fluttertoast 增加依靠,然后调用 Fluttertoast.showToast(msg) 就能显现Toast了,但运转时可能会报错:

uses-sdk:minSdkVersion 19 cannot be smaller than version 21 declared in library [:fluttertoast] D:CodeFlutterflutter_wanandroidbuildfluttertoastintermediatesmerged_manifestdebugAndroidManifest.xml as the library might be using APIs not available in 19

问题概述:运用 fluttertoast,App的minSdkVersion需求为21或以上版别。

解决办法:翻开 android/app/build.gradle 文件,把 minSdkVersion 的值从 flutter.minSdkVersion 改为21或以上版别:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

然后它会调用体系的Toast,不同的体系版别,可能会有不同的款式差异,比方我两台手机的Toast就不一样:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

假如想保证不同体系上都显现 一致的Toast款式,能够运用另一个 支撑自界说Toast 的三方库:another_flushbar。另外,🙊一般为了 便利一致调用,通常会封装一个主张的东西类:

import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
/// Toast东西类
/// Android 11 或以上的版别只要[msg]和[toastLength]特点会收效,其它特点会被忽略
class ToastUtil {
  static void show({
    required String msg,
    Toast toastLength = Toast.LENGTH_SHORT,
    ToastGravity gravity = ToastGravity.BOTTOM,
    Color backgroundColor = Colors.black54,
    Color textColor = Colors.white,
    double fontSize = 16.0,
  }) {
    Fluttertoast.showToast(
        msg: msg,
        toastLength: toastLength,
        gravity: gravity,
        backgroundColor: backgroundColor,
        textColor: textColor,
        fontSize: fontSize);
  }
}

照葫芦画瓢,顺手把注册页UI也画出来:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

页面写完,接着就该折腾:调用查询积分接口数据解析页面改写 的逻辑了,主张下温习下《六、项目实战-非UI部分🤷‍♂️》再往下阅览~

5.5. 封装两个支撑泛型的呼应基类

恳求网络用到 dio 库,数据解析用到 json_serializable 库,接口回来格局都是固定的,data 字段有两种可能的类型:Object列表,界说两个泛型类:

import 'package:json_annotation/json_annotation.dart';
part 'base_response.g.dart';
// 让生成的fromJson()和toJson()中包括额定的函数参数,用于指明:
// 如何将泛型类型T的数据转换为Json,以及如何将Json转换为T
@JsonSerializable(genericArgumentFactories: true)
class DataResponse<T> {
  final T? data;
  final int errorCode;
  final String errorMsg;
  DataResponse({required this.data, required this.errorCode, required this.errorMsg});
  // 运用泛型办法的工厂结构办法来创立一个呼应实例
  factory DataResponse.fromJson(Map<String, dynamic> json, T Function(dynamic json) fromJsonT) =>
      _$DataResponseFromJson(json, fromJsonT);
  // 运用泛型办法将实例转换为Json
  Map<String, dynamic> toJson(dynamic Function(T value) toJsonT) => _$DataResponseToJson(this, toJsonT);
}
// 假如Data是列表类型用这个
@JsonSerializable(genericArgumentFactories: true)
class ListResponse<T> {
  final List<T>? data;
  final int errorCode;
  final String errorMsg;
  ListResponse({required this.data, required this.errorCode, required this.errorMsg});
  // 运用泛型办法的工厂结构办法来创立一个呼应实例
  factory ListResponse.fromJson(Map<String, dynamic> json, T Function(dynamic json) fromJsonT) =>
      _$ListResponseFromJson(json, fromJsonT);
  // 运用泛型办法将实例转换为Json
  Map<String, dynamic> toJson(dynamic Function(T value) toJsonT) => _$ListResponseToJson(this, toJsonT);
}

5.6. 界说积分Model类

个人积分接口回来数据的示例:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

仿制下data字段的数据,直接丢 json2dart_for_json_serializable 或许 JsonToDart插件 生成 Model 类:

import 'package:json_annotation/json_annotation.dart';
part 'integral.g.dart';
@JsonSerializable()
class Integral extends Object {
  @JsonKey(name: 'coinCount')
  int coinCount;
  @JsonKey(name: 'rank')
  String rank;
  @JsonKey(name: 'userId')
  int userId;
  @JsonKey(name: 'username')
  String username;
  Integral(
    this.coinCount,
    this.rank,
    this.userId,
    this.username,
  );
  factory Integral.fromJson(Map<String, dynamic> srcJson) => _$IntegralFromJson(srcJson);
  Map<String, dynamic> toJson() => _$IntegralToJson(this);
}

然后履行下述指令生成对应的序列化和反序列化代码:

flutter pub run build_runner build --delete-conflicting-outputs

生成的 integral.g.dart 文件内容如下:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

5.7. 简略封装下dio库

每次恳求都要去实例化一个Dio实例,并进行各种设置再调用,繁琐之余还糟蹋内存资源,完全能够运用 单例模式 简略封装下。接口API文档中这样描绘错误码:

未登录的错误码为-1001,其他错误码为-1,成功为0

那就抽象地界说两个反常吧,未登录反常其它反常

// 未登录反常
class UnLoginException implements Exception {
  final String message;
  UnLoginException(this.message);
}
// 其它反常
class OtherException implements Exception {
  final String message;
  OtherException(this.message);
}

然后 工厂单例,封装下恳求,依据不同的 errorCode 决议正确呼应,以及抛哪种类型的反常,并供给一个更新恳求头中Cookie的办法:

import 'dart:io';
import 'package:dio/dio.dart';
import '../data/model/base_response.dart';
class DioClient {
  late final Dio _dio;
  static DioClient? _instance;
  // 界说一个命名结构函数
  DioClient._internal(this._dio);
  // 单例初始化办法,需求在实例化前调用
  static void init(String baseUrl) {
    _instance ??= DioClient._internal(Dio(BaseOptions(
        baseUrl: baseUrl,
        responseType: ResponseType.json,
        headers: {'user-agent': 'partner/7.8.0(Android;12;1080*2116;Scale=2.75;Xiaomi=Mi MIX 2S)'}))
      //增加恳求日志拦截器,操控台能够看到恳求日志
      ..interceptors.add(LogInterceptor(responseBody: true, requestBody: true)));
  }
  // 界说一个工厂(私有)结构函数,保证一个类只要一个实例,并供给一个全局拜访点来拜访该实例
  factory DioClient() {
    if (_instance == null) {
      throw Exception('DioClient is not initialized, call init() first');
    }
    return _instance!;
  }
  // 封装恳求
  Future<Response> _performRequest(Future<Response> Function() dioCall) async {
    try {
      Response response = await dioCall();
      var resp = DataResponse<String?>.fromJson(response.data, (json) => json);
      // 依据不同的呼应码履行不同的处理逻辑
      switch (resp.errorCode) {
        case 0:
          return response;
        case -1001:
          throw UnLoginException(resp.errorMsg);
        default:
          throw OtherException(resp.errorMsg);
      }
    } on DioException catch (e) {
      print("${e.message}");
      rethrow;
    }
  }
  // 封装GET恳求
  Future<Response> get(String endpoint, {Map<String, dynamic>? params}) async {
    return _performRequest(() => _dio.get(endpoint, queryParameters: params));
  }
  // 封装POST恳求
  Future<Response> post(String endpoint, {dynamic data, Map<String, dynamic>? params}) async {
    return _performRequest(() => _dio.post(endpoint, data: data, queryParameters: params));
  }
  // 设置Cookie的办法
  setCookies(List<String>? cookies) {
    _dio.options.headers[HttpHeaders.cookieHeader] = cookies;
  }
  // 移除Cookie的办法
  clearCookies() {
    _dio.options.headers.remove("Cookie");
  }
}

然后在 main.dart 履行 runApp() 函数前,调用下 DioClient.init() 设置下 恳求域名

void main() {
  DioClient.init("https://www.wanandroid.com/");
  runApp(const MyApp());
}

5.8. 恳求积分接口并改写UI

_DrawerScreenState 中界说一个 _integral 特点,重写 initState() 办法,在这儿 恳求积分接口,并调用setState() 更新状况,详细完结代码:

class _DrawerScreenState extends State<DrawerScreen> {
  Integral? _integral;
  @override
  void initState() {
    super.initState();
    DioClient().get("lg/coin/userinfo/json").then((value) {
      setState(() {
        _integral = DataResponse<Integral>.fromJson(value.data, (json) => Integral.fromJson(json)).data;
      });
    }).catchError((e) {
      if (e is UnLoginException) {
        ToastUtil.show(msg: "未登录,请先登录!");
      } else if (e is OtherException) {
        ToastUtil.show(msg: e.message);
      } else {
        ToastUtil.show(msg: "恳求失利:${e.toString()}");
      }
    });
  }
  //...
}

然后在 build() 办法中,对应组件获取到 _integral 特点,显现不同的文本和交互,要害代码如下:

DrawerHeader(
  decoration: const BoxDecoration(
    color: Color(0xFF5A78EA),
  ),
  child: GestureDetector(
    child: Text(
      // 为空显现去登陆,不为空则显现用户名
      _integral != null ? _integral!.username : "去登录",
      style: const TextStyle(
        color: Colors.white,
        fontSize: 24,
      ),
    ),
    onTap: () {
      // 为空时点击跳转到登录页
      if(_integral == null) {
        Navigator.push(context, MaterialPageRoute(builder: (context) {
          return const LoginScreen();
        }));
      }
    },
  ),
),
ListTile(
  leading: const Icon(Icons.score),
  // 不为空显现积分
  title: Text('我的积分${(_integral != null ? _integral!.coinCount : "")}'),
),

😁当 _integral 为空时,点击去登录,经过 Navigator.push() 跳转到登录页,接着要完善登录页的逻辑:

  • 恳求登录接口,登录成功,获取呼应头里的 Set-Cookies ,更新恳求头的 Cookies,并耐久化到本地;
  • 封闭页面,告诉导航侧滑页面恳求积分接口,更新UI;

😐 网络恳求是一个耗时过程,用户无感知,网络不佳时,可能存在误操作。为了提高用户体会,一种惯例的处理办法:弹出一个Loading对话框,奉告用户恳求现已主张,请稍后。在Flutter中,能够调用 showDialog() 来进行展现一个对话框。

6. Loading弹窗

6.1. showDialog()

它常用特点如下:

  • context: 当时BuildContext,用于定位对话框的方位。
  • builder: 一个函数,用于构建对话框内的内容。它回来一个Widget,通常是AlertDialog,SimpleDialog或许Dialog等。
  • barrierDismissible: 操控用户是否能够经过点击遮罩层来封闭对话框,默许为true。
  • barrierColor: 遮罩层的色彩。
  • useSafeArea: 默许情况下,AlertDialog将运用SafeArea来防止屏幕如刘海、屏幕边际等的搅扰。能够经过设置为false来封闭这个功能。

6.2. WillPopScope (拦截回退)

😧 点击 物理退后按键或许手势撤退,加载对话框会消失,但在某些场景,为了保证程序运转逻辑正确,咱们不想让用户撤销。能够 WillPopScope 组件来拦截,它供给了一个回调来 决议是否答应页面退出。它的最重要特点:

  • onWillPop: 一个类型为Future Function()的回调。当用户尝试经过体系的办法脱离当时页面时被调用。假如Future解析为false,当时页面不会被退出;假如解析为true,当时页面将被退出。

6.3. CircularProgressIndicator (圆环进展条)

Flutter内置一个 CircularProgressIndicator 组件,用于显现 环形加载指示器(圆环进展条) ,常用特点如下:

  • value: 这个特点承受一个double类型的值,规模从0.0到1.0。假如供给了这个值,CircularProgressIndicator就会展现一个固定进展的进展条。若值为null,则会展现一个不确定进展的旋转指示器。
  • backgroundColor: 进展指示器的布景色彩。
  • valueColor: 进展指示器的色彩。通常是一个Animation目标,用来指示进展条的色彩改动。
  • color:Flutter 2.0前用于设置指示器色彩的特点,2.0开端,为了更灵敏操控色彩,主张运用主张运用valueColor特点。
  • strokeWidth: 边框的粗细,单位是逻辑像素。
  • semanticsLabelsemanticsValue: 程序无障碍阅览时的标签和值。

6.4. 组合封装

整合下,写出完整的Loading弹窗代码:

import 'package:flutter/material.dart';
import 'package:flutter_wanandroid/res/colors.dart';
/// 展现一个加载对话框,[context] 上下文,[canPop] 是否答应封闭对话框
void showLoadingDialog(BuildContext context, {bool canPop = true}) {
  showDialog(
    context: context,
    barrierDismissible: false, // 点击外部不封闭对话宽
    builder: (BuildContext context) {
      return WillPopScope(
          onWillPop: () async => canPop, // 依据canPop参数决议是否答应封闭对话框
          child: const Center(
            child: SizedBox(
              width: 100,
              height: 100,
              child: Card(
                color: Colors.white,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: <Widget>[
                    // 指定一个固定不变的色彩
                    CircularProgressIndicator(valueColor: AlwaysStoppedAnimation<Color>(MyColors.leiMuBlue)),
                  ],
                ),
              ),
            ),
          ));
    },
  );
}

运转看看作用:

😄nice~

7. 简略封装shared_preferences

登录成功完,除了需求更新恳求头外,还需求把 Cookie耐久化到本地,这种小型数据很适合用三方库 shared_preferences 来保存,简略封装下。单例,各种数据类型的put、get,是否存在key,清空、移除,没啥难度,直接写出东西代码:

import 'package:shared_preferences/shared_preferences.dart';
class SharedPreferencesUtil {
  static SharedPreferencesUtil? _instance;
  late final SharedPreferences _preferences;
  // 私有化结构办法
  SharedPreferencesUtil._(this._preferences);
  // 回来实例
  static Future<SharedPreferencesUtil> getInstance() async {
    if (_instance == null) {
      SharedPreferences preferences = await SharedPreferences.getInstance();
      _instance = SharedPreferencesUtil._(preferences);
    }
    return _instance!;
  }
  Future<bool> putString(String key, String value) => _preferences.setString(key, value);
  Future<bool> putStringList(String key, List<String> value) => _preferences.setStringList(key, value);
  Future<bool> putInt(String key, int value) => _preferences.setInt(key, value);
  Future<bool> putDouble(String key, double value) => _preferences.setDouble(key, value);
  Future<bool> putBool(String key, bool value) => _preferences.setBool(key, value);
  String getString(String key, {String defaultValue = ""}) => _preferences.getString(key) ?? defaultValue;
  List<String> getStringList(String key, {List<String> defaultValue = const []}) =>
      _preferences.getStringList(key) ?? defaultValue;
  int getInt(String key, {int defaultValue = 0}) => _preferences.getInt(key) ?? defaultValue;
  double getDouble(String key, {double defaultValue = 0.0}) => _preferences.getDouble(key) ?? defaultValue;
  bool getBool(String key, {bool defaultValue = false}) => _preferences.getBool(key) ?? defaultValue;
  bool containsKey(String key) => _preferences.containsKey(key);
  Future<bool> remove(String key) => _preferences.remove(key);
  Future<bool> clear() => _preferences.clear();
}

接着补全下登录部分的代码:

  void _login() {
    // 登录校验逻辑
    final username = _usernameController.text;
    final password = _passwordController.text;
    if (username.isNotEmpty && password.isNotEmpty) {
      // 弹出登录对话框
      showLoadingDialog(context, canPop: false);
      // 在主张登录恳求
      DioClient().post("user/login", params: {"username": username, "password": password}).then((value) async {
        // 封闭Loading对话框
        Navigator.pop(context);
        var resp = DataResponse<UserInfo>.fromJson(value.data, (json) => UserInfo.fromJson(json));
        ToastUtil.show(msg: "登录成功");
        // 获取呼应头里的Set-Cookie,设置到恳求头中,并经过sp耐久化到本地
        List<String>? cookies = value.headers['Set-Cookie'];
        if (cookies != null) {
          DioClient().setCookies(cookies);
          SharedPreferencesUtil.getInstance().then((value) => value.putStringList("cookies", cookies));
          // 封闭登录页
          Navigator.pop(context);
        }
        Fluttertoast.showToast(msg: resp.errorMsg);
      }).catchError((e) {
        Navigator.pop(context);
        if (e is OtherException) {
          ToastUtil.show(msg: "登录失利:${e.message}");
        } else {
          ToastUtil.show(msg: "登录失利:$e");
        }
      });
    } else {
      Fluttertoast.showToast(msg: "用户名或暗码不能为空");
    }
  }

输入正确账号暗码,点击登录,登录成功后,登录页主动封闭,手动封闭侧滑导航再次点开,能够看到用户名和积分都显现出来了:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

8. 完善侧滑导航逻辑

😅 登录成功,需求 手动封闭侧滑导航再点击展开侧滑才改写积分,有点呆咱们更期望能在登录成功时,就主动恳求接口接口,然后主动改写UI。

8.1. 登录成功主动恳求积分接口

这儿能够经过 状况/数据同享 来完结,运用官方引荐的 Provider 来完结,指令行键入 flutter pub add provider 增加下依靠。接着界说一个类承继 ChangeNotifier 并界说一个告诉更新的办法,在里边调用 notifyListeners() 告诉一切监听者~

import 'package:flutter/cupertino.dart';
class LoginStatus extends ChangeNotifier {
  bool _isLogin = false;
  bool get isLogin => _isLogin;
  void updateLoginStatus(bool isLogin) {
    _isLogin = isLogin;
    notifyListeners();
  }
}

监听者们会在这个办法被调用时得到告诉,在顶层 main.dart 文件中,设置 Provider

runApp(ChangeNotifierProvider(create: (context) => LoginStatus(), child: const MyApp()));

登录页,登录成功时调用updateLoginStatus() 告诉更新:

Provider.of<LoginStatus>(context, listen: false).updateLoginStatus(true);

侧滑导航,能够运用 ConsumerProvider.of() 来监听数据改动:

  @override
  void initState() {
    super.initState();
    // 增加监听,状况改动时回调恳求积分的办法
    Provider.of<LoginStatus>(context, listen: false).addListener(_requestCoin);
    _requestCoin();
  }
	// 恳求积分的办法
  void _requestCoin() {
    DioClient().get("lg/coin/userinfo/json").then((value) {
      setState(() {
        _integral = DataResponse<Integral>.fromJson(value.data, (json) => Integral.fromJson(json)).data;
      });
    }).catchError((e) {
      if (e is UnLoginException) {
        ToastUtil.show(msg: "未登录,请先登录!");
      } else if (e is OtherException) {
        ToastUtil.show(msg: e.message);
      } else {
        ToastUtil.show(msg: "恳求失利:${e.toString()}");
      }
    });
  }

运转后,登录成功,登录主动封闭,侧滑导航主动拉取积分接口,nice😁。当然,侧滑这儿其实没必要每次展开都拉取的,后续再优化下细节~

8.2. 初始化时,获取下Cookie并设置

在恳求库初始化的时分,能够顺带获取下 shared_preferences 里保存的Cookie 并设置到恳求头中:

void main() {
  // 保证Flutter框架初始化完结
  WidgetsFlutterBinding.ensureInitialized();
  DioClient.init("https://www.wanandroid.com/");
  SharedPreferencesUtil.getInstance().then((value) {
    List<String>? cookies = value.getStringList("cookies");
    DioClient().setCookies(cookies);
  });
  runApp(ChangeNotifierProvider(create: (context) => LoginStatus(), child: const MyApp()));
}

😑 侧滑导航就折腾到这吧,接着折腾底部Tab~

9. 底部Tab

直接CV《实战:写个粗陋的静态主页》里的代码改改~

9.1. BottomNavigationBar + BottomNavigationBarItem

class BottomBarWidget extends StatefulWidget {
  final int currentIndex;
  final Function(int) onItemSelected;
  const BottomBarWidget({
    Key? key,
    required this.currentIndex,
    required this.onItemSelected,
  }) : super(key: key);
  @override
  State<StatefulWidget> createState() => _BottomBarWidgetState();
}
class _BottomBarWidgetState extends State<BottomBarWidget> {
  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      type: BottomNavigationBarType.fixed,
      onTap: widget.onItemSelected,
      selectedItemColor: MyColors.leiMuBlue,
      // 选中时的色彩
      unselectedItemColor: Colors.grey,
      // 未选中时的色彩
      showSelectedLabels: true,
      // 选中的label是否展现
      showUnselectedLabels: true,
      // 未选中的label是否展现
      currentIndex: widget.currentIndex,
      items: const [
        BottomNavigationBarItem(icon: Icon(Icons.home), label: '主页'),
        BottomNavigationBarItem(icon: Icon(Icons.article), label: '大众号'),
        BottomNavigationBarItem(icon: Icon(Icons.heart_broken), label: '其它'),
      ],
    );
  }
}

9.2. PageView (切页)

点击切页,用到 PageView 组件:

import 'package:flutter/cupertino.dart';
/// 主页视图
class ContentPageView extends StatefulWidget {
  final PageController pageController;
  final Function(int) onPageChanged;
  const ContentPageView({
    super.key,
    required this.pageController,
    required this.onPageChanged,
  });
  @override
  State<StatefulWidget> createState() => _ContentPageViewState();
}
class _ContentPageViewState extends State<ContentPageView> {
  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: PageView(
        controller: widget.pageController,
        onPageChanged: widget.onPageChanged,
        children: const <Widget>[
          Center(child: Text('主页')),
          Center(child: Text('大众号')),
          Center(child: Text('其它')),
        ],
      ),
    );
  }
}

9.3. 底部Tab + PageView 联动

两者切换时的联动,需求传入一个 PageController,在 页面改动时更新下标状况 以及 点击Tab时切换页面,详细完结代码如下:

class _MyHomePageState extends State<MyHomePage> {
  int _currentIndex = 0;
  late PageController _pageController;
  @override
  void initState() {
    super.initState();
    _pageController = PageController(initialPage: _currentIndex);
  }
  @override
  void dispose() {
    // 组件毁掉时要注销掉操控器
    _pageController.dispose();
    super.dispose();
  }
  void _onPageChanged(int index) {
    // 页面改动时更新下标状况
    setState(() {
      _currentIndex = index;
    });
  }
  void _onItemTapped(int selectedIndex) {
    // 点击Tab时切页
    _pageController.jumpToPage(selectedIndex);
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: MyColors.leiMuBlue,
          title: Text(widget.title, style: const TextStyle(color: Colors.white)),
        ),
        body: Container(
            color: Colors.white,
            child: Column(
              children: [
                ContentPageView(
                  pageController: _pageController,
                  onPageChanged: _onPageChanged,
                ),
                BottomBarWidget(
                  currentIndex: _currentIndex,
                  onItemSelected: _onItemTapped,
                )
              ],
            )),
        drawer: const DrawerScreen());
  }
}

运转看看作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

🤣 作用还阔以哈,接着完善主页~

10. 主页

主页的要素略微杂乱点:下拉改写组件ListView (包裹Banner + 文章列表项)、以及需求支撑 滑动到底部加载更多,一个个来~

10.1. 下拉改写

Flutter 内置一个 RefreshIndicator 组件,能够用来包裹一个 翻滚组件完结下拉改写功能。常用特点如下:

  • onRefresh: 必要特点,类型是Future Function()。当用户下拉可翻滚组件触发改写时调用,你需求在这个回调中进行数据加载的异步操作,并回来一个Future。RefreshIndicator会等候这个Future完结才消失。
  • child: 要包裹的子widget,通常是可翻滚的组件,如ListView、ScrollView。
  • displacement: 操控RefreshIndicator圆圈图标开端显现时在笔直方向的偏移量,默许值是40.0像素。
  • color & backgroundColor:前者用于设置圆形进展指示器的前景色,后者用于设置其布景色。
  • notificationPredicate: 默许情况下,RefreshIndicator会相关界面上的榜首个可翻滚组件。假如你需求相关其他特定的可翻滚组件,能够经过设置这个特点来供给自界说的决策逻辑。
  • triggerMode: 确定RefreshIndicator是在用户下拉时触发 (RefreshIndicatorTriggerMode.onEdge),还是恣意方位下拉都会触发 (RefreshIndicatorTriggerMode.anywhere),默许用户下拉时触发。
  • edgeOffset: 操控RefreshIndicator被触发时翻滚视图顶部的方位。
  • strokeWidth: 设置圆形进展条的粗细。

10.2. Banner

Flutter没有内置的Banner控件,能够运用 PageView + Timer,完结一个 无限循环支撑守时切换的Banner

class AutoScrollBannerWidget extends StatefulWidget {
  final List<String> imageUrls;
  final Function(int pos) onTap;
  const AutoScrollBannerWidget({super.key, required this.imageUrls, required this.onTap});
  @override
  State<StatefulWidget> createState() => _AutoScrollBannerWidgetState();
}
class _AutoScrollBannerWidgetState extends State<AutoScrollBannerWidget> {
  late PageController _pageController;
  late Timer _timer;
  int _currentPage = 0;
  @override
  void initState() {
    super.initState();
    // 为了无限轮播,把_currentPage设置在一个较大的值
    _currentPage = widget.imageUrls.length * 10000;
    // 初始化页面操控器
    _pageController = PageController(initialPage: _currentPage);
    // 发动守时器,每3秒切换页面
    _timer = Timer.periodic(const Duration(seconds: 5), (Timer timer) {
      //  核算下个页面索引
      int nextPageIndex = _pageController.page!.toInt() + 1;
      if (_pageController.hasClients) {
        _pageController.animateToPage(
          nextPageIndex,
          duration: const Duration(milliseconds: 350),
          curve: Curves.easeIn,
        );
      }
    });
  }
  @override
  void dispose() {
    // 组件毁掉时,撤销守时器,开释资源
    _timer.cancel();
    _pageController.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 200,
      width: double.infinity,
      child: PageView.builder(
        controller: _pageController,
        itemBuilder: (context, index) {
          // 取余获得真正有效的index
          var trueIndex = index % widget.imageUrls.length;
          return GestureDetector(
            onTap: () => widget.onTap(trueIndex),
            child: CachedNetworkImage(
                imageUrl: widget.imageUrls[trueIndex],
                placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
                errorWidget: (context, url, error) => const Icon(Icons.error)),
          );
        },
        onPageChanged: (index) {
          _currentPage = index;
        },
      ),
    );
  }
}

🤡 这部分的代码多看几遍就懂了~

10.3. 文章列表项

这儿比较简略,就显现下文章标题、作者、分类及发布日期,预留了一个点击路由跳转 文章阅览页

class ArticleItemWidget extends StatefulWidget {
  final ArticleInfo articleInfo;
  const ArticleItemWidget({Key? key, required this.articleInfo}) : super(key: key);
  @override
  State<StatefulWidget> createState() => _ArticleItemWidgetState();
}
class _ArticleItemWidgetState extends State<ArticleItemWidget> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: Column(
        children: [
          Container(
              padding: const EdgeInsets.all(12.0),
              alignment: Alignment.topLeft,
              child: Text(
                widget.articleInfo.title,
                style: const TextStyle(fontSize: 16, color: Colors.black),
              )),
          const SizedBox(height: 4.0),
          Row(
            children: [
              const SizedBox(width: 12.0),
              Expanded(
                child: Text(
                  widget.articleInfo.author,
                  style: const TextStyle(fontSize: 12, color: Colors.grey),
                ),
              ),
              Text(
                widget.articleInfo.superChapterName,
                style: const TextStyle(fontSize: 12, color: Colors.grey),
              ),
              const SizedBox(width: 12.0),
              Text(
                widget.articleInfo.niceDate,
                style: const TextStyle(fontSize: 12, color: Colors.grey),
              ),
              const SizedBox(width: 12.0),
            ],
          ),
          const SizedBox(height: 8.0),
          const Divider(height: 1, color: Colors.grey, thickness: 0.5),
        ],
      ),
      onTap: () {
        Navigator.push(context, MaterialPageRoute(builder: (context) {
          return Container(color: Colors.white, alignment: Alignment.center, child: const Text('文章阅览页'));
        }));
      },
    );
  }
}

10.4. 滑动到底部加载更多

能够为 ListView.buildercontroller 特点设置一个 ScrollController 来监听是否翻滚到底部,示例代码:

  _scrollController.addListener(() {
    // 翻滚到底部时主动加载更多
    if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
      _requestArticleList();
    }
  });

10.5. 组合封装

接着把这几个东东都组合封装到一同:

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);
  @override
  State<StatefulWidget> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
  int _currentPage = 0; // 当时页数
  List<IndexBannerInfo> _bannerItems = []; // banner列表
  IndexArticleInfo? _indexData; // 文章列表项目
  List<ArticleInfo> _artcileItems = []; // 文章列表
  final ScrollController _scrollController = ScrollController(); // 滑动监听器
  @override
  void initState() {
    super.initState();
    _requestBanner();
    _requestArticleList(isRefresh: true); // 初次加载默许拉取一次
    _scrollController.addListener(() {
      // 翻滚到底部时主动加载更多
      if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
        _requestArticleList();
      }
    });
  }
  @override
  void dispose() {
    super.dispose();
    _scrollController.dispose();
  }
  // 恳求Banner接口
  Future<void> _requestBanner() async {
    DioClient().get("banner/json").then((value) {
      setState(() {
        _bannerItems =
            ListResponse<IndexBannerInfo>.fromJson(value.data, (json) => IndexBannerInfo.fromJson(json)).data ?? [];
      });
    }).catchError((e) {
      ToastUtil.show(msg: "恳求失利:${e.toString()}");
    });
  }
  // 恳求文章列表接口
  Future<void> _requestArticleList({bool isRefresh = false}) async {
    if (isRefresh) {
      _currentPage = 0;
      _artcileItems.clear();
    } else {
      ++_currentPage;
      // 恳求时展现Loading对话框
      showLoadingDialog(context, canPop: false);
    }
    DioClient().get("article/list/$_currentPage/json").then((value) {
      // 加载更多需求封闭加载对话框
      if (!isRefresh) Navigator.pop(context);
      setState(() {
        _indexData =
            DataResponse<IndexArticleInfo>.fromJson(value.data, (json) => IndexArticleInfo.fromJson(json)).data;
        _artcileItems.addAll(_indexData!.datas);
      });
    }).catchError((e) {
      ToastUtil.show(msg: "恳求失利:${e.toString()}");
    });
  }
  @override
  Widget build(BuildContext context) {
    return RefreshIndicator(
        onRefresh: () => _requestArticleList(isRefresh: true),
        child: ListView.builder(
          itemCount: _artcileItems.length,
          itemBuilder: (context, index) {
            // 两个接口都拉取成功,才加载页面
            if (_bannerItems.isNotEmpty && _artcileItems.isNotEmpty) {
              if (index == 0) {
                return AutoScrollBannerWidget(
                  imageUrls: _bannerItems.map((e) => e.imagePath).toList(),
                  onTap: (pos) => ToastUtil.show(msg: "点击了第${pos + 1}个banner"),
                );
              }
              int itemIndex = index - 1;
              return ArticleItemWidget(articleInfo: _artcileItems[itemIndex]);
            }
            return null;
          },
          controller: _scrollController,
        ));
  }
}

运转看下作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

10.6. AutomaticKeepAliveClientMixin (保存页面状况)

😳 正在我预备继续写大众号页面,发现了问题,切去其它页,然后切回主页,主页的内容都会从头加载。查了下,貌似原因是介个:

在 Flutter 中,当一个 widget 不在视图中时,为了节约资源,Flutter 可能会卸载这个 widget,然后当它再次需求显现时从头创立它。

解法之一便是运用:AutomaticKeepAliveClientMixin,用法如下

  • ① 对期望坚持状况的页面的 State 经过 with 混入 AutomaticKeepAliveClientMixin
  • ② 重写 wantKeepAlive() 办法回来 true
  • ③ 在State的 build() 中调用 super.build(context)

要害代码示例如下:

class _HomeScreenState extends State<HomeScreen> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    // ...
  }
 	@override
  bool get wantKeepAlive => true;
}

😁 经过上述装备,切去别的页面再切回来,页面内容也不会从头加载啦。

11. 大众号页

这部分相同能够直接CV《实战:写个粗陋的静态主页》里的代码改改~

11.1. TabBar + TabBarView

两者联动还需求用到 SingleTickerProviderStateMixin 供给一个选中的动画作用,详细完结代码:

class WxArticleScreen extends StatefulWidget {
  const WxArticleScreen({super.key});
  @override
  State<StatefulWidget> createState() => _WxArticleScreenState();
}
class _WxArticleScreenState extends State<WxArticleScreen> with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
  late TabController _tabController;
  late List<WxArticleChapter> _chapterList = [];
  // 恳求大众号列表
  Future<void> _wxArticleChapters() async {
    DioClient().get("wxarticle/chapters/json").then((value) {
      if(mounted) {
        setState(() {
          _chapterList =
              ListResponse<WxArticleChapter>.fromJson(value.data, (json) => WxArticleChapter.fromJson(json)).data ?? [];
          _tabController = TabController(length: _chapterList.length, vsync: this);
        });
      }
    }).catchError((e) {
      ToastUtil.show(msg: "恳求失利:${e.toString()}");
    });
  }
  @override
  void initState() {
    super.initState();
    _wxArticleChapters();
  }
  @override
  Widget build(BuildContext context) {
    super.build(context);
    if(_chapterList.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    } else {
      return Column(
        children: [
          BlogTabBarWidget(tabController: _tabController, chapterList: _chapterList),
          // 高度填满剩余空间
          Expanded(
            child: TabBarView(
              // 相同运用TabBarView
                controller: _tabController, // 相关同一个TabController
                children: _chapterList.map((chapter) => WxArticleListWidget(chapterId: chapter.id)).toList()),
          ),
        ],
      );
    }
  }
  @override
  bool get wantKeepAlive => true;
}

封装下 TabBar 写个组件:

class BlogTabBarWidget extends StatefulWidget {
  final TabController tabController;
  final List<WxArticleChapter> chapterList;
  const BlogTabBarWidget({Key? key, required this.tabController, required this.chapterList}) : super(key: key);
  @override
  State<StatefulWidget> createState() => _BlogTabBarWidgetState();
}
class _BlogTabBarWidgetState extends State<BlogTabBarWidget> {
  @override
  Widget build(BuildContext context) {
    return TabBar(
      controller: widget.tabController,
      isScrollable: true,
      tabs: widget.chapterList.map((chapter) => Tab(text: chapter.name)).toList(),
    );
  }
}

TabBarView 的子项相同封装成一个组件:

class WxArticleListWidget extends StatefulWidget {
  final int chapterId;
  const WxArticleListWidget({Key? key, required this.chapterId}) : super(key: key);
  @override
  State<StatefulWidget> createState() => _WxArticleListWidgetState();
}
class _WxArticleListWidgetState extends State<WxArticleListWidget> with AutomaticKeepAliveClientMixin {
  int _currentPage = 0; // 当时页数
  List<WxArticle> _articleList = []; // 文章列表
  final ScrollController _scrollController = ScrollController(); // 滑动监听器
  @override
  void initState() {
    super.initState();
    _requestArticleList(isRefresh: true);
    _scrollController.addListener(() {
      // 翻滚到底部时主动加载更多
      if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
        _requestArticleList();
      }
    });
  }
  @override
  void dispose() {
    super.dispose();
    _scrollController.dispose();
  }
  // 恳求文章列表
  Future<void> _requestArticleList({bool isRefresh = false}) async {
    if (isRefresh) {
      _currentPage = 0;
      _articleList.clear();
    } else {
      ++_currentPage;
      showLoadingDialog(context, canPop: false);
    }
    DioClient().get("wxarticle/list/${widget.chapterId}/$_currentPage/json").then((value) {
      if (!isRefresh) Navigator.pop(context);
      setState(() {
        var data = DataResponse<WxArticleRes>.fromJson(value.data, (json) => WxArticleRes.fromJson(json)).data;
        _articleList.addAll(data!.datas);
      });
    }).catchError((e) {
      ToastUtil.show(msg: "恳求失利:${e.toString()}");
    });
  }
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return RefreshIndicator(
        onRefresh: () => _requestArticleList(isRefresh: true),
        child: ListView.builder(
          itemCount: _articleList.length,
          itemBuilder: (context, index) {
            // 文章列表不为空才显现
            if (_articleList.isNotEmpty) {
              return WxArticleItemWidget(articleInfo: _articleList[index]);
            } else {
              return null;
            }
          },
          controller: _scrollController,
        ));
  }
  @override
  bool get wantKeepAlive => true;
}

列表项的话,直接复用主页文章列表的组件,改下数据结构就完事了,运转看下作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

😄 还凑合,终究再写一个文章阅览页~

12. 文章阅览页 (嵌套WebView)

如题,便是嵌套一个WebView,Flutter没有内置浏览器组件,这儿用到三方库:flutter_inappwebview,履行 flutter pub add flutter_inappwebview 增加依靠,然后就能够运用库里的 InAppWebView 来加载网页了。此页面结构:

  • 顶部AppBar,蕾姆蓝布景,白色字体,左边一个回退按钮,右边一个 仿制URL跳转手机浏览器 按钮;
  • 中心Stack堆叠布局,包括 InAppWebView 和 依据是否处于加载状况,显现圆形进展条或Container;

12.1. InAppWebView

界说一个符号 _isLoading 表明网页是否正在加载中,在 InAppWebViewonLoadStart(开端加载) 和 onLoadStop(结束加载) 中修正,并调用 setState() 更新状况;

class BrowserPageScreen extends StatefulWidget {
  final String url;
  const BrowserPageScreen({super.key, required this.url});
  @override
  State<StatefulWidget> createState() => _BrowserPageScreenState();
}
class _BrowserPageScreenState extends State<BrowserPageScreen> {
  bool _isLoading = true; // 网页是否正在加载中
  void _copyUrlToClipboard() {
     // 仿制URL到剪切板
  }
  void _openBrowser() async {
    // 跳转手机浏览器
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: MyColors.leiMuBlue,
        title: const Text('Van ♂ Android'),
        leading: IconButton(
          icon: const Icon(Icons.arrow_back, color: Colors.white),
          onPressed: () {
            Navigator.pop(context);
          },
        ),
        actions: <Widget>[
          IconButton(
            icon: const Icon(Icons.copy, color: Colors.white),
            onPressed: _copyUrlToClipboard,
          ),
          IconButton(
            icon: const Icon(Icons.open_in_browser, color: Colors.white),
            onPressed: _openBrowser,
          ),
        ],
      ),
      body: Stack(
        children: [
          InAppWebView(
            initialUrlRequest: URLRequest(url: WebUri(widget.url)),
            onLoadStart: (InAppWebViewController controller, Uri? url) {
              setState(() {
                _isLoading = true; // 页面开端加载,更新状况为 true
              });
            },
            // 页面中止加载时的回调
            onLoadStop: (InAppWebViewController controller, Uri? url) {
              setState(() {
                _isLoading = false; // 页面中止加载,更新状况为 false
              });
            },
          ),
          _isLoading
              ? const Center(child: CircularProgressIndicator()) // 假如正在加载,则显现圆形进展指示器
              : Container(), // 假如不是,则不显现任何内容
        ],
      ),
    );
  }
}

运转后可能会报错:

Dependency ‘androidx.webkit:webkit:1.8.0’ requires libraries and applications that depend on it to compile against version 34 or later of the Android APIs.

问题描绘:webkit:1.8.0 要求 compile SDK version 需求为 Android API 34 或更高的版别;

解决办法:翻开 android/build.gradle 文件,找到 compileSdkVersion 修正为34或更高版别;

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

12.2. 仿制Url到剪切板

Flutter 内置的 services 库中供给了 Clipboard 类用于操作 体系剪切板, 运用代码示例如下

import 'package:flutter/services.dart';
// 设置数据到剪切板
Clipboard.setData(ClipboardData(text: '这儿是要仿制的文字'));
// 读取剪切板数据
final ClipboardData data = await Clipboard.getData('text/plain');
String pastedText = data.text;

顺带完善下,上面的 _copyUrlToClipboard() 办法:

void _copyUrlToClipboard() {
  Clipboard.setData(ClipboardData(text: widget.url));
  // 底部弹出一个SnackBar奉告用户
  ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('链接已仿制到剪贴板')));
}

12.3. 跳转手机浏览器

这个用到Flutter三方库 url_launcher,支撑跨渠道(iOS、Android、Web等)的办法来翻开 外部网页、发送邮件、拨打电话、发送短信等操作。履行 flutter pub add url_launcher 增加依靠,运用办法十分简略:

import 'package:url_launcher/url_launcher.dart';
void _openBrowser() async {
  Uri uri = Uri.parse(widget.url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri);
  } else {
    throw 'Could not launch $uri';
  }
}

运转看看终究作用:

跟🤡杰哥一同学Flutter (七、项目实战-UI部分🤷‍♀️)

13. 小结

🤡 时断时续,总算把这篇堆出来了,牵强算是开发了一个粗陋APP,毕竟还有一大堆 待优化的BUG待完善的功能,不过也是 Flutter入门 了。😄 后面便是 给这个项目添砖加瓦各种Flutter知识点的专项学习,敬请期待~