Flutter TolyUI 框架#01 | 响应式布局#使用篇

发布于:2024-05-07 ⋅ 阅读:(43) ⋅ 点赞:(0)

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


《Flutter TolyUI 框架》系列前言:

TolyUI 是 打造的 Fluter 全平台应用开发 UI 框架。具备 全平台组件化源码开放响应式 四大特点。可以帮助开发者迅速构建具有响应式全平台应用软件:

开源地址:

image.png

该系列将详细介绍 TolyUI 框架使用方式、框架开发过程中的技术知识、设计理念、难题解决等。


一、响应式布局理念和使用

作为一个支持全平台的 UI 界面框架,只要在桌面端和移动端打造应用程序,就注定需要面对一套代码,响应不同设备尺寸的功能需求。Flutter 官方没有一种比较完善的方案。但好在前端 Web 技术早在十几年前就已经为我们摸出了过河的石头,那就是 BootStrap 的栅格系统。目前流行的前端 UI 框架,如 ElementUI 、Ant Design 等,都采用了类似的栅格系统来适应不同尺寸的屏幕。

image.png

如何让 Flutter 支持栅格布局,完成响应式布局的需求,将是本文探讨的核心,也是 TolyUI 需要解决的首要问题。目前 tolyui 的响应式布局模块已经完成,可在 查看介绍信息以及使用方式:

下面通过一个视频展示一下,TolyUI 为 Flutter 打造的响应式布局和栅格系统的功能:


1. TolyUI 的响应式布局模块

为了更好的拆分 TolyUI 的职能,也为了开发者拥有更 细粒度 的选择。将相对独立的模块 单独分包,在通过一个包整合。拿响应式布局模块来说,它将作为 单独存在;也会作为 的一部分。也就是说,使用者如果只想使用响应式布局,可以引入 tolyui_rx_layout 包即可;想要使用全家桶,可以使用 tolyui 包。这种组件化的选择灵活性,是 TolyUI 的一大特性。

# 仅使用响应式布局
dependencies: 
    tolyui_rx_layout: ^last_version
    
# 使用 tolyui 全家桶
dependencies: 
    tolyui: ^last_version 

tolyui 借鉴 ElementUI 、Ant Design 等成熟的前端 UI 框架,将一个区域在横向划分为 24 格。在布局过程中,通过指定单元格的跨度来调节区域宽度:

image.png

响应式布局根据屏幕尺寸宽度,由小到大分为 xssmmdlgxl 五个阶层,我称之为 响应式尺阶 ,简称 尺阶

image.png


界面正是基于此实现的响应式布局。拿 功能特性 条目展示来说来说:宽屏时可以展示四栏,也就是每个条目占据 4 个栅格:

image.png

随着窗口尺寸宽度的变化,内容可以自适应宽度。如下所示,每行两个条目或一个条目。原理是指定单元格占据的栅格个数,比如下面左图每个条目占 12 栅格,所以可以排两个;右侧每个条目占 24 栅格,所以只能排一个,以此类推:

两个条目 一个条目
image.png image.png

2.单元格 cell 与跨度 span

栅格系统最基础的是在布局区域宽度缩放时,其中的单元格尺寸占比保持不变(如下图所示)。下面:

  • 每个色块区间被称为 Cell,可以指定跨度。
  • 若干色块横向排列,形成一行称之为 Row$
    : 为了更好的语义,以及区分内置组件名。响应式组件命名中会以 $ 结尾。

rx_layout_01.gif

在使用方面,引入 tolyui_rx_layout 后,通过 Row$ 组件展示一行,其中每个子区域对应一个 Cell 单元格。单元格可指定 span 表示跨度:

image.png

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 ,然后保持不变。

rx_layout_02.gif

下面是我设计的调用方式,基于 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 框架中响应式的解析逻辑,它限定的尺寸要更大一些:
: 自定义解析主题是 非必须 的,不配置会有默认的解析逻辑。

image.png

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 表示四周的内边距。

这三个都是响应式值,可以通过函数指定不同尺阶对应的数值:

image.png

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 参数配置。它具有六种中元素,下图自上而下依次是 startendcenterspaceBetweenspaceAroundspaceEvenly

enum RxJustify {
  start,
  end,
  center,
  spaceBetween,
  spaceAround,
  spaceEvenly,
}

image.png

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 参数配置。它具有三种元素,下图自上而下依次是 topbottommiddle

enum RxAlign {
  top,
  bottom,
  middle,
}

image.png

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 :

image.png

下面通过

image.png

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 不同的是,pushpush 仅对对单元格进行平移,并不占据栅格空间。如下图所示,24 个栅格是相当于坐标系,push 作用是向右移动指定单位;pull 作用是向左移动指定单位。移动后单元格会发生局部覆盖行为:

image.png

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 以上的三个尺阶中,宽度有足够的空间容纳侧面菜单栏:

image.png

image.png


当尺寸宽度不断变小时,感知到 sm、xs 尺阶后,可以将侧面菜单栏隐藏,并展示菜单按钮,点击展开菜单栏。以此实现响应式的整体布局结构。而在窗口尺寸变化时,感知尺阶数据的核心就是 WindowRespondBuilder

sm xs
image.png image.png

代码实现如下:通过 WindowRespondBuilder 感知 Rx 尺阶。并根据尺阶控制布局逻辑。比如只在尺阶索引小于 1 时展示 AppBar 及设置 drawer; 在尺阶大于 1 时,才通过 _buildMenuBar 在主体内容中展示菜单栏:

image.png

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 在宽屏时展示左右栏;窄平时收起,并通过按钮打开左右抽屉进行展示:

04.gif


2. 响应式尺寸盒 SizedBox$

有时,我们希望一个区域能够感知 Rx 尺阶来设置长宽。如下所示,不同的尺阶中,灰色的区域尺寸会根据指定的长宽进行变化。以此适应各个尺阶中的展示需求。我基于 WindowRespondBuilder 提供了一个便于使用的 SizedBox$ 组件完成这一功能:

02.gif

它有两个响应式参数 widthheight, 使用代码如下所示:

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$ 组件实现响应式边距的功能。

03.gif

它有响应式参数 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 行代码,就可以实现如此丰富的功能。下一篇,将会带来对这个响应式布局的源码分析。包括在我实现过程中的思考、走的弯路、代码的优化等等中间历程。敬请期待~