🔥 三军未动,粮草先行!上节《六、项目实战-非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>
都修正完,运转看看作用:
😄nice~
1.2. 内置字体图标
Flutter 默许内置一套 Material Design的字体图标,详细有哪些能够到 官方文档 或 Google Fonts 中进行检索,支撑两种引证办法:Icons.xxx 或 Code 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,
),)
],
)
运转作用如下:
Tips:字体图标对应的 码点,主张以官方文档为准!!!比方 favorite_outlined 两者对应的码点值如下 (这也是为啥第二个图标是🚘不是♥):
1.3. 自界说字体图标
Flutter中,字体图标 比较 图片 的优势:
体积更小,矢量图(扩大不会影响清晰度)、能够运用文本款式(色彩、对齐等)、能够经过TextSpan和文本混用。
假如内置的字体图标满足不了需求,能够进行自界说,在Flutter中能够运用 ttf格局 的字体图标。iconfont.cn 上有许多字体图标资料,输入查找要害字,找到喜欢的图标,点击 购物车图标 (不要直接点下载,只要SVG、AI和PNG格局),选完所需的图标,点击 右上角的购物车图标,然后点击 下载代码,下载完解压,找到里边的 ttf文件,仿制到Flutter项目的 assets/fonts 目录下:
修正 pubspec.yaml 文件增加字体图标:
fonts:
- family: customIcon # 指定字体名
fonts:
- asset: assets/fonts/iconfont.ttf
然后能够就经过 IconData 来引证咱们的自界说图标啦:
Icon(
// 参数依次为:字体图标对应的16进制数字、字体名
IconData(0xe6c2, fontFamily: 'customIcon'),
size: 26,
color: Colors.yellow,
)
运转作用图:
字体图标对应的16进制值,能够翻开 iconfont.json 文件查看:
每次运用图标都要查看这个 unicode码 还挺烦,能够塞到一个类里,将字体文件中的一切图标都界说成 静态变量,代码示例如下:
class CustomIcons {
static const IconData xiao = IconData(0xe6c2, fontFamily: 'customIcon');
}
// 运用
Icon(CustomIcons.xiao, size: 26,color: Colors.yellow,)
😁再安利两个图标东西站点:fluttericon.com 和 fluttericon.cn
2. 自界说发动页
从 App发动 到 Flutter榜首帧渲染结束前 需求一定的时刻,Flutter项目会默许装备一个简略的发动视图 (白色布景 + 居中的运用图标)。翻开android目录下的 styles.xml 文件,能够看到设置了一个发动主题:
点开它指向的 launch_background.xml 文件:
修正这两个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会裁剪图片中心的圆形部分):
静态发动页的可玩性不高,假如想搞些动效或许展现信息啥的,官方引荐在静态发动页后尽快显现一个 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. 主页草图
本文首要完结的三个页面:侧滑导航、主页、大众号,从左边的侧滑导航开端搞吧~
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('退出登录'),
),
],
),
);
}
}
运转代码后,从左边划出抽屉看看作用:
UI写完,接着要完善下逻辑,咱们当时的期望:
- 翻开侧滑时:查询个人积分接口,假如处于登录改写我的积分,显现账户名和退出登录选项;
- 假如处于未登录专题该,显现去登录文本,点击去登陆或我的积分,都跳转登录页;
- 登陆完,回到此页面,再次查询积分接口,然后改写UI。
🤡 em…要登录,那得先写下登录页UI~
5. 登录页
页面比较简略,顶部一个 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: 光标的厚度;
- minLines、maxLines:最小/最大行数;
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或以上版别:
然后它会调用体系的Toast,不同的体系版别,可能会有不同的款式差异,比方我两台手机的Toast就不一样:
假如想保证不同体系上都显现 一致的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也画出来:
页面写完,接着就该折腾:调用查询积分接口、数据解析 及 页面改写 的逻辑了,主张下温习下《六、项目实战-非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类
个人积分接口回来数据的示例:
仿制下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 文件内容如下:
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: 边框的粗细,单位是逻辑像素。
- semanticsLabel 和 semanticsValue: 程序无障碍阅览时的标签和值。
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: "用户名或暗码不能为空");
}
}
输入正确账号暗码,点击登录,登录成功后,登录页主动封闭,手动封闭侧滑导航,再次点开,能够看到用户名和积分都显现出来了:
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);
侧滑导航,能够运用 Consumer 或 Provider.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());
}
}
运转看看作用:
🤣 作用还阔以哈,接着完善主页~
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.builder 的 controller 特点设置一个 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,
));
}
}
运转看下作用:
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;
}
列表项的话,直接复用主页文章列表的组件,改下数据结构就完事了,运转看下作用:
😄 还凑合,终究再写一个文章阅览页~
12. 文章阅览页 (嵌套WebView)
如题,便是嵌套一个WebView,Flutter没有内置浏览器组件,这儿用到三方库:flutter_inappwebview,履行 flutter pub add flutter_inappwebview 增加依靠,然后就能够运用库里的 InAppWebView 来加载网页了。此页面结构:
- 顶部:AppBar,蕾姆蓝布景,白色字体,左边一个回退按钮,右边一个 仿制URL 和 跳转手机浏览器 按钮;
- 中心:Stack堆叠布局,包括 InAppWebView 和 依据是否处于加载状况,显现圆形进展条或Container;
12.1. InAppWebView
界说一个符号 _isLoading 表明网页是否正在加载中,在 InAppWebView 的 onLoadStart(开端加载) 和 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或更高版别;
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';
}
}
运转看看终究作用:
13. 小结
🤡 时断时续,总算把这篇堆出来了,牵强算是开发了一个粗陋APP,毕竟还有一大堆 待优化的BUG 和 待完善的功能,不过也是 Flutter入门 了。😄 后面便是 给这个项目添砖加瓦 和 各种Flutter知识点的专项学习,敬请期待~