本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
《Flutter TolyUI 框架》系列前言:
TolyUI 是 张风捷特烈 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台、组件化、源码开放、响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:
开源地址: github.com/TolyFx/toly…
该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。
一、响应式布局理念和使用
作为一个支持全平台的 UI 界面框架,只要在桌面端和移动端打造应用程序,就注定需要面对一套代码,响应不同设备尺寸的功能需求。Flutter 官方没有一种比较完善的方案。但好在前端 Web 技术早在十几年前就已经为我们摸出了过河的石头,那就是 BootStrap 的栅格系统。目前流行的前端 UI 框架,如 ElementUI 、Ant Design 等,都采用了类似的栅格系统来适应不同尺寸的屏幕。
如何让 Flutter 支持栅格布局,完成响应式布局的需求,将是本文探讨的核心,也是 TolyUI 需要解决的首要问题。目前 tolyui 的响应式布局模块已经完成,可在 官网组件界面 查看介绍信息以及使用方式:
下面通过一个视频展示一下,TolyUI 为 Flutter 打造的响应式布局和栅格系统的功能:
1. TolyUI 的响应式布局模块
为了更好的拆分 TolyUI 的职能,也为了开发者拥有更 细粒度
的选择。将相对独立的模块 单独分包,在通过一个包整合。拿响应式布局模块来说,它将作为 【tolyui_rx_layout】 单独存在;也会作为 【tolyui】 的一部分。也就是说,使用者如果只想使用响应式布局,可以引入 tolyui_rx_layout 包即可;想要使用全家桶,可以使用 tolyui 包。这种组件化的选择灵活性,是 TolyUI 的一大特性。
# 仅使用响应式布局
dependencies:
tolyui_rx_layout: ^last_version
# 使用 tolyui 全家桶
dependencies:
tolyui: ^last_version
tolyui 借鉴 ElementUI 、Ant Design 等成熟的前端 UI 框架,将一个区域在横向划分为 24 格。在布局过程中,通过指定单元格的跨度来调节区域宽度:
响应式布局根据屏幕尺寸宽度,由小到大分为 xs
、sm
、md
、lg
、xl
五个阶层,我称之为 响应式尺阶 ,简称 尺阶
。
TolyUI 官网 界面正是基于此实现的响应式布局。拿 功能特性
条目展示来说来说:宽屏时可以展示四栏,也就是每个条目占据 4
个栅格:
随着窗口尺寸宽度的变化,内容可以自适应宽度。如下所示,每行两个条目或一个条目。原理是指定单元格占据的栅格个数,比如下面左图每个条目占 12 栅格,所以可以排两个;右侧每个条目占 24 栅格,所以只能排一个,以此类推:
两个条目 | 一个条目 |
---|---|
2.单元格 cell 与跨度 span
栅格系统最基础的是在布局区域宽度缩放时,其中的单元格尺寸占比保持不变(如下图所示)。下面:
- 每个色块区间被称为
Cell
,可以指定跨度。 - 若干色块横向排列,形成一行称之为
Row$
。
注: 为了更好的语义,以及区分内置组件名。响应式组件命名中会以$
结尾。
在使用方面,引入 tolyui_rx_layout
后,通过 Row$ 组件展示一行,其中每个子区域对应一个 Cell 单元格。单元格可指定 span 表示跨度:
import 'package:tolyui_rx_layout/tolyui_rx_layout.dart';
Widget cellAndSpanExample() {
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Row$(cells: [
Cell(span: 5.rx, child: const Box(color: color1, text: '5')),
Cell(span: 4.rx, child: const Box(color: color2, text: '4')),
Cell(span: 9.rx, child: const Box(color: color1, text: '9')),
Cell(span: 6.rx, child: const Box(color: color2, text: '6')),
]);
}
3. 响应式参数: Cell#span
上面 Cell 的 span 赋值时,其后添加的 rx,可能大家会有所诧异。其实 Cell 中的 span 是 响应式的数字。确切来说是
基于响应尺寸创建数字的函数对象
。
其中拓展方法 rx 会返回一个函数,便于创建任何响应尺寸中都一致的数字。响应式布局的精髓在于:可以基于当前窗口尺寸,给出适应性的 span 数字。比如下面在窗口宽度缩小的过程中:
- UI 格对应的 span 会逐阶减小,在最小阶尺寸时消失。
- Toly 格会逐阶增大到 6、7 ,然后保持不变。
下面是我设计的调用方式,基于 Dart 模式匹配的新特性。可以通过 switch 来匹配五个尺阶 Rx
枚举,返会对应 span 的大小。其优势在于可以不多不少 全面枚举:
---->[UI 单元格响应式设置]----
spanSecond(Rx r) => switch (r) {
Rx.xs => 0,
Rx.sm => 1,
Rx.md => 2,
Rx.lg => 3,
Rx.xl => 4,
};
Cell(span: spanSecond, child: const Box(color: color2, text: 'UI')),
通过 switch 匹配还有一点点其他的优势,可以基于匹配值进行逻辑运算。比如上面的逐阶递减,可以通过 4 - r.index
返回即可:
spanSecond(Rx r) => switch (r) { _ => 4 - r.index };
如果只想设置某几阶的响应值,在 switch 中可以通过 _
提供其余的默认值。switch 关键字的模式匹配,简化了基于一个值,构建另一个值的过程。
---->[Toly 单元格响应式设置]----
spanFirst(Rx r) => switch (r) { Rx.lg => 6, Rx.xl => 5, _ => 7 };
Cell(span: spanFirst, child: const Box(color: color1, text: 'Toly')),
4. 响应式解析策略与自定义
其中五阶尺寸和前端响应式布局一致,通过 Rx 枚举表示。具体如下:
---->[源码,使用者无需在意]----
enum Rx {
xs, // (超小屏):
sm, // (小屏幕):
md, // (中屏幕):
lg, // (大屏幕):
xl, // (超大屏幕):
}
在设计的过程中,我发现前端不同的 UI 框架对响应阶层的划分并不一致。为了使用者可以 更灵活 地使用响应式布局,这里将五阶的解析逻辑进行抽象,并提供默认的解析方式 defaultParserStrategy
。
---->[源码,使用者无需在意]----
/// xs: [0,576)
/// sm: [576,768)
/// xs: [768,992)
/// xs: [992,1200)
/// xs: [1200,)
Rx defaultParserStrategy(double width) {
if (width < 576) return Rx.xs;
if (width >= 576 && width < 768) return Rx.sm;
if (width >= 768 && width < 992) return Rx.md;
if (width >= 992 && width < 1200) return Rx.lg;
return Rx.xl;
}
如果你想要自定义五阶的解析范围,可以通过 ReParserStrategyTheme
主题进行设置。比如下面是 ElementUI 框架中响应式的解析逻辑,它限定的尺寸要更大一些:
注: 自定义解析主题是 非必须
的,不配置会有默认的解析逻辑。
Rx _elementUiRxParserStrategy(double width) {
if (width < 768) return Rx.xs;
if (width >= 768 && width < 992) return Rx.sm;
if (width >= 992 && width < 1200) return Rx.md;
if (width >= 1200 && width < 1920) return Rx.lg;
return Rx.xl;
}
二、 响应式间隔与对齐方式
响应式布局组件 Row$
,在构造时可以传入其他参数控制单元格的排列信息。右如下五个属性:
名称 | 响应式类型 | 作用 |
---|---|---|
gutter | double | 响应式水平间隔 |
verticalGutter | double | 响应式竖直间隔 |
padding | EdgeInsetsGeometry | 响应式内边距 |
justify | RxAlign | 竖直方向对其方式 |
align | RxJustify | 水平方向对其方式 |
1. 间隔与边距
Row$
支持 24 栅格,如果单元格总长度大于 24 栅格,将会自动换行。如下图所示:
- gutter 表示每个单元格的间距。
- verticalGutter 表示换行后,竖直间距。
- padding 表示四周的内边距。
这三个都是响应式值,可以通过函数指定不同尺阶对应的数值:
Widget gutterExample(){
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Row$(
gutter: 20.0.rx,
verticalGutter: 12.0.rx,
padding: const EdgeInsets.symmetric(horizontal: 40).rx,
cells: [
Cell(span: 6.rx, child: const Box(color: color1, text: 'Toly')),
Cell(span: 6.rx, child: const Box(color: color2, text: 'UI')),
Cell(span: 6.rx, child: const Box(color: color1, text: 'Responsive')),
Cell(span: 6.rx, child: const Box(color: color2, text: 'Layout')),
Cell(span: 12.rx, child: const Box(color: color2, text: '12')),
Cell(span: 6.rx, child: const Box(color: color2, text: '6')),
Cell(span: 2.rx, child: const Box(color: color2, text: '2')),
Cell(span: 4.rx, child: const Box(color: color2, text: '4')),
]);
}
2. 水平方向对齐方式
在水平方向上,单元格有六种对齐方式,通过 justify
参数配置。它具有六种中元素,下图自上而下依次是 start
、end
、center
、spaceBetween
、spaceAround
、spaceEvenly
:
enum RxJustify {
start,
end,
center,
spaceBetween,
spaceAround,
spaceEvenly,
}
Widget justifyExample(){
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Column(
children: RxJustify.values.map((e) => Row$(
justify: e,
padding: const EdgeInsets.symmetric(horizontal:12,vertical: 8).rx,
cells: [
Cell(span: 4.rx, child: const Box(color: color1, text: 'Toly')),
Cell(span: 2.rx, child: const Box(color: color2, text: 'UI')),
Cell(span: 6.rx, child: const Box(color: color1, text: 'Responsive')),
Cell(span: 6.rx, child: const Box(color: color2, text: 'Layout')),
])).toList(),
);
}
3. 竖直方向对齐方式
在竖直方向上,单元格有三种对齐方式,通过 align
参数配置。它具有三种元素,下图自上而下依次是 top
、bottom
、middle
:
enum RxAlign {
top,
bottom,
middle,
}
Widget alignExample(){
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Column(
children: RxAlign.values.map((e) => Row$(
align: e,
padding: const EdgeInsets.symmetric(horizontal:12,vertical: 8).rx,
cells: [
Cell(span: 6.rx, child: const Box(color: color1, text: 'Toly')),
Cell(span: 4.rx, child: const Box(color: color2, text: 'UI',height: 54,)),
Cell(span: 8.rx, child: const Box(color: color1, text: 'Responsive',height: 72,)),
Cell(span: 6.rx, child: const Box(color: color2, text: 'Layout')),
])).toList(),
);
}
三、Cell 单元格其他响应式参数
上面是响应式布局 Row$
的核心用法,在实际使用过程中。单元格 Cell 有其他的辅助参数便于操作和布局。
名称 | 响应式类型 | 作用 |
---|---|---|
span | int | 单元格跨度 |
offset | int | 偏移单元格数量 |
push | int | 右移数量 |
pull | int | 左移数量 |
1. offset 参数
offset 可以指定某个单元格左侧的偏移边距,单位是栅格宽度。如下所示,第三个单元格偏移 2 格,跨度为 7 :
下面通过
Widget cellOffsetExample() {
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Column(
children: [
Row$(gutter: 20.0.rx, cells: [
Cell(span: 6.rx, child: const Box(color: color1)),
Cell(span: 6.rx, offset: 6.rx, child: const Box(color: color2)),
]),
const SizedBox(height: 12),
Row$(gutter: 20.0.rx, cells: [
Cell(span: 6.rx, offset: 6.rx, child: const Box(color: color2)),
Cell(span: 6.rx, offset: 6.rx, child: const Box(color: color2)),
]),
const SizedBox(height: 12),
Row$(
gutter: 20.0.rx, cells: [
Cell(span: 12.rx, offset: 6.rx, child: const Box(color: color1)),
]),
],
);
}
2.偏移 push 和 pull
和 offset 不同的是,push
和 push
仅对对单元格进行平移,并不占据栅格空间。如下图所示,24 个栅格是相当于坐标系,push 作用是向右移动指定单位;pull 作用是向左移动指定单位。移动后单元格会发生局部覆盖行为:
Widget cellPushPullExample() {
const Color color1 = Color(0xffd3dce6);
const Color color2 = Color(0xffe5e9f2);
return Column(
children: [
Row$(
gutter: 10.0.rx,
cells: List.generate(24, (index) => Cell(span: 1.rx, child: Box2(color: color1, text: '${index + 1}'))).toList()),
const SizedBox(height: 12),
const SizedBox(height: 8),
Row$(gutter: 10.0.rx, cells: [
Cell(span: 6.rx, child: const Box(color: color1, text: 'Toly')),
Cell(span: 4.rx, push: 1.rx, child: const Box2(color: color2, text: 'push#1')),
Cell(span: 8.rx, child: const Box(color: Color(0x660000ff), text: 'Responsive')),
Cell(span: 6.rx, pull: 2.rx, child: const Box2(color: Color(0x99e5e9f2), text: 'pull#2')),
]),
],
);
}
四、响应式布局构造器
Row$
组件实现了栅格系统+响应式参数,但它并不是响应式布局的根本。为了满足更一般的响应式布局需求。我封装了 WindowRespondBuilder
组件,便于在任何界面逻辑中使用响应式布局。 在 Row$
组件 的源码实现中,也是依赖于 WindowRespondBuilder
感知窗口当前尺阶的。
1. 整体布局结构中使用响应式布局
如下是组件的展示界面,在 sm
以上的三个尺阶中,宽度有足够的空间容纳侧面菜单栏:
当尺寸宽度不断变小时,感知到 sm、xs 尺阶后,可以将侧面菜单栏隐藏,并展示菜单按钮,点击展开菜单栏。以此实现响应式的整体布局结构。而在窗口尺寸变化时,感知尺阶数据的核心就是 WindowRespondBuilder
。
sm | xs |
---|---|
代码实现如下:通过 WindowRespondBuilder
感知 Rx 尺阶。并根据尺阶控制布局逻辑。比如只在尺阶索引小于 1 时展示 AppBar 及设置 drawer
; 在尺阶大于 1 时,才通过 _buildMenuBar
在主体内容中展示菜单栏:
Widget? _buildDrawer(Rx r){
if(r.index > 1) return null;
return Material(
child: _buildMenuBar(),
);
}
PreferredSizeWidget? _buildAppBar(Rx r){
if(r.index > 1) return null;
return AppBar(
toolbarHeight: 56,
leading: Builder(
builder: (BuildContext context) {
return IconButton(
icon: const Icon(Icons.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
);
},
),
);
}
另外,联系与赞助界面,也是基于 WindowRespondBuilder 在宽屏时展示左右栏;窄平时收起,并通过按钮打开左右抽屉进行展示:
2. 响应式尺寸盒 SizedBox$
有时,我们希望一个区域能够感知 Rx 尺阶来设置长宽。如下所示,不同的尺阶中,灰色的区域尺寸会根据指定的长宽进行变化。以此适应各个尺阶中的展示需求。我基于 WindowRespondBuilder
提供了一个便于使用的 SizedBox$
组件完成这一功能:
它有两个响应式参数 width
和 height
, 使用代码如下所示:
class LayoutDemo5 extends StatelessWidget {
const LayoutDemo5({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xffd3dce6),
child: SizedBox$(
child: const Center(child: Text("宽高根据屏幕尺寸变化的盒子")),
width: (re) => switch (re) {
Rx.xs => 200,
Rx.sm => 200,
Rx.md => 300,
Rx.lg => 400,
Rx.xl => 500,
},
height: (re) => switch (re) { _ => 40.0 * (re.index + 1) }));
}
}
3. 响应式边距 Padding$
有时,在宽屏下希望边距打一些,窄屏中布局小一些。这就是响应式边距的需求。为了简单使用我也通过了一个 Padding$
组件实现响应式边距的功能。
它有响应式参数 padding
设置内边距, 使用代码如下所示:
class LayoutDemo6 extends StatelessWidget {
const LayoutDemo6({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xffd3dce6),
child: SizedBox(
width: 300,
height: 150,
child: Padding$(
child: Container(
color: Colors.orange.withOpacity(0.6),
alignment: Alignment.center,
child: const Text("边距根据屏幕尺寸变化")),
padding: (re) => switch (re) {
Rx.xs => const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
Rx.sm => const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
Rx.md => const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
Rx.lg => const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
Rx.xl => const EdgeInsets.symmetric(horizontal: 40, vertical: 30),
}),
));
}
}
这就是 TolyUI 为 Flutter 打造的响应式布局和栅格系统。感兴趣的朋友可以研究一下我写的源码,一共也不过 200 行代码,就可以实现如此丰富的功能。下一篇,将会带来对这个响应式布局的源码分析。包括在我实现过程中的思考、走的弯路、代码的优化等等中间历程。敬请期待~