End

Flutter 陈航 14-布局控件 Container Row Column Stack 组合与自绘

本文地址


目录

14 | 经典布局:子控件在父容器中的位置

Flutter 提供了 40+ 种布局 Widget,同一视觉效果可以通过多种布局控件实现。

单子 Widget 布局

单子 Widget 类容器有 15+ 种,一般用来对其唯一的子 Widget 进行样式包装,比如限制大小、添加背景色样式、内间距、旋转变换等。

Container

Container 可以单独作为控件存在,比如单独设置背景色、宽高;也可以作为其他控件的父级存在,但仅能包含一个子 Widget。

对于多个子 Widget 的布局场景,可以先用一个根 Widget 去包装这些子 Widget,然后把这个根 Widget 放到 Container 中。

Container(
  padding: const EdgeInsets.all(20),
  margin: const EdgeInsets.all(20),
  width: 200,
  height: 100,
  alignment: Alignment.center,
  decoration: BoxDecoration(
    color: Colors.green, // 背景色
    borderRadius: BorderRadius.circular(10), // 圆角边框
  ),
  child: const Text(
    'Container',
    style: TextStyle(color: Colors.white),
  ),
);

Padding

如果只需要给子 Widget 设定间距,则可以使用 Padding:

Padding(
     padding: EdgeInsets.all(20),
     child: Text('Padding', style: TextStyle(color: Colors.green)),
)

通过 EdgeInsets 的不同构造函数,可以分别制定四个方向的不同补白方式。

Center

Center 会将其子 Widget 居中排列。

// 把一个 Text 包在 Center 里,实现居中展示
Center(
  child: Text('Center', style: TextStyle(color: Colors.green)),
);

注意:为了实现居中布局,Center 所占据的空间一定要比其子 Widget 大才行。

Container + Center 可实现 Container 容器中的 alignment: Alignment.center 效果。

Container 与 Center 底层都依赖 Align 实现的对齐。

多子 Widget 布局

Row 和 Column

参考

class _HomePageState extends State<HomePage> {
  bool _row = false;
  List<Widget> list = <Widget>[
    Container(color: Colors.deepPurple, width: 50, height: 50),
    Container(color: Colors.greenAccent, width: 40, height: 40),
    Container(color: Colors.blue, width: 30, height: 30),
    Container(color: Colors.green, width: 50, height: 50),
  ];

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Container(
        width: 200,
        height: 200,
        margin: const EdgeInsets.all(10),
        color: Colors.black,
        child: _row ? Row(children: list) : Column(children: list),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => _row = !_row),
        child: Text(_row ? "Row" : "Column"),
      ));
}

Expanded

单纯使用 Row 和 Column 控件,在子 Widget 的尺寸较小时,无法将容器填满,通过 Expanded 控件,可以制定填满容器剩余空间的分配规则。

List<Widget> list = <Widget>[
  Expanded(flex: 1, child: Container(color: Colors.deepPurple, width: 10, height: 10)), // 宽、高 由 Expanded 分配
  Container(color: Colors.greenAccent, width: 40, height: 40),
  Container(color: Colors.blue, width: 30, height: 30),
  Expanded(flex: 1, child: Container(color: Colors.green, width: 10, height: 10)), // 宽、高 即使设置了也不会生效
];

坐标轴对齐规则

Row 与 Column 的主轴表示容器依次摆放子 Widget 的方向,纵轴则是与主轴垂直的另一个方向。

mainAxisAlignment 表示子组件在 Row 所占用的水平空间内对齐方式

  • 如果 mainAxisSize 的值为 MainAxisSize.min,属性 mainAxisAlignment 无意义,因为子组件的宽度等于 Row 的宽度
  • 只有 mainAxisSize 的值为 MainAxisSize.max,属性 mainAxisAlignment 才有意义

Row 主轴方向上的对齐规则 mainAxisAlignment

  • MainAxisAlignment.start:沿 textDirection 的初始方向对齐,默认左对齐
  • MainAxisAlignment.end:默认靠右对齐
  • MainAxisAlignment.center:横向居中对齐
  • MainAxisAlignment.spaceEvenly:按固定间距对齐

textDirection 表示水平方向子组件的布局顺序,默认为系统当前 Locale 环境的文本方向。中文、英语都是从左往右,而阿拉伯语是从右往左。

Row 纵轴方向上的对齐规则 crossAxisAlignment

  • CrossAxisAlignment.start:靠上对齐
  • CrossAxisAlignment.center:纵向居中对齐
  • CrossAxisAlignment.end:靠下对齐

mainAxisSize

RowmainAxisSize 表示在主轴(水平)方向占用的空间:

  • MainAxisSize.max:表示尽可能多的占用水平空间,类似 match_parent,此时无论子 widgets 占用多少水平空间,Row 的宽度始终等于水平方向的最大宽度
  • MainAxisSize.min:表示尽可能少的占用水平空间,类似 wrap_content,此时若子 widgets 没有占满水平空间,则 Row 的宽度等于所有子组件占用的的水平空间之和

Row 与 Column 自身的大小由父 widget 的大小子 widget 的大小mainAxisSize 的设置 三者共同决定的:

  • 主轴值为 max:主轴大小等于屏幕主轴方向大小,或者父 widget 在主轴方向大小
  • 主轴值为 min:主轴大小等于所有子 widget 组合在一起的大小

层叠 Widget 布局

Stack 与 Android 中的 FrameLayout 非常类似,子 Widget 之间允许叠加。Stack 按照其子 Widget 创建的先后顺序进行层叠摆放。

可以借助 Positioned 控制 Stack 中子 Widget 的摆放位置,还可以根据 Stack 的上、下、左、右四个角的位置来确定子 Widget 的位置。

Stack(
  children: <Widget>[
    Container(color: Colors.yellow, width: 150, height: 150),
    Positioned(left: 10, top: 10, child: Container(color: Colors.green, width: 50, height: 50)),
    Positioned(left: 20, top: 40, child: Container(color: Colors.blue, width: 50, height: 50)),
    Positioned(right: 10, bottom: 0, child: Container(color: Colors.red, width: 100, height: 100))
  ],
)

Positioned 控件只能在 Stack 中使用,在其他容器中使用会报错。

15 | 组合与自绘,自定义 Widget

在 Flutter 中自定义 Widget 与其他平台类似,可以使用基本 Widget 组合成一个高级别的 Widget,也可以自己在画板上根据特殊需求来画界面

组合

布局分析

  • 控件间的边距如何设置:使用 Padding 包装
  • 中间部分的伸缩、截断规则如何设置:使用 Expanded 包装
  • 图片圆角怎么实现:使用 ClipRRect 包装

数据结构

class UpdateItemModel {
  String icon; // 图标
  String name; // 名称
  double size; // 大小
  late DateTime dateTime; // 更新日期
  String desc; // 更新文案
  int version; // 更新版本
  UpdateItemModel({this.icon = "", this.name = "", this.size = 0, this.desc = "", this.version = 0}) {
    dateTime = DateTime.now();
  }
}

上半部分

Widget buildTopRow(UpdateItemModel model) {
  Padding leftPadding = Padding(
    padding: const EdgeInsets.all(10),
    child: ClipRRect(
      borderRadius: BorderRadius.circular(8), // 圆角矩形裁剪控件,圆角半径
      child: Image.asset(model.icon, width: 80, height: 80),
    ),
  );
  Expanded centerExpanded = Expanded(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center, // 垂直方向居中对齐
      crossAxisAlignment: CrossAxisAlignment.start, // 水平方向居左对齐
      children: <Widget>[
        Text(
          model.name,
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
        ), // 名字
        Text(
          "${model.dateTime.year}年${model.dateTime.month}月${model.dateTime.day}日",
          maxLines: 1,
          style: const TextStyle(color: Colors.grey, fontSize: 12),
        ), // 更新日期
      ],
    ),
  );
  var border = const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20)));
  Padding rightPadding = Padding(
    padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), // 左右边距为 10
    child: TextButton(
      style: ButtonStyle(
        backgroundColor: MaterialStateProperty.resolveWith((states) => const Color.fromARGB(10, 0, 0, 0)),
        foregroundColor: MaterialStateProperty.resolveWith((states) => Colors.blue),
        shape: MaterialStateProperty.resolveWith((states) => border),
        textStyle: MaterialStateProperty.resolveWith((states) => const TextStyle(fontWeight: FontWeight.bold)),
        minimumSize: MaterialStateProperty.resolveWith((states) => const Size(20, 20)),
        fixedSize: MaterialStateProperty.resolveWith((states) => const Size(70, 35)),
      ),
      onPressed: () => log("TextButton onPressed"),
      child: const Text("OPEN"),
    ),
  );
  return Row(children: <Widget>[leftPadding, centerExpanded, rightPadding]);
}

下半部分

Widget buildBottomRow(UpdateItemModel model) {
  return Padding(
    padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), // 左边距和右边距为 15
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start, // 水平方向距左对齐
      children: <Widget>[
        Text(
          model.desc,
          maxLines: 4,
          overflow: TextOverflow.ellipsis,
        ), // 更新文案
        Padding(
          padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), // 上边距为 10
          child: Text(
            "Version ${model.version} • ${model.size} MB",
            style: const TextStyle(color: Colors.grey),
          ),
        )
      ],
    ),
  );
}

组合控件

class _HomePageState extends State<HomePage> {
  UpdateItemModel model = UpdateItemModel(
    icon: "assets/images/monkey.jpg",
    name: "白乾涛白乾涛白乾涛白乾涛白乾涛",
    size: 100.5,
    desc: "更新文案\n更新文案\n更新文案更新\n更新文案更新文案更新文案更新文案更新文案更新文案更新文案",
    version: 88,
  );

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start, // 水平方向居左对齐
        children: <Widget>[buildTopRow(model), buildBottomRow(model)],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => model.version++),
        child: Text(model.version.toString()),
      ));
}

最终效果

自绘

在原生 iOS 和 Android 开发中,可以继承 UIView/View,在 drawRect/onDraw 方法里进行绘制操作。在 Flutter 中也有类似的方案,那就是 CustomPaint

  • Paint:画笔,配置颜色、样式、粗细
  • Canvas:画布,提供绘制方法,drawLine/drawRect/drawPoint/drawPath/drawCircle/drawArc
  • CustomPainter:绘制逻辑,在 paint 方法里,通过 Canvas 与 Paint 定义要绘制的内容
  • CustomPaint:自绘控件,并不负责真正的绘制,通过其 painter 属性关联 CustomPainter

CustomPaint

class _HomePageState extends State<HomePage> {
  double size = 100;

  @override
  Widget build(BuildContext context) => Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Cake(size: size),
      floatingActionButton: FloatingActionButton(
        onPressed: () => setState(() => size += 20),
        child: Text(size.toInt().toString()),
      ));
}
class Cake extends StatefulWidget {
  final double size;

  const Cake({super.key, required this.size});

  @override
  State<StatefulWidget> createState() => _CakeState();
}

class _CakeState extends State<Cake> {
  @override
  Widget build(BuildContext context) => CustomPaint(
        size: Size(widget.size, widget.size), // CustomPaint 的宽高
        painter: WheelPainter(), // CustomPaint 所关联的 CustomPainter
      );
}

CustomPainter

class WheelPainter extends CustomPainter {
  Paint getPaint(Color color) => Paint()..color = color;

  @override
  void paint(Canvas canvas, Size size) {
    double radius = min(size.width, size.height) / 2; // 饼图的尺寸
    Rect rect = Rect.fromCircle(center: Offset(radius + 10, radius + 10), radius: radius); // 包裹饼图的矩形框
    double angle = 2 * pi / 6; // 弧度,分成 6 份,每份 1/6 圆
    canvas.drawArc(rect, 0, angle, true, getPaint(Colors.orange));
    canvas.drawArc(rect, angle, angle, true, getPaint(Colors.black38));
    canvas.drawArc(rect, angle * 2, angle, true, getPaint(Colors.green));
    canvas.drawArc(rect, angle * 3, angle, true, getPaint(Colors.red));
    canvas.drawArc(rect, angle * 4, angle, true, getPaint(Colors.blue));
    canvas.drawArc(rect, angle * 5, angle, true, getPaint(Colors.pink));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; // 判断是否需要重绘
}

效果

总结

以组装的方式构建 UI,需要将视图分解成各个 UI 小元素。通常,可以按照从上到下、从左到右的布局顺序去对控件层次结构进行拆解,将基本视觉元素封装到 Column、Row 中;对于有着固定间距的视觉元素,可以通过 Padding 进行包装;对于大小伸缩可变的视觉元素,可以通过 Expanded 控件让其填充父容器的空白区域。

以自绘的方式定义控件,需要借助于 CustomPaint 容器,以及最终承接真实绘制逻辑的 CustomPainter。CustomPainter 是绘制逻辑的封装,在其 paint 方法中,可以使用不同类型的画笔 Paint,借助画布 Canvas 提供的不同类型的绘制图形能力,实现控件自定义绘制。

2023-1-2

posted @ 2023-01-02 22:09  白乾涛  阅读(627)  评论(0编辑  收藏  举报