first commit
This commit is contained in:
161
专栏/Flutter入门教程/01前言-教程内容导读.md
Normal file
161
专栏/Flutter入门教程/01前言-教程内容导读.md
Normal file
@ -0,0 +1,161 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 前言-教程内容导读
|
||||
一、 教程的是什么,不是什么
|
||||
|
||||
本系列教程是完全面向 Flutter 新手朋友,即使没有任何编程基础,也可以观看。希望本册以最有趣和通俗的方式,来 迎接 你们走到 Flutter 新手村的第一站。就像不可能在小学一年级就教学生微积分,所以本册:
|
||||
|
||||
|
||||
[1]. 不会 涉及复杂的算法和数据结构
|
||||
[2]. 不会 涉及系统全面的 Dart 语法介绍
|
||||
[3]. 不会 涉及项目组织、代码管理的思想
|
||||
[4]. 不会 涉及框架原理、源码的原理剖析
|
||||
[5]. 不会 涉及跨平台适配、插件编写知识
|
||||
[6]. 不会 涉及应用性能分析和优化
|
||||
|
||||
|
||||
不过这些知识点在你的编程之路上,总会在某处等待着你。对知识而言,每个阶段有不同的渴求,很多知识需要门槛和经验积累才能领悟,需要时间来沉淀,所以并不用急于求成。入门的第一站,别给自己太大的压力,玩好就行了,如果能有些自己的体悟,那就是锦上添花了。
|
||||
|
||||
|
||||
|
||||
本教程只是一个起点,我会尽可能通过有趣的例子,让你在最初的路途中不对编程产生不适。如果能燃起你的一丝兴趣,将是本教程的荣光。学完本课程,你将会:
|
||||
|
||||
|
||||
[1]. 初步认知 Flutter 框架是什么,能干什么。
|
||||
[2]. 初步了解 最基础的 Dart 语法知识。
|
||||
[3]. 学会 通过常用组件构建出简单的界面。
|
||||
[4]. 学会 在 Flutter 项目中使用别人的依赖库。
|
||||
[5]. 初步掌握 Flutter 中数据的持久化手段。
|
||||
[6]. 学会 通过界面交互完成一些简单的功能逻辑。
|
||||
|
||||
|
||||
|
||||
|
||||
二、 教程的五大模块
|
||||
|
||||
本教程共 26 章,分为如下 5 大模块:
|
||||
|
||||
|
||||
|
||||
1.Flutter 基础
|
||||
|
||||
第一个模块是 Flutter 最基础的前置知识准备阶段,包括环境搭建、Dart 基础语法介绍、计数器项目解读个三部分。如果已经开发过 Flutter 项目的朋友,可以选择跳过本模块,也可以温故知新。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 猜数字模块
|
||||
|
||||
第二个模块是猜数字项目,这是我设计的一个比较简单有趣的小案例,生成随机数后,在头部输入框猜数字。其中包含着 Flutter 最基础的知识点,比如基础组件的使用、界面的布局、逻辑的控制、动画的使用等。麻雀虽小五脏俱全,非常适合新手学习。
|
||||
|
||||
|
||||
|
||||
|
||||
生成随机数
|
||||
输入比较
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3.电子木鱼模块
|
||||
|
||||
第三个模块是电子木鱼项目,也是一个比较简单有趣的小案例,最主要的功能是点击图片发出木鱼的音效。另外支持功德记录的查看,以及音效、图片的选择。其中包含也着 Flutter 很多的知识点,比如基础组件的使用、状态类生命周期回调、依赖库的使用、本地资源配置等。
|
||||
|
||||
|
||||
|
||||
|
||||
点击木鱼
|
||||
查看功德记录
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4.白板绘制模块
|
||||
|
||||
第四个模块是白板绘制项目,用户可以通过手势交互在界面上绘制线条,交互性很强,也非常有趣;支持线颜色和线宽的选择,并可以回退上一步和撤销回退。其中包含也着 Flutter 很多的知识点,比如绘制的使用、手势监听器的使用、组件封装等。
|
||||
|
||||
|
||||
|
||||
|
||||
画板绘制
|
||||
回退和撤销
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
5.项目整合
|
||||
|
||||
最后一部分将介绍如何将之前的一个个孤零零的界面,通过导航结构整合为一个项目。并了解如何在切换界面时,保活状态数据。这部分还会介绍数据的持久化存储,这样用户的选择项和一些记录数据就可以存储到本地,不会随着应用的退出而重置。最后,会介绍对网络数据的访问,完成下面文章展示页的小案例:
|
||||
|
||||
|
||||
|
||||
|
||||
下拉刷新
|
||||
加载更多
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
三、关于本教程的源码
|
||||
|
||||
项目源码在 github 上托管,项目名是 : flutter_first_station ,寓意是 Flutter 的第一站。
|
||||
|
||||
|
||||
|
||||
另外,源码是最终版效果,中间一步步的实现过程通过提交节点来查看,在文章相关位置会有对应节点的连接地址,访问即可看到当前步骤下的源码。
|
||||
|
||||
|
||||
|
||||
比如上面是 13 章介绍电子木鱼静态界面构建的章节,点击就会进入当前节点所处的源码位置:
|
||||
|
||||
|
||||
|
||||
那废话不多说,一起开始本教程的旅程吧 ~
|
||||
|
||||
|
||||
|
||||
|
176
专栏/Flutter入门教程/02Flutter开发环境的搭建.md
Normal file
176
专栏/Flutter入门教程/02Flutter开发环境的搭建.md
Normal file
@ -0,0 +1,176 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 Flutter 开发环境的搭建
|
||||
如果你已经有了 Flutter 的开发环境,可以跳过本篇。先声明一点: Flutter 虽然可以开发 Windows、Linux、Macos、Android、iOS 、web 六大主流平台的应用程序。但作为初学者,最好先在一端上学习 Flutter 的基础知识,不用过分追逐在每个平台上都跑一遍。
|
||||
|
||||
对于编程的新手朋友,我比较建议先在 Android 平台学习,首先 Android 真机设备或模拟器的门槛比较低,只需要一个 windows 电脑就可以了,而 iOS 应用需要 Mac 笔记本才能开发;其次,安卓的应用可以直接打包分享给别人用,iOS 则比较复杂;最后,移动端要比桌面端成熟一些,并不建议新手一开始从桌面端应用来学习 Flutter 。
|
||||
|
||||
当然,如果你以前是做 iOS 开发的,或者手上有 Mac 笔记本、iOS 真机,也可以选择通过 iOS 应用来学习。对于入门级别的 Flutter 知识来说,各个平台没有什么大的差异,所以不用过于纠结。
|
||||
|
||||
对于新手而言,开发环境搭建是一个非常大的坎,特别是跨平台的技术,涉及面比较广。我以前在 bilibili 发表过几个视频,介绍 Flutter 在各个平台开发应用的环境搭建。不想看文章或者是看文章无法理解的朋友,可以根据视频中的操作来搭建开发环境,尽可能降低门槛。
|
||||
|
||||
|
||||
(主要) Flutter 全平台开发环境 | SDK 和 开发工具
|
||||
(主要) Flutter 全平台开发环境 | Android 设备运行项目
|
||||
(选看) Flutter 全平台开发环境 | Window 平台桌面应用
|
||||
(选看) Flutter 全平台开发环境 | iOS/macOS 平台应用
|
||||
|
||||
|
||||
在介绍 Flutter 开发环境之前,先打个比方:如果说编写应用程序是在做一道 麻婆豆腐;Flutter 环境本身相当于 原材料 ,提供应用中需要的素材。但只有原材料是不足以做出一道菜的,还需要厨具进行烹调,厨具就相当于 IDE (集成开发环境),也就是编辑代码和调试的工具。最后,才是盛到盘子里,给用户品尝。
|
||||
|
||||
|
||||
|
||||
1. FlutterSDK 的下载与安装
|
||||
|
||||
对于 Flutter 本身而言,最重要的是下载 FlutterSDK ,地址如下:
|
||||
|
||||
|
||||
docs.flutter.dev/development…
|
||||
|
||||
|
||||
根据计算机的操作系统,选择最新稳定版的文件,比如现在最新版是 3.7.10 ,点击一下版本号就下载压缩包。这里以 Windows 操作系统为例:
|
||||
|
||||
|
||||
|
||||
下载完后,解压到一个文件夹下:
|
||||
|
||||
|
||||
|
||||
为了可以在计算机的任何地方都可以访问 flutter 提供的可执行文件,一般都会将 flutter\bin 文件夹路径配置到 Path 环境变量中。如下所示,红框中的路径和上一步解压的路径有关:
|
||||
注: 如果不会配置环境变量,可以参考上面第一个视频中的操作,或自己搜索解决。
|
||||
|
||||
|
||||
|
||||
另外,由于网络原因,可能国外网站的依赖难以下载,可以顺便在系统变量中配置官方提供的国内镜像,第一个视频中也有介绍操作方式。
|
||||
|
||||
PUB_HOSTED_URL=https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
|
||||
|
||||
|
||||
|
||||
|
||||
如果在命令行中执行 flutter --version,能给出结果,说明 Flutter 环境没有问题。这时烹饪的材料就已经准备好了,接下来看看怎么拿到厨具。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. IDE 开发工具的准备
|
||||
|
||||
对于一道菜来说,用什么厨具并不重要,重要的是如何把菜做好。有人喜欢用大锅,有人喜欢用小锅,个人偏好没有什么值得争吵的,工具最重要的是自己用着 趁手。 对于开发工具而言,我个人比较推荐 AndroidStudio ,因为:
|
||||
|
||||
|
||||
|
||||
AndroidStudio 的调试功能非常强大,也方便查阅源码
|
||||
AndroidStudio 方便管理和下载 AndroidSDK,可以创建安卓模拟器
|
||||
AndroidStudio 是 Android 的官方开发工具,对 Android 开发比较友好
|
||||
|
||||
|
||||
|
||||
如果不喜欢 AndroidStudio,也可以自己选择其他的开发工具,比如 VSCode 等
|
||||
|
||||
|
||||
|
||||
AndroidStudio 下载地址如下:
|
||||
|
||||
|
||||
developer.android.google.cn/studio
|
||||
|
||||
|
||||
|
||||
|
||||
下载完后运行安装包,一直下一步即可。首次安装时,会引导你下载 AndroidSDK,在接受之后进行下载:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
最后,在 Settings/Plugins 中安装 Dart 和 Flutter 插件,即可完成开发工具的准备工作。如果有什么不清楚的,可以参考上面的第一个视频。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 创建 Flutter 项目
|
||||
|
||||
安装完插件之后,重启 AndroidStudio,在新建项目时会有创建 Flutter 项目的选项。红框中选择 Flutter SDK 的路径位置:
|
||||
|
||||
|
||||
|
||||
在下一步中,填写应用的基本信息:
|
||||
|
||||
|
||||
|
||||
然后,就会创建出 Flutter 默认的计数器项目,这个小项目将在下一节进行分析。接下来看一下如何将项目运行到 Android 设备中。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4. Android 模拟器创建与运行项目
|
||||
|
||||
Flutter 全平台开发环境 | Android 设备运行项目 视频中介绍了 Android 模拟器的创建过程,这里简单说一下要点。首先,最好在环境变量中添加一个 ANDROID_HOME 的环境变量,值为 Android SDK 下载的目录:
|
||||
|
||||
|
||||
|
||||
在 AndroidStudio 上栏图标中找到如下设备管理器,点击 Create device 创建设备:
|
||||
|
||||
|
||||
|
||||
选择一个你觉得合适的手机尺寸:
|
||||
|
||||
|
||||
|
||||
另外最好在 New Hardware Profile 中将 RAM 调大一些,否则可能在运行时内存不足,程序安装不上:
|
||||
|
||||
|
||||
|
||||
然后选择下载镜像,一般都选最高版本:
|
||||
|
||||
|
||||
|
||||
最后,模拟器创建完成,点击运行:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
要将项目运行到手机中,点击上面菜单栏的小三角按钮即可:
|
||||
|
||||
|
||||
|
||||
项目运行之后,可以看到模拟器上展示了一个计数器应用,随着点击右下角的按钮,中间的数字会进行自加。这就是默认的计数器项目。
|
||||
|
||||
|
||||
|
||||
|
||||
项目运行
|
||||
点击加号
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
到这里 Flutter 的开发环境就已经搭建完成,其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。本篇到这里就告一段落,巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。
|
||||
|
||||
|
||||
|
||||
5.本章小结
|
||||
|
||||
到这里 Flutter 的开发环境就已经搭建完成,这里主要以 Android 应用开发的视角对 Flutter 框架进行学习。开发其他平台应用的开发环境基本上类似,如有需要可以参考视频中的操作。对于初学者而言,建议在起初专注在某一个平台中,学习 Flutter 基础知识,这些基础知识是全平台通用的。
|
||||
|
||||
本篇到这里就告一段落,如果把应用开发比作烹饪一席晚宴,那么环境搭建就相当于准备炊具和食材。其中编辑器是应用开发的炊具; Flutter SDK 中提供的 Dart 语法、类型体系就是食材。正所谓巧妇难为无米之炊,在介绍计数器项目之前,有必要先简单了解一下 Dart 的基础语法,你才有使用代码完成逻辑的能力。
|
||||
|
||||
|
||||
|
||||
|
477
专栏/Flutter入门教程/03新手村基础Dart语法(上).md
Normal file
477
专栏/Flutter入门教程/03新手村基础Dart语法(上).md
Normal file
@ -0,0 +1,477 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 新手村基础 Dart 语法 (上)
|
||||
无论你处于编程的任何阶段,都应该铭记一点:
|
||||
|
||||
|
||||
对于计算机编程而言,最重要的是 维护数据 的变化。
|
||||
|
||||
|
||||
比如你支付宝中的账户金额;你微信聊天记录的内容;你游戏中的资源装备;你随手拍的一张照片、录制的一段意义非凡的视频,都是数据在计算机中的不同表现形式。作为编程者要通过代码逻辑,保证数据在 用户与应用交互 过程中的准确性,来完成功能需求。
|
||||
|
||||
本教程只会介绍最最基础的 Dart 语法,能支撑后续教程的内容即可。无论是自然语言,还是编程语言,语法的学习都是非常枯燥的。对于新手而言,不必任何细节都面面俱到,正所谓贪多嚼不烂。学习应该循序渐进、细水长流,先自己能站立了,再想跑的事。如果已经有 Dart 基础的朋友,本章简单过一下就行了,也可能温故而知新哦 ~
|
||||
|
||||
|
||||
|
||||
一、基础数据类型
|
||||
|
||||
先思考一下,在现实世界中,我们如何表示不同类别的事物? 其实很简单,就是根据事物的特征进行划分,然后 取名字 进行表示。当这种表示形式在全人类中达到共识,就会成为一个认知标准,比如下面是一只猫:
|
||||
|
||||
|
||||
|
||||
而提到猫,自然就会想到它体型不大,有两个眼睛,两个耳朵,四个腿,脸上有胡须,这就是类型的特征。而 猫 这个名字本身只是一个标志,用于对一类事物的指代。虽然不同的地域可能有不同的叫法,但重要的其实不是它叫什么,而是它是什么,能干什么。
|
||||
|
||||
|
||||
|
||||
在编程中,数据类型也是类似,它的存在是为了解决:
|
||||
|
||||
|
||||
如何在代码中 表示数据种类 的问题。
|
||||
|
||||
|
||||
解决方法也是一样: 取名字 —- 通过名称区分不同类型数据的特点。在任何编程语言中有 内置基础类型 以供使用,下面来简单看一下 Dart 中基础数据类型变量的定义。到这里,先简单认识一下四种数据类型:
|
||||
数字、文字是人们日常生活在接触最多的数据,对于任何编程语言来说都是至关重要的。布尔型本身非常简单,只能表示真或假,对逻辑判断有着重要的意义。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. 数字类型
|
||||
|
||||
在日常生活在,数字 是非常必要的,它可以描述数量,也可以进行数学运算。在 Dart 语言中,数字有两大类型: 整型(整数) int 和浮点型(小数) double 。定义变量的语法是 类型名 变量名 = 值 ,下面代码中定义了两个变量:
|
||||
|
||||
void main(){
|
||||
// 整型 age,表示年龄的数值
|
||||
int age = 2;
|
||||
// 浮点型 weight,表示体重的数值
|
||||
double weight = 4.5;
|
||||
}
|
||||
|
||||
|
||||
在这里学习 Dart 基础知识时,可以在项目的 test 文件夹里,编辑代码做测试。点击三角可以运行代码:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
现在通过一个小练习,认识一下代码如何进行简单的逻辑运算:
|
||||
|
||||
|
||||
定义三个浮点型变量 a,b,c,值分别是 2.3, 4.5, 2.5 ;
|
||||
通过代码计算出平均值 avg ;并将结果输出到控制台。
|
||||
|
||||
|
||||
代码如下,数字量之间可以进行四则运算,求平均值就是将数字相加,除以个数。通过 print 函数可以在控制台输出量的信息:
|
||||
|
||||
void main(){
|
||||
double a = 2.3;
|
||||
double b = 4.5;
|
||||
double c = 2.5;
|
||||
|
||||
double avg = (a+b+c)/3;
|
||||
print(avg);
|
||||
}
|
||||
|
||||
|
||||
运行后,可以看到计算的结果。比如 a,b,c 变量分别代表三只小猫的体重,那这段代码就是:解决获得平均体重问题的一个方案。代码本身的意义在于 解决问题, 脱离问题需求而写代码,就像是在和空气斗智斗勇。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 字符串类型
|
||||
|
||||
在日常生活中,除了数字之外,最重要的就是 文字,在编程中称之为 字符串。 文字可以为我们提供信息,如何通过代码来表示和操作它,是一件非常重要的事。
|
||||
在 Dart 中,通过 String 类型表示字符串,字符串值可以通过单引号 'str' 或 双引号 "str" 进行包裹,一般情况下,两者没有太大的区别,用自己喜欢的即可。字符串直接可以通过 + 号进行连接,如下所示:
|
||||
|
||||
void main() {
|
||||
String hello1 = 'Hello, World!';
|
||||
String hello2 = "Hello, Flutter!";
|
||||
print(hello1 + hello2);
|
||||
}
|
||||
|
||||
---->[输出结果]----
|
||||
Hello, World!Hello, Flutter!
|
||||
|
||||
|
||||
|
||||
|
||||
通过 $变量名 可以在字符串内插入变量值。如下所示,将计算平均值的输出表述进行了完善:
|
||||
|
||||
void main() {
|
||||
double a = 2.3;
|
||||
double b = 4.5;
|
||||
double c = 2.5;
|
||||
|
||||
double avg = (a + b + c) / 3;
|
||||
String output = '$a,$b,$c 的平均值是$avg';
|
||||
print(output);
|
||||
}
|
||||
|
||||
---->[输出结果]----
|
||||
2.3,4.5,2.5 的平均值是3.1
|
||||
|
||||
|
||||
另外,也可以通过 ${表达式} 来嵌入表达式,比如:
|
||||
|
||||
String output = '$a,$b,$c 的平均值是${(a + b + c) / 3}';
|
||||
|
||||
|
||||
也可以理解为变量是特殊的表达式,在插入时可以省略 {}。对于字符串来说,先了解如何定义和使用就行了,以后如有需要,可以在其他资料中系统学习。
|
||||
|
||||
|
||||
|
||||
3. 布尔型 bool
|
||||
|
||||
布尔型用于表示真假,其中只有 true 和 false 两个值,一般用于判断的标识。布尔值可以直接书写,可以通过一些运算得到。比如数字的比较、布尔值的逻辑运算等。
|
||||
|
||||
void main() {
|
||||
// 直接赋值
|
||||
bool enable = true;
|
||||
double height = 1.18;
|
||||
// 布尔值可以通过运算获得
|
||||
bool free = height < 1.2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
二、运算符
|
||||
|
||||
运算符可以和值进行连接,进行特定运算,产出结果;比如加减乘除,大小比较,逻辑运算等。可以说运算符是代码逻辑的半壁江山。对于初学者而言,先掌握下面的三类运算符:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. 算数运算符
|
||||
|
||||
算数运算符作为数学的基础运算,从小就陪伴着我们,大家应该不会感到陌生。它连接两个数字进行运算,返回数值结果:
|
||||
|
||||
|
||||
|
||||
void main() {
|
||||
print(1 + 2);//3 加
|
||||
print(1 - 2);//-1 减
|
||||
print(1 * 2);//2 乘
|
||||
print(1 / 2);//0.5 除
|
||||
print(10 % 3);//1 余
|
||||
print(10 ~/ 3);//3 商
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2.比较运算符
|
||||
|
||||
比较运算符,也是日常生活中极为常见的。它连接两个值进行运算,返回比较的 bool 值结果 :
|
||||
|
||||
|
||||
|
||||
void main() {
|
||||
print(1 > 2); //false 大于
|
||||
print(1 < 2); //true 小于
|
||||
print(1 == 2); //false 等于
|
||||
print(1 != 2); //true 不等
|
||||
print(10 >= 3); //true 大于等于
|
||||
print(10 <= 3); //false 小于等于
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3.逻辑运算符
|
||||
|
||||
逻辑运算符,用于连接 bool 值进行运算,返回 bool 值。 理解起来也很简单,&& 表示两个 bool 值都为 true,才返回 true ; || 表示两个 bool 值有一个是 true,就返回 true ; ! 之后连接一个 bool 值,返回与之相反的值。
|
||||
|
||||
|
||||
|
||||
如下代码所示,open 和 free 是两个 bool 值,表示条件。通过 && 运算得到的 bool 值表示需要同时满足这两个条件,即 免费进入 需要公园开放,并且可以免费进入:
|
||||
|
||||
void main() {
|
||||
// 公园是否开放
|
||||
bool open = true;
|
||||
// 是否免费
|
||||
bool free = false;
|
||||
|
||||
// 公园是否免费进入
|
||||
bool freeEnter = open && free;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
三、流程控制
|
||||
|
||||
如果是运算符是代码逻辑的半壁江山,那流程控制 就是另外一半。流程控制可以分为 条件控制 和 循环控制;其中:
|
||||
|
||||
|
||||
条件控制 可以通过逻辑判断的语法规则,执行特定的分支代码块。
|
||||
循环控制 可以让某个代码块执行若干次,直到符合某些条件节点结束。
|
||||
|
||||
|
||||
|
||||
|
||||
1. 条件流程 : if - else
|
||||
|
||||
如果怎么样,就做什么,这种选择执行的场景,在日常生活中非常常见。而 if - else 就是处理这种选择分支的语法。 if 之后的 () 中填入布尔值,用于逻辑判断;条件成立时,执行 if 代码块;条件不成立,执行 else 代码块。如下代码根据 free 布尔值,打印是否可以免费入园:
|
||||
|
||||
void main() {
|
||||
double height = 1.18;
|
||||
// 布尔值可以通过运算获得
|
||||
bool free = height < 1.2;
|
||||
if(free){
|
||||
print("可免费入园");
|
||||
}else{
|
||||
print("请购买门票");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 条件控制 : switch - case
|
||||
|
||||
if - else 只能对 bool 值进行逻辑判断,进行分支处理。某些场景中需要对更多类型的值进行判断,这时就可以使用 switch - case 进行分支处理。
|
||||
如下代码所示,根据字母等级,打印对应的评级信息,其中 switch 后面的括号在是校验值, case 之后是同类型的值,当 case 后的值和校验值一致时,会触发其下的分支逻辑。 default 分支用于处理无法匹配的场合。
|
||||
|
||||
void main() {
|
||||
String mark = 'A';
|
||||
switch (mark) {
|
||||
case 'A':
|
||||
print("优秀");
|
||||
break;
|
||||
case 'B':
|
||||
print("良好");
|
||||
break;
|
||||
case 'C':
|
||||
print("普通");
|
||||
break;
|
||||
case 'D':
|
||||
print("较差");
|
||||
break;
|
||||
case 'E':
|
||||
print("极差");
|
||||
break;
|
||||
default:
|
||||
print("未知等级");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3. 循环流程 - for 循环
|
||||
|
||||
有些情况下,我们需要不断执行某一段逻辑(循环体),直到条件完成(循环条件),这就是循环控制。
|
||||
for 循环中,() 里有三个表达式,通过 ; 隔开,
|
||||
|
||||
|
||||
第一个表达式是进入循环之前执行的语句,在循环过程中不会再次执行;
|
||||
第二个表达式是循环条件,每次循环体执行完毕,都会校验一次。当条件满足时,会执行下次循环。
|
||||
第三个表达式在每次循环体执行完毕后,都会执行一次。
|
||||
|
||||
|
||||
如下代码的含义就是:在循环开始时定义 i 整数变量,赋值为 0, 在 i < 5 的条件下,执行循环体;每次循环体执行完毕后,让 i 增加 1 。 循环体中对 i 值进行累加,并且打印信息:
|
||||
|
||||
void main() {
|
||||
int sum = 0;
|
||||
for (int i = 0; i < 5; i = i + 1) {
|
||||
sum = sum + i;
|
||||
print("第 $i 次执行,sum = $sum");
|
||||
}
|
||||
}
|
||||
|
||||
---->[输出结果]----
|
||||
第 0 次执行,sum = 0
|
||||
第 1 次执行,sum = 1
|
||||
第 2 次执行,sum = 3
|
||||
第 3 次执行,sum = 6
|
||||
第 4 次执行,sum = 10
|
||||
|
||||
|
||||
注: i = i + 1 可以简写为 i += 1, 其他的算数运算符也都有这种简写形式。
|
||||
另外,表示整数 i 自加 1 ,也可以简写为 i++ 或 ++i。两者的区别在于对 i 赋值的先后性, 自减 1 同理。如果初学者觉得简写难看懂,可以不用简写。
|
||||
|
||||
---->[情况1:i++]----
|
||||
int i = 3;
|
||||
int a = i++; //执行赋值后i才自加,故a=3
|
||||
print('a=$a,i=$i');//a=3,i=4
|
||||
|
||||
---->[情况2:++i]----
|
||||
int i = 3;
|
||||
int a = ++i; //执行赋值前i已经自加,故a=4
|
||||
print('a=$a,i=$i');//a=4,i=4
|
||||
|
||||
|
||||
|
||||
|
||||
4. 循环流程 - while 循环
|
||||
|
||||
for 循环和 while 循环并没有什么本质上的区别,只是形式上的不同,两者可以进行相互转换。下面是类比 for 循环中的三块,将上面的代码转换为 while 循环。
|
||||
|
||||
|
||||
|
||||
可能有人会问,既然 while 循环和 for 循环可以相互转化,那为什么不干掉一个呢? 就像菜刀和美工刀虽然都可以完成切割的任务,但不同的场景下会有更适合的工具。 while 只需要关注循环条件,在某些场合下更简洁,语义也很不错; for 循环的固定格式,更便于阅读,可以一眼看出循环的相关信息。
|
||||
|
||||
|
||||
|
||||
另外还有 do - while 循环,算是 while 的变式。 do 代码块中是循环体,while 后依然是条件。 do - while 循环的最大特点是: 先执行循环体,再校验条件。也就是说,它的循环体必然会被执行一次。
|
||||
|
||||
void main() {
|
||||
int sum = 0;
|
||||
int i = 0;
|
||||
do{
|
||||
sum += i;
|
||||
print("第 $i 次执行,sum = $sum");
|
||||
i = i + 1;
|
||||
} while (i < 5);
|
||||
}
|
||||
|
||||
|
||||
根据不同的场景,可以选择不同的形式。 但千万别被形式整的晕头转向,记住一点:它们在本质上并没有区别,都是控制条件,执行循环体。而且它们之间都可以进行转换。
|
||||
|
||||
|
||||
|
||||
5. 中断控制 - break 和 continue
|
||||
|
||||
在循环流程中,除了循环条件可以终止循环,还可以通过其他关键字来中断循环。
|
||||
|
||||
|
||||
break 关键字: 直接跳出循环,让循环终止。
|
||||
continue 关键字:跳出本次循环,将进入下次循环。
|
||||
|
||||
|
||||
void main() {
|
||||
// ---->[break情景]----
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (i % 3 == 2) {
|
||||
break; //直接跳出循环
|
||||
}
|
||||
print("i:$i"); //打印了 0,1
|
||||
}
|
||||
|
||||
// ---->[continue情景]----
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (i % 3 == 2) {
|
||||
continue; //跳出本次循环,将进入下次循环
|
||||
}
|
||||
print("i:$i"); //打印了 0,1,3,4,6,7,9
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
四、函数的定义和使用
|
||||
|
||||
函数是一段可以有输入和输出的逻辑单元;通过函数,可以将特定的算法进行封装,简化使用。这里,我们通过一个实际的问题场景来介绍函数:
|
||||
|
||||
|
||||
身体质量指数,是BMI(Body Mass Index)指数,简称体质指数,是国际上常用的衡量人体胖瘦程度以及是否健康的一个标准。
|
||||
|
||||
|
||||
对于一个人来说,只要知道自己的身高和体重,就可以通过公式 : BMI=体重÷身高^2 计算出体质指数。如果每次计算时,都要根据公式计算是非常麻烦的。
|
||||
|
||||
void main() {
|
||||
double toly = 1.8 / (70 * 70);
|
||||
double ls = 1.79 / (65 * 65);
|
||||
double wy = 1.69 / (50 * 50);
|
||||
}
|
||||
|
||||
|
||||
另外,如果某天体质指数 的计算公式改变了,那散落在代码中各处的直接计算都需要修改,这无疑是非常麻烦的。 函数的价值就在于封装算法,下面来体会一下函数的价值。
|
||||
|
||||
|
||||
|
||||
1. 函数的简单定义
|
||||
|
||||
函数可以有若干个输入值,和一个输出值。比如对于计算体质指数 来说,有身高 height 和体重 weight 两个小数。输出是 体质指数 的具体值。如下所示,定义 bmi 函数:函数名前是返回值类型;之后的括号中是参数列表,通过 , 号隔开,每个参数由 参数类型 参数名 构成;{} 中是函数的具体算法逻辑:
|
||||
|
||||
double bmi(double height, double wight) {
|
||||
// 具体算法
|
||||
double result = wight / (height * height);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
需要获取一个人的 体质指数 ,只要调用 bmi 函数,传入身高和体重即可。使用者不需要在意公式具体是什么,如果某天公式改变了,只需要修改 bmi 中的算法实现,其他代码都无需改动。所以,函数的价值不仅在于实现算法逻辑,也能为某个功能提供一个公共的接入口,以应对可能改变的风险。
|
||||
|
||||
void main() {
|
||||
double toly = bmi(1.8, 70);
|
||||
double ls = bmi(1.79, 65);
|
||||
double wy = bmi(1.69, 50);
|
||||
|
||||
print("===toly:$toly===ls:$ls===wy:$wy===");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 命名参数
|
||||
|
||||
有些时候,函数的参数过多,在调用时需要记清顺序,是比较麻烦的。Dart 中支持命名参数,可以通过参数的名称来传参,不需要在意入参的顺序。通过 {} 包裹命名的参数,其中 required 关键字表示该入参必须传入; 另外,可以用 = 提供参数的默认值,使用者在调用时可以选填:
|
||||
|
||||
double bmi({
|
||||
required double height,
|
||||
double weight = 65,
|
||||
}) {
|
||||
// 具体算法
|
||||
double result = weight / (height * height);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
如下所示,在使用时可以通过 weight 和 height 指定参数,参数的顺序是无所谓的;另外由于 weight 有默认值,在调用时可以不填,计算时会取默认值:
|
||||
|
||||
void main() {
|
||||
double toly = bmi(weight: 70, height: 1.8);
|
||||
double ls = bmi(height: 1.79);
|
||||
double wy = bmi(height: 1.69, weight: 50);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3. 位置参数
|
||||
|
||||
方括号 [] 包围参数列表,位置参数可以给默认值:
|
||||
|
||||
double bmi([double height = 1.79, double weight = 65]) {
|
||||
// 具体算法
|
||||
double result = weight / (height * height);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
在使用时必须要按照参数顺序传入,它和普通参数列表的区别在于:在调用时,可以省略若干个参数,省略的参数使用默认值:
|
||||
|
||||
void main() {
|
||||
double toly = bmi(70, 1.8);
|
||||
double ls = bmi();
|
||||
double wy = bmi(1.69);
|
||||
}
|
||||
|
||||
|
||||
函数是一个非常有用的语法功能,在一个函数中可以调用其他函数,来组织逻辑。上面的 bmi 函数其实只是将一行代码的运算进行封装,已经显现出了很大的价值,更不用说能完成某些特定功能的复杂函数。但封装也很容易造成只会使用,不懂内部实现逻辑的局面,这在即使好事(不用在意底层逻辑,专注上层使用),也是坏事(底层逻辑有问题,难以自己修复)。
|
||||
|
||||
|
||||
这里给个小作业: 找一个计算公式,通过函数来封装对它的调用
|
||||
|
||||
|
||||
|
||||
|
||||
五、本章小结
|
||||
|
||||
到这里,对于编程来说最最基础的三个模块: 基本数据类型、运算符、流程控制,就有了基本的认识。这些语法点是代码逻辑实现的基石,对烹饪来说,相当于水、火、面、米、油、盐等最基础的材料,对一顿饭来说不可或缺。
|
||||
|
||||
基本的食材可以保证能吃饱,但对于一席盛大的晚宴来说,是远远不够的:酱醋茶酒、鸡鸭鱼肉,是更高等的食物素材。函数、对象可以对代码逻辑进行封装,从而更好地维护代码结构。下一篇,将继续介绍 Dart 基础语法,了解面向对象的相关知识。
|
||||
|
||||
|
||||
|
||||
|
458
专栏/Flutter入门教程/04新手村基础Dart语法(下).md
Normal file
458
专栏/Flutter入门教程/04新手村基础Dart语法(下).md
Normal file
@ -0,0 +1,458 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 新手村基础 Dart 语法 (下)
|
||||
一、面向对象
|
||||
|
||||
在学习的时候我们要明确一点,语言是一种工具,语法特性是为了方便我们实现需求的,不是故意制造出来刁难我们的。任何语法点的存在,都有它的价值和独特性。面向对象是 Dart 语法体系中非常重要的一支,也是一个威力无穷的工具。对于初学者而言,很难对其有深刻的认知,需要你在实践中累积与体悟。这一小结,将介绍一下面向对象最基础的语法点。
|
||||
|
||||
|
||||
|
||||
1. 自定义数据类型
|
||||
|
||||
上一篇说了,Dart 中有一些基础的数据类型。但有些场景下,只用基础的数据类型,解决问题是非常麻烦的。比如,现在要记录人身高体重数据信息。
|
||||
编程的本质是对数据的维护,而 身高、体重、姓名 三个数据是成对存在的,可以将其视为一个事物的三个属性,而在代码中如何表示有若干属性的某个事物呢? 答案就是 自定义数据类型,也就是人们常说的面向对象思想。
|
||||
|
||||
如下所示,通过 class 关键字来定义一个类型,{} 内是类的具体内容;其中可以定义若干个属性,也称之为 成员属性 。
|
||||
|
||||
class Human {
|
||||
String name = '';
|
||||
double weight = 0;
|
||||
double height = 0;
|
||||
}
|
||||
|
||||
|
||||
下面 tag1 处表示定义了一个 Human 类型的 toly 对象,将其赋值为 Human() ; 其中 Human() 表示通过构造函数来创建对象,也可以称之为 实例化 Human 对象 。 通过 对象.属性 =可以对属性进行赋值; 通过 对象.属性 可以访问属性对应的数据。
|
||||
|
||||
|
||||
|
||||
void main(){
|
||||
Human toly = Human(); // tag1
|
||||
toly.name = "捷特";
|
||||
toly.weight = 70;
|
||||
toly.height = 180;
|
||||
|
||||
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
|
||||
}
|
||||
|
||||
|
||||
这就是最简单的面向对象思想:通过一个自定义类型,定义若干个属性;通过实例化对象,来维护属性数据。
|
||||
|
||||
|
||||
|
||||
2. 构造函数
|
||||
|
||||
构造函数本身也是一个函数,它的价值在于:实例化对象时,可以对属性进行初始化。如下所示,构造函数的函数名和类名相同;参数列表就是函数的参数列表语法,不再赘述,这里是普通的参数列表传递参数;在实例化对象时,会触发函数体的逻辑,对属性进行赋值,这就是通过构造函数初始化成员属性。
|
||||
|
||||
注: 当入参名称和成员属性名称相同时,使用 this 关键字表示当前对象,对属性进行操作,从而避免歧义。
|
||||
|
||||
class Human {
|
||||
String name = '';
|
||||
double weight = 0;
|
||||
double height = 0;
|
||||
|
||||
Human(String name,double weight,double height){
|
||||
this.name = name;
|
||||
this.weight = weight;
|
||||
this.height = height;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这样就可以在实例化对象时,传入参数,为成员属性进行赋值:
|
||||
|
||||
void main(){
|
||||
Human toly = Human("捷特",70,180);
|
||||
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
另外,构造函数中,通过 this 对象进行赋值的操作,可以进行简化书写,如下所示:
|
||||
|
||||
class Human {
|
||||
// 略同...
|
||||
|
||||
Human(this.name,this.weight,this.height);
|
||||
}
|
||||
|
||||
|
||||
|
||||
小作业: 自己可以练习一下,构造方法中 命名传参{} 和 位置传参 []
|
||||
|
||||
|
||||
|
||||
|
||||
3. 成员函数(方法)
|
||||
|
||||
自定义类型中,不仅可以定义成员属性,也可以定义成员函数。一般来说,在面向对象的语言中,我们习惯于称类中的函数为 方法 。姓名通过一个小例子,来体会一下成员方法的价值:
|
||||
|
||||
如下所示,创建了三个 Human 对象,并且打印了他们的信息。可以看到 print 里面的信息格式基本一致,只是对象不同而已。每次都写一坨,非常繁琐。
|
||||
|
||||
|
||||
|
||||
void main(){
|
||||
Human toly = Human("捷特",70,180);
|
||||
print("Human: name{${toly.name},weight:${toly.weight}kg,height:${toly.height}cm}");
|
||||
|
||||
Human ls = Human("龙少",65,179);
|
||||
print("Human: name{${ls.name},weight:${ls.weight}kg,height:${ls.height}cm}");
|
||||
|
||||
Human wy = Human("巫缨",65,179);
|
||||
print("Human: name{${wy.name},weight:${wy.weight}kg,height:${wy.height}cm}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
我们可以定义一个成员方法,来处理介绍信息的获取工作:在成员方法中可以访问成员属性,这就相当于通过函数给出一个公共的访问入口,任何该类对象都可以通过 .info() 获取信息。如下所示,在使用时就会非常简洁和方便:
|
||||
|
||||
|
||||
|
||||
|
||||
小作业: 为 Human 类型添加一个 bmi 方法,用于计算 体质指数 。
|
||||
|
||||
|
||||
|
||||
|
||||
4. 类的继承
|
||||
|
||||
比如要记录的信息针对于学生,需要了解学生的学校信息,同时也可以基于身高体重计算 bmi 值。在已经有 Human 类型的基础上,可以使用关键字 extends,通过继承来派生类型。
|
||||
|
||||
在 Student 类中可以定义额外的成员属性 school, 另外 super.name 语法是:在入参中为父类中的成员赋值。
|
||||
|
||||
class Student extends Human {
|
||||
final String school;
|
||||
|
||||
Student(
|
||||
super.name,
|
||||
super.weight,
|
||||
super.height, {
|
||||
required this.school,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
这样就可以通过 Student 来创建对象,通过继承可以访问父类的方法,如下所示,Student 对象也可以使用 bmi 方法获取 体质指数 :
|
||||
|
||||
void main() {
|
||||
Student toly = Student("捷特", 70, 180,school: "安徽建筑大学");
|
||||
print(toly.bmi());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
5. 子类覆写父类方法
|
||||
|
||||
当子类中存在和父类同名的方法时,就称 子类覆写了父类的方法 ,在对象调用方法时,会优先使用子类方法,子类没有该方法时,才会触发父类方法。比如下面的代码,子类中也定义了 info 方法,在程序运行时如下:
|
||||
|
||||
|
||||
|
||||
注: 通过 super. 可调用父类方法; 一般子类覆写方法时,加 @override 注解进行示意 (非强制)
|
||||
|
||||
class Student extends Human {
|
||||
|
||||
// 略同...
|
||||
|
||||
@override
|
||||
String info() {
|
||||
String info = super.info() + "school: $school ";
|
||||
return info;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void main() {
|
||||
Student toly = Student("捷特", 70, 180,school: "安徽建筑大学");
|
||||
print(toly.bmi());
|
||||
print(toly.info());
|
||||
}
|
||||
|
||||
|
||||
对于初学者而言,面向对象的知识了解到这里就差不多了。这里介绍的是基础中的基础,随着知识的累计,未来肯定会接触到更多其他的知识。
|
||||
|
||||
|
||||
|
||||
二、聚合类型
|
||||
|
||||
日常生活中,还有一类数据总是批量呈现的:比如一个班级里有很多学生,一个英文字典有很多对应关系,围棋盘中有很多点位。如何对结构相似的批量数据进行维护,也是编程中非常重要的事。可以称这样的数据为 聚合类型 或 容器类型 。在 Dart 中,有三个最常用的聚合类型,分别是 列表 List、 映射 Map 和 集合 Set :
|
||||
|
||||
|
||||
|
||||
对于聚合类型而言,本质上是 Dart 语言提供的内置自定义类型,也就是说他们也是通过 class 定义的,其中有成员属性,也有成员方法。我们在一开始并不能对所有的成员方法进行面面俱到的讲解,只会对元素的添加、修改、访问、删除进行介绍,了解最基础的使用。
|
||||
|
||||
|
||||
|
||||
1. 列表 List
|
||||
|
||||
列表类型中可以盛放若干个同类型的对象,并且允许重复。在声明列表对象时,其中盛放的对象类型放在 <> 中,我们称之为 泛型 。如下定义 int 泛型的列表,就表示列表中只能盛放整数数据;可以通过 [] 便捷的创建列表对象,其中盛放初始的数据:
|
||||
|
||||
List<int> numList = [1,9,9,4,3,2,8];
|
||||
|
||||
|
||||
我们一般称元素在列表中的位置为 索引 , 索引从 0 开始计数。通过索引可以对索引处的值进行获取和修改的操作,代码如下 :
|
||||
|
||||
List<int> numList = [1,9,9,4,3,2,8];
|
||||
int second = numList[1];
|
||||
print(second);
|
||||
numList[3] = 6;
|
||||
print(numList);
|
||||
|
||||
---->[控制台输出]----
|
||||
9
|
||||
[1, 9, 9, 6, 3, 2, 8]
|
||||
|
||||
|
||||
|
||||
|
||||
通过 add 方法,可以在列表的末尾添加一个元素;insert 方法,可以在指定的索引处插入一个元素:
|
||||
|
||||
List<int> numList = [1,9,9,4,3,2,8];
|
||||
numList.add(10);
|
||||
numList.insert(0,49);
|
||||
print(numList);
|
||||
|
||||
---->[控制台输出]----
|
||||
[49, 1, 9, 9, 4, 3, 2, 8, 10]
|
||||
|
||||
|
||||
|
||||
|
||||
列表方法中 remove 相关方法用于移除元素,比如 removeAt 移除指定索引处的元素;remove 移除某个元素值; removeLast 移除最后元素:
|
||||
|
||||
List<int> numList = [1,9,9,4,3,2,8];
|
||||
numList.removeAt(2);
|
||||
numList.remove(3);
|
||||
numList.removeLast();
|
||||
print(numList);
|
||||
|
||||
---->[控制台输出]----
|
||||
[1, 9, 4, 2]
|
||||
|
||||
|
||||
|
||||
|
||||
对于聚合型的对象来说,还有一个比较重要的操作,就是如何遍历访问其中的元素。通过 .length 可以得到列表的长度,所以自然可以想到使用 for 循环,让索引自加,就能依次输出对应索引的值:
|
||||
|
||||
List<int> numList = [1, 9, 9, 4];
|
||||
for (int i = 0; i < numList.length; i++) {
|
||||
int value = numList[i];
|
||||
print("索引:$i, 元素值:$value");
|
||||
}
|
||||
|
||||
---->[控制台输出]----
|
||||
索引:0, 元素值:1
|
||||
索引:1, 元素值:9
|
||||
索引:2, 元素值:9
|
||||
索引:3, 元素值:4
|
||||
|
||||
|
||||
如果遍历过程中,不需要索引信息,也可以通过 for-in 循环的语法,方便地遍历列表中的值:
|
||||
|
||||
for(int value in numList){
|
||||
print("元素值:$value");
|
||||
}
|
||||
|
||||
---->[控制台输出]----
|
||||
元素值:1
|
||||
元素值:9
|
||||
元素值:9
|
||||
元素值:4
|
||||
|
||||
|
||||
|
||||
|
||||
2. 集合 Set
|
||||
|
||||
集合类型也可以盛放若干个同类型的对象,它最大的区别是 不允许重复 ,它同样也支持一个泛型。如下定义 int 泛型的集合,就表示列表中只能盛放整数数据;可以通过 {} 便捷的创建集合对象,其中盛放初始的数据。
|
||||
如下所示,当创建的集合在存在重复元素,将被自动合并,在输出时只有一个 9 元素:
|
||||
|
||||
Set<int> numSet = {1, 9, 9, 4};
|
||||
print(numSet);
|
||||
|
||||
---->[控制台输出]----
|
||||
{1, 9, 4}
|
||||
|
||||
|
||||
集合本身是没有索引概念的,所以无法通过索引来访问和修改元素,因为集合本身在数学上的概念就是无序的。它可以通过 add 方法在集合中添加元素;以及 remove 方法移除某个元素值:
|
||||
|
||||
Set<int> numSet = {1, 9, 4};
|
||||
numSet.add(10);
|
||||
print(numSet);
|
||||
|
||||
---->[控制台输出]----
|
||||
{1, 4, 10}
|
||||
|
||||
|
||||
|
||||
|
||||
集合最重要的特征是可以进行集合间的运算,这点 List 列表是无法做到的。两个集合间通过 difference、union 、intersection 方法可以分别计算差集、并集、交集。计算的结果也是一个集合:
|
||||
|
||||
Set<int> a = {1, 9, 4};
|
||||
Set<int> b = {1, 9, 3};
|
||||
print(a.difference(b));// 差集
|
||||
print(a.union(b)); // 并集
|
||||
print(a.intersection(b)); // 交集
|
||||
|
||||
---->[控制台输出]----
|
||||
{4}
|
||||
{1, 9, 4, 3}
|
||||
{1, 9}
|
||||
|
||||
|
||||
|
||||
|
||||
由于集合没有索引概念,使用无法像 List 那样通过 for 循环增加索引来访问元素;但可以通过 for-in 循环来遍历元素值:
|
||||
|
||||
Set<int> numSet = {1, 9, 4};
|
||||
for(int value in numSet){
|
||||
print("元素值:$value");
|
||||
}
|
||||
|
||||
---->[控制台输出]----
|
||||
元素值:1
|
||||
元素值:9
|
||||
元素值:4
|
||||
|
||||
|
||||
|
||||
|
||||
3. 映射 Map
|
||||
|
||||
地图上的一个点,和现实中的移除位置一一对应,这种就是映射关系。地图上的点可以称为 键 key ,实际位置称为 值 value ; Map 就是维护若干个键值对的数据类型。 日常生活中有很多映射关系,比如字典中的字和对应释义、书目录中的标题和对应的页数、钥匙和对应的锁等。
|
||||
应用映射中的一个元素记录着两个对象,所以 Map 类型有两个泛型,分别表示 key 的类型和 value 的类型。如下所示,定义一个 Map<int, String> 的映射对象,其中维护数字和英文单词;remove 方法可以根据 key 移除元素:
|
||||
|
||||
Map<int, String> numMap = {
|
||||
0: 'zero',
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
};
|
||||
print(numMap);
|
||||
numMap.remove(1);
|
||||
print(numMap);
|
||||
|
||||
---->[控制台输出]----
|
||||
{0: zero, 1: one, 2: two}
|
||||
{0: zero, 2: two}
|
||||
|
||||
|
||||
|
||||
|
||||
通过 [key] = value 语法可以向映射中添加元素,如果 key 已经存在,这个行为就是修改对应的值:
|
||||
|
||||
Map<int, String> numMap = {
|
||||
0: 'zero',
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
};
|
||||
numMap[3] = 'three';
|
||||
numMap[4] = 'four';
|
||||
print(numMap);
|
||||
|
||||
---->[控制台输出]----
|
||||
{0: zero, 1: one, 2: two, 3: three, 4: four}
|
||||
|
||||
|
||||
|
||||
|
||||
对于映射来说,可以通过 forEach 方法来遍历元素值:
|
||||
|
||||
Map<int, String> numMap = {
|
||||
0: 'zero',
|
||||
1: 'one',
|
||||
2: 'two',
|
||||
};
|
||||
numMap.forEach((key, value) {
|
||||
print("${key} = $value");
|
||||
});
|
||||
|
||||
---->[控制台输出]----
|
||||
0 = zero
|
||||
1 = one
|
||||
2 = two
|
||||
|
||||
|
||||
|
||||
|
||||
三、 语言特性
|
||||
|
||||
Dart 中有一些特殊的语言特性,比如空安全、异步等知识。这里简单介绍一下,能满足本教程的使用即可。
|
||||
|
||||
1. 空安全
|
||||
|
||||
Dart 是一个空安全的语言,也就是说,你无法将一个非空类型对象值设为 null :
|
||||
|
||||
|
||||
|
||||
如果希望对象可以赋值为 null ,需要在类型后加上 ? 表示可空:
|
||||
|
||||
|
||||
|
||||
这样,如果一个函数中是 String 入参,那么函数体内的 word 对象就必定不为 null ,这样就可以在编码时明确对象的可空性,做到被访问对象的空安全。
|
||||
|
||||
|
||||
|
||||
如果希望在调用时可以传入 null ,入参类型就是 String? ,那么在函数体内访问可空对象时,也在编码阶段给出警告示意。如果没有空安全的支持,编码期间就很难确定 String 对象是否可空,从而 null 调用方法的异常只能在运行时暴露;有了空安全机制,在编码期间就可以杜绝一些空对象调用方法导致的异常。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 异步任务
|
||||
|
||||
关于异步是一个很大的话题,这里只简单介绍一下用法。想要更深入了解,可以研读我在掘金发表过一个专栏 《Flutter 知识进阶 - 异步编程》
|
||||
|
||||
|
||||
|
||||
异步任务可以在未完成之前,让程序继续执行其他的逻辑,在完成之后接收到通知。拿最常见的文件读取异步任务来说:如下 test 函数中,使用 readAsString 异步方法读取一个文件,通过 then 监听对调,回调中的参数就是读取的字符串。
|
||||
|
||||
此时下面的 做些其他的事 将会在读取完毕之前触发。也就是说:一个任务没有完成,第二个任务可以进行,这就是异步。就像烧水和扫地两个任务可以同时进行。
|
||||
|
||||
String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml';
|
||||
File file = File(path);
|
||||
print("开始读取");
|
||||
file.readAsString().then((value) {
|
||||
print("===读取完毕: 文字内容长度 = ${value.length}====");
|
||||
});
|
||||
print("做些其他的事");
|
||||
|
||||
|
||||
|
||||
|
||||
有些时候,需要等待异步任务完成,才能继续之后的任务。比如,只要水烧开才能去倒水,可以通过 await 关键字等待异步任务的完成,获取结果:
|
||||
|
||||
Future<void> test2() async{
|
||||
String path = r'E:\Projects\Flutter\flutter_first_station\pubspec.yaml';
|
||||
File file = File(path);
|
||||
print("开始读取");
|
||||
String content = await file.readAsString();
|
||||
print("===读取完毕: 文字内容长度 = ${content.length}====");
|
||||
print("做些其他的事");
|
||||
}
|
||||
|
||||
|
||||
有一点需要注意:在控制台输出时,main 函数结束后就会停止,而文件读取的结果要稍后才能完成,导致无法打印出读取结果。由于应用程序交互时一直在启动中,这个问题不会出现,也不用太在意。不过,大家可以在这里埋个小问题,在以后的生涯中尝试解决:
|
||||
|
||||
|
||||
想个方法让 main 函数等一下,可以完成如下的输出效果:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
四、本章小结
|
||||
|
||||
最基础的 Dart 语法就介绍到这里,对本教程的学习来说,这些基本上够用了。如果后面遇到其他的语法,会单独介绍一下。再次强调:这些知识只够入个门而言,Dart 还有非常丰富的语言特性。后期如果对 Flutter 感兴趣,请务必系统地学习一下 Dart 语言。
|
||||
|
||||
目前,本教程的晚宴食材已经准备就绪。下一章将分析一下 Flutter 计数器项目,了解官方提供的这道初始点心的烹饪手法,以及其中蕴含的知识点。
|
||||
|
||||
|
||||
|
||||
|
319
专栏/Flutter入门教程/05Flutter计数器项目解读.md
Normal file
319
专栏/Flutter入门教程/05Flutter计数器项目解读.md
Normal file
@ -0,0 +1,319 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 Flutter 计数器项目解读
|
||||
通过前面两篇基础语法的学习,已经掌握了 Dart 语言最基础的逻辑控制能力。接下来我们来看一下新建时的默认计数器项目,对项目几个问题进行有一个简单的认知:
|
||||
|
||||
|
||||
|
||||
界面上的文字是由代码中的何处决定的?
|
||||
点击按钮时,数字自加和更新界面是如何完成的?
|
||||
界面中的显示部件是如何布局的?
|
||||
我们如何修改界面中展示的信息,比如颜色、字体等?
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
界面显示部件
|
||||
修改展示信息
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
一、代码定位工具
|
||||
|
||||
不管是自己写的代码,还是别人写的,随着代码量的增长,代码的可读性将面临考验。特别是新手朋友,喜欢把所有代码的实现逻辑塞在一块。如何在复杂的代码中找到关键的位置;或如何提纲挈领,将复杂的文字展示出结构性,对编程来说是非常重要的。在看代码之前,有必要先介绍几个快速定位代码的小技巧。
|
||||
|
||||
|
||||
|
||||
1. 全局搜索
|
||||
|
||||
在顶部栏 Edit/Find/Find in Files 打开搜索面板,面板中也可以看到对应的快捷键。如果快捷键没响应,很大可能是和输入法的快捷键冲突了。比如搜狗输入法,禁用其快捷键即可。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
从视觉上可以看出,界面中有一些固定的文字,这些文字很可能在代码之中。所以全局的搜索是一个很有用的技巧,比如搜索 You have pushed 时,可以找到代码中与之对应的地方:
|
||||
注:
|
||||
|
||||
|
||||
|
||||
在点击之后,就可以跳转到对应的位置,此时光标会在那里闪烁,如下所示:
|
||||
|
||||
|
||||
|
||||
当鼠标 悬浮 在上方的文件名上时, 会弹出文件所在的磁盘地址,这样能便于我们找到文件所在:
|
||||
|
||||
|
||||
|
||||
这样就能分析出,界面上展示的信息是由 lib/main.dart 决定的。这就是一个非常基本的 逻辑推理 过程,而整个过程和并不需要用到什么编程知识。相比于推理结果,这种推理的意识更加重要,很多时候初学者都会处于: 我不知道自己该知道什么,而推理的意识就是在让自己:我要知道自己想知道什么 。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 类结构信息
|
||||
|
||||
当分析一份代码文件时,在 AndroidStudio 中可以打开 Structure 页签,其中会展示出当前文件中的所有类的结构信息,比如成员属性、成员方法等。这样,你可以快速了解这份代码有哪些东西,在点击信息时,也能立刻跳转到对应的代码处:
|
||||
|
||||
|
||||
|
||||
其中 C 图标表示类,m 图标表示方法, f 图标表示成员属性。当前文件夹中定义了三个类型和一个 main 方法。每个类型中会定义若干方法和属性,其中可以清晰地看出函数的名称、入参和返回值。
|
||||
|
||||
|
||||
|
||||
3. 布局分析
|
||||
|
||||
在 Flutter Inspector 页签中,可以看出当前界面的布局结构。点击某项时,会跳转到代码对应的位置,这就是不过展示布局结构,辅助我们快速定位到对应代码位置:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如果布局结构过于复杂,在树中寻找节点也非常麻烦。如果现在已经有了一个项目,运行起来,如何迅速找到界面中的部件,对应代码中的位置呢? Flutter Inspector 中提供了选择模式,点击下面的图标开启:
|
||||
|
||||
|
||||
|
||||
选择模式下,当点击界面上的显示部件,就会自动挑战到对应的代码位置。对于定位代码来说,可谓神器。另外注意一点,点击后左下角会有个搜索按钮,如果想选择其他部件,要先点一下那个搜索按钮:
|
||||
|
||||
|
||||
|
||||
|
||||
选择模式
|
||||
选择模式
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
二、计数器代码分析
|
||||
|
||||
去除注释之后,计数器项目也就 68 行 代码,算是一个非常简单的小项目,但它完成了一个基本的功能交互。可谓麻雀虽小五脏俱全。一开始是 main 方法,表示程序的入口,其中先创建 MyApp 类型对象,并将该对象作为参数传入 runApp 函数中。
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
1. 初见 Widget 类型
|
||||
|
||||
MyApp 继承自 StatelessWidget 类,并覆写了其 build 方法,返回 Widget 类型对象;在方法的实现中,创建了 MaterialApp 对象并返回,其中 theme 入参表示主题, 通过 Colors.blue 可以看到蓝色主题的来源。
|
||||
|
||||
这时,你可以将 blue 改为 red 然后按 Ctrl+S 进行保存,可以看到界面中的主题变成了红色。这种在开发过程中,不重新启动就可以更新界面的能力,称之为 热重载 hot reload 。不用每次都重新编译、启动,这可以大大提升开发的时间效率。
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
决定界面展示的配置信息的类型我们称之为 组件 Widget, runApp 方法的入参是 Widget 类型,而 MyApp 可以占位参数,就说明它是 Widget 的派生类;MyApp#build 方法返回的是 Widget 类型,方法实现中实际返回的是 MaterialApp 对象,就说明 MaterialApp 也是 Widget 的派生类。这也是一个简单的逻辑推理过程。
|
||||
|
||||
另外 MaterialApp 构造方法的 home 入参,也是需要传入一个 Widget 类型,所以下面的 MyHomePage 也是 Widget 的派生类。从这里可以看出, Flutter 框架中界面的展示和 Widget 一族息息相关。
|
||||
|
||||
|
||||
|
||||
2. MyHomePage 代码分析
|
||||
|
||||
从代码中可以看出 MyHomePage 继承自 StatefulWidget , 其中有一个 String 类型的成员属性 title,并在构造时进行赋值。另外,还覆写了 createState 方法,创建 _MyHomePageState 对象并返回。
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
|
||||
对于初学者而言,并不需要太关注覆写的方法是何时触发的,应该在意的是它能提供什么功能。就像现实生活中,你学习画画,如果纠结为什么蓝色颜料和黄色颜料混合起来会是绿色,而去研究大量的光学资料、人视觉的成像原理,是本末倒置的。 探索世界(框架)的原理固然重要,但绝不是新手阶段需要做的事,除非你是天赋异禀,或你的目的不是画画,而是科研。
|
||||
|
||||
|
||||
|
||||
3. _MyHomePageState 代码分析
|
||||
|
||||
上面的 MyApp 和 MyHomePage 两者都是 Widget 的派生类,其中的代码逻辑并不是太复杂,主要是覆写父类方法,完成特定的任务。代码中还剩下一个 _MyHomePageState 类。 从结构上来看,其中有一个整型的 _counter 成员属性;两个成员方法:
|
||||
|
||||
|
||||
|
||||
从类定义可以看出, _MyHomePageState 继承自 State 类:
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
|
||||
|
||||
|
||||
|
||||
其中 _incrementCounter 方法中,会让 _counter 变量自加;很明显 _counter 变量就是计数器中展示的数值。而点击按钮时将会触发 _incrementCounter方法完成数值自加:
|
||||
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
最后,就是 _MyHomePageState 中覆写的 build 方法,可以看出界面中的信息是和代码中的内容是对应的。所以,很容易理解代码如何决定界面显示内容。
|
||||
|
||||
|
||||
|
||||
|
||||
动手小实验: 大家可将 _incrementCounter 中的 setState 去掉 (如下),运行后点击按钮查看效果。将其作为一个对比实验,思考一下 setState 的作用。
|
||||
|
||||
|
||||
void _incrementCounter() {
|
||||
_counter++;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
三、修改界面展示的信息
|
||||
|
||||
到这里,我们已经 感性地 认识了代码如何决定界面的显示。接下来,通过修改界面上的内容,更切身地体验一下,通过代码控制界面展示的感觉。
|
||||
|
||||
|
||||
|
||||
一、文字的修改
|
||||
|
||||
即使没有任何编程基础,也知道代码中的哪些字对应着屏幕中的哪些字,所以修改文字的展示是最简单的。比如现在将屏幕中间的英文改成中文,只需要把字符串换一下即可:
|
||||
|
||||
|
||||
|
||||
这样,在 Ctrl+S 保存之后,界面就会立刻更新:
|
||||
|
||||
|
||||
|
||||
|
||||
小练习: 试着把顶部的英文标题改成 计数器 三个字。
|
||||
|
||||
|
||||
|
||||
|
||||
经常玩手机的都知道,界面上文字有着非常多样式可以配置,最常见的就是文字和字号。在计数器的案例中,下面的数字要大很多,从代码中可以看出,区别在于指定了 style 入参。现在我们来尝试修改一下上方文字的大小和颜色:
|
||||
|
||||
|
||||
|
||||
实现方式就是在 Text 对象构造函数中传入 style 参数,参数类型是 TextStyle;除了文字和颜色之外,它还有其他的配置信息,以后可以慢慢了解。
|
||||
|
||||
const Text(
|
||||
'你点击按钮的次数:',
|
||||
style: TextStyle(color: Colors.purple, fontSize: 16),
|
||||
),
|
||||
|
||||
|
||||
到这里,我们知道了可以通过 Text 对象在屏幕上展示文字信息。
|
||||
|
||||
|
||||
|
||||
2.查看界面布局的技巧
|
||||
|
||||
对于布局来说,我们要清楚各个区域占据在屏幕中的哪些位置,就像古代皇帝分封土地,那片区域归谁管,是非常明确的。但初学者在不明白布局组件特性的情况下,很难知晓界面中的 “势力范围”。这时可以通过 Flutter Performance 页签中的按钮来开启 布局网格辅助 ,来快速了解界面情况:
|
||||
|
||||
|
||||
|
||||
如下所示,在有辅助线的界面中,有哪些 “势力范围” 一清二楚。另外,也可以通过上面介绍的 选择模式 来快速定位哪块区域是谁的 “地盘” 。
|
||||
|
||||
|
||||
|
||||
|
||||
无辅助
|
||||
有辅助
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下,通过选择模式,可以很轻松地知道,中间的区域是 Column 的地盘。代码中的表现是 Column 在构造时将两个文字作为孩子; Column 单词的含义是 列 ,它的作用是将若干个子组件竖直排列。
|
||||
|
||||
|
||||
|
||||
其中 Column 在构造时传入的 mainAxisAlignment 入参可以控制子组件在它地盘内的对其方式,比如下面是修改该属性时的表现效果。这里简单了解一下即可,感兴趣的也可以自己尝试一下,就像神农尝百草,了解效力。
|
||||
|
||||
|
||||
|
||||
|
||||
MainAxisAlignment.start
|
||||
MainAxisAlignment.spaceBetween
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
之前说过,在选择模式下,点击树中条目,对选中对于的位置。比如下面点击 Center,就可以看到它的地盘,它的作用是让它地盘中的子组件居中对其;这也是 Column 能在中间的根本原因:
|
||||
|
||||
|
||||
|
||||
|
||||
小练习: 试着在代码中去除掉 Center 组件,查看表现效果。
|
||||
|
||||
|
||||
有了 布局网格辅助 和 选择模式 两大利器,对于新手认知布局结构是非常友好的。新手应该多多使用它们,逐渐形成布局划分,领域约束的认知,对之后的工作会大有裨益。初始项目代码解读到这里就差不多了,其实这里新手需要关注的只有 mian.dart 中的代码。关于项目中的其他东西,暂时不用理会,专注于一点,更有利于新手的学习,细枝末节的东西,以后可以慢慢了解。最后,留个小练习:
|
||||
|
||||
|
||||
小练习: 修改代码,使得每次在点击按钮时,数字 + 10
|
||||
|
||||
|
||||
|
||||
|
||||
四、本章小结
|
||||
|
||||
本章主要结合初始计数器项目的代码,分析一下代码与界面间的关系。同时认识一下 Flutter 最基础的组件概念,通过更改界面的呈现,感性地了解代码中的文字是如何决定界面展示的。
|
||||
|
||||
另外,也介绍了 AndroidStudio 中的 Flutter Inspector 和 Flutter Performance 两个界面布局分析的工具;以及 Structure 页签查看当前文件类结构信息。合理地使用工具,可以让你更快地理解和掌握知识。接下来,我们将正式新手村进入第一个小案例 —- 猜数字项目。
|
||||
|
||||
|
||||
|
||||
|
169
专栏/Flutter入门教程/06猜数字界面交互与需求分析.md
Normal file
169
专栏/Flutter入门教程/06猜数字界面交互与需求分析.md
Normal file
@ -0,0 +1,169 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 猜数字界面交互与需求分析
|
||||
1. 界面交互介绍
|
||||
|
||||
猜数字是本教程的第一个案例,功能比较简单,非常适合新手朋友入门学习。下面是两个最基础的交互:
|
||||
|
||||
|
||||
点击按钮生成 0~99 的随机数,并将随机数密文隐藏。
|
||||
头部的输入框,点击时弹出软键盘,可输入猜测的数字。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
点击生成随机数
|
||||
可输入文字
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下所示,点击右上角的运行按钮,可以比较输入猜测值和生成值的大小,并在界面上通过两个色块进行提示。每次比较时,提示面板中的文字会有动画的变化,给出交互示意。
|
||||
|
||||
|
||||
|
||||
|
||||
比较结果:小了
|
||||
比较结果:大了
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这三个交互就是本案例的所有功能需求。你可以找几个朋友一起玩这个猜数字的小游戏,比如随机生成一个数后,每人输入一个数,最后猜中的人获取胜利。其中控制猜测的范围,使其更利于自己猜出结果,也是一点斗智斗勇。
|
||||
|
||||
|
||||
|
||||
2. 猜数字需求分析
|
||||
|
||||
现在从数据和界面的角度,来分析一下猜数字中的需求:
|
||||
|
||||
|
||||
随机数的生成
|
||||
|
||||
|
||||
随机数生成的需求中,有两个需要变化的数据,其一是待猜测的数字 _value 的赋值;其二是游戏的状态 _guessing 置为 true。
|
||||
|
||||
int _value = 0;
|
||||
bool _guessing = false;
|
||||
|
||||
|
||||
对于界面来说,当生成随机数后,要禁用按钮。也就是说按钮的表现形式,会受到 _guessing 数据的限制。
|
||||
|
||||
|
||||
|
||||
同样,中间数字的显示也会受到 _guessing 的影响,猜测过程中为密文;猜对之后游戏结束,展示明文数字。
|
||||
|
||||
|
||||
|
||||
|
||||
游戏进行中
|
||||
游戏结束
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
输入框的输入功能
|
||||
|
||||
|
||||
输入框输入需求中,需要一个数据来记录输入的内容。一般使用 TextEditingController 类型的数据和输入框进行双向绑定:也就是说用户的输入会导致控制器数值的变化,控制器数值的修改也会导致输入框内容的变化。
|
||||
|
||||
TextEditingController _guessCtrl = TextEditingController();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
猜测需求分析
|
||||
|
||||
|
||||
猜测需求中,需要一个数据表示猜测结果;猜测结果有三种:大了、小了和相等,这里使用一个可空的 bool 类型对象 _isBig 表示三种状态:
|
||||
|
||||
bool? _isBig;
|
||||
|
||||
null: 相等
|
||||
true: 大了
|
||||
false: 小了
|
||||
|
||||
|
||||
对于界面来说,需要根据 _isBig 的值,给出提示信息。其中 大了和小了的展示面板叠放在主题界面之上,占据一般的高度空间:
|
||||
|
||||
|
||||
|
||||
|
||||
大了
|
||||
小了
|
||||
相等
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
需求中的功能和数据这里简单地分析了一下,最后来说一下本案例中蕴含的知识点。
|
||||
|
||||
|
||||
|
||||
3. 猜数字中的知识点
|
||||
|
||||
首先,猜数字项目会接触到如下的常用组件,大家再完成猜数字项目的同时,也会了解这些组件的使用方式。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
另外,会对 Flutter 中的界面相关的知识有初步的认知:
|
||||
|
||||
|
||||
组件与界面的关系
|
||||
界面构建逻辑的封装
|
||||
状态数据与界面更新
|
||||
组件有无状态的差异性
|
||||
|
||||
|
||||
|
||||
|
||||
最后,在逻辑处理的过程中,是对 Dart 语法使用练习的好机会,在完成需求的过程中,会收获一些技能点。
|
||||
|
||||
|
||||
回调函数的使用
|
||||
动画控制器的使用
|
||||
随机数的使用
|
||||
|
||||
|
||||
界面交互和需求分析就到这里,下面一起开始第一个小项目的学习吧!
|
||||
|
||||
|
||||
|
||||
|
346
专栏/Flutter入门教程/07使用组件构建静态界面.md
Normal file
346
专栏/Flutter入门教程/07使用组件构建静态界面.md
Normal file
@ -0,0 +1,346 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 使用组件构建静态界面
|
||||
1. 代码的分文件管理
|
||||
|
||||
在计数器项目中,我们知道界面中展示的内容和组件息息相关,其中其决定性作用的是 MyHomePage 组件。但所有的代码都塞在了 main.dart 文件中,随着需求的增加,把所有代码都放一块,显然是不明智的。所以首先来看一下如何分文件来管理代码。
|
||||
|
||||
如下所示,先创建一个 counter 文件夹,用于盛放计数器界面的相关代码文件;然后创建 counter_page.dart 文件,并把 MyHomePage 的相关代码放入其中:
|
||||
|
||||
|
||||
|
||||
这时,可以将 main.dart 中 MyHomePage 的相关代码删除;会发现红色的波浪线,表示找不到 MyHomePage 类型:
|
||||
|
||||
|
||||
|
||||
此时只需要通过 import 关键字,在 main.dart 上方导入文件即可:
|
||||
|
||||
---->[main.dart]----
|
||||
import 'counter/counter_page.dart';
|
||||
|
||||
// 略同...
|
||||
|
||||
|
||||
分文件管理代码就像整理书籍,分门别类地进行摆放,各个区域各司其职,自己容易检阅,别人也容易看懂。下面创建一个 guess 文件夹,用于盛放本模块 猜数字 小项目的相关代码:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 创建你自己的组件
|
||||
|
||||
首先要明确一点,文件夹的名称、文件的名称、类型的名称、属性的名称、函数的名称,都是可以任意的,甚至可以使用汉字(但不建议),只要在使用时对应访问即可。但一个好名字对于阅读来非常重要,取 a1,c,b,d45,rrr 这样的名字,对阅读者而言是灾难,也许过两天,连你自己也认不得。所以一个好名字是个非常重要,不要偷懒, 最好有明确的含义。
|
||||
|
||||
比如对于猜数字这个需求来说,整体的界面可以叫 GuessPage , 这里我们可以先借用一下计数器中 MyHomePage 代码,照葫芦画瓢,改巴改巴。先把 MyHomePage 代码复制到 guess_page.dart 中。
|
||||
|
||||
|
||||
重命名小技巧: 当你想对一个类型、函数、属性名进行重命名,并且想让在它们使用使用处自动修改。可以将鼠标点在名称上,右键 -> Refactor -> Rename ; 也可以使用后面的快捷键直接操作。
|
||||
|
||||
|
||||
|
||||
|
||||
输入新名称后,点击 Refactor , 所有使用处都会同步更新:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
然后在 main.dart 中,将 GuessPage 作为 home 参数,即可展示 GuessPage 组件中的界面效果:
|
||||
|
||||
---->[main.dart]----
|
||||
import 'guess/guess_page.dart';
|
||||
// 略同...
|
||||
home: const GuessPage(title: '猜数字'),
|
||||
|
||||
|
||||
接下来的任务是如何修改代码,来完成猜数字的功能需求。首先我们来完成一件简单的事:
|
||||
|
||||
|
||||
点击按钮生成随机 0~99 之间的数字
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
初始效果
|
||||
点击生成随机数
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3.随机数的生成与界面更新
|
||||
|
||||
计数器项目中,点击按钮之所以界面数字自加,是因为 _incrementCounter 方法中,触发了 _counter++ 。所以想要在点击时显示随机数,思路很简单:将 _counter 变量赋值为随机数即可。
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
所以首先需要了解一下 Dart 中如何生成随机数:在 dart 的 math 包中,通过 Random 类型可以生成随机数。这里生成的是随机整数,使用 nextInt 方法,它有一个入参,表示生成随机数的最大值(不包含)。比如传入 100 时,将返回 0~99 之间的随机整数:
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
Random _random = Random();
|
||||
_random.nextInt(100);
|
||||
|
||||
|
||||
|
||||
|
||||
这样,点击按钮时只要将 _counter 赋值为随机数即可,如下所示:
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
_counter = _random.nextInt(100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
但之前说过,名字非常重要。但这里 _counter 含义是计数器,_incrementCounter 含义是自增计数器,在当前需求的语境中并不是非常适合。起名字最好和其功能相关,比如可以将数值变量可以称之 _value ; 方法可以称之 _generateRandomValue,这样代码阅读起来就会更容易理解。
|
||||
同样,也可以使用 Refactor 重命名:
|
||||
|
||||
class _GuessPageState extends State<GuessPage> {
|
||||
|
||||
int _value = 0;
|
||||
|
||||
Random _random = Random();
|
||||
|
||||
void _generateRandomValue() {
|
||||
setState(() {
|
||||
_value = _random.nextInt(100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4. 头部栏 AppBar 和输入框 TextFiled 的使用
|
||||
|
||||
接下来,我们要将头部的标题栏 AppBar 改成如下的样式,中间是可以输入的输入框,右侧是一个运行的图标按钮。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
AppBar 常用于左中右布局结构,如下所示:
|
||||
|
||||
AppBar(
|
||||
leading: 左侧,
|
||||
actions: [右侧列表],
|
||||
title: 中间部分,
|
||||
)
|
||||
|
||||
|
||||
也就是说在不同的参数中,可以插入不同的组件进行显示。比如这里 leading 指定为 Icon 组件,展示图标:
|
||||
|
||||
leading: Icon(Icons.menu, color: Colors.black,),
|
||||
|
||||
|
||||
actions 入参是一个组件列表,这里放入一个 IconButton 组件,展示图标按钮:
|
||||
|
||||
actions: [
|
||||
IconButton(
|
||||
splashRadius: 20,
|
||||
onPressed: (){},
|
||||
icon: Icon(Icons.run_circle_outlined, color: Colors.blue,)
|
||||
)
|
||||
],
|
||||
|
||||
|
||||
title 入参是中间部分,使用 TextField 组件展示输入框。这里组件构造时的入参对象都是用于配置展示信息的,可以简单认识一下,不用急着背诵,以后慢慢接触,早晚都会非常熟悉。
|
||||
|
||||
TextField(
|
||||
keyboardType: TextInputType.number, //键盘类型: 数字
|
||||
decoration: InputDecoration( //装饰
|
||||
filled: true, //填充
|
||||
fillColor: Color(0xffF3F6F9), //填充颜色
|
||||
constraints: BoxConstraints(maxHeight: 35), //约束信息
|
||||
border: UnderlineInputBorder( //边线信息
|
||||
borderSide: BorderSide.none,
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
),
|
||||
hintText: "输入 0~99 数字", //提示字
|
||||
hintStyle: TextStyle(fontSize: 14) //提示字样式
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
最后说一下,如何去掉顶部的灰块:AppBar 组件在构造时,可以通过 systemOverlayStyle 入参控制状态类和导航栏的信息。如下代码可以使顶部状态栏变成透明色,文字图标是暗色:
|
||||
|
||||
|
||||
|
||||
AppBar(
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarColor: Colors.transparent
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
5. 叠放 Stack 组件和列 Column 组件的使用
|
||||
|
||||
当用户输入的数字大了,或小了。需要界面上给予提示。为了更加醒目,这里给出的设计如下所示。如果大了,上半屏亮红色,展示 大了;如果小了,下半屏亮蓝色,展示 小了:
|
||||
|
||||
|
||||
|
||||
|
||||
小了提示
|
||||
大了提示
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
可以看出此时中间的文字是浮在提示色块之上的,想实现这种多个组件层层堆叠的效果,可以使用 Stack 组件来完成。其构造函数中 children 入参传入组件列表,在显示层次上后来居上。
|
||||
如下所示,将 Stack 作为 body , 在其中放入一个红色的 Container 容器组件, 以及之前的主内容。运行后会看到:中间文字就会浮在红色容器之上。
|
||||
|
||||
|
||||
|
||||
body: Stack(
|
||||
children: [
|
||||
Container(color: Colors.redAccent),
|
||||
//主内容略...
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
除了堆叠的效果,还有一个问题,如何实现 上下平分区域 呢? 我们前面知道 Column 组件可以让两个组件竖直排列。在 Column 中可以通过 Expanded 组件延展剩余区域,另外 Spacer() 组件相当于空白的 Expanded。当存在多个 Expanded 组件时,就可以按比例分配剩余空间,默认是 1:1 ,也可以通过 flex 入参调节占比。举个小例子,如下所示:
|
||||
|
||||
|
||||
|
||||
|
||||
Expanded 红+Expanded 蓝
|
||||
Expanded 红+ Spacer
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---->[红+蓝]----
|
||||
Column(
|
||||
children: [
|
||||
Expanded( child: Container(color: Colors.redAccent)),
|
||||
Expanded( child: Container(color: Colors.blueAccent)),
|
||||
],
|
||||
),
|
||||
|
||||
---->[红+空]----
|
||||
Column(
|
||||
children: [
|
||||
Expanded( child: Container(color: Colors.redAccent)),
|
||||
Spacer()
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
到这里,猜数字项目中需要使用的基础组件就已经会师完毕。大家可以基于现在已经掌握的知识,完成如下效果:
|
||||
注: Container 组件的 child 入参可以设置内容组件。 本例参考代码见: guess_page.dart
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
6.组件的简单封装
|
||||
|
||||
上面虽然完成了布局效果,但是从 guess_page.dart 中可以感觉到,随着需求的增加,各种组件全都塞到了一块。这才只是一个简单的布局,就已经有点不堪入目了;如果再加上交互的逻辑,恐怕要乱成一锅粥了。
|
||||
|
||||
其实对于新手而言,编程语言语法本身并不是什么难事,对规整代码的把握才是最大的挑战;很容易要什么,写什么,最后什么东西都塞在一块,连自己都看不下去了,从而心灰意冷,劝退放弃。小学时,老师就教导我们,遇到巨大的问题,要尝试将它分解成若干个小问题,逐一解决。
|
||||
|
||||
其实有些大问题在肢解过程中,会有某些类似的小问题,这些小问题可以通过某种相同的解决方案来处理。这种通用解决方案,就是一种封装的思想,问题的专属解决方案一旦封装完毕,输入问题,就可以解决问题,使用者不必在意处理的过程,可以大大提升解决问题的效率。回忆一下,在介绍函数时,通过 bmi 函数,封装体质指数的计算公式,就是通过函数来封装解决方案。
|
||||
|
||||
|
||||
|
||||
对于组件来说,也是一样:某些相似的结构,也可以通过 封装 进行复用。比如这里 大了 和 小了 只是颜色和文字不同,两者的结构类似。就可以通过封装来简化代码:
|
||||
最简单的封装形式是通过函数封装,通过入参来提供界面中差异性的信息,如下所示 _buildResultNotice 函数接收颜色和消息,返回 Widget 组件:
|
||||
|
||||
Widget _buildResultNotice(Color color, String info) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: color,
|
||||
child: Text(
|
||||
info,
|
||||
style: TextStyle(
|
||||
fontSize: 54, color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
如果把相似的结果写两遍,只会徒增无意义的代码。而封装之后,只需要调用方法,就可以完成任务:
|
||||
|
||||
Column(
|
||||
children: [
|
||||
_buildResultNotice(Colors.redAccent,'大了'),
|
||||
_buildResultNotice(Colors.blueAccent,'小了'),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
如果封装体的代码非常复杂,或者需要单独维护,以便之后修改方便定位,也可以通过新组件的形式来封装组件。如下所示,新创建 result_notice.dart 文件,在其中定义 ResultNotice 组件,专门处理结果提示信息的展示任务。
|
||||
|
||||
|
||||
|
||||
一方面,专人专用,需要更改时直接在这里更改。比如你让另一个人帮忙改改某处的代码,而他对项目不熟悉,如果代码分离的得当,你告诉他这个界面由 xxx.dart 文件负责,他就可以在不了解项目的前提下,对界面进行修改。这就是 职责分离 的益处。不同人干自己擅长的事,有利于整体结构的稳定。
|
||||
|
||||
另一方面也能缓解 guess_page.dart 中的代码压力,不至于随着需求的增加代码量激增,对可读性友好。在使用时,可以将 ResultNotice 视为普通的组件,放在 Column 之中:
|
||||
|
||||
Column(
|
||||
children: [
|
||||
ResultNotice(color:Colors.redAccent,info:'大了'),
|
||||
ResultNotice(color:Colors.blueAccent,info:'小了'),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
同样,这里 AppBar 组件的构建逻辑也是太复杂的,可以在 guess_app_bar.dart 中单独维护。这里主要是出于简化 guess_page.dart 中代码的考量,不强求可复用性。
|
||||
|
||||
|
||||
|
||||
这样 guess_page.dart 中的代码就整洁了很多,其他两个文件也在各司其职。相对与之前全塞在一块,更便于阅读,封装之后的代码见 guess_page.dart 。
|
||||
|
||||
|
||||
|
||||
7.本章小结
|
||||
|
||||
本章主要学习了如何通过 Flutter 框架提供的组件,来搭建期望的界面呈现效果。期间介绍了如何分文件来管理代码、以及通过自定义组件来封装构建逻辑。最后简单分析了一下组件封装的优势。
|
||||
|
||||
学完本章,你应该能够自己动手搭建一些简单的静态界面了。但应用程序是要和用户进行交互的,就需要界面随着用户的交互进行变化。下一篇将从用户交互的角度,通过代码来实现猜数字的具体功能。
|
||||
|
||||
|
||||
|
||||
|
179
专栏/Flutter入门教程/08状态数据与界面更新.md
Normal file
179
专栏/Flutter入门教程/08状态数据与界面更新.md
Normal file
@ -0,0 +1,179 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 状态数据与界面更新
|
||||
编程中的一切都是操作数据,界面上的表现都需要具体数据来支撑,决定界面展现的数据称之为 状态数据。然而:
|
||||
|
||||
|
||||
有些数据中界面中是恒定不变的,比如一些固定的文字、图标、颜色等;
|
||||
也有些数据会随着用户的交互发生改变,比如计数器项目中,数字会随着用户的点击而增加。
|
||||
|
||||
|
||||
如何通过代码逻辑,维护状态数据在交互过程中的正确性,就是对状态数据的管理。在一个需求中,哪些 状态数据 是可变的,需要具体问题具体分析。
|
||||
|
||||
|
||||
|
||||
1. 从按钮禁用开始说起
|
||||
|
||||
在猜数字的需求之中,点击按钮生成随机数。但在猜测的过程中,我们期望禁止点击来重新生成,否则又要重新猜测。也就是说,在一次猜数字过程中,只能生成一个随机数;同时,猜对时,需要解除禁止,进入下一次游戏。
|
||||
|
||||
对于生成随机数的需求,需要一个量来标识是否是在游戏过程中。这就是根据具体需求,来分析必要的状态数据。比如这里通过 bool 类型的 _guessing 对象标识是否在游戏过程中。界面和交互的逻辑表现在:当 _guessing 为 false 时,支持点击,按钮呈蓝色;为 true 时,禁止点击,按钮呈灰色:
|
||||
|
||||
|
||||
|
||||
|
||||
标题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下所示,在 _GuessPageState 中定义 _guessing 属性,FloatingActionButton 按钮组件在创建时根据 _guessing 值控制相关属性。比如 _guessing 为 true 时 onPressed 为 null, 表示不响应点击,且背景色是灰色 Colors.grey :
|
||||
|
||||
---->[guess_page.dart#_GuessPageState]----
|
||||
bool _guessing = false;
|
||||
|
||||
// 略...
|
||||
// 按钮组件构建逻辑 :
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _guessing ? null : _generateRandomValue,
|
||||
backgroundColor: _guessing ? Colors.grey : Colors.blue,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.generating_tokens_outlined),
|
||||
),
|
||||
|
||||
|
||||
注: boolValue ? a : b 称三目运算符,相当于一种简写的赋值语句;boolValue 为 true 时取 a,反之取 b 。下面是一个小例子:
|
||||
|
||||
int a = 5;
|
||||
int b = 6;
|
||||
bool boolValue = true;
|
||||
|
||||
int c = boolValue ? a : b
|
||||
// 上行代码等价于下面代码:
|
||||
int c;
|
||||
if(boolValue){
|
||||
c = a;
|
||||
}else{
|
||||
c = b;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
上面通过 _guessing 状态数据控制组件构造,从而达到控制表现的效果。写一个问题就是,如何在交互逻辑中,正确地维护 _guessing 状态数据值。这里的逻辑是:点击之后,表示游戏开始,将 _guessing 置为 true 。所以只需要在 _generateRandomValue 方法中添加一行即可:
|
||||
|
||||
void _generateRandomValue() {
|
||||
setState(() {
|
||||
_guessing = true; // 点击按钮时,表示游戏开始
|
||||
_value = _random.nextInt(100);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
大家可以将当前代码自己跑一下,点击操作。体会一下状态数据变化的过程 (业务逻辑),和对界面表现的控制力(界面构建逻辑)。
|
||||
|
||||
|
||||
|
||||
2.密文的展示
|
||||
|
||||
既然是猜数字,那么随机生成的数字肯定不能明晃晃地摆在那里,而且生成之后,上方的 点击生成随机数值 的提示信息也不需要了。可以看出这些界面表现都是通过 _guessing 状态数据决定的,通过状态数据控制界面呈现,一般称之为 界面构建逻辑 。
|
||||
|
||||
|
||||
|
||||
也就是在猜数字过程中,要隐藏数字的展示;取除上方的提示字,效果如下:
|
||||
|
||||
|
||||
|
||||
代码实现也比较简单,和之前一样,通过 _guessing 值,决定组件构造的内容。从这里可以看出,一个状态数据,可以控制界面中很多部件的表现形式。
|
||||
|
||||
|
||||
|
||||
|
||||
注: 在列表中可以通过 if(boolValue) 来控制是否添加某个元素。
|
||||
|
||||
|
||||
当前代码提交位置: guess_page.dart
|
||||
|
||||
|
||||
|
||||
3. 回调事件的传递
|
||||
|
||||
这样我们就实现了随机数字生成的需求,现在需要做的是猜数字需求。 在输入框中输入数字,点击确定按钮,比较后想用户提示大小信息。现在首要问题是知道如何获取输入的数字,以及如何触发按钮的点击事件。
|
||||
|
||||
|
||||
|
||||
我们之前将头部栏单独封装成一个组件,独立存放。现在想处理头部栏的相关工作,直接看 guess_app_bar.dart 文件即可。先来看一下点击事件的回调:
|
||||
|
||||
这里使用 IconButton ,也就是图标按钮,在 onPressed 构造入参中可以传入无参函数,用于回调。也就是说,你点击按钮就会触发一个函数(方法),如下所示,点击一下在控制台输出信息:
|
||||
|
||||
|
||||
|
||||
在 GuessAppBar 类中,并没有猜数字过程中的相关数据,在这里校验大小并不是很合适。这时就可以通过回调,将事件触发的任务移交给自己的使用者。因为函数本身也可以视为一个对象,如下所示,将函数作为属性成员,通过构造函数进行赋值:
|
||||
|
||||
|
||||
|
||||
这样在构造 GuessAppBar 组件时,就可以将回调事件交由 _GuessPageState 处理,而这里有我们维护的状态数据。处理如下:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4.输入控制器的使用
|
||||
|
||||
目前,输入框可以进行输入,但如何获取到输入内容呢? 我们可以使用输入控制器 TextEditingController 它可以承载输入的内容。它会作为 TextField 的构造入参,而 TextField 在 GuessAppBar 中;又因为由于核心逻辑的维护在 _GuessPageState 中,所以控制器对象可以交由 _GuessPageState 维护,并可以通过 GuessAppBar 构造函数来传入:
|
||||
|
||||
---->[guess_page.dart#GuessAppBar]----
|
||||
class GuessAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final VoidCallback onCheck;
|
||||
final TextEditingController controller;
|
||||
|
||||
const GuessAppBar({
|
||||
Key? key,
|
||||
required this.onCheck,
|
||||
required this.controller,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
// 略同...
|
||||
title: TextField(
|
||||
controller: controller,
|
||||
|
||||
|
||||
这样,在 _GuessPageState 里创建 _guessCtrl 属性,作为 GuessAppBar 构造入参,就可以和输入框进行绑定。当输入文字,点击按钮后,查看控制台,就可以看到输入信息。
|
||||
|
||||
|
||||
|
||||
另外,注意一下,输入控制器有销毁的方法,需要覆写状态类的 dispose 方法,调用一下:
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_guessCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
5.本章小结
|
||||
|
||||
到这里,相关的数据和界面就准备完毕,当前代码提交位置: guess_page.dart 。本章最主要的知识是通过改变数据来修改界面的呈现效果。比如在交互过程中,按钮的禁用、文字的密文展示会发生变化,它们的表现都在构造逻辑中由数据决定。大家可以通过当前的源码好好思考一下,状态数据和界面之间的关系。
|
||||
|
||||
下一章,将继续完善功能,处理校验以及提示用户输入值大了还是小了。
|
||||
|
||||
|
||||
|
||||
|
156
专栏/Flutter入门教程/09校验结果与提示信息.md
Normal file
156
专栏/Flutter入门教程/09校验结果与提示信息.md
Normal file
@ -0,0 +1,156 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 校验结果与提示信息
|
||||
1. 需求中的状态数据分析
|
||||
|
||||
在上一篇中,已经准备好了数据和界面。本篇将介绍比较结果的校验,以及展示用户输入值和随机目标值的大小信息。点击确定时,可能的结果有 3 中,如下所示:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下所示,是点击确定时不同情况下期望的界面效果;也就是说,界面的展现需要随着用户交互而变化。所以在当前需求之下,需要引入新的状态数据,用于控制界面的表现。
|
||||
|
||||
|
||||
|
||||
|
||||
大了
|
||||
小了
|
||||
相等
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这个状态数据将用于表示校验结果,该用什么类型呢?首先要明确一点:比较结果有三种情况: 大了、小了、相等 。其实只要某种数据类型包含三个值,都可以用于表示校验状态,只要逻辑正确即可,比如:
|
||||
|
||||
int 型:
|
||||
0: 大了
|
||||
1: 小了
|
||||
2: 相等
|
||||
|
||||
String 型:
|
||||
'big': 大了
|
||||
'small': 小了
|
||||
'equal': 相等
|
||||
|
||||
|
||||
但如果用 int 或 String 表示,首先必须提前做出规定。而对于不了解规定的人,在阅读时会增加理解的难度。如果这种类似的规定场景在一个项目里经常出现,就是一个个雷点,迟早会爆炸。其实 bool 类型的出现,就是为了避免用 0 和 1 整数来表示真假,带来语义上的难以理解。
|
||||
|
||||
能且仅能是一种语法上的约束,可以在根源上杜绝一些可能发生的错误。那问一个问题,什么类型有且仅有 3 个值?很多人会说 枚举,这确实可以,不过稍显麻烦。这里想用 bool?来表示三态,它有如下三个值:
|
||||
|
||||
null: 相等
|
||||
true: 大了
|
||||
false: 小了
|
||||
|
||||
|
||||
分析完后,现在 _GuessPageState 里定义为变量 _isBig 作为状态数据,控制校验结果。默认为 null :
|
||||
|
||||
bool? _isBig;
|
||||
|
||||
|
||||
|
||||
|
||||
2. 校验逻辑与状态数据的维护
|
||||
|
||||
校验逻辑算是比较简单的,对状态数据的维护可以称之为 业务逻辑,校验逻辑将在 _onCheck 回调中进行。上篇说过,这里可以得到目标值和输入值,输入值可以通过 int.tryParse 吧字符串转为整型。
|
||||
有些小细节要注意一下:如果 _guessing 为 false ,表示游戏未开始;或输入的不是整数,此时应该不做响应,直接返回即可。
|
||||
|
||||
void _onCheck() {
|
||||
print("=====Check:目标数值:$_value=====${_guessCtrl.text}============");
|
||||
|
||||
int? guessValue = int.tryParse(_guessCtrl.text);
|
||||
// 游戏未开始,或者输入非整数,无视
|
||||
if (!_guessing || guessValue == null) return;
|
||||
|
||||
//猜对了
|
||||
if (guessValue == _value) {
|
||||
setState(() {
|
||||
_isBig = null;
|
||||
_guessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 猜错了
|
||||
setState(() {
|
||||
_isBig = guessValue > _value;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
如果猜对了,表示游戏结束,将 _guessing 置为 true、_isBig 置为 null ; 如果猜错了,通过 guessValue 和 _value 的比较结果,为 _isBig 赋值。这就是对状态数据的维护过程。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 状态数据与界面构建逻辑
|
||||
|
||||
状态数据以及维护完毕,下面来看一下最后一步:使用 _isBig 状态控制界面的呈现。从最终效果,可以推断出_isBig 对界面的功效:
|
||||
|
||||
|
||||
|
||||
|
||||
大了
|
||||
小了
|
||||
相等
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
背景的色框提示,只有在大了或小了时才会出现,也就是说当 _isBig != null 时才会出现,对应下面代码的 69 行 。
|
||||
大了和小了是互斥的,不会同时出现,通过 Spacer 进行占位,大了时占下半;小了时占上半。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
注: isBig! 是空安全的语法,如果一个可空对象 100% 确定非 null。可以通过 对象名! 表示 非空对象。 由于上面 if(_isBig) 才会走下方逻辑,所以使用处 100% 非空。
|
||||
|
||||
|
||||
|
||||
|
||||
4.本章小结
|
||||
|
||||
到这里,猜数字简单版本就已经完成了。当前代码提交位置: guess_page.dart 你可以随机生成数字,在输入框中猜测数字,并校验猜测的值,给出提示。虽然是个小案例,但相比于计数器来说复杂了一些,额外维护了几个状态数据,界面布局上也更加复杂。是一个初学者进一步了解 Flutter 的好案例,把它吃透,会对你受益匪浅。
|
||||
|
||||
虽然现在可以完成猜数字需求,但是还有一点缺陷。比如当校验 小了, 下次校验还小时,由于 _isBig 的状态类没变,提示界面就没有任何变化(下左图)。如果用户的一个操作得不到反馈,体感上会觉得可能没点好;此时给予用户视觉上的反馈就会有比较好的体验,比如加一个动画效果(下左图):
|
||||
|
||||
|
||||
|
||||
|
||||
无动画
|
||||
有动画
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
299
专栏/Flutter入门教程/10动画使用与状态周期.md
Normal file
299
专栏/Flutter入门教程/10动画使用与状态周期.md
Normal file
@ -0,0 +1,299 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 动画使用与状态周期
|
||||
1. 什么是动画
|
||||
|
||||
接下来,我们将进入猜数字项目的最后一个模块:动画的使用。上一节末尾说了,此处使用动画的目的是 增加交互反馈效果 。动画本质上就是不断更新界面展示的内容,玩过翻页动画的感触会深一些:
|
||||
|
||||
|
||||
在每页纸上绘制连续的动作,手快速翻动时,内容快速变化,形成连续运动的动画效果。
|
||||
|
||||
|
||||
|
||||
|
||||
这里根据翻页动画,先统一给定几个概念描述,方便后续的表述:
|
||||
|
||||
|
||||
动画帧 : 一页纸上的内容。
|
||||
动画时长 : 从开始翻看,到结束的时间差。
|
||||
帧率 : 动画过程中,每秒钟包含动画帧的个数。
|
||||
动画控制动器: 动画进行的动力来源,比如翻页动画中的手。
|
||||
|
||||
|
||||
|
||||
|
||||
其实对于界面来说也是类似的,屏幕上展示的内容不断变化,给人视觉上的动画效果。对于界面编程来说,动画一般都是改变某些属性值;比如这里是对中间文字的大小进行动画表现:
|
||||
|
||||
|
||||
|
||||
|
||||
标题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
我们之前将提示信息的界面封装成 ResultNotice 组件进行展示,现在想要修改面板的展示效果,只要对该组件进行优化即可。可以很快定位到 result_notice.dart, 这也是各司其职的一个好处。下面就来看一下,让文字进行动画变化的流程。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 动画控制器的创建
|
||||
|
||||
要进行动画,首先要找到 驱动力, 也就是翻页的那只手怎么得到。Flutter 框架层对这只手 (Ticker) 进行了封装,给出一个更易用的 AnimationController 类型。想创该类型对象需要两步:
|
||||
|
||||
|
||||
1. 一般在 State 派生类中创建 AnimationController 对象,使用这里将 ResultNotice 改为继承自 StatefulWidget :
|
||||
|
||||
|
||||
class ResultNotice extends StatefulWidget {
|
||||
final Color color;
|
||||
final String info;
|
||||
|
||||
const ResultNotice({
|
||||
Key? key,
|
||||
required this.color,
|
||||
required this.info,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ResultNotice> createState() => _ResultNoticeState();
|
||||
}
|
||||
|
||||
class _ResultNoticeState extends State<ResultNotice>{
|
||||
//...
|
||||
}
|
||||
|
||||
|
||||
|
||||
1. 将状态类通过 with 关键字混入 SingleTickerProviderStateMixin, 让状态类拥有创建 Ticker 的能力。这样在 AnimationController 构造方法中 vsync 入参就可以传入当前状态类。
|
||||
对于新手来说,这可能比较难理解,可以先记成固定的流程。不用太纠结,以后有能力时,可以在动画小册中探索更深层的原理。
|
||||
|
||||
|
||||
class _ResultNoticeState extends State<ResultNotice> with SingleTickerProviderStateMixin{
|
||||
|
||||
late AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
这样动画控制器对象就在创建完毕了,在创建对象时 duration 入参用于控制动画时长。默认情况下,动画控制器的值会在指定时长内,从 0 匀速变化到 1 。 下面来看一下如何通过动画控制器,来驱动字号大小的变化。
|
||||
|
||||
|
||||
|
||||
3. 动画构造器 AnimatedBuilder 的使用
|
||||
|
||||
动画本身就决定它需要频繁地变化,但很多时候我们只需要局部一小部分进行动画,比如这里只针对于文字。对于这种频繁变化的场景,最好尽可能小地进行重新构建。这里推荐新手使用 AnimatedBuilder 组件,可以非常方便地处理局部组件的动画变化。 由于需要动画的只是文字,所步骤如下:
|
||||
|
||||
|
||||
将 AnimatedBuilder 套在 Text 组件之上。
|
||||
将动画控制器作为 animation 入参。
|
||||
将需要动画变化的属性值,根据 animation.value 进行计算即可。
|
||||
|
||||
|
||||
|
||||
|
||||
刚才说过,默认情况下,动画控制器启动之后,它的值会在指定时长内,从 0 匀速变化到 1。所以,这里 fontSize 会从 0 匀速变到 54 。
|
||||
|
||||
|
||||
小思考: 通过简单的数学知识,思考一下如何让 fontSize 从 12 ~ 54 匀速变化。
|
||||
|
||||
|
||||
|
||||
|
||||
4. 状态类的生命周期回调方法
|
||||
|
||||
这里介绍一下 State 派生类常用的几个生命周期回调方法;生命周期 顾名思义就是对象从生到死的过程,回调就是生命中特定时机的触发点;回调是 Flutter 框架中触发的,派生类可以通过 覆写 的方式,来感知某个特定时机。
|
||||
|
||||
比如,我们一般在 initState 回调中处理状态类中成员对象的初始化;如下这里对 controller 对象的初始化。在创建后可以通过 controller.forward 方法,启动动画器,让数值运动:
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
controller.forward();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
既然有生,就必然有死。当状态类不再需要时,其中持有的一些资源需要被释放,必然动画控制器。这时可以通过 dispose 回调监听到状态销毁的时机:
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
人除了生和死,就是工作,状态类也是一样。而状态类最重要的工作就是 build 方法构建 Widget 组件,它也是生命周期回调中的一环:
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 略同...
|
||||
}
|
||||
|
||||
|
||||
生和死都是只会触发一次,而工作是每天都要进行。所以,框架中对 State 对象的 initState 和 dispose 只会触发一次, build 方法可能触发多次。
|
||||
|
||||
|
||||
|
||||
现在一个问题,由于 controller 只会在 initState 触发一次,所以两次校验结果相同,状态类还活着,也只能进行一次动画。状态类如何监听到更新信息呢? 答案也是生命周期回调:
|
||||
|
||||
|
||||
当上级状态触发 setState 时,会触发子级的 didUpdateWidget 生命回调
|
||||
|
||||
|
||||
代码中在点击按钮时,会触发 setState , 所以 _ResultNoticeState 里可以覆写 didUpdateWidget 获得点击的时机,在此触发 controller 的 forward 进行动画。
|
||||
|
||||
如果 ResultNotice 提供了动画时长的参数,如果外界需要修改动画时长,而外界无法直接访问状态类。就可以通过 didUpdateWidget 来 间接 修改动画控制器的时长。其中 oldWidget 是之前的组件配置信息,另外最新的组件信息是 widget 成员,可以比较两者时长是否不同,对动画控制器进行修改。
|
||||
|
||||
---->[result_notice.dart]----
|
||||
@override
|
||||
void didUpdateWidget(covariant ResultNotice oldWidget) {
|
||||
controller.forward(from: 0);
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
|
||||
didUpdateWidget 可能对新手来说比较难理解,它提供了外界更新时机的回调,并根据新旧组件配置,来维护 状态类内部数据 。 在 State 的生命之中也可以被调用多次。
|
||||
|
||||
|
||||
当前代码提交位置: result_notice.dart
|
||||
|
||||
|
||||
initState 、build 、didUpdateWidget、dispose 三者是最基础的 State 生命周期回调。除此之外还有几个回调,不过对于新手来说并不重要,以后有能力时可以通过渲染机制小册,从源码的角度去了解它们。
|
||||
|
||||
|
||||
|
||||
5. Statless Or Statful
|
||||
|
||||
对于新手而言,面临的一个非常难的问题就是,我该选择 StatelessWidget 还是 StatefulWidget 。 这其实要取决于你对需求的理解,以及对组件的封装思路:比如这里 ResultNotice,由于想让它进行动画,而动画控制器的控制和维护需要在状态类中处理,所以就选择了 StatefulWidget 。
|
||||
|
||||
但这并不是绝对的,因为上面选择 StatefulWidget 本质上就是由于 动画控制器 对象。那 ResultNotice 直接摆烂,由构造函数传入动画控制器。这就相当于动画控制器由外界维护,此时 ResultNotice 就可以是 StatelessWidget。
|
||||
|
||||
|
||||
大家可以细品一下 StatefulWidget 变为 StatelessWidget 的过程。先自己思考一下两者的差异,后面我会进行分析。
|
||||
|
||||
|
||||
class ResultNotice extends StatelessWidget {
|
||||
final Color color;
|
||||
final String info;
|
||||
final AnimationController controller;
|
||||
|
||||
const ResultNotice({
|
||||
Key? key,
|
||||
required this.color,
|
||||
required this.info,
|
||||
required this.controller,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: color,
|
||||
child: AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (_, child) => Text(
|
||||
info,
|
||||
style: TextStyle(
|
||||
fontSize: 54 * (controller.value),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
当前代码提交位置: result_notice.dart
|
||||
|
||||
|
||||
|
||||
|
||||
俗话说,冤有头,债有主 。只要还想进行动画,控制器就需要有一个状态类做接盘侠(维护者)。由于 ResultNotice 已经甩锅了,那这里就需要上层状态 _GuessPageState 来维护:
|
||||
|
||||
|
||||
|
||||
这时,在构建 ResultNotice 时,传入控制器即可:
|
||||
|
||||
Column(
|
||||
children: [
|
||||
if(_isBig!)
|
||||
ResultNotice(color:Colors.redAccent,info:'大了',controller: controller,),
|
||||
Spacer(),
|
||||
if(!_isBig!)
|
||||
ResultNotice(color:Colors.blueAccent,info:'小了',controller: controller,),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
最后在点击时触发动画器:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
现在就 ResultNotice 来分析一下 StatlessWidget 和 StatefulWidget 在使用上的差异性。
|
||||
|
||||
|
||||
ResultNotice 为 StatefulWidget 时,外界使用者无需和 AnimationController 就能进行动画。也就是说将动画控制器的逻辑封装到了内部,拿来即用,用起来简洁方便。
|
||||
ResultNotice 为 StatlessWidget 时,外界使用者需要主动维护 AnimationController 对象,使用门槛较高。另外,由于使用者主动掌握控制器,可以更灵活地操作。
|
||||
之前是静态的界面,现在想要进行动画,对于功能拓展来说,使用 StatefulWidget 来独立维护状态的变化。可以在不修改之前其他代码的前提下,完成需求。
|
||||
|
||||
|
||||
好用 和 灵活 是一组矛盾,封装度越高,使用者操心的事就越少,用起来就越好用。但同时想要修改封装体内部的细节就越麻烦,灵活性就越差。所以,没有什么真正的好与坏,只有场景的适合于不适合。在面临选择时,要想一下:
|
||||
|
||||
|
||||
你是只想看电视的用户,还是以后电视开膛破肚的维修也要自己做。
|
||||
|
||||
|
||||
Flutter 中内置的很多 StatefulWidget 组件,我们就是使用者。比如点击按钮有水波纹的变化、点击 Switch 有滑动效果等,这些内部的状态变化逻辑是不用我们操心的。作为使用者可以非常轻松地完成复杂的交互效果,这就是封装的优势。但同时,如果需求的表现有一点不符合,改框架源码将会非常复杂,门槛也很高,这就是封装的劣势。对于选取 StatfulWidget 我的标准是:
|
||||
|
||||
|
||||
当前组件展示区域,在交互中需要改变内容;且外界无需在意内部状态数据。
|
||||
|
||||
|
||||
对于 ResultNotice 来说, StatlessWidget 或 StatefulWidget 差别并不是非常大,只是 AnimationController 交给谁维护的问题。每种方式都有好处,也有坏处,但都可以实现需求。所以结合场景,选取你觉得更好的即可。对于新手而言,能完成需求是第一要务,至于如何更优雅,你可以在以后的路中慢慢揣摩。
|
||||
|
||||
|
||||
|
||||
6.本章小结
|
||||
|
||||
本章主要介绍了 Flutter 中使用动画的方式和步骤,并简单了解了一下状态类的生命周期回调方法。最后分析了一个对于新手而言比较重要的话题 StatlessWidget 和 StatefulWidget 的差异性。
|
||||
|
||||
到这里,我们的第一个猜数字小案例的全部功能就实现完毕了。从中可以了解很多 Flutter 的基础知识,在下一篇中将对猜数字的小案例进行一个总结,看看现在我们已经用到了哪些知识,以及当前代码还有哪些优化的空间。
|
||||
|
||||
|
||||
|
||||
|
317
专栏/Flutter入门教程/11猜数字整理与总结.md
Normal file
317
专栏/Flutter入门教程/11猜数字整理与总结.md
Normal file
@ -0,0 +1,317 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 猜数字整理与总结
|
||||
通过上面 5 章的学习,我们已经完成了一个简单的猜数字小项目。这里将对项目在的一些知识点进行整理和总结,主要从界面相关知识和获得的技能点两个方面进行总结:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
一、 界面相关的知识
|
||||
|
||||
对于新手来说,Flutter 框架最最重要的就是完成界面在设备屏幕上的展示;并让用户在交互过程中展示准确的界面信息,以完成项目的功能需求。 从计数器和猜数字两个小项目中不难发现,组件 Widget 和界面呈现息息相关。
|
||||
|
||||
1.组件与界面的关系
|
||||
|
||||
现实中的一处建筑,在地图上可以用经纬度信息进行表示;界面的呈现效果,也可以用组件的信息进行表示。通过一个东西,可以确定另一个东西,这是一种非常典型的 映射关系 。组件对象可以决定界面上的展示内容,组件在构建过程中的配置参数,就是用于控制界面呈现效果的。
|
||||
|
||||
|
||||
所以,组件(Widget) 本质上是对界面呈现的一种配置信息描述。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2.界面构建逻辑的封装
|
||||
|
||||
世界会分为很多国家、国家会分为很多省、省会分成很多市、市会分成很多县、区…等等。这是一个非常典型的 树形嵌套结构 ,对于管理来说,这种结构是不可或缺却的,比如一个公司的组织架构、一本书的目录结构。
|
||||
|
||||
当个体数量非常庞大,需要进行维护管理时,树形嵌套结构 可以很有效地将整体划分为若干层级。从而各司其职,完成整体的有序运转,社会是如此,对于界面来说也是一样。对于可交互软件的开发者而言,设备的界面就是一个世界,包含着各种各样的视觉元素,用户在使用软件交互过程中的正确性,就是世界的有序运转。
|
||||
|
||||
只靠一个人管理整个社会,其他全部躺平是不现实的; 同样只靠一个组件来维护界面也不是明智之举。组件可以表示界面上的一部分,合理地划分 "屏幕世界" 的区域,交由不同的组件进行管理,是一个好的习惯。这得益于组件可以对界面构建逻辑的进行封装。
|
||||
|
||||
|
||||
|
||||
3.状态数据与界面更新
|
||||
|
||||
Widget 对象的所有属性都需要是 final 修饰的,也就是说你无法修改 Widget 对象的属性。前面说过 Widget 可以决定界面的呈现效果,也就是说对于一个 Widget 对象 而言,它对应的界面效果是无法变化的。但界面在交互过程中,一定会有界面变化的需求,比如说计数器项目,点击按钮时文字发生变化。
|
||||
|
||||
可能会有人疑惑,既然你说 Widget 对象无法修改属性,那计数器的数字为什么会变化。理解这个问题是非常重要的,所以先举个小例子:
|
||||
|
||||
|
||||
现实生活中,一只狗自诞生那一刻,毛色属性就无法修改。
|
||||
有一天,你去小明家,站在一只白狗面前。
|
||||
小明对你说: “你把眼睛闭上。”
|
||||
过一会你睁开眼,看到面前有一只黑狗。
|
||||
那么,你是否会惊奇的认为,自己的闭眼操作会改变一只狗的毛色。
|
||||
(其实只是小明换了一只体型一样的黑狗)
|
||||
|
||||
|
||||
同理,计数器项目中,你点击按钮时,数字从 0 -> 1。并不是 Text("0") 的文字属性变成了 1,而是你面前的是一个新的 Text("1") 对象。数字需要在交互时发送变化,但不能再 Widget 类中变化,所以 Flutter 框架中,提供了一个状态数据维护的场所: State 的衍生类。它可以提供 setState触发重新构建,从而更新界面呈现。
|
||||
|
||||
|
||||
|
||||
4.组件有无状态的差异性
|
||||
|
||||
对于新手而言,自己创建 Widget 的派生类,有两个选择。其一是继承自 StatelessWidget , 其二是继承自 StatefulWidget, 两者都可以通过已有的组件完成拼装的构建逻辑。
|
||||
|
||||
StatelessWidget 派生类是很简单直接的,它在 build 方法中通过已有组件来完成构建逻辑,返回 Widget 对象。 界面上需要什么,就在构造函数中传什么。相当于一个胶水,把其他组件黏在一块,作为新的组件个体。
|
||||
|
||||
|
||||
|
||||
StatefulWidget 和 StatelessWidget 在功能上是类似的,只不过 StatefulWidget 自己不承担 build 组件的责任,将构建任务委托给 State 派生类。它会通过 createState 方法创建 State 对象:
|
||||
|
||||
|
||||
|
||||
在上一点中提到,State 派生类中可以维护状态数据的变化和重新触发自己的 build 构建方法,实现界面的更新。另外,State 对象有生命周期回调,可以通过覆写方法进行感知。
|
||||
|
||||
|
||||
需要注意一点: 不要将 StatefulWidget 和 State 混为一谈,两者是不同的两个类型。可以感知生命周期、维护状态数据变化的是 State 类。 StatefulWidget 的任务是承载配置信息,和创建 State 对象。
|
||||
|
||||
|
||||
|
||||
|
||||
二、 技能点
|
||||
|
||||
虽然说界面上展示的内容都是通过 Widget 确定的,但 Flutter 中除了 Widget 还有很多其他的类型对象。它们在一起共同工作,维护界面世界的运转。
|
||||
|
||||
1. 回调函数的使用
|
||||
|
||||
函数本身可以视为一个对象,作为函数的参数进行传递,这样可以在一个类中,很方便地感知另一个对象事件的触发。比如在 _GuessPageState 类中,使用 FloatingActionButton 组件,它的入参 onPressed 是一个函数,这个函数是由框架内部在恰当的时机触发的,这个时机就是点击。
|
||||
|
||||
也就是说,点击会触发 onPressed 参数传入的函数对象,这样我们就可以方便地 监听到 事件,并处理数据变化的逻辑。这种函数,就称之为 回调函数 。既然函数要作为对象传递,那最好要有类型名,可以通过 typedef 让函数有用类名:
|
||||
|
||||
比如, FloatingActionButton 的 onPressed 参数类型是 VoidCallback ,定义如下:表示一个无参的返回值为空的函数。
|
||||
|
||||
typedef VoidCallback = void Function();
|
||||
|
||||
|
||||
AnimatedBuilder 组件的 builder 参数也是一个回调函数,类型为 TransitionBuilder ,定义如下:表示一个返回值为 Widget, 两个入参分别是 BuildContext 和 Widget? 的函数。
|
||||
|
||||
typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child);
|
||||
|
||||
|
||||
|
||||
|
||||
2. 动画控制器的使用
|
||||
|
||||
动画控制器是界面可以进行动画变化的驱动力,使用过程分为三步: 创建动画控制器、 在合适时机启动控制器、使用动画器的值构建界面
|
||||
|
||||
1.创建动画控制器主要在 State 派生类中进行:让 State 派生类混入 SingleTickerProviderStateMixin 后,将状态自身作为 AnimationController 的 vsync 入参:
|
||||
|
||||
class _GuessPageState extends State<GuessPage> with SingleTickerProviderStateMixin{
|
||||
late AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
2.在恰当的时机可以通过 AnimationController 的 forward 方法启动控制器。让它的值从 0 变化到 1 :
|
||||
|
||||
controller.forward(from: 0);
|
||||
|
||||
|
||||
3.根据动画控制器的值,设置需要动画变化的属性。由于动画的触发非常频繁,推荐使用 AnimatedBuilder 监听控制器,实现局部组件的更新和构建:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 随机数的使用
|
||||
|
||||
Dart 内部提供了 Random 类,因此获取随机数就非常方便。只要创建 Random 对象,提供 nextInt 就可以得到 0 ~ 100 的随机整数 (不包括 100) :
|
||||
|
||||
Random _random = Random();
|
||||
_random.nextInt(100);
|
||||
|
||||
|
||||
另外,可以通过 nextDouble 方法获取 0 ~ 1 的随机小浮点数 (不包括 1) ;通过 nextBool 方法获取随机的 bool 值:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
三、接触的内置组件
|
||||
|
||||
最后来整理一下目前猜数字项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. 基础组件
|
||||
|
||||
基础组件是常用的简单组件,功能单一,相当于积木的最小单元:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Text
|
||||
文本展示
|
||||
展示相关的文字信息
|
||||
|
||||
|
||||
|
||||
TextField
|
||||
输入框展示
|
||||
头部的输入框,得到用户输入
|
||||
|
||||
|
||||
|
||||
Container
|
||||
一个容器
|
||||
对比较结果界面进行着色
|
||||
|
||||
|
||||
|
||||
Icon
|
||||
图标展示
|
||||
作为图标按钮的内容
|
||||
|
||||
|
||||
|
||||
IconButton
|
||||
图标按钮,通过 onPressed 监听点击回调
|
||||
头部运行按钮,
|
||||
|
||||
|
||||
|
||||
FloatingActionButton
|
||||
浮动按钮 ,通过 onPressed 监听点击回调
|
||||
右下角按钮,生成随机数
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 组合结构型
|
||||
|
||||
有些组件是比较复杂的,在构造中可以配置若干个组件,作为各个部分。比如 AppBar 可以设置左中右下四个区域,Scaffold 可是设置上中下左右,它们像躯干一样,把布局结构已经固定了,只需将组件插入卡槽中即可。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
MaterialApp
|
||||
整体应用配置
|
||||
作为代码中的组件顶层,提供主题配置
|
||||
|
||||
|
||||
|
||||
Scaffold
|
||||
通用界面结构
|
||||
这里通过 appBar 设置标题、 body 设置字体内容
|
||||
|
||||
|
||||
|
||||
AppBar
|
||||
应用标题栏
|
||||
展示标题栏
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 布局组件
|
||||
|
||||
布局组件无法在界面上进行任何色彩的展示,它们的作用是对其他组件进行排布与定位。比如 Row 和 Column 用于水平和竖直排列组件;Stack 让若干组件叠放排布;Center 可以将子组件居中。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Row、Column
|
||||
水平、竖直摆放若干组件
|
||||
通过 Column 竖直排列文字信息和比较结果
|
||||
|
||||
|
||||
|
||||
Expanded、Spacer
|
||||
延展区域
|
||||
比较结果通过 Spacer 占位
|
||||
|
||||
|
||||
|
||||
Stack
|
||||
叠放若干组件
|
||||
比较结果和主体内容进行叠放
|
||||
|
||||
|
||||
|
||||
Center
|
||||
居中定位
|
||||
主体内容居中
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4. 构建器
|
||||
|
||||
构建器是指,通过该组件回调来完成构建组件的任务。也就是说回调触发时会进行重新构建,从而让构建缩小在局部。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
AnimatedBuilder
|
||||
监听动画器,通过回调局部构建组件
|
||||
比较结果中文字的大小动画
|
||||
|
||||
|
||||
|
||||
到这里,猜数字的项目就总结完了,希望大家可以好好思考和体会,下面将进入 电子木鱼 的模块。
|
||||
|
||||
|
||||
|
||||
|
154
专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md
Normal file
154
专栏/Flutter入门教程/12电子木鱼界面交互与需求分析.md
Normal file
@ -0,0 +1,154 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 电子木鱼界面交互与需求分析
|
||||
1. 界面交互介绍
|
||||
|
||||
电子木鱼是本教程的第二个案例,相比于猜数字项目,功能需求比较复杂一点,适合新手朋友进一步了解 Flutter 相关知识。下面是两个最基础的交互:
|
||||
|
||||
|
||||
点击木鱼图片发出敲击声,并增加功德,展示增加动画。
|
||||
点击标题栏右侧按钮,近进入功德记录页面。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
点击木鱼
|
||||
查看功德记录
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
光是点击图片发出音效未免有些单调,这里提供了两个选择功能,让应用的表现更丰富一些。
|
||||
|
||||
|
||||
点击右上角按钮,选择木鱼音效
|
||||
点击右上角按钮,选择木鱼样式
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
切换音效
|
||||
切换样式
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 电子木鱼需求分析
|
||||
|
||||
现在从数据和界面的角度,来分析一下猜数字中的需求:
|
||||
|
||||
|
||||
木鱼点击发声及功德记录
|
||||
|
||||
|
||||
在这个需求中,在每次点击时,需要产生一份记录数据,并将数据添加到列表中。这份记录数据包括 增加量、记录时间、当前音频、当前图片 四个数据。所以可以将四者打包在一个类中作为数据模型,比如定义 MeritRecord 类型:
|
||||
|
||||
class MeritRecord {
|
||||
final String id;
|
||||
final int timestamp;
|
||||
final int value;
|
||||
final String image;
|
||||
final String audio;
|
||||
|
||||
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id":id,
|
||||
"timestamp": timestamp,
|
||||
"value": value,
|
||||
"image": image,
|
||||
"audio": audio,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
对于界面来说,当功德记录发生变化时,触发当前功德的展示动画,功德数字会进行透明度、移动、缩放的叠加动画;进入功德详情页是,功德记录列表的数据将被传入其中,作为界面构建时的数据信息。由于数据量会很多,所以视图需要支持滑动。
|
||||
|
||||
|
||||
|
||||
|
||||
音效和样式的选择
|
||||
|
||||
|
||||
这两个需求的操作流程是类似的。拿音频来说,需要的数据有:支持的音频列表,列表中的每个元素需要有名称和资源两个数据,也可以通过一个类进行维护:
|
||||
|
||||
class AudioOption{
|
||||
final String name;
|
||||
final String src;
|
||||
|
||||
const AudioOption(this.name, this.src);
|
||||
}
|
||||
|
||||
|
||||
有了支持的列表数据,还需要当前激活的数据,这里维护 int 型的激活索引,即可通过列表获取到激活数据。对于木鱼样式来说也是一样,通过一个类型维护每种样式需要的数据:
|
||||
|
||||
class ImageOption{
|
||||
final String name; // 名称
|
||||
final String src; // 资源
|
||||
final int min; // 每次点击时功德最小值
|
||||
final int max; // 每次点击时功德最大值
|
||||
|
||||
const ImageOption(this.name, this.src, this.min, this.max);
|
||||
}
|
||||
|
||||
|
||||
对于界面来说,需要处理按钮的点击事件,从底部弹出选择的面板,在选择之后隐藏。选择面板中根据数据列表展示可选项,并根据激活索引控制当前的激活状态。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 电子木鱼中的知识点
|
||||
|
||||
首先,电子木鱼项目会 额外 接触到如下的常用组件,大家再完成电子木鱼项目的同时,也会了解这些组件的使用方式。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
另外,会对 Flutter 中的界面相关的知识有进一步的认知:
|
||||
|
||||
|
||||
数据模型与界面展示
|
||||
组件的封装性
|
||||
State 状态类的生命周期回调
|
||||
组件有无状态的差异性
|
||||
界面的跳转
|
||||
|
||||
|
||||
|
||||
|
||||
最后,Flutter 中可以依赖别人的类库完成自己项目的某些需求,将在电子木鱼项目中了解依赖库的使用方式。通过多种动画变换的使用,也可以加深对动画的理解。
|
||||
|
||||
|
||||
资源配置与依赖库的使用
|
||||
多种动画变换
|
||||
短音效的播放
|
||||
唯一标识 uuid
|
||||
|
||||
|
||||
界面交互和需求分析就到这里,下面一起开始第二个小项目的学习吧!
|
||||
|
||||
|
||||
|
||||
|
267
专栏/Flutter入门教程/13电子木鱼静态界面构建.md
Normal file
267
专栏/Flutter入门教程/13电子木鱼静态界面构建.md
Normal file
@ -0,0 +1,267 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 电子木鱼静态界面构建
|
||||
下面我们正式开始 电子木鱼 小项目的开发,在 lib 下创建一个 muyu 的文件夹,用于存放电子木鱼相关代码文件:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1、界面布局分析
|
||||
|
||||
本篇主要完成电子木鱼的主要静态界面的构建、如下所示,分别是界面效果和布局分析效果。从右图可以很轻松地看出界面中的布局情况:
|
||||
|
||||
|
||||
|
||||
|
||||
界面效果
|
||||
布局查看
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
分为上中下三块,上方是标题、中间是文字和按钮、下方是图片。
|
||||
整体使用 Scaffold 组件,头部使用 AppBar 组件;主体内容上下平分,可以使用 Column + Expanded 组合。
|
||||
上半部分的两个绿色按钮,可以通过 Stack + Positioned 组合叠放在右上角。
|
||||
下半部分通过 Centent + Image 组件让图片居中对其,并通过 GestureDetector 监听点击事件。
|
||||
|
||||
|
||||
|
||||
|
||||
2. 电子木鱼整体界面 MuyuPage
|
||||
|
||||
电子木鱼在功能上和计数器是非常相似的,只不过是敲击点不同,界面不同不同罢了。由于点击过程中 功德数 会进行变换,使用这里让 MuyuPage 继承自 StatefulWidget ,通过对于的状态类 _MuyuPageState 来维护数据的和界面的构建及更新。
|
||||
|
||||
class MuyuPage extends StatefulWidget {
|
||||
const MuyuPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MuyuPage> createState() => _MuyuPageState();
|
||||
}
|
||||
|
||||
|
||||
在 _MuyuPageState 的 build 方法中,对整体界面进行构建。使用 Scaffold 组件提供通用结构,appBar 入参组件确实头部标题栏,一般使用 AppBar 组件。其中有很多配置属性:
|
||||
|
||||
|
||||
backgroundColor: 标题栏的背景色。
|
||||
elevation:标题栏的阴影深度。
|
||||
titleTextStyle: 标题的文字样式。
|
||||
iconTheme: 标题栏的图标主题。
|
||||
actions : 标题栏右侧展示的组件列表。
|
||||
|
||||
|
||||
这里说一下 titleTextStyle 和 iconTheme 。 如果直接为 title 中的文字设置样式,直接为 actions 中的图标设置颜色,效果是一样的。但如果 actions 有很多图标按钮,一个个配置就非常麻烦,而使用主题,可以提供默认样式,减少很多重复的操作。
|
||||
|
||||
|
||||
|
||||
class _MuyuPageState extends State<MuyuPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
titleTextStyle: const TextStyle(color: Colors.black,fontSize: 16,fontWeight: FontWeight.bold),,
|
||||
iconTheme: const IconThemeData(color: Colors.black),
|
||||
title: const Text("电子木鱼"),
|
||||
actions: [
|
||||
IconButton(onPressed: _toHistory,icon: const Icon(Icons.history))
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toHistory() {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
主体内容是上下平分区域,可以使用 Column + Expanded 组件。建议再写构建组件代码时,不要塞在一块,适当的通过函数或类进行隔离封装。比如下面代码中,两个部分组件的构建,交给两个方法完成,这样可以让组件构件的条理更清晰,易于阅读和修改。
|
||||
|
||||
Scaffold(
|
||||
// 略同...
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: _buildTopContent()),
|
||||
Expanded(child: _buildImage()),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
3. 主体内容的构建
|
||||
|
||||
上半部分界面交由 _buildTopContent 方法构建,效果如下: 其中 功德数 文字居中显示;上角有两个按钮,分别用于切换音效和切换图片;按可以通过 ElevatedButton 组件进行展示,并通过 style 入参调节按钮样式。
|
||||
|
||||
另外,两个按钮上下排列,可以使用 Column 组件,也可以使用竖直方向的 Wrap 组件。使用 Wrap 组件可以通过 spacing 参数,控制子组件在排列方向上的间距,想比 Column 来说方便一些。
|
||||
|
||||
|
||||
|
||||
Widget _buildTopContent() {
|
||||
// 按钮样式
|
||||
final ButtonStyle style = ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(36, 36), // 最小尺寸
|
||||
padding: EdgeInsets.zero, // 边距
|
||||
backgroundColor: Colors.green, // 背景色
|
||||
elevation: 0, // 阴影深度
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'功德数: 0',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: 10,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
direction: Axis.vertical,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
style: style,
|
||||
onPressed: () {},
|
||||
child: Icon(Icons.music_note_outlined),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: style,
|
||||
onPressed: () {},
|
||||
child: Icon(Icons.image),
|
||||
)
|
||||
],
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
下半部分界面交由 _buildImage 方法构建,效果如下:是一个居中展示的图片,在 Flutter 中,展示图片可以使用 Image 组件。这里使用 Image.asset 构造函数,从本地资源中加载图片:
|
||||
|
||||
|
||||
|
||||
Widget _buildImage() {
|
||||
return Center(
|
||||
child: Image.asset(
|
||||
'assets/images/muyu.png',
|
||||
height: 200, //图片高度
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
想要使用本地资源,需要进行配置。一般来说,会在项目中创建一个 assets 文件夹,用于盛放本地资源文件,比如图片、音频、文本等。这里把图片放在 images 文件夹中,如何在 pubspec.yaml 文件的 flutter 节点下配置资源文件夹,这样 images 中的资源就可以使用了。
|
||||
|
||||
|
||||
|
||||
到这里,我们就已经实现了期望的布局效果,当前代码位置 muyu_page.dart 。从静态界面的构建过程中,不难体会出:这就像通过已经存在的积木,拼组成我们期望的展示效果。
|
||||
|
||||
|
||||
|
||||
|
||||
界面效果
|
||||
布局查看
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4. 组件的封装
|
||||
|
||||
一个自定义的 Widget ,可以封装一部分界面展示内容的构建逻辑。在开发过程中,我们应该避免让一个 Widget 干所有的事,否则代码会非常杂乱。应该有意识地合理划分结构,将部分的构建逻辑独立出去,以便之后的修改和更新。
|
||||
|
||||
当前代码中,通过两个函数来封装上下部分界面的构建逻辑。但函数仍在状态类 _MuyuPageState 中,随着需求的增加,会导致一个类代码会越来越多。我们也可以将界面某部分的构建逻辑,通过自定义 Widget 来分离出去。
|
||||
|
||||
|
||||
|
||||
这里通过 CountPanel 组件来封装上半部分界面的构建逻辑。可以分析一下,界面在构建过程中需要依赖的数据,并通过构造函数传入数据。其中的构建逻辑和上面的 _buildTopContent 方法一样的。
|
||||
|
||||
如果把这些构建逻辑比作一个人,那函数封装和组件封装,就相当于这个人穿了不同的衣服。其内在的本质上并没有太大的差异,只是外部的表现不同罢了。函数封装,在其他的类中,相当于给别人打工;组件封装,有自己的类名,正规编制,相当于自己当老板,经营构建界面逻辑。
|
||||
|
||||
|
||||
|
||||
class CountPanel extends StatelessWidget {
|
||||
final int count;
|
||||
final VoidCallback onTapSwitchAudio;
|
||||
final VoidCallback onTapSwitchImage;
|
||||
|
||||
const CountPanel({
|
||||
super.key,
|
||||
required this.count,
|
||||
required this.onTapSwitchAudio,
|
||||
required this.onTapSwitchImage,
|
||||
});
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
//同上 _buildTopContent 方法
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
另外,对于组件封装而言,会遇到一些事件的触发。比如这里点击切换音效按钮该做什么,对于 CountPanel 而言是不关心的,它只需要经营好界面的构建逻辑即可。这时可以通过 回调 的方式,交由使用者来处理,其实按钮组件的 onPressed 回调入参也是这种思路:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这样,在 _MuyuPageState 中,可以直接使用 CountPanel 完成上半部分的展示,从而减少状态类中的代码量。而 CountPanel 组件的职责也很专一,就更容易做好一件事。
|
||||
|
||||
Expanded(
|
||||
child: CountPanel(
|
||||
count: 0,
|
||||
onTapSwitchAudio: _onTapSwitchAudio,
|
||||
onTapSwitchImage: _onTapSwitchImage,
|
||||
),
|
||||
),
|
||||
|
||||
void _onTapSwitchAudio() {}
|
||||
|
||||
void _onTapSwitchImage() {}
|
||||
|
||||
|
||||
|
||||
|
||||
5.本章小结
|
||||
|
||||
本章主要对电子木鱼的静态界面进行搭建,除了学习使用基础组件布局之外,最主要的目的是了解组件对构建逻辑的封装。封装可以自责进行隔离,并在相似的场景中可以复用。
|
||||
|
||||
就像皇帝不可能一个人治理整个国家。无法良好地组织和管理各个区域,把所有事交由一个人来做,随着疆域的扩大,早晚会因为臃肿而无法前进,会阻碍社会(应用)的发展。虽然对于新手而言,关注代码的组织方式为时过早,但要有这种意识,避免出现一个掌控所有逻辑的 上帝类 。
|
||||
|
||||
|
||||
小练习: 通过 MuyuAssetsImage 组件封装下半部分的界面构建逻辑。 当前代码位置 muyu
|
||||
|
||||
|
||||
到这里,基本的静态界面就搭建完成了,下一章将处理一下基本的交互逻辑。
|
||||
|
||||
|
||||
|
||||
|
322
专栏/Flutter入门教程/14计数变化与音效播放.md
Normal file
322
专栏/Flutter入门教程/14计数变化与音效播放.md
Normal file
@ -0,0 +1,322 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 计数变化与音效播放
|
||||
1. 组件点击事件的监听
|
||||
|
||||
计时器项目中的 FloatingActionButton 按钮,猜数字项目中的 IconButton 点击事件,都是通过构造中的 onPressed 参数传入函数,执行相关逻辑。我们称这种以函数对象作为参数的形式为 回调函数 。现在我们的需求是:让图片可以响应点击事件,每次点击时功德数随机增加 1~3 点,并发出敲击的音效。
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下所示,在 MuyuAssetsImage 中使用 GestureDetector 嵌套在 Image 之上,这样图片区域内的点击事件,可以通过 onTap 回调监听到。另外,点击时功德累加的逻辑,和图片构建并没有太大关系,这里通过 onTap 入参,将事件向上级传递,交由使用者处理。
|
||||
|
||||
class MuyuAssetsImage extends StatelessWidget {
|
||||
final String image;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MuyuAssetsImage({super.key, required this.image, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: GestureDetector( // 使用 GestureDetector 组件监听手势回调
|
||||
onTap: onTap,
|
||||
child: Image.asset(
|
||||
image,
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
通过 GestureDetector 组件的 onTap 参数,可以监听到任何组件在其区域内的点击事件。
|
||||
|
||||
|
||||
|
||||
|
||||
在 _MuyuPageState#build 方法中,使用 MuyuAssetsImage 组件时,通过 onTap 参数指定逻辑处理函数,这和 FloatingActionButton的onPressed 点击事件本质上是一样的。这里通过 _onKnock 函数处理点击木鱼的逻辑:
|
||||
|
||||
---->[_MuyuPageState#build]----
|
||||
Expanded(
|
||||
child: MuyuAssetsImage(
|
||||
image: 'assets/images/muyu.png',
|
||||
onTap: _onKnock,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
在 _MuyuPageState 中维护界面中需要的状态数据,这里最主要的是功德计数变量,通过 _counter 进行表示。另外,点击时功德数随机增加 1~3 ,需要随机数对象。这样,目前的逻辑就比较清晰了:在 _onKnock 函数中,对 _counter 进行累加即可,每次累加值为 1 + _random.nextInt(3) :
|
||||
|
||||
int _counter = 0;
|
||||
final Random _random = Random();
|
||||
|
||||
void _onKnock() {
|
||||
setState(() {
|
||||
int addCount = 1 + _random.nextInt(3);
|
||||
_counter += addCount;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
最后,将 _counter 作为上半界面 CountPanel 的入参即可。这样点击时,更新 _counter 后,会重新构建,从而展示最新的数据。到这里本质上和计数器没有太大的差异:
|
||||
|
||||
|
||||
|
||||
|
||||
当前代码位置 muyu
|
||||
|
||||
|
||||
|
||||
|
||||
2. 插件的使用和配置
|
||||
|
||||
Flutter 是一个跨平台的 UI 框架,而音效的播放是平台的功能。在开发过程中,一般使用 插件 来完成平台功能。得益于 Flutter 良好的生态环境,在 pub 中有丰富的优秀插件和包。这里使用 flame_audio 插件来实现短音效播放的功能:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
使用插件,首先要在 pubspec.yaml 的 dependencies 节点下配置依赖,在 pub 中的 installing 页签中,可以看到依赖的版本号:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如下所示,加入依赖后,点击 pub get 获取依赖包:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
现在要播放音效,首先需要相关的音频资源,而使用本地资源,需要在 pubspec.yaml 中进行配置:这里准备了三个木鱼点击的音效,放在 audio 文件夹中。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 完成点击播放音效功能
|
||||
|
||||
点击事件的逻辑处理在 _MuyuPageState 类中,所以播放音效在该类中处理比较方便。插件的使用也比较简单,首先需要加载资源,通过 FlameAudio.createPool 方法创建 AudioPool 对象。在状态类的 initState 方法中使用 _initAudioPool 创建 pool 对象。
|
||||
|
||||
其中第一个参数是资源的名称,flame_audio 默认将本地资源放在 assets/audio 中,指定资源时直接写文件名即可。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
AudioPool? pool;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initAudioPool();
|
||||
}
|
||||
|
||||
void _initAudioPool() async {
|
||||
pool = await FlameAudio.createPool(
|
||||
'muyu_1.mp3',
|
||||
maxPlayers: 4,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
然后,只需要在 _onKnock 方法在调用 pool 的 start 方法进行播放即可:
|
||||
|
||||
void _onKnock() {
|
||||
pool?.start();
|
||||
// 略同...
|
||||
}
|
||||
|
||||
|
||||
到这里,点击木鱼时,就可以听到敲击木鱼的音效,同时功德数也会随机增加 1~3 点。这样就完成了木鱼最基础的功能需求,下面我们将继续优化和拓展,添加一些新的视觉表现和功能。
|
||||
|
||||
|
||||
当前代码位置 muyu
|
||||
|
||||
|
||||
|
||||
|
||||
4. 增加功德时的动画展示
|
||||
|
||||
如下所示,现在需要在每次点击时展示功德增加的数字,并让数值有动画效果。比如这里是 缩放、移动、透明度 三个动画的叠加,从效果的表现上来说就是文字一边上移,一边缩小、一边透明度降低。
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
首先来分析一下界面构建:界面上需要展示的信息增加了 当前增加的功德值 ,使用需要增加一个状态数据用于表示。这里使用 _cruValue 变量,该数据维护的时机也很清晰,在点击时更新 _cruValue 的值:
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
int _cruValue = 0;
|
||||
|
||||
void _onKnock() {
|
||||
pool?.start();
|
||||
setState(() {
|
||||
_cruValue = 1 + _random.nextInt(3);
|
||||
_counter += _cruValue;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
这样数据层面就搞定了,下面看一下界面构建逻辑。这里 当前功德 相当于一个浮层,可以通过 Stack 组件和下半部分进行叠放。如下所示,文字从下向上运动到下半部分的顶部:
|
||||
|
||||
|
||||
|
||||
由于动画的逻辑比较复杂,这里封装 AnimateText 组件来完成动画文字的功能,其中构造函数传入需要展示的文本信息。下半部分通过 Stack 进行叠放,alignment 入参可以控制孩子们的对齐方式,比如这里 Alignment.topCenter , 将会以上方中心对齐。表现上来说,就是文字在区域的上方中间:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
现在数据和布局已经完成,就差动画效果了。在猜数字中我们简单地了解过动画的使用,通过 AnimatedBuilder 组件,监听动画控制器的变化,在构建组件的过程中让某些属性值不断变化。这里介绍一下几个 XXXTransition 动画组件的使用。
|
||||
|
||||
先拿 FadeTransition 组件来说,它的构造中需要传入 Animation<double> 的动画器,表示透明度的动画变化。AnimationController 的数值运动是从 0 ~ 1 ,而这里透明度的变化是 1 ~ 0,此时可以使用 Tween 来得到期望的补间动画器。如下 tag1 处,创建了 1~0 变化的动画器 opacity 。在 build 中,使用 FadeTransition 传入 opacity 动画器,当动画控制器运动时,子组件就会产生透明度动画。
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class AnimateText extends StatefulWidget {
|
||||
final String text;
|
||||
|
||||
const AnimateText({Key? key, required this.text}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AnimateText> createState() => _FadTextState();
|
||||
}
|
||||
|
||||
class _FadTextState extends State<AnimateText> with SingleTickerProviderStateMixin {
|
||||
late AnimationController controller;
|
||||
late Animation<double> opacity;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 500));
|
||||
opacity = Tween(begin: 1.0, end: 0.0).animate(controller); // tag1
|
||||
controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimateText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
controller.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: opacity,
|
||||
child: Text(widget.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
当前代码位置 muyu
|
||||
|
||||
|
||||
|
||||
|
||||
同理,使用 ScaleTransition 可以实现缩放动画;使用 SlideTransition 可以实现移动动:
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: scale,
|
||||
child: SlideTransition(
|
||||
position: position,
|
||||
child: FadeTransition(
|
||||
opacity: opacity,
|
||||
child: Text(widget.text),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
XXXTransition 组件都需要指定对应类型的 Animation 动画器,而这些动画器可以以 AnimationController 为动力源,通过 Tween 来生成。 缩放变换传入 scale 动画器,从 1 ~ 0.9 变化;移动变化传入 position 动画器,泛型为 Offset,从 Offset(0, 2) ~ Offset.zero 变化,对应的效果就是:在竖直方向上的偏移量,从两倍子组件高度变化到 0 。
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class _FadTextState extends State<AnimateText> with SingleTickerProviderStateMixin {
|
||||
late AnimationController controller;
|
||||
late Animation<double> opacity;
|
||||
late Animation<Offset> position;
|
||||
late Animation<double> scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController(vsync: this, duration: Duration(milliseconds: 500));
|
||||
opacity = Tween(begin: 1.0, end: 0.0).animate(controller);
|
||||
scale = Tween(begin: 1.0, end: 0.9).animate(controller);
|
||||
position = Tween<Offset>(begin: const Offset(0, 2), end: Offset.zero,).animate(controller);
|
||||
controller.forward();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
5.本章小结
|
||||
|
||||
本章我们学习了如何在自己的项目中使用别人提供的类库,这样就可以很轻松地完成复杂的功能。比如这里点击时的音效播放,如果完全靠自己来写代码实现,将会非常困难。但使用别人提供的插件,几行代码就搞定了。所以说,对于一项技术而言,良好的生态是非常重要的。
|
||||
|
||||
你可以使用别人的代码实现功能,方便大家;也可以分享自己的代码以供别人使用,让大家一起完善,这就是开源的价值。到这里,就完成了增加功德数动画的展示效果,当前代码位置 muyu 。下一篇将继续对当前项目进行功能拓展,增加切换音效和木鱼图片的效果。
|
||||
|
||||
|
||||
|
||||
|
419
专栏/Flutter入门教程/15弹出选项与切换状态.md
Normal file
419
专栏/Flutter入门教程/15弹出选项与切换状态.md
Normal file
@ -0,0 +1,419 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 弹出选项与切换状态
|
||||
1. 选择木鱼界面分析
|
||||
|
||||
现在想要的效果如下所示,点击第二个绿色按钮时,从底部弹出木鱼图片的选项。从界面上来说,有如下几个要点:
|
||||
|
||||
|
||||
需要展示木鱼样式的基本信息,包括名称、图片及每次功德数范围。
|
||||
需要将当前展示选择的木鱼边上蓝色边线,表示选择状态。
|
||||
选择切换木鱼时,同时更新主界面木鱼样式。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
木鱼界面
|
||||
选择界面
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
仔细分析可以看出,两个选项的布局结构是类似的,不同点在于木鱼的信息以及激活状态。所以只要封装一个组件,就可以用来构建上面的两个选择项,这就是封装的可复用性。不过在此之前先梳理一下木鱼的数据信息:
|
||||
根据目前的需求设定,木鱼需要 名称、图片资源、增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 ImageOption 类:
|
||||
|
||||
--->[muyu/models/image_option.dart]---
|
||||
class ImageOption{
|
||||
final String name; // 名称
|
||||
final String src; // 资源
|
||||
final int min; // 每次点击时功德最小值
|
||||
final int max; // 每次点击时功德最大值
|
||||
|
||||
const ImageOption(this.name, this.src, this.min, this.max);
|
||||
}
|
||||
|
||||
|
||||
对于一个样式来说,依赖的数据是 ImageOption 对象和 bool 类型的激活状态。这里封装一个 ImageOptionItem 组件用于构建一种样式的界面:
|
||||
|
||||
|
||||
|
||||
代码如下,红框中的单体是上中下的结果,通过 Column 组件竖向排列,另外使用装饰属性的 border ,根据是否激活,添加边线。其中的文字、图片数据都是通过 ImageOption 类型的成员确定的。
|
||||
|
||||
--->[muyu/options/select_image.dart]---
|
||||
class ImageOptionItem extends StatelessWidget {
|
||||
final ImageOption option;
|
||||
final bool active;
|
||||
|
||||
const ImageOptionItem({
|
||||
Key? key,
|
||||
required this.option,
|
||||
required this.active,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const Border activeBorder = Border.fromBorderSide(BorderSide(color: Colors.blue));
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: !active ? null : activeBorder,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(option.name, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Image.asset(option.src),
|
||||
),
|
||||
),
|
||||
Text('每次功德 +${option.min}~${option.max}',
|
||||
style: const TextStyle(color: Colors.grey,fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2.底部弹框及界面构建
|
||||
|
||||
由于有了新需求,_MuyuPageState 状态类需要添加一些状态数据来实现功能。如下,新增了 imageOptions 对象表示木鱼选项的列表;已及 _activeImageIndex 表示当前激活的木鱼索引:
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
final List<ImageOption> imageOptions = const [
|
||||
ImageOption('基础版','assets/images/muyu.png',1,3),
|
||||
ImageOption('尊享版','assets/images/muyu_2.png',3,6),
|
||||
];
|
||||
|
||||
int _activeImageIndex = 0;
|
||||
|
||||
|
||||
|
||||
|
||||
点击时的底部弹框,可以使用 showCupertinoModalPopup 方法实现。其中 builder 入参中返回底部弹框中的内容组件,这里通过自定义的 ImageOptionPanel 组件来完成弹框内界面的构建逻辑。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
void _onTapSwitchImage() {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return ImageOptionPanel(
|
||||
imageOptions: imageOptions,
|
||||
activeIndex: _activeImageIndex,
|
||||
onSelect: _onSelectImage,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
很容易可以看出选择木鱼的面板中需要 ImageOption 列表、当前激活索引两个数据;由于点击选择时需要更新主界面的数据,所以这里通过 onSelect 回调,让处理逻辑交由使用者来实现。
|
||||
|
||||
--->[muyu/options/select_image.dart]---
|
||||
class ImageOptionPanel extends StatelessWidget {
|
||||
final List<ImageOption> imageOptions;
|
||||
final ValueChanged<int> onSelect;
|
||||
final int activeIndex;
|
||||
|
||||
const ImageOptionPanel({
|
||||
Key? key,
|
||||
required this.imageOptions,
|
||||
required this.activeIndex,
|
||||
required this.onSelect,
|
||||
}) : super(key: key);
|
||||
|
||||
|
||||
界面的构建逻辑如下,整体结构并不复杂。主要是上下结构,上面是标题,下面是选项的条目。由于这里只要两个条目,使用 Row + Expanded 组件,让单体平分水平空间:
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold);
|
||||
const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8.0, vertical: 16);
|
||||
return Material(
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 46,
|
||||
alignment: Alignment.center,
|
||||
child: const Text( "选择木鱼", style: labelStyle)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _buildByIndex(0)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _buildByIndex(1)),
|
||||
],
|
||||
),
|
||||
))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
在 _buildByIndex 中,简单封装一下根据索引生成条目组件的逻辑,并通过点击事件,触发 onSelect 回调,将条目对应的索引传递出去。
|
||||
|
||||
Widget _buildByIndex(int index) {
|
||||
bool active = index == activeIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(index),
|
||||
child: ImageOptionItem(
|
||||
option: imageOptions[index],
|
||||
active: active,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
到这里,万事俱备只欠东风,在点击木鱼样式回调中,需要更新 _activeImageIndex 的值即可,这里如果点击的是当前样式,则不进行更新操作。另外,在选择时通过 Navigator.of(context).pop() 可以关闭底部弹框。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
void _onSelectImage(int value) {
|
||||
Navigator.of(context).pop();
|
||||
if(value == _activeImageIndex) return;
|
||||
setState(() {
|
||||
_activeImageIndex = value;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
最后,主页面中的图片和点击时增加的值需要根据 _activeImageIndex 来确定。这里在 _MuyuPageState 中给两个 get 方法,方便通过 _activeImageIndex 和 imageOptions 获取需要的信息:
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
// 激活图像
|
||||
String get activeImage => imageOptions[_activeImageIndex].src;
|
||||
|
||||
// 敲击是增加值
|
||||
int get knockValue {
|
||||
int min = imageOptions[_activeImageIndex].min;
|
||||
int max = imageOptions[_activeImageIndex].max;
|
||||
return min + _random.nextInt(max+1 - min);
|
||||
}
|
||||
|
||||
|
||||
//...
|
||||
MuyuAssetsImage(
|
||||
image: activeImage, // 使用激活图像
|
||||
onTap: _onKnock,
|
||||
),
|
||||
|
||||
void _onKnock() {
|
||||
pool?.start();
|
||||
setState(() {
|
||||
_cruValue = knockValue; // 使用激活木鱼的值
|
||||
_counter += _cruValue;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
这样,就可以选择木鱼样式的功能,在敲击时每次功德的增加量也会不同。如下右图中,尊享版木鱼每次敲击,数字增加在 3~6 之间随机。当前代码位置 muyu
|
||||
|
||||
|
||||
|
||||
|
||||
选择
|
||||
切换后敲击
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这里有个小问题,可以作为思考,这个问题将会在下一篇解决:
|
||||
如上左图,在切换图片时,会看到 功德+2 的动画,这是为什么呢? 有哪些方式可以解决这个问题。
|
||||
|
||||
|
||||
|
||||
|
||||
3. 选择音效功能
|
||||
|
||||
选择音效的整体思路和上面类似,点击主界面右上角第一个按钮,从底部弹出选择界面,这里准备了三个音效以供选择。当前音效也会高亮显示,另外右侧有一个播放按钮可以试听:
|
||||
|
||||
|
||||
|
||||
|
||||
主界面
|
||||
选择音效界面
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
首先还是对数据进行一下封装,对于音频选择界面而言,两个信息数据: 名称 和 资源。这里通过 AudioOption 类进行
|
||||
|
||||
--->[muyu/models/audio_option.dart]---
|
||||
class AudioOption{
|
||||
final String name;
|
||||
final String src;
|
||||
|
||||
const AudioOption(this.name, this.src);
|
||||
}
|
||||
|
||||
|
||||
在 _MuyuPageState 中,通过 audioOptions 列表记录音频选项对应的数据;_activeAudioIndex 表示当前激活的音频索引:
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
final List<AudioOption> audioOptions = const [
|
||||
AudioOption('音效1', 'muyu_1.mp3'),
|
||||
AudioOption('音效2', 'muyu_2.mp3'),
|
||||
AudioOption('音效3', 'muyu_3.mp3'),
|
||||
];
|
||||
|
||||
int _activeAudioIndex = 0;
|
||||
|
||||
|
||||
如何同样,在点击按钮时,通过 showCupertinoModalPopup 弹出底部栏,使用 AudioOptionPanel 构建展示内容:
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
void _onTapSwitchAudio() {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AudioOptionPanel(
|
||||
audioOptions: audioOptions,
|
||||
activeIndex: _activeAudioIndex,
|
||||
onSelect: _onSelectAudio,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
目前只有三个数据,这里通过 Column 竖直排放即可,如果你想支持更多的选项,可以使用滑动列表(下篇介绍)。 另外,对于条目而言,Flutter 提供了一些通用的结构,比如这里可以使用 ListTile 组件,来构建左中右的视图。
|
||||
|
||||
|
||||
|
||||
AudioOptionPanel 界面构建逻辑如下,其中提供 List.generate 构造函数生成条目组件列表;每个条目由 _buildByIndex 方法根据 index 索引进行构建;构建的内容使用 ListTile 根据 AudioOption 数据进行展示。
|
||||
|
||||
|
||||
注: 这里 listA = [1,2,…listB] 表示在创建 listA 的过程中,将 listB 列表元素嵌入其中。
|
||||
|
||||
|
||||
--->[muyu/options/select_audio.dart]---
|
||||
class AudioOptionPanel extends StatelessWidget {
|
||||
final List<AudioOption> audioOptions;
|
||||
final ValueChanged<int> onSelect;
|
||||
final int activeIndex;
|
||||
|
||||
const AudioOptionPanel({
|
||||
Key? key,
|
||||
required this.audioOptions,
|
||||
required this.activeIndex,
|
||||
required this.onSelect,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold);
|
||||
return Material(
|
||||
child: SizedBox(
|
||||
height: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 46,
|
||||
alignment: Alignment.center,
|
||||
child: const Text("选择音效", style: labelStyle, )),
|
||||
...List.generate(audioOptions.length, _buildByIndex)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildByIndex(int index) {
|
||||
bool active = index == activeIndex;
|
||||
return ListTile(
|
||||
selected: active,
|
||||
onTap: () => onSelect(index),
|
||||
title: Text(audioOptions[index].name),
|
||||
trailing: IconButton(
|
||||
splashRadius: 20,
|
||||
onPressed: ()=> _tempPlay(audioOptions[index].src),
|
||||
icon: const Icon(
|
||||
Icons.record_voice_over_rounded,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _tempPlay(String src) async{
|
||||
AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1);
|
||||
pool.start();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
最后,在 _MuyuPageState 中,选择音频回调时,根据激活索引,更新 pool 对象即可。这样在点击时就可以播放对应的音效。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
String get activeAudio => audioOptions[_activeAudioIndex].src;
|
||||
|
||||
void _onSelectAudio(int value) async{
|
||||
Navigator.of(context).pop();
|
||||
if (value == _activeAudioIndex) return;
|
||||
_activeAudioIndex = value;
|
||||
pool = await FlameAudio.createPool(
|
||||
activeAudio,
|
||||
maxPlayers: 1,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4.本章小结
|
||||
|
||||
本章通过弹出框展示切换音效和木鱼样式,可以进一步理解数据和界面视图之间的关系:数据为界面提供内容信息、界面交互操作更改数据信息。
|
||||
|
||||
另外,两个选项的面板通过自定义组件进行封装隔离,面板在需要的数据通过构造函数传入、界面中的事件通过回调函数交由外界执行更新数据逻辑。也就是说,封装的 Widget 在意的是如何构建展示内容,专注于做一件事,与界面构建无关的任务,交由使用者处理。
|
||||
|
||||
到这里,本篇新增的两个功能就完成了,当前代码位置 muyu 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。
|
||||
|
||||
|
||||
|
||||
|
224
专栏/Flutter入门教程/16用滑动列表展示记录.md
Normal file
224
专栏/Flutter入门教程/16用滑动列表展示记录.md
Normal file
@ -0,0 +1,224 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 用滑动列表展示记录
|
||||
1. 功德记录的功能需求
|
||||
|
||||
现在想要记录每次点击时的功德数据,以便让用户了解功德增加的历史。对于一个需求而言,最重要的是分析其中需要的数据,以及如何维护这些数据。
|
||||
|
||||
效果如下,点击左上角按钮,跳转到功德记录的面板。功德记录界面是一个可以滑动的列表,每个条目展示当前功德的 木鱼图片、音效名称、时间、功德数 信息:
|
||||
|
||||
|
||||
|
||||
|
||||
标题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
根据界面中的数据,可以封装一个类来维护,如下 MeritRecord 类。其中 id 是一条记录的身份标识,就像人的身份证编号,就可以确保他在社会中的唯一性。
|
||||
|
||||
class MeritRecord {
|
||||
final String id; // 记录的唯一标识
|
||||
final int timestamp; // 记录的时间戳
|
||||
final int value; // 功德数
|
||||
final String image; // 图片资源
|
||||
final String audio; // 音效名称
|
||||
|
||||
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
|
||||
}
|
||||
|
||||
|
||||
对于功德记录的需求而言,需要在 _MuyuPageState 中新加的数据也很明确: 对 MeritRecord 列表的维护。接下来的任务就是,在点击时,为 _records 列表添加 MeritRecord 类型的元素。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
List<MeritRecord> _records = [];
|
||||
|
||||
|
||||
|
||||
|
||||
2. 列表数据的维护
|
||||
|
||||
对于生成唯一 id , 这里使用 uuid 库,目前最新版本 3.0.7。记得在 pubspec.yaml 的依赖节点配置:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
uuid: ^3.0.7
|
||||
|
||||
|
||||
如下代码是 _onKnock 函数处理点击时的逻辑,其中创建 MeritRecord 对象,构造函数中的相关数据,在状态类中都可以轻松的到。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
final Uuid uuid = Uuid();
|
||||
|
||||
void _onKnock() {
|
||||
pool?.start();
|
||||
setState(() {
|
||||
_cruValue = knockValue;
|
||||
_counter += _cruValue;
|
||||
// 添加功德记录
|
||||
String id = uuid.v4();
|
||||
_records.add(MeritRecord(
|
||||
id,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
_cruValue,
|
||||
activeImage,
|
||||
audioOptions[_activeAudioIndex].name,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
这里来分析一下上一篇中切换木鱼样式,会导致动画触发的问题。原因非常简单,因为动画控制器会在 didUpdateWidget 中启动。而切换木鱼样式时,通过触发了 setState 更新主界面的木鱼图片。于是会连带触发动画器的启动,解决方案有很多:
|
||||
|
||||
|
||||
解决方案 1 : 将动画控制器交由上层维护,仅在点击时启动动画。
|
||||
解决方案 2 :为 didUpdateWidget 回调中动画启动添加限制条件。
|
||||
解决方案 3 :切换木鱼样式时,使用局部更新图片,不重新构建整个 _MuyuPageState 。
|
||||
|
||||
|
||||
|
||||
|
||||
上面的三个方案中各有优劣,综合来看,方案 1 是最好的;方案 2 次之;方案 3 虽然可以解决当前问题,但治标不治本。就当前代码而言,使用 方案 2 处理最简单,那么动画启动的限制条件是什么呢?
|
||||
|
||||
|
||||
当功德发生变化时,才需要启动动画。
|
||||
|
||||
|
||||
而现在每个功德对应一个 MeritRecord 对象,且 id 可以作为功德的身份标识。功德发生变化 ,也就意味着新旧功德 id 的不同。那么具体的方案就呼之欲出了,如下所示:让 AnimateText 持有 MeritRecord 数据
|
||||
|
||||
|
||||
|
||||
在状态类 didUpdateWidget 回调中,可以访问到旧的组件,通过对比新旧 AnimateText 组件持有的 record 记录 id ,当不同时才启动动画。这样就避免了在功德未变化的情况下,启动动画的场景。通过个问题的解决,想必大家对状态类的 didUpdateWidget 方法,有更深一点的了解。
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimateText oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.record.id != widget.record.id) {
|
||||
controller.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
小练习: 自己尝试使用解决方案 1 ,解决动画误启动问题。
|
||||
|
||||
|
||||
|
||||
|
||||
3. ListView 的简单使用
|
||||
|
||||
我们已经知道,通过 Column 组件可以实现若干个组件的竖向排列。但当组件数量众多时,超越 Column 组件尺寸范围后,就无法显示;并且在 Debug 模式中会出现越界的异常(下左图)。很多场景中,需要支持视图的滑动,可以使用 ListView 组件来实现 (下右图):
|
||||
|
||||
|
||||
|
||||
|
||||
Column
|
||||
ListView
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这里历史记录界面通过 RecordHistory 组件构建,构造函数中传入 MeritRecord 列表。主体内容通过 ListView.builder 构建滑动列表,通过 _buildItem 方法,根据索引值返回条目组件。
|
||||
|
||||
class RecordHistory extends StatelessWidget {
|
||||
final List<MeritRecord> records;
|
||||
|
||||
const RecordHistory({Key? key, required this.records}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar:_buildAppBar(),
|
||||
body: ListView.builder(
|
||||
itemBuilder: _buildItem,
|
||||
itemCount: records.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar() =>
|
||||
AppBar(
|
||||
iconTheme: const IconThemeData(color: Colors.black),
|
||||
centerTitle: true,
|
||||
title: const Text(
|
||||
'功德记录', style: TextStyle(color: Colors.black, fontSize: 16),),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.white,
|
||||
);
|
||||
|
||||
|
||||
在 _buildItem 方法中,使用 ListTile 组件构建条目视图;其中:
|
||||
|
||||
|
||||
leading 表示左侧组件,使用 CircleAvatar 展示圆形图形;
|
||||
title 表示标题组件,展示功德数;
|
||||
subtitle 表示副标题组件,展示音效名称;
|
||||
trailing 表示尾部组件,展示日期。
|
||||
注: 这里使用 DateFormat 来格式化时间,需要在依赖中添加 intl: ^0.18.1 包。
|
||||
|
||||
|
||||
DateFormat format = DateFormat('yyyy年MM月dd日 HH:mm:ss');
|
||||
|
||||
Widget? _buildItem(BuildContext context, int index) {
|
||||
MeritRecord merit = records[index];
|
||||
String date = format.format(DateTime.fromMillisecondsSinceEpoch(merit.timestamp));
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.blue,
|
||||
backgroundImage: AssetImage(merit.image),
|
||||
),
|
||||
title: Text('功德 +${merit.value}'),
|
||||
subtitle: Text(merit.audio),
|
||||
trailing: Text(
|
||||
date, style: const TextStyle(fontSize: 12, color: Colors.grey),),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
最后在 _MuyuPageState 中处理 _toHistory 点击事件,通过 Navigator 跳转到 RecordHistory 。
|
||||
|
||||
---->[_MuyuPageState]----
|
||||
void _toHistory() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RecordHistory( records: _records.reversed.toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4.本章小结
|
||||
|
||||
本章主要介绍了如何使用可滑动列表展示非常多的内容,ListView 组件可以很便捷地帮我们让记录列表支持滑动。滑动视图使用起来并不是很复杂,但真的能用好,理解其内部的原理,还有很长的路要走。对于新手而言,目前简单能用就行了,以后可以基于小册来深入研究。
|
||||
|
||||
到这里,电子木鱼的基本功能完成了,当前代码位置 muyu 。不过现在的数据都是存储在内存中的,应用退出之后无论是选项,还是功德记录都会重置。想要数据持久化存储,在后面的 数据的持久化存储 一章中再继续完善,木鱼项目先告一段落。
|
||||
|
||||
|
||||
|
||||
|
308
专栏/Flutter入门教程/17电子木鱼整理与总结.md
Normal file
308
专栏/Flutter入门教程/17电子木鱼整理与总结.md
Normal file
@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 电子木鱼整理与总结
|
||||
通过上面 5 章的学习,我们已经完成了一个简单的电子木鱼小项目。这里将对项目在的一些知识点进行整理和总结,主要从 界面相关知识 和 获得的技能点 两个方面进行总结:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
一、 界面相关的知识
|
||||
|
||||
电子木鱼的界面交互要比猜数字要复杂很多,比如选择切换音效和图片、跳转历史记录界面。所以需要维护的数据以及组件的构建逻辑也更复杂,其中蕴含的知识也就更多。
|
||||
|
||||
1. 数据模型与界面展示
|
||||
|
||||
在界面中往往会有一些数据相关性很强,界面构建中需要很多份这样的数据。比如每个木鱼的样式,都有名称、资源、功德范围。这些数据就像一个个豌豆粒,一个个单独维护会非常复杂,把它们封装在一个类中,就相当于让它们住进一个豌豆皮中。
|
||||
|
||||
|
||||
|
||||
所以这种对若干个数据进行封装的类型也被形象地称之为 Bean,也可以称之为 数据模型 Model 。比如这里的 ImageOption 类型就是对木鱼样式数据的封装,一个 ImageOption 对象就对应着界面展示中的一个图片选项。
|
||||
|
||||
class ImageOption{
|
||||
final String name; // 名称
|
||||
final String src; // 资源
|
||||
final int min; // 每次点击时功德最小值
|
||||
final int max; // 每次点击时功德最大值
|
||||
|
||||
const ImageOption(this.name, this.src, this.min, this.max);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 组件的封装性
|
||||
|
||||
还拿木鱼选择的界面来说,可以看出两个木鱼选项的界面结构是完全一样的,只不过是内容数据信息的不同。这时并没有必要写两份构建逻辑,通过一个组件进行封装,界面构建这依赖的数据通过构造中传入。比如这里依赖 ImageOption 和是否激活两个数据:
|
||||
|
||||
|
||||
|
||||
封装数据模型类之后,在传参时也更加方便,否则就需要传入一个个的豌豆粒。在构建逻辑中就可以根据传入的数据,构建对应的组件,完成界面展示的任务。
|
||||
|
||||
|
||||
|
||||
其中 ImageOptionItem 组件也像一个豌豆,里面的豌豆粒就是构建过程中使用的组件。豌豆把豌豆粒包裹在一块,这就是一种现实中的 封装 。为 ImageOptionItem 组件提供不同的 ImageOption,就可以展示不同的界面。所以这里只需要准备份数据就行了,而不是重复写两次构建逻辑。
|
||||
|
||||
|
||||
这就是封装的一大特点: 可复用性。
|
||||
|
||||
|
||||
复用的目标就是封装的内容,对于组件来说,封装的目标就是构建逻辑。
|
||||
|
||||
ImageOptionItem(option: option1, active: true),
|
||||
ImageOptionItem(option: option2, active: false),
|
||||
|
||||
|
||||
可复用性往往会让使用变得简单,比如 Text 组件是源码提供的组件,它封装了文本渲染的过程。对于使用者来说,只要创建 Text 对象,并不需要理解底层的渲染原理。如果 ImageOptionItem 组件是 A 同学写的,那么把它发给 B 同学,那 B 同学只需要准备数据即可。所以,逻辑一旦封装,也会有 普适性 : 一人封装,万人可用,这也是类库生态的基石。
|
||||
|
||||
|
||||
|
||||
3. State 状态类的生命周期回调
|
||||
|
||||
State 的生命周期回调是非常重要的,但对于初学者来说,目前只能先了解这些回调的作用和使用方式。
|
||||
|
||||
|
||||
initState 回调方法会在 State 对象创建后被触发一次,可以在其中处理成员对象的初始化逻辑。比如动画控制器创建、音频播放器对象的创建等:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
dispose 回调方法会在 State 对象销毁前触发一次,用于释放一些资源,比如动画控制器、文字控制器、音频播放器等的销毁工作。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
build 回调方法返回 Widget 对象,也就是组件的构建逻辑。该方法可能在一个 State 对象生命之中可能触发多次,通过 State#setState 方法可以导致 build 方法重新执行。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
didUpdateWidget 会在 State 对应的组件发生变化时被触发;它在一个 State 对象生命之中也可能触发多次。最常见的场合是上层状态类触发 setState,导致组件发生变化,该回调中可以访问到旧的组件,通常会根据新旧组件的配置属性来触发事件,或更新一些状态类内部数据。
|
||||
|
||||
|
||||
|
||||
|
||||
现在只需要会用这四个回调即可,想要完全把握 State 状态类的生命周期回调,需要从源码的角度去理解回调触发的流程。希望之后,你可以在后续的路途上,通过 《Flutter 渲染机制 - 聚沙成塔》 找到满意的答案。
|
||||
|
||||
|
||||
|
||||
4. 界面的跳转
|
||||
|
||||
MaterialApp 的内部集成了一套路由体系,在代码中只通过 Navigator.of(context) 来获取 NavigatorState 对象,该对象的 push 方法实现可以跳转界面的功能。下面代码中 MaterialPageRoute 是一个默认的跳转动画界面路由,其中的 builder 方法用来构建跳转到的界面:
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => RecordHistory(
|
||||
records: _records.reversed.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
二、 技能点
|
||||
|
||||
在电子木鱼项目中,涉及到了资源的引入,以及使用依赖库。依赖库的存在,极大方便了开发者。你可以轻松地获取和使用别人封装好的代码,来为自己的项目服务,比如这里的音频播放器、uuid 的获取以及日期时间的格式化。
|
||||
|
||||
1. 资源配置与依赖库的使用
|
||||
|
||||
Flutter 项目的配置文件是 pubspec.yaml ,其中依赖库配置在 dependencies 节点下。配置之后记得添加一右上角的 pub get 按钮获取依赖,或者在根目录下的命令行中输入:
|
||||
|
||||
|
||||
flutter pub get
|
||||
|
||||
|
||||
|
||||
|
||||
获取依赖之后,就可以导入包中的文件,然后使用其中的类或方法:
|
||||
|
||||
import 'package:flame_audio/flame_audio.dart';
|
||||
|
||||
|
||||
图片、文本等本地资源需要在 flutter 节点下的 asstes 下配置,一般习惯于将资源文件放在项目根目录的 assets 文件夹中。配置时只需要写文件夹,就可以访问期内的所有文件:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 多种动画变换
|
||||
|
||||
木鱼在点击时,当前功德文字会以 缩放+移动+透明度 动画变化,代码中通过多个 XXXTransition 嵌套实现的。除了这三种,还有些其他的 Transition ,在使用时都需要使用者提供相关类型的动画器:
|
||||
|
||||
|
||||
|
||||
这里想强调的是 : 基于一个动画控制器,可以通过 Tween 来生成其他类型的动画器 Animation ,它们共用一个动力源。也就是说 controller 启动时,这些动画器都会进行动画变化:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 短音效的播放
|
||||
|
||||
flame_audio 插件可以实现音频播放的功能,使用起来也非常简单:通过 FlameAudio.createPool 静态方法,指定资源创建 AudioPool ,执行 start 即可。
|
||||
|
||||
AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1);
|
||||
pool.start();
|
||||
|
||||
|
||||
Flutter 中音频播放还有一个 audioplayers 库,其实 flame_audio 是对该库的一个上层封装,引入了缓存池,在播放短音效的场景中更好一些。我也试过 audioplayers ,感觉连续点击短音效有一点杂音,而 flame_audio 表现良好。
|
||||
|
||||
|
||||
|
||||
4. 唯一标识 uuid
|
||||
|
||||
有些时候,我们需要用一个成员属性来标识对象的唯一性, 一般应用通过 uuid 获取标识符可以视为唯一的。这里使用 uuid 的目的是为每次功德的增加记录提供一个身份标识。
|
||||
|
||||
final Uuid uuid = Uuid();
|
||||
String id = uuid.v4();
|
||||
|
||||
|
||||
得益于 Flutter 的良好生态环境,像这些基础功能,都已经有了依赖库,所以两行代码就能完成需求。以后有通用的能需要求,可以在 pub 中看一下有没有依赖库。当然,你有好的功能代码,也可以创建依赖库,提交到 pub 中,供大家一起使用。
|
||||
|
||||
|
||||
|
||||
三、接触的内置组件
|
||||
|
||||
最后来整理一下目前电子木鱼项目中用到的 Flutter 内置组件,大家可以根据下表,结合源码以及应用界面,思考一下这些组件的作用和使用方式:
|
||||
|
||||
1. 基础组件
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Image
|
||||
图片展示
|
||||
展示木鱼图片
|
||||
|
||||
|
||||
|
||||
GestureDetector
|
||||
手势事件监听器
|
||||
监听点击图片事件,处理敲击逻辑
|
||||
|
||||
|
||||
|
||||
ElevatedButton
|
||||
升起按钮
|
||||
左上角选择音频和木鱼样式和按钮
|
||||
|
||||
|
||||
|
||||
ScaleTransition
|
||||
缩放变换
|
||||
功德数字缩放动画
|
||||
|
||||
|
||||
|
||||
SlideTransition
|
||||
移动变换
|
||||
功德数字移动动画
|
||||
|
||||
|
||||
|
||||
FadeTransition
|
||||
透明度变换
|
||||
功德数字透明度动画
|
||||
|
||||
|
||||
|
||||
Material
|
||||
材料组件
|
||||
为选择界面提供材料设计默认主题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 组合结构型
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ListTile
|
||||
列表条目通用结构
|
||||
音频选项以及列表条目
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 布局组件
|
||||
|
||||
|
||||
|
||||
|
||||
组件名称
|
||||
功能
|
||||
猜数字中的使用
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Expanded
|
||||
Row/Column 中延展区域
|
||||
主页面上下平分区域
|
||||
|
||||
|
||||
|
||||
Positioned
|
||||
Stack 中定位组件
|
||||
左上角按钮的定位
|
||||
|
||||
|
||||
|
||||
Wrap
|
||||
包裹若干组件
|
||||
左上角两个按钮的竖直包裹
|
||||
|
||||
|
||||
|
||||
Padding
|
||||
设置内边距
|
||||
木鱼样式选择界面中的布局
|
||||
|
||||
|
||||
|
||||
SizedBox
|
||||
尺寸设置
|
||||
选择界面的高度设置
|
||||
|
||||
|
||||
|
||||
ListView
|
||||
滑动列表
|
||||
历史记录界面
|
||||
|
||||
|
||||
|
||||
最后想说一下关于木鱼项目的演变:当前项目的功能有点击发声、切换图片、数据记录。其实可以基于此完成另一个小项目,比如电子宠物饲养类型的应用,可以喂食,抚摸发出叫声,切换宠物等操作。这和点击敲木鱼是殊途同归的,有时间和兴趣的可以自己尝试一下。
|
||||
|
||||
|
||||
|
||||
|
132
专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md
Normal file
132
专栏/Flutter入门教程/18白板绘制界面交互与需求分析.md
Normal file
@ -0,0 +1,132 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
18 白板绘制界面交互与需求分析
|
||||
1. 为什么需要 Canvas
|
||||
|
||||
之前我们都是通过 Flutter 已有的组件进行组合来构建界面,这种组合式的界面构建方式虽然方便易用,但是也有一些局限性。有些特殊场景的展示效果通过内置的组件拼组很难实现,比如在画板上进行绘制、特殊图案、统计图表的展示等。我们需要一种自己控制绘制逻辑,来完成视图表现的手段,这就是 Canvas 绘制体系。
|
||||
|
||||
|
||||
|
||||
任何一种界面交互的开发框架,无论是 Web、 还是 Android、iOS 、还是桌面端的界面开发,都会提供 Canvas 让使用者更灵活地控制界面展现。同时,各种平台的 Canvas 操作接口基本一致,所以这项技能一通百通。绘制相当于通过编程来画画,是一项创造性的活动,具有很大的发散空间。
|
||||
|
||||
其实,本质上来说 Widget 之所以能展示出来,底层也是依赖于 Canvas 绘制实现的,所以它也不是 Flutter 体系中的异物。掌握 Canvas 的绘制,就能实现更多复杂的界面展示需求,是一个门槛比较高的技能。本教程只是简单的认识,并不能做系统的介绍。如果对绘制感兴趣,可以研读我的小册 《Flutter 绘制指南 - 妙笔生花》
|
||||
|
||||
|
||||
|
||||
2. 界面交互介绍
|
||||
|
||||
白板绘制是本教程的第三个案例,相比于前两个项目,可操作性更强一些,也更有趣。适合新手朋友进一步了解 Flutter 绘制相关知识,体会其创造性,绘制过程中练习 Dart 语法也是个不错的选择。下面是两个最基础的交互:
|
||||
|
||||
|
||||
通过监听用户的拖拽手势,让界面留下触点的线条痕迹。
|
||||
可以选择绘制时线条的颜色和粗细两个配置项。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
画板绘制
|
||||
颜色和线宽选择
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
对于界面绘制内容的管理,提供了两个功能:
|
||||
|
||||
|
||||
当界面中存在绘制的线条时,可以回退上一步;在有回退历史时,可以撤销回退。
|
||||
右上角的清除按钮,点击时会弹出对话框确认清除,完成清空绘制的功能。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
回退和撤销
|
||||
清除内容
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 白板绘制需求分析
|
||||
|
||||
现在从数据和界面的角度,来分析一白板绘制中的需求:
|
||||
|
||||
|
||||
用户手指拖拽绘制线条
|
||||
|
||||
|
||||
在这个需求中,对于数据来说:线由多个点构成,并且每条线有颜色和粗细的属性,可以通过如下 Line 类型维护点集数据。那么多条线就是 Lines 列表,列表中的元素会根据用户的拖拽操作进行更新。
|
||||
|
||||
class Line {
|
||||
List<Offset> points;
|
||||
Color color;
|
||||
double strokeWidth;
|
||||
|
||||
Line({
|
||||
required this.points,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 1,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
和木鱼项目选择类似,这里的颜色和线宽,也需要给出支持的选项列表,以及激活的索引值。用户在点击选项条目时更新激活索引数据,在手指拖拽开始是,添加的 Line 宽度和颜色使用激活的数据即可。
|
||||
|
||||
对于界面来说,绘制功能通过 CustomPaint 组件 + CustomPainter 画板实现,拖拽手势的监听使用 GestureDetector 组件实现。颜色和线宽的选择器,通过 Flutter 内置的组件来封装。
|
||||
|
||||
|
||||
|
||||
|
||||
回退撤销清空功能
|
||||
|
||||
|
||||
这三个功能点,都是对线列表数据的维护。回退是将线列表末尾元素移除;由于要撤销回退,需要额外维护被回退移除的元素,撤销回退时再加回线列表中。
|
||||
|
||||
对于界面来说,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退:
|
||||
|
||||
|
||||
|
||||
当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。
|
||||
|
||||
|
||||
|
||||
这就是白板绘制的需求分析,这个案例的核心是 CustomPaint 组件 + GestureDetector 组件的使用。在数据维护的过程中,是练习语法的好机会,另外它交互性比较强,使用趣味性上是最好的,可以让家里的小朋友随便画画 (如下小外甥女作品),下面一起开始第二个小项目的学习吧!
|
||||
|
||||
|
||||
|
||||
|
||||
标题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
233
专栏/Flutter入门教程/19认识自定义绘制组件.md
Normal file
233
专栏/Flutter入门教程/19认识自定义绘制组件.md
Normal file
@ -0,0 +1,233 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
19 认识自定义绘制组件
|
||||
1.从绘制点开始说起
|
||||
|
||||
我们先通过绘制点来了解一下 Flutter 中绘制的使用方式。左图中是新建的 Paper 组件作为白板主界面;右图在白板的指定坐标处绘制了四个方形的点:
|
||||
|
||||
|
||||
|
||||
|
||||
空白
|
||||
绘制四个点
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Paper 组件如下,由于之后需要进行切换画笔颜色、粗细的操作;涉及到界面中状态的变化,所以这里继承自 StatefulWidget,在状态类的 build 方法中,完成界面的构建逻辑。 PaperAppBar 是单独封装的头部标题组件,和之前类似,就不赘述了;现在主要是想让 body 处设置为可绘制的组件。
|
||||
|
||||
class Paper extends StatefulWidget {
|
||||
const Paper({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<Paper> createState() => _PaperState();
|
||||
}
|
||||
|
||||
class _PaperState extends State<Paper> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: PaperAppBar(onClear: _clear,),
|
||||
body: //TODO
|
||||
);
|
||||
}
|
||||
|
||||
void _clear() {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
我们已经知道, Flutter 中的所有界面呈现,都和 Widget 息息相关,绘制也不例外。在 Flutter 中接触到 Canvas 最常用的方式是 CustomPaint 组件 + CustomPainter 画板组合。下面代码,将 body 设置为 CustomPaint 组件;CustomPaint 在构造时传入 painter 参数是 CustomPainter 的子类。
|
||||
|
||||
自定义绘制,就是指继承 CustomPainter 完成绘制逻辑,比如这里的 PaperPainter 画板。另外,我们希望画布的尺寸填充剩余空间,可以将 child 指定为 ConstrainedBox ,并通过 BoxConstraints.expand() 的约束。
|
||||
|
||||
body: CustomPaint(
|
||||
painter: PaperPainter(),
|
||||
child: ConstrainedBox(constraints: const BoxConstraints.expand()),
|
||||
),
|
||||
|
||||
|
||||
也就是说,对于自定义绘制来说,最重要的是 CustomPainter 子类代码的实现,这里通过 PaperPainter 来完成绘制逻辑。在 paint 回调中,可以访问到 Canvas 对象和画板的尺寸 Size 。
|
||||
在该方法中通过调用 Canvas 的相关方法就可以进行绘制。比如这里使用 drawPoints 绘制点集,其中需要传入三个参数,分别是:
|
||||
|
||||
|
||||
点的模式 PointMode : 共三种模式, points、lines、polygon
|
||||
点集 List<Offset> : 点的坐标列表
|
||||
画笔 Paint : 绘制时的配置参数
|
||||
|
||||
|
||||
class PaperPainter extends CustomPainter{
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
List<Offset> points = const [
|
||||
Offset(100,100),
|
||||
Offset(100,150),
|
||||
Offset(150,150),
|
||||
Offset(200,100),
|
||||
];
|
||||
|
||||
Paint paint = Paint();
|
||||
paint.strokeWidth = 10;
|
||||
canvas.drawPoints(PointMode.points, points , paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
上面点模式是 PointMode.points ,也就是绘制一个个点。如下是另外两者模式的效果,看起来也很清晰:PointMode.lines 会将点集分为若干对,没对连接成线;PointMode.polygon 会将点依次连接;
|
||||
|
||||
|
||||
|
||||
|
||||
PointMode.lines
|
||||
PointMode.polygon
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
到这里,就通过 Canvas 完成了一个最基础的点集绘制案例,当前代码位置 paper.dart 。接下来,介绍一下 Paint 对象,看看你的画笔有哪些功能。
|
||||
|
||||
|
||||
|
||||
2. 简单认识画笔的属性
|
||||
|
||||
可以先回想一下,我们现实中画画时用的笔有哪些特性:很自然的可以想到: 颜色、粗细 。 这两个配置项分别对应 strokeWidth 和 color 属性,在绘制之前通过直接 paint 对象设置即可:
|
||||
|
||||
Paint paint = Paint();
|
||||
paint.strokeWidth = 10;
|
||||
paint.color = Colors.red;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
粗细 strokeWidth
|
||||
颜色 color
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
之前线间的连接很突兀,可以使用将 strokeCap 设置为 StrokeCap.round 让线编程圆头:
|
||||
|
||||
paint.strokeCap = StrokeCap.round;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
StrokeCap.butt
|
||||
StrokeCap.round
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这里面向新手,对于绘制的知识也点到为止,能满足当前需求即可,不会进行非常系统的介绍。不过感兴趣的可以看我的 《Flutter 绘制指南 - 妙笔生花》 小册,其中对于绘制方方面面都介绍的比较详细。
|
||||
|
||||
|
||||
|
||||
3. 简单的基础图形绘制
|
||||
|
||||
Canvas 中提供了一些基础图形的绘制,比如 圆形、矩形、圆角矩形、椭圆、圆弧 等,这里简单了解一下。
|
||||
|
||||
|
||||
drawCircle 绘制圆形: 三个入参分别是圆心坐标 Offset、半径 double 、 画笔 Paint。
|
||||
|
||||
|
||||
另外,Paint 默认是填充样式,如下左图会填满内部;可以将 style 设置为 PaintingStyle.stroke变成线型模式,如下右图:
|
||||
|
||||
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint paint = Paint();
|
||||
canvas.drawCircle(Offset(100, 100), 50, paint);
|
||||
paint.style = PaintingStyle.stroke;
|
||||
canvas.drawCircle(Offset(250, 100), 50, paint);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
drawRect 绘制矩形:两个入参分别是矩形 Rect、画笔 Paint。
|
||||
drawRRect 绘制圆角矩形:两个入参分别是矩形 RRect、画笔 Paint。
|
||||
|
||||
|
||||
|
||||
|
||||
Paint paint = Paint();
|
||||
paint.style = PaintingStyle.stroke;
|
||||
paint.strokeWidth = 2;
|
||||
// 绘制矩形
|
||||
Rect rect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80);
|
||||
canvas.drawRect(rect, paint);
|
||||
// 绘制圆角矩形
|
||||
paint.style = PaintingStyle.fill;
|
||||
RRect rrect = RRect.fromRectXY(rect.translate(150, 0), 8, 8);
|
||||
canvas.drawRRect(rrect, paint);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
drawOval 绘制椭圆:两个入参分别是矩形 Rect、画笔 Paint。
|
||||
drawArc 绘制圆弧:五个入参分别是矩形 RRect、起始弧度 double、扫描弧度 double、是否闭合 bool、画笔 Paint。
|
||||
|
||||
|
||||
|
||||
|
||||
Paint paint = Paint();
|
||||
paint.strokeWidth = 2;
|
||||
// 绘制椭圆
|
||||
Rect overRect = Rect.fromCenter(center: Offset(100, 100), width: 100, height: 80);
|
||||
canvas.drawOval(overRect, paint);
|
||||
// 绘制圆弧
|
||||
canvas.drawArc(overRect.translate(150, 0), 0, pi*1.3,true,paint);
|
||||
|
||||
|
||||
|
||||
|
||||
4. 本章小结
|
||||
|
||||
本章主要介绍了如果在 Flutter 中通过 Canvas 自定义绘制内容。界面就相当于一张白纸、绘制接口方法就相当于画笔,使用已经存在的组件固然简单,但学会自己控制画笔绘制内容可以创造更多的精彩。当然,想要精通绘制也不是一朝一夕可以达成的,但凡工艺技能,都是熟能生巧。
|
||||
|
||||
这里只是简单认识了在 Flutter 中使用 Canvas 绘制的方式,接下来将结合手势和绘制,完成在手指在界面上拖拽留下痕迹的绘制效果。
|
||||
|
||||
|
||||
|
||||
|
295
专栏/Flutter入门教程/20通过手势在白板上绘制.md
Normal file
295
专栏/Flutter入门教程/20通过手势在白板上绘制.md
Normal file
@ -0,0 +1,295 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 通过手势在白板上绘制
|
||||
1. 白板绘制思路分析
|
||||
|
||||
对功能需求的分析主要从 界面数据信息、交互与数据维护、界面构建逻辑 三个方面来思考。
|
||||
|
||||
|
||||
数据信息
|
||||
|
||||
|
||||
界面上需要呈现多条线,每条线由若干个点构成,另外可以指定线的颜色和粗细。所以这里可以封装一个 Line 类维护这些数据:
|
||||
|
||||
class Line {
|
||||
List<Offset> points;
|
||||
Color color;
|
||||
double strokeWidth;
|
||||
|
||||
Line({
|
||||
required this.points,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
然后需要 List<Line> 线列表来表示若干条线;由于支持颜色和粗细选择,需要给出支持的颜色、粗细选项列表,以及两者的激活索引:
|
||||
|
||||
List<Line> _lines = []; // 线列表
|
||||
|
||||
int _activeColorIndex = 0; // 颜色激活索引
|
||||
int _activeStorkWidthIndex = 0; // 线宽激活索引
|
||||
|
||||
// 支持的颜色
|
||||
final List<Color> supportColors = [
|
||||
Colors.black, Colors.red, Colors.orange,
|
||||
Colors.yellow, Colors.green, Colors.blue,
|
||||
Colors.indigo, Colors.purple,
|
||||
];
|
||||
|
||||
// 支持的线粗
|
||||
final List<double> supportStorkWidths = [1,2, 4, 6, 8, 10];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
交互与数据维护
|
||||
|
||||
|
||||
线列表数据的维护和用户的拖拽事件息息相关:
|
||||
|
||||
|
||||
用户开始拖拽开始时,需要创建 Line 对象,加入线列表。
|
||||
用户拖拽过程中,将触点添加到线列表最后一条线中。
|
||||
用户点击清除时,清空线列表。
|
||||
用户可以通过交互选择颜色,更新颜色激活索引。
|
||||
用户可以通过交互选择线宽,更新线宽激活索引。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
界面构建逻辑
|
||||
|
||||
|
||||
在该需求中的数据和交互分析完后,就可以考虑界面的构建逻辑了。下面是一个示意简图:
|
||||
|
||||
|
||||
|
||||
|
||||
使用 Scaffold + Appbar 组件构建整体结构。
|
||||
使用 CustomPaint 组件构建主体内容,作为可绘制区域。
|
||||
通过 GestureDetector 组件监听拖拽手势,维护数据变化。
|
||||
通过 Stack 组件将画笔颜色和线粗选择器叠放在绘制区上。
|
||||
画笔颜色和线粗选择器的构建将在下一篇详细介绍。
|
||||
|
||||
|
||||
到这里,主要的数据和交互,以及实现的思路就分析的差不多了,接下来就进入项目代码的编写。
|
||||
|
||||
|
||||
|
||||
2. 手势交互与数据维护
|
||||
|
||||
如下,新建一个 paper 文件夹,用于盛放白板绘制的相关代码。其中:
|
||||
|
||||
|
||||
model.dart 中盛放相关的数据模型,比如 Line 类。
|
||||
paper_app_bar.dart 是抽离的头部标题组件。
|
||||
paper.dart 是白板绘制的主界面。
|
||||
|
||||
|
||||
首先在 _PaperState 中放入之前分析的数据:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
想要监听用户的拖拽手势,可以使用 GestureDetector 组件的 pan 系列回调。如下所示,在 CustomPaint 之上嵌套 GestureDetector 组件;通过 onPanStart 可以监听到用户开始拖拽那刻的事件、通过 onPanUpdate 可以监听到用户拖拽中的事件:
|
||||
|
||||
body: GestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
child: CustomPaint(
|
||||
//略同...
|
||||
),
|
||||
)
|
||||
|
||||
void _onPanStart(DragStartDetails details) {}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {}
|
||||
|
||||
|
||||
|
||||
|
||||
回调中的逻辑处理也比较简单,在开始拖拽时为线列表添加新线;此时新线就是 _lines 的最后一个元素,在拖拽中,为新线添加点即可:
|
||||
|
||||
// 拖拽开始,添加新线
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
_lines.add(Line(points: [details.localPosition],));
|
||||
}
|
||||
|
||||
// 拖拽中,为新线添加点
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
_lines.last.points.add(details.localPosition);
|
||||
setState(() {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3. 画板的绘制逻辑
|
||||
|
||||
在 PaperPainter 中处理绘制逻辑,绘制过程中需要线列表的数据,而数据在 _PaperState 中维护。可以通过构造函数,将数据传入 PaperPainter 中,以供绘制时使用:
|
||||
|
||||
class PaperPainter extends CustomPainter {
|
||||
PaperPainter({
|
||||
required this.lines,
|
||||
}) {
|
||||
_paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
}
|
||||
|
||||
late Paint _paint;
|
||||
final List<Line> lines;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
drawLine(canvas, lines[i]);
|
||||
}
|
||||
}
|
||||
|
||||
///根据点集绘制线
|
||||
void drawLine(Canvas canvas, Line line) {
|
||||
_paint.color = line.color;
|
||||
_paint.strokeWidth = line.strokeWidth;
|
||||
canvas.drawPoints(PointMode.polygon, line.points, _paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
|
||||
绘制逻辑在上面的 paint 方法中,便历 Line 列表,通过 drawPoints 绘制 PointMode.polygon 类型的点集; Line 对象中的颜色和边线数据,可以在绘制前为画笔设置对应属性。最后,在 _PaperState 状态类中,当 PaperPainter 对象创建时,将 _lines 列表作为入参提供给画板即可:
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
--->[_PaperState]----
|
||||
child: CustomPaint(
|
||||
painter: PaperPainter(
|
||||
lines: _lines
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
4.弹出对话框
|
||||
|
||||
在点击清除按钮时,清空线列表。一般对于清除的操作,需要给用户一个确认的对话框,从而避免误操作。如下所示:
|
||||
|
||||
|
||||
|
||||
|
||||
点击弹框
|
||||
确认清除
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
弹出对话框可以使用框架中提供的 showDialog 方法,在 builder 回调函数中创建需要展示的组件。这里封装了 ConformDialog 组件用于展示对话框,将一些文字描述作为参数。这样其他地方想弹出类似的对话框,可以用 ConformDialog 组件,这就是封装的可复用性。
|
||||
|
||||
void _showClearDialog() {
|
||||
String msg = "您的当前操作会清空绘制内容,是否确定删除!";
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => ConformDialog(
|
||||
title: '清空提示',
|
||||
conformText: '确定',
|
||||
msg: msg,
|
||||
onConform: _clear,
|
||||
));
|
||||
}
|
||||
|
||||
// 点击清除按钮,清空线列表
|
||||
void _clear() {
|
||||
_lines.clear();
|
||||
setState(() {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
对话框背景是不透明灰色,这种展示效果可以使用 Dialog 组件;弹框整体结构也比较简单,是上中下竖直排列,可以使用 Column 组件;标题、消息内容、按钮这里通过三个函数来构建:
|
||||
|
||||
|
||||
小练习: 自己完成 ConformDialog 中三个构建函数的逻辑。
|
||||
|
||||
|
||||
|
||||
|
||||
class ConformDialog extends StatelessWidget {
|
||||
final String title;
|
||||
final String msg;
|
||||
final String conformText;
|
||||
final VoidCallback onConform;
|
||||
|
||||
ConformDialog({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.msg,
|
||||
required this.onConform,
|
||||
this.conformText = '删除',
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
_buildTitle(),
|
||||
_buildMessage(),
|
||||
_buildButtons(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
//...
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
5. 本章小结
|
||||
|
||||
本章主要完成白板绘制的基础功能,包括整体布局结构、监听拖拽事件,维护点集数据、以及绘制线集数据。其中蕴含着数据和界面展现的关系,比如,数据是如何产生的、数据在用户的交互期间有哪些变化、数据是如何呈现到界面上的,大家可以自己多多思考和理解。
|
||||
|
||||
到这里,界面上的线条会随着手指的拖动而呈现,完成了最基础的功能,当前代码位置 paper。下一篇将介绍一下,如何通过交互来修改画笔颜色和粗细,让界面的呈现更加丰富。
|
||||
|
||||
|
||||
|
||||
|
308
专栏/Flutter入门教程/21白板画笔的参数设置.md
Normal file
308
专栏/Flutter入门教程/21白板画笔的参数设置.md
Normal file
@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
21 白板画笔的参数设置
|
||||
1. 线粗选择器
|
||||
|
||||
如下所示,左下角可以选择线的粗细,激活状态通过蓝色线框表示。在选择之后,激活状态发生变化,绘制时线的宽度也会变化。这个选择器的构建逻辑相对独立,以后也有复用的可能,可以单独抽离为一个组件维护,这里创建 StorkWidthSelector 组件,并将其通过 Stack 组件叠放在画板之上:
|
||||
|
||||
|
||||
|
||||
|
||||
线粗 = 1
|
||||
线粗 = 8
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
通过选择器在界面上的展示效果,不难看出组件需要依赖的数据有:
|
||||
|
||||
|
||||
支持的线宽列表 List<double>
|
||||
激活索引 int
|
||||
条目的颜色 Color
|
||||
点击条目时的回调 ValueChanged<int>
|
||||
|
||||
|
||||
类定义如下:
|
||||
|
||||
class StorkWidthSelector extends StatelessWidget {
|
||||
final List<double> supportStorkWidths;
|
||||
final int activeIndex;
|
||||
final Color color;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
|
||||
const StorkWidthSelector({
|
||||
Key? key,
|
||||
required this.supportStorkWidths,
|
||||
required this.activeIndex,
|
||||
required this.onSelect,
|
||||
required this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
|
||||
|
||||
|
||||
在组件构建逻辑中,主要是便历 supportStorkWidths 列表,通过 _buildByIndex 方法根据索引值构建条目。其中可以通过 index == activeIndex 来确定当前条目是否被激活;再通过激活状态确定是否添加边线:
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(
|
||||
supportStorkWidths.length,
|
||||
_buildByIndex,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildByIndex(int index) {
|
||||
bool select = index == activeIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(index),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
width: 70,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: select ? Border.all(color: Colors.blue) : null),
|
||||
child: Container(
|
||||
width: 50,
|
||||
color: color,
|
||||
height: supportStorkWidths[index],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
最后通过 Stack + Positioned 组件,将 StorkWidthSelector 组件叠放在界面的右下角;并通过 _onSelectStorkWidth 作为选择的回调处理界面状态数据的变化逻辑:
|
||||
|
||||
|
||||
|
||||
处理逻辑很简单,只要更新 _activeStorkWidthIndex 激活索引即可;另外在 _onPanStart 创建 Line 对象时,设置激活线宽即可:
|
||||
|
||||
void _onSelectStorkWidth(int index) {
|
||||
if (index != _activeStorkWidthIndex) {
|
||||
setState(() {
|
||||
_activeStorkWidthIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
_lines.add(Line(
|
||||
points: [details.localPosition],
|
||||
// 使用激活线宽
|
||||
strokeWidth: supportStorkWidths[_activeStorkWidthIndex],
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
到这里,就完成了线条宽度的选择功能,当前代码位置 paper 。
|
||||
|
||||
|
||||
|
||||
2. 颜色选择器
|
||||
|
||||
颜色选择器的原理也是一样,选择激活,在创建 Line 对象时设置颜色。只不过是条目的界面表现不同罢了,条目的构建逻辑也是通过 _buildByIndex 实现的。这里通过圆圈也表示颜色,点击时激活条目,激活状态由外圈的圆形边线进行表示:
|
||||
|
||||
|
||||
|
||||
|
||||
标题
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ColorSelector extends StatelessWidget {
|
||||
final List<Color> supportColors;
|
||||
final ValueChanged<int> onSelect;
|
||||
final int activeIndex;
|
||||
|
||||
const ColorSelector({
|
||||
Key? key,
|
||||
required this.supportColors,
|
||||
required this.activeIndex,
|
||||
required this.onSelect,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8),
|
||||
child: Wrap(
|
||||
// crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(
|
||||
supportColors.length,
|
||||
_buildByIndex,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildByIndex(int index) {
|
||||
bool select = index == activeIndex;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(index),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.all(2),
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: select ? Border.all(color: Colors.blue) : null
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: supportColors[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
同理,在 _PaperState 中,颜色选择器也通过 Stack + Positioned 叠放在画板的左下角;点击回调时处理激活颜色索引数据的更新;以及创建 Line 对象时设置激活颜色:
|
||||
|
||||
|
||||
|
||||
void _onSelectColor(int index) {
|
||||
if (index != _activeColorIndex) {
|
||||
setState(() {
|
||||
_activeColorIndex = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
_lines.add(Line(
|
||||
points: [details.localPosition],
|
||||
strokeWidth: supportStorkWidths[_activeStorkWidthIndex],
|
||||
color: supportColors[_activeColorIndex],
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
到这里,颜色选择和线宽选择功能就已经实现了,当前代码位置 paper 。但这里在布局上还有些问题,下面来分析处理一下。
|
||||
|
||||
|
||||
|
||||
3. 布局分析
|
||||
|
||||
这里通过 Positioned 将两块分别叠放在 Stack 的两侧,上面颜色少时没有什么问题。但如果颜色过多,可以发现这种方式的叠放会让后者把前者覆盖住
|
||||
|
||||
|
||||
|
||||
|
||||
多颜色时
|
||||
布局边界
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
在布局树中可以发现,默认情况下 Positioned 之下的约束为无限约束,也就是子组件想要多大都可以。所以子组件在竖直方向上没有约束,颜色太多时就会溢出的原因。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
解决方案方案有很多,其中最简单的是指定 Positioned 组件的 width 参数,在水平方向施加紧约束。如下所示,可以限制 ColorSelector 的宽度等于 240。 ColorSelector 内部通过 Wrap 进行构建,在区域之内会自动换行:
|
||||
|
||||
|
||||
|
||||
Positioned(
|
||||
bottom: 40,
|
||||
width: 240,
|
||||
child: ColorSelector(
|
||||
supportColors: supportColors,
|
||||
activeIndex: _activeColorIndex,
|
||||
onSelect: _onSelectColor,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
通过布局查看器可以看出,此时 ColorSelector 受到的约束宽度就固定在 240。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
另外,我们还可以将 Positioned 提供的约束尺寸设为屏幕宽度,通过 Row 来水平排列,其中 ColorSelector 的宽度通过 Expanded 延展成剩余宽度。当前代码位置 paper
|
||||
|
||||
|
||||
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ColorSelector(
|
||||
supportColors: supportColors,
|
||||
activeIndex: _activeColorIndex,
|
||||
onSelect: _onSelectColor,
|
||||
),
|
||||
),
|
||||
StorkWidthSelector(
|
||||
supportStorkWidths: supportStorkWidths,
|
||||
color: supportColors[_activeColorIndex],
|
||||
activeIndex: _activeStorkWidthIndex,
|
||||
onSelect: _onSelectStorkWidth,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
如果这里不提供 width ,而使用 Row + Expanded 组件的话,就会报错;根本原因是 Positioned 施加了无限约束,而 Row 使用了 Expanded 组件,延展无限的宽度区域是不被允许的:
|
||||
|
||||
|
||||
|
||||
通过这个小问题,带大家简单认识一下布局中约束的分析。很多布局上的问题,都可以从约束的角度解决。这里点到为止,如果对约束感兴趣,或有很多布局的困扰,可以研读一下我的布局小册: Flutter 布局探索 - 薪火相传
|
||||
|
||||
|
||||
|
||||
4. 本章小结
|
||||
|
||||
本章主要任务是完成白板画笔的参数设置,为用户提供修改颜色和线宽的操作,以便于绘制更复杂多彩的图案。从中可以体会出:新增加一个需求,往往会引入相关的数据来实现功能。比如对于修改颜色的需求,需要引入支持的颜色列表和激活的颜色索引两个数据。所以对于任何功能需求而言,不要只看其表面的界面呈现,更重要的是分析其背后的用户交互过程中的数据变化情况。
|
||||
|
||||
下一章,将继续对当前的画板项目进行一些小优化,比如支持回退和撤销回退的功能;以及优化一下点集的收集策略,来尽可能地避免收录过多无用点,减小绘制的压力。
|
||||
|
||||
|
||||
|
||||
|
210
专栏/Flutter入门教程/22撤销功能与画板优化.md
Normal file
210
专栏/Flutter入门教程/22撤销功能与画板优化.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 撤销功能与画板优化
|
||||
1. 回退与撤销功能需求分析
|
||||
|
||||
如下效果,在头部标题栏的左侧添加两个按钮,分别用于 向前回退 和 撤销回退 :
|
||||
|
||||
|
||||
向前回退: 移除当前线列表中的最后一条线。
|
||||
撤销回退: 向当前线列表中添加上次回退的线。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
向前回退
|
||||
撤销回退
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
由于需要 "后悔",所以需要引入一个线列表作为 "后悔药",也就是收集向前回退过程中被抛弃的线。这里在状态类中添加 _historyLines 列表来维护:
|
||||
|
||||
List<Line> _historyLines = [];
|
||||
|
||||
|
||||
另外,在界面构建逻辑中,需要注意按钮可操作性的限制,比如当线列表为空时,无法向前回退:
|
||||
|
||||
|
||||
|
||||
当界面上有内容时,才允许点击左侧按钮回退。撤销按钮同理,只有回退历史中有元素,才可以操作。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 回退与撤销界面构建
|
||||
|
||||
首先看一下两个按钮的构建逻辑,这里封装一个 BackUpButtons 组件进行维护。其中有两个可空的回调函数,分别用于处理两个按钮的点击事件。当函数为空时,表示当前按钮不可用,呈灰色状态示意,代码中对应的是 backColor 和 revocationColor 颜色的赋值。
|
||||
|
||||
IconButton 默认情况下是 48*48 的尺寸,看起来比较大,可以设置 constraints 参数来修改约束,从而控制尺寸。这里用了 Transform 组件,通过 scale 构造让图标按钮沿 Y 轴镜像,就可以得到与右侧对称的效果。
|
||||
|
||||
class BackUpButtons extends StatelessWidget {
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onRevocation;
|
||||
|
||||
const BackUpButtons({
|
||||
Key? key,
|
||||
required this.onBack,
|
||||
required this.onRevocation,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const BoxConstraints cts = BoxConstraints(minHeight: 32, minWidth: 32);
|
||||
Color backColor = onBack == null?Colors.grey:Colors.black;
|
||||
Color revocationColor = onRevocation == null?Colors.grey:Colors.black;
|
||||
return Center(
|
||||
child: Wrap(
|
||||
children: [
|
||||
Transform.scale(
|
||||
scaleX: -1,
|
||||
child: IconButton(
|
||||
splashRadius: 20,
|
||||
constraints: cts,
|
||||
onPressed: onBack,
|
||||
icon: Icon(Icons.next_plan_outlined,color: backColor),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
splashRadius: 20,
|
||||
onPressed: onRevocation,
|
||||
constraints: cts,
|
||||
icon: Icon(Icons.next_plan_outlined, color: revocationColor),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
然后将 BackUpButtons 放在恰当的位置即可,由于两个按钮属于头部标题,而头部标题栏的构建逻辑封装在 PaperAppBar 中。这里有两种思路:
|
||||
|
||||
|
||||
将 BackUpButtons 封装在 PaperAppBar 内部,将两个按钮的点击事件继续向上传递。
|
||||
将左侧组件作为插槽位置,通过构造将组件传入到 PaperAppBar 里进行使用。
|
||||
|
||||
|
||||
其实两者本质上没有区别,只是形式上的差异、这里使用前者,这样对于 PaperAppBar 的使用者而言,只需要在意其中的事件,不需要关注标题中构造逻辑。代码如下:
|
||||
|
||||
|
||||
|
||||
在使用 PaperAppBar 组件时,为 onBack 和 onRevocation 设置回调处理函数。当 _lines 为空,表示不可回退,onBack 设为 null 即可,这样在 BackUpButtons 组件构建时就会将左侧按钮置位灰色,onRevocation 同理。
|
||||
|
||||
appBar: PaperAppBar(
|
||||
onClear: _showClearDialog,
|
||||
onBack: _lines.isEmpty ? null : _back,
|
||||
onRevocation: _historyLines.isEmpty ? null : _revocation,
|
||||
),
|
||||
|
||||
|
||||
|
||||
|
||||
3. 状态数据的维护
|
||||
|
||||
最后一步就是在 _back 方法中处理回退的逻辑;在 _revocation 中处理撤销的逻辑。在回退方法中移除 _lines 的最后一个元素,然后让 _historyLines 列表添加移除的线,再更新界面即可:
|
||||
|
||||
void _back() {
|
||||
Line line = _lines.removeLast();
|
||||
_historyLines.add(line);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
在撤销回退方法中移除 _historyLines 的最后一个元素,然后让 _lines 列表添加移除的线,再更新界面即可:
|
||||
|
||||
void _revocation() {
|
||||
Line line = _historyLines.removeLast();
|
||||
_lines.add(line);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
到这里, 向前回退和撤销回退的功能就已经实现了,当前代码位置 paper.dart。虽然现在画板操作时看起开没什么问题,但内部是危机四伏的。
|
||||
|
||||
|
||||
|
||||
4.拖拽更新的频繁触发
|
||||
|
||||
如下所示,在拖拽更新的回调 _onPanUpdate 中打印一下日志,会发现它的触发非常频繁。而每触发一次都会像线中添加一个点,就会导致点非常多。
|
||||
|
||||
|
||||
|
||||
特别是缓慢移动的过程中,会加入很多相近的无用点,不仅占据内存,也会造成绘制的负担。如下所示,右图通过 PointMode.points 模式展示点前的点,可以看出虽然中间点线很短,但非常密集:
|
||||
|
||||
|
||||
|
||||
|
||||
PointMode.polygon
|
||||
PointMode.points
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
我们可以在收集点时优化一下逻辑,根据与前一点的距离决定加不加入改点,这样可以有效降低点的数量,减缓绘制压力。处理逻辑并不复杂,如下所示,只要校验当前点和线的最后一点的距离,是否超过阈值即可。
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
Offset point = details.localPosition;
|
||||
double distance = (_lines.last.points.last - point).distance;
|
||||
if (distance > 5) {
|
||||
_lines.last.points.add(details.localPosition);
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
阈值越大,忽略的点就越多,线条越不精细,相对来说绘制压力也就越低,需要酌情处理。这里用 5 个逻辑像素,在操作体验上没什么影响,也能达到一定的优化效果。下面是缓慢移动过程中添加点集的情况,可以看出已经避免了点过于密集的问题:
|
||||
|
||||
|
||||
|
||||
|
||||
PointMode.polygon
|
||||
PointMode.points
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
5. 本章小结
|
||||
|
||||
到这里,白板绘制的基础功能就已经完成了,当前代码位置 paper。还有些值得优化和改进的地方,比如:
|
||||
|
||||
|
||||
现在的线是通过点进行连接的折线,可以通过贝塞尔曲线进行拟合,让点之间的连接更加圆滑;
|
||||
可以提供一些基础图形的绘制操作,让绘制更加丰富。
|
||||
现在每次添加点都会将所有的内容绘制一边,随着绘制内容的增加,会带来频繁的复杂绘制。
|
||||
如何存储绘制的信息到本地,这样即使在退出应用后,也可以在下次开启时恢复绘制的内容。
|
||||
|
||||
|
||||
对这些问题的改进,大家可以在今后的路途中通过自己思考和理解,来尝试解决。接下来,我们将对这三个小项目进行整合,放入到一个项目中。
|
||||
|
||||
|
||||
|
||||
|
303
专栏/Flutter入门教程/23应用界面整合.md
Normal file
303
专栏/Flutter入门教程/23应用界面整合.md
Normal file
@ -0,0 +1,303 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
23 应用界面整合
|
||||
1. 界面整合的需求分析
|
||||
|
||||
如下所示,在应用的底部添加导航栏,进行界面间的切换操作。下面从数据和界面的角度对该进行分析:
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
当前界面中需要添加的数据有:
|
||||
|
||||
|
||||
底部栏的文字、图标资源列表
|
||||
底部栏的激活索引
|
||||
|
||||
|
||||
在点击底部栏的按钮时,需要更新激活索引,并进行界面的重新构建。这里定义一个 MenuData 类,用于维护标签和图标数据:
|
||||
|
||||
class MenuData {
|
||||
// 标签
|
||||
final String label;
|
||||
|
||||
// 图标数据
|
||||
final IconData icon;
|
||||
|
||||
const MenuData({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
对于界面构建逻辑来说,这是一个上下结构,上面是内容区域,下面是底部导航栏。所以,可以通过 Column 组件上下排列,其中内容区域通过 Expanded 组件进行延展,内容组件根据激活的索引值构建不同的界面。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 代码实现:第一版
|
||||
|
||||
Flutter 中提供了 BottomNavigationBar 组件可以展示底部栏,这里单独封装一个 AppBottomBar 组件用于维护底部栏的界面构建逻辑。其中需要传入激活索引、点击回调、菜单数据列表:
|
||||
|
||||
class AppBottomBar extends StatelessWidget {
|
||||
final int currentIndex;
|
||||
final List<MenuData> menus;
|
||||
final ValueChanged<int>? onItemTap;
|
||||
|
||||
const AppBottomBar({
|
||||
Key? key,
|
||||
this.onItemTap,
|
||||
this.currentIndex = 0,
|
||||
required this.menus,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BottomNavigationBar(
|
||||
backgroundColor: Colors.white,
|
||||
onTap: onItemTap,
|
||||
currentIndex: currentIndex,
|
||||
elevation: 3,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
iconSize: 22,
|
||||
selectedItemColor: Theme.of(context).primaryColor,
|
||||
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||
showUnselectedLabels: true,
|
||||
showSelectedLabels: true,
|
||||
items: menus.map(_buildItemByMenuMeta).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
BottomNavigationBarItem _buildItemByMenuMeta(MenuData menu) {
|
||||
return BottomNavigationBarItem(
|
||||
label: menu.label,
|
||||
icon: Icon(menu.icon),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
然后就是构建整体结构,这里创建一个 AppNavigation 组件来处理。由于激活索引数据需要在交互时改变,并重新构建界面,所以 AppNavigation 继承自 StatefulWidget,在状态类中处理界面构建和状态数据维护的逻辑。
|
||||
|
||||
class AppNavigation extends StatefulWidget {
|
||||
const AppNavigation({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AppNavigation> createState() => _AppNavigationState();
|
||||
}
|
||||
|
||||
class _AppNavigationState extends State<AppNavigation> {
|
||||
int _index = 0;
|
||||
|
||||
final List<MenuData> menus = const [
|
||||
MenuData(label: '猜数字', icon: Icons.question_mark),
|
||||
MenuData(label: '电子木鱼', icon: Icons.my_library_music_outlined),
|
||||
MenuData(label: '白板绘制', icon: Icons.palette_outlined),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded( child: _buildContent(_index)),
|
||||
AppBottomBar(
|
||||
currentIndex: _index,
|
||||
onItemTap: _onChangePage,
|
||||
menus: menus,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onChangePage(int index) {
|
||||
setState(() {
|
||||
_index = index;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
内容区域的构建使用 _buildContent 方法,根据不同的激活索引,返回创建不同的界面:
|
||||
|
||||
|
||||
index = 0 时,构建猜数字界面;
|
||||
index = 1 时,构建电子木鱼界面;
|
||||
index = 2 时,构建白板绘制界面;
|
||||
|
||||
|
||||
Widget _buildContent(int index) {
|
||||
switch(index){
|
||||
case 0:
|
||||
return const GuessPage();
|
||||
case 1:
|
||||
return const MuyuPage();
|
||||
case 2:
|
||||
return const Paper();
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
到这里就完成了点击底部导航,切换界面的功能,当前代码位置: navigation 。 但这种方式处理会有一些问题:伴随着界面的消失,状态类会被销毁;下次再到该界面时会重新初始化状态类,如下所示:
|
||||
|
||||
|
||||
|
||||
|
||||
在绘制面板绘制
|
||||
切换后,状态重置
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 状态类数据的保持
|
||||
|
||||
想要避免每次切换都会重置状态数据,大体上有三种解决方案:
|
||||
|
||||
|
||||
1.使用 AutomaticKeepAliveClientMixin 对状态类进行保活,这种方案只能用于可滑动组件中。这里可以使用 PageView 组件来实现切页并保活的效果。
|
||||
2.将状态数据提升到上层,比如将三个界面的状态数据都交由 _AppNavigationState 状态类维护。如果直接用这种方式,很容易造成一个超级大的类,来维护很多数据。其实状态管理工具,就是基于这种思路,将数据交由上层维护,同时提供了分模块处理数据的能力。
|
||||
3.保持数据的持久性,比如将数据保存到本地文件或数据库,每次初始化时进行加载复现。这种方式处理起来比较麻烦,初始化加载数据也需要一点时间。但这种方式在界面不可见时,可以释放内存中的数据。
|
||||
|
||||
|
||||
|
||||
|
||||
这里使用 方式 1 来处理是最简单的。在 _buildContent 方法中返回 PageView 组件,并将三个内容界面作为 children 入参,通过 PageController 来控制界面的切换。注意一点:将 physics 设置设置为 NeverScrollableScrollPhysics 可以禁止 PageView 的滑动,如果想要运行滑动切页,可以去除。
|
||||
|
||||
---->[_AppNavigationState]----
|
||||
final PageController _ctrl = PageController();
|
||||
|
||||
Widget _buildContent() {
|
||||
return PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _ctrl,
|
||||
children: const [
|
||||
GuessPage(),
|
||||
MuyuPage(),
|
||||
Paper(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onChangePage(int index) {
|
||||
_ctrl.jumpToPage(index);
|
||||
setState(() {
|
||||
_index = index;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
另外如果期望某个状态类保活,需要让其混入 AutomaticKeepAliveClientMixin, 并覆写 wantKeepAlive 返回 true 。如下是对画板状态类的处理,其他两个同理:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
在绘制面板绘制
|
||||
切换后,状态保活
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
到这里,就将之前的三个小案例,集成到了一个应用中,并且在切换界面的过程中,可以保持状态数据不被重置。当前代码位置 navigation。
|
||||
|
||||
上面可以保证程序运行期间,各界面状态类的保活,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态,想要记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,下一篇将介绍数据的持久化存储。
|
||||
|
||||
|
||||
|
||||
4. 优化一些缺陷
|
||||
|
||||
如下所示,左侧是 Column 组件上下排列,当键盘顶起之后,底部会留出一块空白,高为底部导航高度。想解决这个问题,使用 Scaffold 组件即可,它有一个 bottomNavigationBar 的插槽,不会被键盘顶起。
|
||||
|
||||
|
||||
|
||||
|
||||
Column 结构
|
||||
Scaffold 结构
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
这时,将 _AppNavigationState 的构建方法改为如下代码:
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: _buildContent(),
|
||||
bottomNavigationBar: AppBottomBar(
|
||||
currentIndex: _index,
|
||||
onItemTap: _onChangePage,
|
||||
menus: menus,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
下面以 AppBar 的主题介绍一下 Flutter 默认配置的能力。项目中希望所有的 AppBar 都是白色背景、状态类透明、标题居中、图标颜色、文字颜色为黑色。
|
||||
|
||||
|
||||
|
||||
如果每次使用 AppBar 组件就配置一次,那代码书写将会非常复杂。Flutter 在主题数据的功能,只要指定主题,其下节点中的对应组件,就会默认使用的配置数据。如下所示,在 MaterialApp 的 theme 入参中可以配置主题数据:
|
||||
|
||||
|
||||
|
||||
这样,以前使用 AppBar 的地方就不用再配置那么多信息了。比如电子木鱼界面的 AppBar 就可以清爽多了:
|
||||
|
||||
|
||||
|
||||
这里只是拿 AppBarTheme 举个例子,还有其他很多的主题可以配置,大家可以在以后慢慢了解。
|
||||
|
||||
|
||||
|
||||
5. 本章小结
|
||||
|
||||
本章我们主要将之前的三个小案例整合到了一个项目中,通过底部导航栏 + PageView 实现界面间的切换。另外也就 State 的状态保活进行了简单地认识,这里只是程序运行期间,保证各界面状态类的活性,但是当应用关闭之后,内存中的数据会被清空。再次进入应用时还是无法恢复到之前的状态。
|
||||
|
||||
想要永久记住用户的信息,就必须对数据进行持久化的存储。比如存储为本地文件、数据库、网络数据等,在程序启动时进行加载,恢复状态数据。这是应用程序非常重要的一个部分,下一篇将介绍数据的持久化存储。
|
||||
|
||||
|
||||
|
||||
|
477
专栏/Flutter入门教程/24数据的持久化存储.md
Normal file
477
专栏/Flutter入门教程/24数据的持久化存储.md
Normal file
@ -0,0 +1,477 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 数据的持久化存储
|
||||
一、猜数字项目的配置信息存储
|
||||
|
||||
在猜数字项目中,界面的状态数据有三个:
|
||||
|
||||
|
||||
|
||||
|
||||
数据名
|
||||
类型
|
||||
含义
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_guessing
|
||||
bool
|
||||
是否在猜数字游戏中
|
||||
|
||||
|
||||
|
||||
_value
|
||||
int
|
||||
待猜测的数字
|
||||
|
||||
|
||||
|
||||
_isBig
|
||||
bool?
|
||||
是否更大
|
||||
|
||||
|
||||
|
||||
现在的目的是,在退出应用后:可以继续上次的游戏进程,那么需要记录 _guessing 和 _value 两个数据。对于这种简单的配置数据,可以通过 shared_preferences 插件存储为 xml 配置文件。首先需要添加依赖:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
shared_preferences: ^2.1.1
|
||||
|
||||
|
||||
|
||||
|
||||
1. 单例模式访问对象和存储配置
|
||||
|
||||
数据的持久化中,我们需要在很多地方对数据进行读取和写入。这里将该功能封装为一个类进行操作,并提供唯一的静态对象,方便访问。 如下所示,创建一个 SpStorage 的类,私有化构造并提供实例对象的访问途径:
|
||||
|
||||
---->[lib/storage]----
|
||||
class SpStorage {
|
||||
SpStorage._(); // 私有化构造
|
||||
|
||||
static SpStorage? _storage;
|
||||
|
||||
// 提供实例对象的访问途径
|
||||
static SpStorage get instance {
|
||||
_storage = _storage ?? SpStorage._();
|
||||
return _storage!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 配置信息的存储
|
||||
|
||||
如下所示,在类中提供 saveGuessConfig 方法用于保存猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 setString 方法,根据 key 值存储字符串。这里通过 json.encode 方法将 Map 对象编码成字符串:
|
||||
|
||||
const String kGuessSpKey = 'guess-config';
|
||||
|
||||
class SpStorage {
|
||||
SpStorage._();
|
||||
|
||||
// 略同...
|
||||
|
||||
SharedPreferences? _sp;
|
||||
|
||||
Future<void> initSpWhenNull() async {
|
||||
if (_sp != null) return;
|
||||
_sp = _sp ?? await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
Future<bool> saveGuess({
|
||||
required bool guessing,
|
||||
required int value,
|
||||
}) async {
|
||||
await initSpWhenNull();
|
||||
String content = json.encode({'guessing': guessing, 'value': value});
|
||||
return _sp!.setString(kGuessSpKey, content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
由于 SpStorage 提供了静态的单例对象,所以在任何类中都可以通过 SpStorage.instance 得到实例对象。比如下面在 _GuessPageState 中生成随机数时,调用 saveGuessConfig 方法来存储记录,在如下文件中可以看到存储的配置信息:
|
||||
|
||||
|
||||
/data/data/com.toly1994.flutter_first_station/shared_prefs/FlutterSharedPreferences.xml
|
||||
|
||||
|
||||
|
||||
|
||||
---->[_GuessPageState#_generateRandomValue]----
|
||||
void _generateRandomValue() {
|
||||
setState(() {
|
||||
_guessing = true;
|
||||
_value = _random.nextInt(100);
|
||||
SpStorage.instance.saveGuessConfig(guessing: _guessing,value: _value);
|
||||
print(_value);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3. 访问配置与恢复状态
|
||||
|
||||
光存储起来,只完成了一半,还需要读取配置,并根据配置来设置猜数字的状态数据。如下所示,在 SpStorage 类中提供 readGuessConfig 方法用于读取猜数字的配置信息。核心方法是使用 SharedPreferences 对象的 getString 方法,根据 key 值获取存储的字符串。这里通过 json.decode 方法将字符串解码成 Map 对象:
|
||||
|
||||
class SpStorage {
|
||||
|
||||
// 略同...
|
||||
|
||||
Future<Map<String,dynamic>> readGuessConfig() async {
|
||||
await initSpWhenNull();
|
||||
String content = _sp!.getString(kGuessSpKey)??"{}";
|
||||
return json.decode(content);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
方便起见,这里在 _GuessPageState 的 initState 中读取配置文件,并为状态类赋值,完成存储数据的回显。在实际项目中,这些配置信息可以在闪屏页中提前读取。
|
||||
|
||||
class _GuessPageState extends State<GuessPage> with SingleTickerProviderStateMixin,AutomaticKeepAliveClientMixin{
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
// 略...
|
||||
_initConfig();
|
||||
}
|
||||
|
||||
void _initConfig() async{
|
||||
Map<String,dynamic> config = await SpStorage.instance.readGuessConfig();
|
||||
_guessing = config['guessing']??false;
|
||||
_value = config['value']??0;
|
||||
setState(() {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
这样,在生成数字之后,杀死应用,然后打开应用,就可以看到仍会恢复到之前的猜数字状态中,这就是数据持久化的意义所在。当前代码位置: sp_storage.dart
|
||||
|
||||
|
||||
|
||||
二、电子木鱼项目的配置信息存储
|
||||
|
||||
在电子木鱼项目中,需要存储的配置数据有:
|
||||
|
||||
|
||||
|
||||
|
||||
数据名
|
||||
类型
|
||||
含义
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
counter
|
||||
int
|
||||
功德总数
|
||||
|
||||
|
||||
|
||||
activeImageIndex
|
||||
int
|
||||
激活图片索引
|
||||
|
||||
|
||||
|
||||
activeAudioIndex
|
||||
int
|
||||
激活音频索引
|
||||
|
||||
|
||||
|
||||
|
||||
1. 配置信息的存储
|
||||
|
||||
同样,在 SpStorage 中定义 saveMuYUConfig 方法存储木鱼配置的信息。通过 SpStorage 统一对配置信息进行操作,一方面可以集中配置读写的代码逻辑,方便使用,另一方面可以避免在每个状态类内部都获取 SharedPreferences 对象进行操作。
|
||||
|
||||
const String kMuYUSpKey = 'muyu-config';
|
||||
|
||||
class SpStorage {
|
||||
// 略...
|
||||
|
||||
Future<bool> saveMuYUConfig({
|
||||
required int counter,
|
||||
required int activeImageIndex,
|
||||
required int activeAudioIndex,
|
||||
}) async {
|
||||
await initSpWhenNull();
|
||||
String content = json.encode({
|
||||
'counter': counter,
|
||||
'activeImageIndex': activeImageIndex,
|
||||
'activeAudioIndex': activeAudioIndex,
|
||||
});
|
||||
return _sp!.setString(kMuYUSpKey, content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
然后需要在配置数据发生变化的事件中保存配置,也就是在 _MuyuPageState 类中敲击木鱼、选择音频,选择图片三个场景,这三处的代码位置大家应该非常清楚。为了方便调用,这里写一个 saveConfig 方法来触发。然后操作界面,配置文件中就会存储对应的信息:
|
||||
|
||||
|
||||
|
||||
--->[_MuyuPageState]---
|
||||
void saveConfig() {
|
||||
SpStorage.instance.saveMuYUConfig(
|
||||
counter: _counter,
|
||||
activeImageIndex: activeAudioIndex,
|
||||
activeAudioIndex: _activeAudioIndex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 配置信息的读取
|
||||
|
||||
同理,在 SpStorage 中读取配置信息:
|
||||
|
||||
class SpStorage {
|
||||
// 略同...
|
||||
Future<Map<String, dynamic>> readMuYUConfig() async {
|
||||
await initSpWhenNull();
|
||||
String content = _sp!.getString(kMuYUSpKey) ?? "{}";
|
||||
return json.decode(content);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
并在 _MuyuPageState 初始化状态回调中,读取配置对状态数据进行设置。
|
||||
|
||||
class _MuyuPageState extends State<MuyuPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
// 略同...
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initAudioPool();
|
||||
_initConfig();
|
||||
}
|
||||
|
||||
void _initConfig() async{
|
||||
Map<String,dynamic> config = await SpStorage.instance.readMuYUConfig();
|
||||
_counter = config['counter']??0;
|
||||
_activeImageIndex = config['activeImageIndex']??0;
|
||||
_activeAudioIndex = config['activeAudioIndex']??0;
|
||||
setState(() {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
这样,电子木鱼的配置信息就存储和读取的功能就实现完毕了,当前代码位置: sp_storage.dart
|
||||
|
||||
|
||||
小练习:自己尝试完成白板绘制中颜色、线宽配置的数据持久化。
|
||||
|
||||
|
||||
|
||||
|
||||
三、通过数据库进行存储
|
||||
|
||||
上面属于通过文件的方式来持久化数据,比较适合存储一些小的配置数据。如果想存储大量的数据,并且希望可以进行复杂的查询,最好使用数据库来存储。这里将对木鱼点击时的功德数记录,使用 sqlite 数据库进行存储。不过不会介绍的太深,会创建数据库和表,存储数据、读取数据即可。毕竟数据库的操作是另一门学问,感兴趣的可以系统地学习一下。
|
||||
|
||||
|
||||
|
||||
1. sqlite 数据库插件
|
||||
|
||||
目前来说,最完善的 sqlite 数据库插件是 sqlite , 使用前首先需要添加依赖:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
sqflite: ^2.2.8+2
|
||||
|
||||
|
||||
|
||||
|
||||
对于数据库操作来说,全局提供一个访问对象即可,也可以通过单例模式来处理,如下定义 DbStorage 类:
|
||||
|
||||
---->[storage/db_storage/db_storage.dart]----
|
||||
class DbStorage {
|
||||
DbStorage._();
|
||||
|
||||
static DbStorage? _storage;
|
||||
|
||||
static DbStorage get instance {
|
||||
_storage = _storage ?? DbStorage._();
|
||||
return _storage!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 数据库操作对象 Dao
|
||||
|
||||
由于应用程序中可能存在多个数据表,一般每个表会通过一个类来单独操作。比如电子木鱼中的功德记录,是对一条条的 MeritRecord 对象进行记录,这里通过 MeritRecordDao 进行维护。在其构造函数中传入 Database 对象,以便在方法中操作数据库。
|
||||
|
||||
首先是数据库的创建语句,通过下面的 createTable 方法完成;使用 Database 的 execute 方法执行 sql 语句:
|
||||
|
||||
---->[storage/db_storage/dao/merit_record_dao.dart]----
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
class MeritRecordDao {
|
||||
final Database database;
|
||||
|
||||
MeritRecordDao(this.database);
|
||||
|
||||
static String tableName = 'merit_record';
|
||||
|
||||
static String tableSql = """
|
||||
CREATE TABLE $tableName (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
value INTEGER,
|
||||
image TEXT,
|
||||
audio TEXT,
|
||||
timestamp INTEGER
|
||||
)""";
|
||||
|
||||
static Future<void> createTable(Database db) async{
|
||||
return db.execute(tableSql);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
然后在 DbStorage 中提供 open 方法打开数据库,如果数据库不存在的话 openDatabase 方法会创建数据库,并触发 _onCreate 回调。在其中可以使用 MeritRecordDao 执行数据表创建的逻辑。另外 DbStorage 持有 MeritRecordDao 类型对象,在数据库打开之后,初始化对象:
|
||||
|
||||
---->[storage/db_storage/db_storage.dart]----
|
||||
|
||||
class DbStorage {
|
||||
//略同...
|
||||
|
||||
late Database _db;
|
||||
|
||||
late MeritRecordDao _meritRecordDao;
|
||||
MeritRecordDao get meritRecordDao => _meritRecordDao;
|
||||
|
||||
void open() async {
|
||||
String databasesPath = await getDatabasesPath();
|
||||
String dbPath = path.join(databasesPath, 'first_station.db');
|
||||
_db = await openDatabase(dbPath, version: 1, onCreate: _onCreate);
|
||||
_meritRecordDao = MeritRecordDao(_db);
|
||||
}
|
||||
|
||||
void _onCreate(Database db, int version) async {
|
||||
await MeritRecordDao.createTable(db);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
像打开数据库、加载本地资源的操作,在实际项目中可以放在闪屏页中处理。不过这里方便起见,直接程序开始时打开数据库。现在运行项目之后,就可以看到数据库已经创建了:
|
||||
|
||||
|
||||
|
||||
void main() async{
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await DbStorage.instance.open(); // 打开数据库
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
|
||||
在 AndroidStudio 的 App inspection 中,可以查看当前运行项目在的数据库情况:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 数据的存储和读取方法
|
||||
|
||||
如下所示,在 MeritRecordDao 中定义 insert 方法插入记录数据;定义 query 方法读取记录列表。
|
||||
|
||||
class MeritRecordDao {
|
||||
// 略同...
|
||||
Future<int> insert(MeritRecord record) {
|
||||
return database.insert(
|
||||
tableName,
|
||||
record.toJson(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<MeritRecord>> query() async {
|
||||
List<Map<String, Object?>> data = await database.query(
|
||||
tableName,
|
||||
);
|
||||
return data
|
||||
.map((e) => MeritRecord(
|
||||
e['id'].toString(),
|
||||
e['timestamp'] as int,
|
||||
e['value'] as int,
|
||||
e['image'].toString(),
|
||||
e['audio'].toString(),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
插入时需要传入 Map 对象,这里为 MeritRecord 类提供一个 toJson 的方法,以便将对象转为 Map :
|
||||
|
||||
class MeritRecord {
|
||||
final String id;
|
||||
final int timestamp;
|
||||
final int value;
|
||||
final String image;
|
||||
final String audio;
|
||||
|
||||
MeritRecord(this.id, this.timestamp, this.value, this.image, this.audio);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id":id,
|
||||
"timestamp": timestamp,
|
||||
"value": value,
|
||||
"image": image,
|
||||
"audio": audio,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4.使用 Dao 完成数据读写功能
|
||||
|
||||
前面数据操作层准备完毕之后,使用起来就非常简单了。就剩两件事:
|
||||
|
||||
|
||||
在 _MuyuPageState 中点击时存入数据库。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
在 _MuyuPageState 中状态初始化时读取数据。
|
||||
|
||||
|
||||
|
||||
|
||||
然后点击木鱼后就可以看到数据表中会存储对于的数据,应用退出之后也能从数据库中加载数据。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
四、 本章小结
|
||||
|
||||
本章主要介绍使用 shared_preferences 通过 xml 存储配置数据;以及使用 sqflite 通过 sqlite3 数据库存储数据记录。其中也涉及了对单例模式的使用,让程序中只有一个数据的访问对象,一方面可以简化使用方式,另一方面也可以避免多次连接数据库,造成无意义的浪费。
|
||||
|
||||
到这里数据的本地持久化就介绍的差不多了,当前代码位置 db_storage.dart 。对于新手而言这算比较复杂的,希望大家可以好好消化。当然这些只是最简单的 Demo 级应用,怎么简单怎么来。对实际项目来说,整体的应用结构,数据维护和传递的方式,逻辑触发的时机都需要认真的考量,本教程只在新手的指引,就不展开介绍了。
|
||||
|
||||
|
||||
|
||||
|
484
专栏/Flutter入门教程/25网络数据的访问.md
Normal file
484
专栏/Flutter入门教程/25网络数据的访问.md
Normal file
@ -0,0 +1,484 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 网络数据的访问
|
||||
一、需求介绍和准备工作
|
||||
|
||||
上一章介绍了本地数据的持久化,它可以让应用退出后,仍可以在启动后通过读取数据,恢复状态数据。但如果手机丢了,或者本地数据被不小心清空了,应用就又会 "失忆" 。
|
||||
|
||||
1. 本章目的
|
||||
|
||||
现在移动互联网已经极度成熟了,将数据存储在远程的服务器中,通过网络来访问、操作数据对于现在的人已经是家常便饭了。比如微信应用中的聊天记录、支付宝应用中的余额、美团应用中的店铺信息、游戏里的资源装备、抖音里的视频评论… 现在的网络数据已经无处不在了。所以对于应用开发者来说,网络请求的技能是必不可少的。
|
||||
|
||||
但是学习网络请求有个很大的问题,一般的网络接口都是肯定不会暴露给大众使用,而自己想要搭建一个后端提供网络接口又很麻烦。所以一般会使用开放 api ,我曾建议过掘金提供一套开放 api , 以便写网络相关的教程,但目前还没什么动静。这里就选用 wanandroid 的开发 api 接口来进行测试。
|
||||
|
||||
本章目的是完成一个简单的应用场景:从网络中加载文章列表数据,展示在界面中。点击条目时,可以跳转到详情页,并通过 WebView 展示网页。
|
||||
|
||||
|
||||
|
||||
|
||||
文章列表
|
||||
文章详情
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
2. 界面准备
|
||||
|
||||
现在想在底部栏添加一个网络文章的按钮,点击时切换到网络请求测试的界面。只需要在 _AppNavigationState 的 menus 增加一个 MenuData :
|
||||
|
||||
|
||||
|
||||
然后在 PageView 内增加一个 NetArticlePage 组件,用于展示网络文章的测试界面:
|
||||
|
||||
|
||||
|
||||
新建一个 net_article 的文件夹用于盛放网络文章的相关代码,其中:
|
||||
|
||||
|
||||
views 文件夹盛放组件视图相关的文件,比如主页面、详情页等。
|
||||
model 文件夹用于盛放数据模型,比如文章数据的封装类。
|
||||
api 文件夹盛放网络数据请求的代码,在功能上相当于上一章的 storage , 负责读取和写入数据。只不过对于网络数据再说,是存储在服务器上的,需要提供接口来操作。
|
||||
|
||||
|
||||
|
||||
|
||||
NetArticlePage 组件现在先准备一下:通过 Scaffold 构建界面结构,由于之前已经提供了 AppBar 的主题,这里直接给个 title 即可,其他配置信息会默认跟随主题。接下来最重要的任务就是对 body 主体内容的构建。
|
||||
|
||||
class NetArticlePage extends StatelessWidget {
|
||||
const NetArticlePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('网络请求测试'),
|
||||
),
|
||||
body: Container(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
3. 接口介绍
|
||||
|
||||
这里只使用一个获取文章列表的如下接口,其中 0 是个可以改变的参数,表示文章的页数:
|
||||
|
||||
|
||||
www.wanandroid.com/article/lis…
|
||||
|
||||
|
||||
通过浏览器可以直接看到接口提供的 json 数据:
|
||||
|
||||
|
||||
|
||||
使用 json 美化工具可以看出如下的结构,主要的文章列表数据在 data["datas"] 中 :
|
||||
|
||||
|
||||
|
||||
每条记录的数据如下,其中数据有很多,不过没有必要全都使用。这里展示文章信息,只需要标题 title 、地址 link 、 时间 niceDate 即可。
|
||||
|
||||
{
|
||||
"adminAdd": false,
|
||||
"apkLink": "",
|
||||
"audit": 1,
|
||||
"author": "",
|
||||
"canEdit": false,
|
||||
"chapterId": 502,
|
||||
"chapterName": "自助",
|
||||
"collect": false,
|
||||
"courseId": 13,
|
||||
"desc": "",
|
||||
"descMd": "",
|
||||
"envelopePic": "",
|
||||
"fresh": true,
|
||||
"host": "",
|
||||
"id": 26411,
|
||||
"isAdminAdd": false,
|
||||
"link": "https://juejin.cn/post/7233067863500849209",
|
||||
"niceDate": "7小时前",
|
||||
"niceShareDate": "7小时前",
|
||||
"origin": "",
|
||||
"prefix": "",
|
||||
"projectLink": "",
|
||||
"publishTime": 1684220135000,
|
||||
"realSuperChapterId": 493,
|
||||
"route": false,
|
||||
"selfVisible": 0,
|
||||
"shareDate": 1684220135000,
|
||||
"shareUser": "张风捷特烈",
|
||||
"superChapterId": 494,
|
||||
"superChapterName": "广场Tab",
|
||||
"tags": [],
|
||||
"title": "Dart 3.0 语法新特性 | Records 记录类型 (元组)",
|
||||
"type": 0,
|
||||
"userId": 31634,
|
||||
"visible": 1,
|
||||
"zan": 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
4.数据模型的封装
|
||||
|
||||
这样,可以写出如下的 Article 类承载数据,并通过一个 formMap 构造通过 map 数据构造 Article 对象。
|
||||
|
||||
class Article {
|
||||
final String title;
|
||||
final String url;
|
||||
final String time;
|
||||
|
||||
const Article({
|
||||
required this.title,
|
||||
required this.time,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
factory Article.formMap(dynamic map) {
|
||||
return Article(
|
||||
title: map['title'] ?? '未知',
|
||||
url: map['link'] ?? '',
|
||||
time: map['niceDate'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Article{title: $title, url: $url, time: $time}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
二、基础功能的实现
|
||||
|
||||
俗话说巧妇难为无米之炊,如果说界面是一碗摆在台面上的饭,那数据就是生米,把生米煮成熟饭就是组件构建的过程。所以实现基础功能有两大步骤: 获取数据、构建界面。
|
||||
|
||||
1. 网络数据的请求
|
||||
|
||||
网络请求是非常通用的能力,开发者自己来写非常复杂,所以一般使用三方的依赖库。对于 Flutter 网络请求来说,最受欢迎的是 dio , 使用前先添加依赖:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
dio: ^5.1.2
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
下面看一下最简单的使用,如下在 ArticleApi 中持有 Dio 类型的 _client 对象,构造时可以设置 baseUrl 。然后提供 loadArticles 方法,用于加载第 page 页的数据,其中的逻辑处理,就是加载网络数据的核心。
|
||||
|
||||
使用起来也很方便,提供 Dio#get 方法就可以异步获取数据,得到之后,从结果中拿到自己想要的数据,生成 Article 列表即可。
|
||||
|
||||
class ArticleApi{
|
||||
|
||||
static const String kBaseUrl = 'https://www.wanandroid.com';
|
||||
|
||||
final Dio _client = Dio(BaseOptions(baseUrl: kBaseUrl));
|
||||
|
||||
Future<List<Article>> loadArticles(int page) async {
|
||||
String path = '/article/list/$page/json';
|
||||
var rep = await _client.get(path);
|
||||
if (rep.statusCode == 200) {
|
||||
if(rep.data!=null){
|
||||
var data = rep.data['data']['datas'] as List;
|
||||
return data.map(Article.formMap).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2. 文章内容界面展示
|
||||
|
||||
这里单独创建一个 ArticleContent 组件负责展示主题内容,由于需要加载网络数据,加载成功后要更新界面,使用需要使用状态类来维护数据。所以让它继承自 StatefulWidget :
|
||||
|
||||
class ArticleContent extends StatefulWidget {
|
||||
const ArticleContent({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ArticleContent> createState() => _ArticleContentState();
|
||||
}
|
||||
|
||||
|
||||
对于状态类来说,最重要数据是 Article 列表,build 构建逻辑中通过 ListView 展示可滑动列表,其中构建条目时依赖列表中的数据:
|
||||
|
||||
class _ArticleContentState extends State<ArticleContent> {
|
||||
List<Article> _articles = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemExtent: 80,
|
||||
itemCount: _articles.length,
|
||||
itemBuilder: _buildItemByIndex,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemByIndex(BuildContext context, int index) {
|
||||
return ArticleItem(
|
||||
article: _articles[index],
|
||||
onTap: _jumpToPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
另外这里单独封装了 ArticleItem 组件展示条目的单体,效果如下,大家可以自己处理一下,这里就不放代码了,处理不好的话可以参考源码。
|
||||
|
||||
|
||||
|
||||
最后只要在 initState 回调中通过 ArticleApi 加载网络数据即可,加载完成后通过 setState 更新界面:
|
||||
|
||||
ArticleApi api = ArticleApi();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() async{
|
||||
_articles = await api.loadArticles(0);
|
||||
setState(() {
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
到这里,最基础版的网络请求数据,进行界面展示的功能就完成了。当然现在的代码还存在很大的问题,下面将逐步进行优化。
|
||||
|
||||
|
||||
|
||||
|
||||
————————————————————
|
||||
————————————————————
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 在应用中展示 Web 界面
|
||||
|
||||
文章数据中有一个链接地址,可以通过 WebView 来展示内容。同样也是使用三方的依赖库 webview_flutter 。 使用前先添加依赖:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
webview_flutter: ^4.2.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
使用起来来非常简单,创建 WebViewController 请求地址,然后使用 WebViewWidget 组件展示即可:
|
||||
|
||||
class ArticleDetailPage extends StatefulWidget {
|
||||
final Article article;
|
||||
|
||||
const ArticleDetailPage({Key? key, required this.article}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ArticleDetailPage> createState() => _ArticleDetailPageState();
|
||||
}
|
||||
|
||||
class _ArticleDetailPageState extends State<ArticleDetailPage> {
|
||||
late WebViewController controller;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..loadRequest(Uri.parse(widget.article.url));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.article.title)),
|
||||
body: WebViewWidget(controller: controller),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
最后,在列表界面点击时挑战到 ArticleDetailPage 即可。这样就完成了 Web 界面在应用中的展示,当前代码位置 net_article:
|
||||
|
||||
void _jumpToPage(Article article) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ArticleDetailPage(article: article),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
文章1
|
||||
文章2
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
三、功能优化
|
||||
|
||||
现在有三个值得优化的地方:
|
||||
|
||||
|
||||
网络加载数据的过程比较慢,加载成功之前文章列表是空的,界面展示空白页体验不好。可以在加载过程中展示 loading 界面。
|
||||
当前加载数据完后,无法在重新加载,可以增加下拉刷新功能。
|
||||
现在只能加载一页数据,可以在滑动到底部,加载下一页内容,也就是加载更多的功能。
|
||||
|
||||
|
||||
|
||||
|
||||
1. 增加 loading 状态
|
||||
|
||||
如下左图在网络上请求时没有任何处理,会有有一段时间的白页;如右图所示,在加载过程中给出一些界面示意,在体验上会好很多。
|
||||
|
||||
|
||||
|
||||
|
||||
无 loading 状态
|
||||
有 loading 状态
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
其实处理起来也并不复杂,由于界面需要感知加载中的状态,示意需要增加一个状态数据用于控制。比如这里在状态类中提供 _loading 的布尔值来表示,该值的维修事件也很明确:加载数据前置为 true 、加载完后置为 false 。
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
void _loadData() async {
|
||||
_loading = true;
|
||||
setState(() {});
|
||||
_articles = await api.loadArticles(0);
|
||||
_loading = false;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
上面是状态数据的逻辑处理,下面来看一下界面构建逻辑。只要在 _loading 为 true 时,返回加载中对应的组件即可。如果加载中的界面比较复杂,或想要在其他地方复用,也可以单独封装成一个组件来维护。
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(_loading){
|
||||
return Center(
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
direction: Axis.vertical,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: const [
|
||||
CupertinoActivityIndicator(),
|
||||
Text("数据加载中,请稍后...",style: TextStyle(color: Colors.grey),)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemExtent: 80,
|
||||
itemCount: _articles.length,
|
||||
itemBuilder: _buildItemByIndex,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
这样,就完成了展示界面加载中的功能,当前代码位置 article_content.dart。
|
||||
|
||||
|
||||
|
||||
2. 下拉刷新功能
|
||||
|
||||
如下所示,在列表下拉时,头部只可以展示加载的信息,这种效果组件手写起来非常麻烦。
|
||||
|
||||
|
||||
|
||||
这是一个通用的功能,好在我们可以依赖别人的代码,使用三方库来实现,这里用的是 easy_refresh。使用前先添加依赖:
|
||||
|
||||
dependencies:
|
||||
...
|
||||
easy_refresh: ^3.3.1+2
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
使用方式也非常简单,将 EasyRefresh 组件套在 ListView 上即可。在 header 中可以放入头部的配置信息,通过 onRefresh 参数设置下拉刷新的回调,也就是从网络加载数据,成功后更新界面。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3. 加载更多功能
|
||||
|
||||
上面只能展示一页的数据,如果需要展示多页怎么办? 一般来说应用在滑动到底部会加载更多,如下所示:
|
||||
|
||||
|
||||
|
||||
实现起来也非常简单 EasyRefresh 的 onLoad 参数设置下拉回调,加载下一页数据,并加入 _articles 数据中即可。这里一页数据有 20 条,下一页也就是 _articles.length ~/ 20 :
|
||||
|
||||
void _onLoad() async{
|
||||
int nextPage = _articles.length ~/ 20;
|
||||
List<Article> newArticles = await api.loadArticles(nextPage);
|
||||
_articles = _articles + newArticles;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
四、本章小结
|
||||
|
||||
本章主要介绍了如何访问网络数据,实现了文章列表的展示,以及通过 WebView 在应用中展示网页内容,完成简单的文章查看功能。并且基于插件实现了下拉刷新、加载更多的功能。
|
||||
|
||||
到这里一个最基本的网络文章数据的展示就实现完成了, 当前代码位置 article_content。也标志着本系列教程进入了尾声,还有很多值得优化的地方,希望大家再以后的路途中可以自己思考和处理。
|
||||
|
||||
|
||||
|
||||
|
193
专栏/Flutter入门教程/26教程总结与展望.md
Normal file
193
专栏/Flutter入门教程/26教程总结与展望.md
Normal file
@ -0,0 +1,193 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 教程总结与展望
|
||||
1. 教程小结
|
||||
|
||||
对于应用程序来说,最重要的有两方面: 数据和界面。
|
||||
|
||||
|
||||
数据的信息为界面提供构建资源
|
||||
界面的交互让数据内容发生变更
|
||||
|
||||
|
||||
学完本教程,希望大家可以意识到 Flutter 中界面由 Widget 派生类来决定;而组件实例化时的配置信息就是界面中所依赖的数据。数据和界面就像一枚硬币的两面,两者相辅相成,缺一不可。用户站在界面的面前,开发者站在数据的面前。作为开发者,我们不能只关注界面,而忽略数据关系。
|
||||
|
||||
|
||||
|
||||
|
||||
关于组件(界面)
|
||||
|
||||
|
||||
组件 Widget 作为界面的决定因素,对界面来说,主要就是通过定义组件来处理 构建逻辑(Build Logic) 。在很长一段时间,你自己创建的组件只有 StatelessWidget 和 StatefulWidget 这两个族系。它们可以使用已经存在的组件进行组合,成为新的组件。这种组合形式,可以封装一些构建逻辑,以便复用或拆分结构。
|
||||
|
||||
StatefulWidget 依赖 State 状态类完成 build 构建任务,并且 State 有对应的生命周期回调,可以处理相关逻辑;也能使用 setState 方法重新触发当前状态类的构建任务,实现界面更新。
|
||||
|
||||
对 State 的认知也伴随你很长的路途,如果希望在 Flutter 有长远的发展,以后有时间和能力时,还是建议从框架底层的源码中去思考状态类的作用,才能看清 Flutter 机器的整个运转流程。
|
||||
|
||||
|
||||
|
||||
|
||||
关于数据
|
||||
|
||||
|
||||
数据是界面中依赖的信息,有的数据是死的,在程序运行期间永远不会改变,比如标题的文字、或固定的描述信息。图片、图标等;有些数据是活的,会通过函数进行传递、流动、变化,比如白板中的线列表、木鱼中的功德数、网络文章中的文章列表。
|
||||
|
||||
如何获取数据、如果保存数据、如何更改数据,是程序开发者最需要关注的事,数据的维护直接关系到程序功能的正确性。这些处理数据的逻辑可以称为 业务逻辑(Business Logic)。
|
||||
|
||||
对于 Flutter 开发者,甚至是任何和界面相关软件的开发者,都需要意识到构建逻辑和业务逻辑这两条命脉。如何合理地维护这两类逻辑,也是今后值得不断深入思考的事。
|
||||
|
||||
本教程的总结,我只想点出上面的两点,希望大家可以在以后的路途中铭记于心。
|
||||
|
||||
|
||||
|
||||
2. 关于 Flutter 组件的认知
|
||||
|
||||
Flutter 框架中提供的内置组件估计已经接近 400 个了,每个组件都有各自的特点。把它们一一背下来是不现实的,对于初学者来说,应该注重常用的组件,比如本教程四个案例中涉及到了组件,都可以深入了解一下。组件本质上只是配置信息,通过传参控制界面展示效果,当你了解玩常用组件,其余的都可以一通百通。在日常开发中逐渐接触,把组件看成可以帮里完成构建界面的朋友,而不是不得不接触的敌人。
|
||||
|
||||
教程中的四个案例中使用的组件,都没有进行非常细致的介绍。因为:
|
||||
|
||||
|
||||
[1] 并不是非常必要。
|
||||
|
||||
|
||||
考虑到对组件一一介绍起来非常繁琐和无聊,不仅会占据很多的篇幅,而且学起来也很枯燥,很容易拘泥于琐碎的组件而无法对 Flutter 有整体的认知。所以我才构思了四个小项目,让大家在实践中了解它们的存在和使用方式,在交互中体验更有趣。
|
||||
|
||||
|
||||
|
||||
|
||||
[2] 已经的历史文章积累。
|
||||
|
||||
|
||||
我在掘金中为很多常用的组件写过专文介绍,大家后期对某个组件感兴趣可以各取所需去了解。专栏地址为 Flutter 组件集录 :
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[3] 完善的组件介绍开源项目。
|
||||
|
||||
|
||||
|
||||
|
||||
关于Flutter的组件介绍,我有一个使用 Flutter 框架实现开源项目 FlutterUnit ,其中收录了 350 多个组件的介绍和使用范例,支持范例的代码查看和分享。并且支持全平台,可以在手机和电脑上安装应用程序来体验:
|
||||
|
||||
|
||||
|
||||
|
||||
主页面
|
||||
详情页
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
桌面端界面:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
想了解 Flutter 里有哪些组件,或通过组件名搜索查看使用方式, FlutterUnit 都是一个很好的选择。现在项目的 star 个数已经 6000 多了,对于 Flutter 开源项目来说算是比较高了,希望大家可以多多支持。
|
||||
|
||||
|
||||
|
||||
3. 对未来发展方向的展望
|
||||
|
||||
一个侠客行走江湖,需要精进的方向有两个,其一是 修炼内力,其二是 修炼招式 。对于开发者来说,理解底层运转的机理就是内力;如何开发出应用程序就是招式。使用刚步入江湖时,修炼的方向各有不同,人各有志,也不必强求。
|
||||
|
||||
如果你并不急着打造一个软件产品,在起步时打牢基础,修炼内力,是不错的选择。这时,推荐你去研读我的七本小册,我称之为 "Flutter 七剑",助你在未来的道路上披荆斩棘:
|
||||
|
||||
|
||||
|
||||
|
||||
小册名称
|
||||
发布时间
|
||||
代码仓库
|
||||
售价(RMB)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
《Flutter 绘制指南 - 妙笔生花》
|
||||
2020年11月11日
|
||||
idraw
|
||||
3.28
|
||||
|
||||
|
||||
|
||||
《Flutter 手势探索 - 执掌天下》
|
||||
2021年05月13日
|
||||
itouch
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
《Flutter 动画探索 - 流光幻影》
|
||||
2021年07月09日
|
||||
ianim
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
《Flutter 滑动探索 - 珠联璧合》
|
||||
2022年02月10日
|
||||
iscroll
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
《Flutter 布局探索 - 薪火相传 》
|
||||
2022年03月30日
|
||||
ilayout
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
《Flutter 渲染机制 - 聚沙成塔 》
|
||||
2022年04月27日
|
||||
irender
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
《Flutter 语言基础 - 梦始之地 》
|
||||
2022年09月14日
|
||||
idream
|
||||
3.5
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如果你迫于工作压力,或者急需上手开发项目,最好的方式是找一些开源项目来研究,学习招式。这里推荐几个项目,都是在持续维护更新的:
|
||||
|
||||
|
||||
flutter_deer
|
||||
gsy_github_app_flutter
|
||||
FlutterUnit (我的)
|
||||
RegExpo(我的)
|
||||
|
||||
|
||||
另外,如果你学习编程只是业余的兴趣爱好,可以体验一下基于 flame 开发小型游戏,在游戏中学习可谓乐趣无穷。我也写过一个专栏 《Flutter&Flame 游戏专栏》:
|
||||
|
||||
|
||||
|
||||
其实不管开始修炼的道路是什么,只要想在这江湖中立足,最终内力和招式都要精进。所以不用过于纠结什么先,什么后,需要什么就去修炼什么。到这里,本教程就已经完结了,感谢大家的观看,以后有缘再见 ~
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user