flutter经验汇总

2024-01-08 fishedee 前端

0 概述

flutter经验汇总。flutter明显要比微信小程序严谨多了,唯一缺点是没有微信的生态,动态性也不好。

参考资料:

本文章中的demo代码和描述大多来自flutter官方文档flutter实战第二版,推荐大家移步看他们的文档,本文档仅作快速索引和补充。

1 快速入门

安装的时候遇到较多的问题,参考资料有:

代码在这里

1.1 安装flutter

首先到这里,下载Windows下的工具

PATH=C:\Users\MyUser\Util\flutter\bin

将安装包解压,并且将flutter目录下的bin文件夹加入到PATH环境变量中

flutter doctor --android-licenses

如果我们这个时候,直接执行flutter docker会提示报错,因为Android SDK还没有安装完毕

1.2 安装Android命令行工具

按照这里的经验,安装好Android SDK

创建好一个Android模拟器,注意要打开硬件加速,可以看这里这里

打开Sdk Manager,我们需要安装

  • Android SDK Platform, API 33.0.0
  • Android SDK Command-line Tools,注意必须要安装这个
  • Android SDK Build-Tools
  • Android SDK Platform-Tools
  • Android Emulator
ANDROID_HOME=C:\Users\fishe\AppData\Local\Android\Sdk
PATH=%ANDROID_HOME%\platform-tools:%ANDROID_HOME%\cmdline-tools\latest\bin:%PATH%

加入以上环境变量

能启动adb,证明platform-tools安装成功了

能出现avdmanager,证明command-line tools安装成功了

1.3 配置flutter

export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn 
export PUB_HOSTED_URL=https://pub.flutter-io.cn

加入flutter的镜像地址

flutter doctor --android-licenses

执行android签名,这里需要多次输入y,来确认同意

flutter doctor

执行这一步就能成功了

$Env:http_proxy="http://127.0.0.1:7890"
$Env:https_proxy="http://127.0.0.1:7890"
$Env:no_proxy="localhost,127.0.0.1,::1"

有时候,配置了镜像地址还是失败,这是因为flutter向github请求的时候失败了,这个时候需要配置代理,在命令行输入以上代码即可,配置为你对应的代理地址。

1.4 新建项目

flutter create test_drive
cd test_drive

创建项目

这是生成的项目目录,源代码都放在了lib文件夹

1.5 启动和调试项目

flutter run

启动flutter

启动的时候有显示调试地址,直接复制到浏览器就能显示了,比较简单

另外,在代码变化以后,需要手动在控制台输入r,才能刷新UI。

1.6 FAQ

参考资料:

1.6.1 Gradle报错

启动Android项目的时候,如果遇到Gradle报错,一般是因为没有配置镜像导致的,可以看这里

1.6.2 iOS编译报错,找不到Generated.xcconfig

以下操作即可

  • 在Xcode12上运行”Product > Clean Build Folder“。
  • 在终端上运行flutter clean
  • 在终端上运行flutter pub get (这将创建/project/ios/flutter/Generated.xcconfig)
  • 在终端上运行pod install (假设您在/project/ios文件夹中)

然后可以在xcode上进行构建。

1.6.3 iOS编译错误,Module … not found

XCode打开的文件不是ios/Runner.xcodeproj, 而是ios/Runner.xcworkspace

2 状态管理

本章描述从React迁移到flutter,快速掌握flutter的状态处理。代码在这里

2.1 基础

import 'package:flutter/material.dart';

class BasicWidget extends StatelessWidget {
  const BasicWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    );
  }
}

Hello World快速入门,没啥好说的

2.2 无状态组件(纯组件)

import 'package:flutter/material.dart';

class BasicWidget2 extends StatelessWidget {
  const BasicWidget2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MyText(text: "Hello World");
  }
}

class MyText extends StatelessWidget {
  const MyText({
    Key? key,
    required this.text,
    this.backgroundColor = Colors.grey, //默认为灰色
  }) : super(key: key);

  final String text;
  final Color backgroundColor;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        color: backgroundColor,
        child: Text(text, textDirection: TextDirection.ltr),
      ),
    );
  }
}

无状态组件,直接使用StatlessWidget,继承后直接使用就可以了。

2.3 状态组件(非纯组件)

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  //counter的props发生变化的时候,Counter实例不会发生变化,会绑定到新的state上
  const Counter({super.key, required this.initValue});

  final int initValue;

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      // This call to setState tells the Flutter framework
      // that something has changed in this State, which
      // causes it to rerun the build method below so that
      // the display can reflect the updated values. If you
      // change _counter without calling setState(), then
      // the build method won't be called again, and so
      // nothing would appear to happen.
      _counter++;
    });
  }

  @override
  void initState() {
    super.initState();
    //初始化状态
    _counter = widget.initValue;
    print("initState");
  }

  @override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called,
    // for instance, as done by the _increment method above.
    // The Flutter framework has been optimized to make
    // rerunning build methods fast, so that you can just
    // rebuild anything that needs updating rather than
    // having to individually changes instances of widgets.
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }

  //同一个Element上更新Widget时出现的
  //相当于React里面getDerivedStateFromProps
  @override
  void didUpdateWidget(Counter oldWidget) {
    super.didUpdateWidget(oldWidget);
    print("didUpdateWidget ");
  }

  @override
  void deactivate() {
    super.deactivate();
    print("deactivate");
  }

  @override
  void dispose() {
    super.dispose();
    print("dispose");
  }

  @override
  void reassemble() {
    super.reassemble();
    print("reassemble");
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print("didChangeDependencies");
  }
}

class BasicWidget3 extends StatelessWidget {
  const BasicWidget3({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Counter(initValue: 0);
  }
}

状态组件,就是React里面的非纯组件了。要点如下:

  • flutter里面的组件只能是Immutable的,一旦创建就不能修改。Widget相当于React.createElement。但是React.Element可以通过cloneElement来修改属性。flutter的Widget不可以属性。
  • flutter的StatfulWidget在immutable的基础上,提供了createState,在State<Counter>里面可以修改属性。
  • flutter的Widget可以看出是React.createElement,State<Counter>就是实际的组件的ref,这个ref里面可以通过.widget属性来获取最新的Widget。

生命周期可以看这里

  • initState:当 widget 第一次插入到 widget 树时会被调用,对于每一个State对象,Flutter 框架只会调用一次该回调,所以,通常在该回调中做一些一次性的操作,如状态初始化、订阅子树的事件通知等。不能在该回调中调用BuildContext.dependOnInheritedWidgetOfExactType(该方法用于在 widget 树上获取离当前 widget 最近的一个父级InheritedWidget,关于InheritedWidget我们将在后面章节介绍),原因是在初始化完成后, widget 树中的InheritFrom widget也可能会发生变化,所以正确的做法应该在在build()方法或didChangeDependencies()中调用它。
  • didChangeDependencies():当State对象的依赖发生变化时会被调用;例如:在之前build() 中包含了一个InheritedWidget (第七章介绍),然后在之后的build() 中Inherited widget发生了变化,那么此时InheritedWidget的子 widget 的didChangeDependencies()回调都会被调用。典型的场景是当系统语言 Locale 或应用主题改变时,Flutter 框架会通知 widget 调用此回调。需要注意,组件第一次被创建后挂载的时候(包括重创建)对应的didChangeDependencies也会被调用。
  • build():此回调读者现在应该已经相当熟悉了,它主要是用于构建 widget 子树的,会在如下场景被调用。1. 在调用initState()之后。2. 在调用didUpdateWidget()之后。3. 在调用setState()之后。4. 在调用didChangeDependencies()之后。5. 在State对象从树中一个位置移除后(会调用deactivate)又重新插入到树的其他位置之后。
  • reassemble():此回调是专门为了开发调试而提供的,在热重载(hot reload)时会被调用,此回调在Release模式下永远不会被调用。
  • didUpdateWidget ():在 widget 重新构建时,Flutter 框架会调用widget.canUpdate来检测 widget 树中同一位置的新旧节点,然后决定是否需要更新,如果widget.canUpdate返回true则会调用此回调。正如之前所述,widget.canUpdate会在新旧 widget 的 key 和 runtimeType 同时相等时会返回true,也就是说在在新旧 widget 的key和runtimeType同时相等时didUpdateWidget()就会被调用。
  • deactivate():当 State 对象从树中被移除时,会调用此回调。在一些场景下,Flutter 框架会将 State 对象重新插到树中,如包含此 State 对象的子树在树的一个位置移动到另一个位置时(可以通过GlobalKey 来实现)。如果移除后没有重新插入到树中则紧接着会调用dispose()方法。
  • dispose():当 State 对象从树中被永久移除时调用;通常在此回调中释放资源。

2.4 State

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  //注意,counter的props发生变化的时候,state不会丢失
  //counter设计为immutable,可以大幅简化build里面diff的效率。
  //如果diff的前后引用是相同的,例如是const类型,那这个Widget的整颗树都是肯定没变化的。这也是为什么build里面这么多const变量的原因。
  //如果Widget设计为非immutable,用户可以偷偷复用同一个Widget,只是里面的数据变更了,build里面就无法通过引用来快速筛选掉不变的Widget树。
  final String prefixShow;

  const Counter({required this.prefixShow, super.key});

  //要不要createState的根本取决于Widget的key有没有发生变化
  //没有变化的话,复用原来的State。有变化的话,需要创建新的state
  //state上面的widget引用不一定是原来的那个。
  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    //build的时候,可以使用两种变量
    //本地变量,_counter
    //state当前widget的变量,也就是widget.prefixShow。同一个state对应的widget在每次build的时候都是不同,或者相同的。
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('${widget.prefixShow}_Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

class Message extends StatefulWidget {
  const Message({super.key});

  @override
  State<Message> createState() => _MessageState();
}

class _MessageState extends State<Message> {
  int _counter2 = 0;

  void _increment() {
    setState(() {
      _counter2++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('IncrementMessage'),
        ),
        const SizedBox(width: 16),
        Counter(prefixShow: 'msg_$_counter2'),
      ],
    );
  }
}

class BasicWidget4 extends StatelessWidget {
  const BasicWidget4({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Message();
  }
}

要点如下:

  • 我们在更新Counter的Props,但是State<Counter>并不会变成新实例
  • StatefulWidget是否沿用同一个State,取决于两点,它的key是否不变,它的Widget类型是否不变。可以看一下Widget.canUpdate的源代码

因此,我们得到

  • counter的props发生变化的时候,state不会丢失。counter设计为immutable,可以大幅简化build里面diff的效率。如果diff的前后引用是相同的,例如是const类型,那这个Widget的整颗树都是肯定没变化的。这也是为什么build里面这么多const变量的原因。如果Widget设计为非immutable,用户可以偷偷复用同一个Widget,只是里面的数据变更了,build里面就无法通过引用来快速筛选掉不变的Widget树。
  • 要不要createState的根本取决于Widget的key有没有发生变化。没有变化的话,复用原来的State。有变化的话,需要创建新的state。state上面的widget引用不一定是原来的那个。
  • Counter在build的时候,可以使用两种变量。本地变量,_counter。state当前widget的变量,也就是widget.prefixShow。同一个state对应的widget在每次build的时候都是不同,或者相同的。

2.5 LocalKey

2.5.1 没有LocalKey

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  final String prefixShow;

  const Counter({required this.prefixShow, super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('${widget.prefixShow}_Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

class Message extends StatefulWidget {
  const Message({super.key});

  @override
  State<Message> createState() => _MessageState();
}

class _MessageState extends State<Message> {
  int _counter2 = 0;

  void _increment() {
    setState(() {
      _counter2++;
    });
  }

  @override
  Widget build(BuildContext context) {
    Widget child1 = const Counter(prefixShow: 'Count a');
    Widget child2 = const Counter(prefixShow: 'Count b');
    //Swap Counter以后,发现只是label变了,state依然没变
    if (_counter2 % 2 == 1) {
      (child2, child1) = (child1, child2);
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Swap Counter'),
        ),
        const SizedBox(width: 16),
        Column(children: [child1, child2]),
      ],
    );
  }
}

class BasicWidget5_1 extends StatelessWidget {
  const BasicWidget5_1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Message();
  }
}

要点如下;

  • 没有localKey的时候,切换两个Counter,发现他们的State没有变更。

2.5.2 有LocalKey

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  final String prefixShow;

  const Counter({required this.prefixShow, super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('${widget.prefixShow}_Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

class Message extends StatefulWidget {
  const Message({super.key});

  @override
  State<Message> createState() => _MessageState();
}

class _MessageState extends State<Message> {
  int _counter2 = 0;

  void _increment() {
    setState(() {
      _counter2++;
    });
  }

  @override
  Widget build(BuildContext context) {
    Widget child1 = const Counter(key: ValueKey("a"), prefixShow: 'Count a');
    Widget child2 = const Counter(key: ValueKey("b"), prefixShow: 'Count b');
    //在Counter上加上key以后,Swap就能正确对应到原来Counter的state上了
    //Swap Counter发现label变了,且state变了
    if (_counter2 % 2 == 1) {
      (child2, child1) = (child1, child2);
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Swap Counter'),
        ),
        const SizedBox(width: 16),
        Column(children: [child1, child2]),
      ],
    );
  }
}

class BasicWidget5_2 extends StatelessWidget {
  const BasicWidget5_2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Message();
  }
}

要点:

  • 给节点加上localKey以后,flutter就能识别当Widget交换的时候,State也得一起交换。

2.6 GlobalKey

2.6.1 没有GlobalKey

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  final String prefixShow;

  const Counter({required this.prefixShow, super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('${widget.prefixShow}_Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

class Message extends StatefulWidget {
  const Message({super.key});

  @override
  State<Message> createState() => _MessageState();
}

class _MessageState extends State<Message> {
  int _counter2 = 0;

  void _increment() {
    setState(() {
      _counter2++;
    });
  }

  @override
  Widget build(BuildContext context) {
    Widget child1 = const Counter(key: ValueKey("a"), prefixShow: 'Count a');
    Widget child2 = Container(
        child: const Counter(key: ValueKey("b"), prefixShow: 'Count b'));
    //在Counter上加上localKey以后,Swap依然不能正确取得Counter的state。
    //因为无法跨层级取到原来对应的Widget
    //切换以后,每次Counter b总是重置
    if (_counter2 % 2 == 1) {
      (child2, child1) = (child1, child2);
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Swap Counter'),
        ),
        const SizedBox(width: 16),
        Column(children: [child1, child2]),
      ],
    );
  }
}

class BasicWidget6_1 extends StatelessWidget {
  const BasicWidget6_1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Message();
  }
}

要点如下:

  • 这次我们也是使用localKey,但是State依然丢失了。这是因为child1和child2并不在同一个层级的子树中。flutter默认并不能跨层级找到两个Widget交换了

2.6.2 有GlobalKey

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  final String prefixShow;

  const Counter({required this.prefixShow, super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: Text('${widget.prefixShow}_Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

class Message extends StatefulWidget {
  const Message({super.key});

  @override
  State<Message> createState() => _MessageState();
}

class _MessageState extends State<Message> {
  int _counter2 = 0;

  void _increment() {
    setState(() {
      _counter2++;
    });
  }

  //GlobalKey可以实现跨层级复用Widget
  final GlobalKey<_CounterState> _globalKey1 = GlobalKey();

  //GlobalKey可以实现跨层级复用Widget
  final GlobalKey<_CounterState> _globalKey2 = GlobalKey();

  @override
  Widget build(BuildContext context) {
    Widget child1 = Counter(key: _globalKey1, prefixShow: 'Count a');
    Widget child2 =
        Container(child: Counter(key: _globalKey2, prefixShow: 'Count b'));
    //在Counter上加上globalKey以后,Widget就能正常地重用了
    if (_counter2 % 2 == 1) {
      (child2, child1) = (child1, child2);
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Swap Counter'),
        ),
        const SizedBox(width: 16),
        Column(children: [child1, child2]),
      ],
    );
  }
}

class BasicWidget6_2 extends StatelessWidget {
  const BasicWidget6_2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Message();
  }
}

要点如下:

  • 我们改为globalKey作为key,这一次跨子树也能交换State了。这个是React没有的功能。
  • GlobalKey不仅可以作为跨子树diff的工具,还能直接从globalKey中获取widget的实例state。
  • GlobalKey的性能耗费要较大,谨慎使用

2.7 子树渲染

2.7.1 默认子树全渲染

import 'dart:developer';

import 'package:flutter/material.dart';

class BasicWidget7_1 extends StatelessWidget {
  const BasicWidget7_1({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Page1();
  }
}

class Page1 extends StatefulWidget {
  const Page1({super.key});

  @override
  State<StatefulWidget> createState() {
    return _Page1();
  }
}

class _Page1 extends State<Page1> with SingleTickerProviderStateMixin {
  late AnimationController controller;

  String text = "Hello World";

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    controller.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    const curve = Curves.ease;

    var tween =
        Tween<double>(begin: 100.0, end: 10).chain(CurveTween(curve: curve));

    return Center(
        child: Column(
      children: [
        ElevatedButton(
            onPressed: () {
              controller.forward();
            },
            child: const Text('Go!')),
        SizedBox(height: tween.evaluate(controller)),
        RedText(text: text)
      ],
    ));
  }
}

class RedText extends StatelessWidget {
  final String text;
  const RedText({super.key, required this.text});

  @override
  Widget build(BuildContext context) {
    //触发了很多次的build,因为每次的RedText都是重新创建的
    print('red text build2');
    return Container(
        color: Colors.redAccent,
        constraints: const BoxConstraints(maxHeight: 200, maxWidth: 100),
        padding: const EdgeInsets.symmetric(horizontal: 8),
        child: Text(text));
  }
}

在默认的情况下,flutter是setState为起点,将所有的子树都重新build一次。它缺少了React的shouldComponentUpdate的diff操作。

2.7.2 避免子树渲染

import 'dart:developer';

import 'package:flutter/material.dart';

class BasicWidget7_2 extends StatelessWidget {
  const BasicWidget7_2({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Page1();
  }
}

class ShouldRebuildWidget<T extends Widget> extends StatefulWidget {
  final bool Function(T oldWidget) shouldRebuild;

  final T Function() build;

  const ShouldRebuildWidget(
      {super.key, required this.shouldRebuild, required this.build});

  @override
  State<ShouldRebuildWidget<T>> createState() => _ShouldRebuildWidget<T>();
}

class _ShouldRebuildWidget<T extends Widget>
    extends State<ShouldRebuildWidget<T>> {
  T? _oldWidget;

  @override
  Widget build(BuildContext context) {
    var old = _oldWidget;
    if (old == null || widget.shouldRebuild(old)) {
      var newWidget = widget.build();
      _oldWidget = newWidget;
      return newWidget;
    }
    return old;
  }
}

class Page1 extends StatefulWidget {
  const Page1({super.key});

  @override
  State<StatefulWidget> createState() {
    return _Page1();
  }
}

class _Page1 extends State<Page1> with SingleTickerProviderStateMixin {
  late AnimationController controller;

  String text = "Hello World";

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    controller.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    const curve = Curves.ease;

    var tween =
        Tween<double>(begin: 100.0, end: 10).chain(CurveTween(curve: curve));

    return Center(
        child: Column(
      children: [
        ElevatedButton(
            onPressed: () {
              controller.forward();
            },
            child: const Text('Go!')),
        SizedBox(height: tween.evaluate(controller)),
        //使用缓存方式的Widget,可以避免渲染子组件
        ShouldRebuildWidget(
            build: () => RedText(text: text),
            shouldRebuild: (oldWidget) => oldWidget.text != text)
      ],
    ));
  }
}

class RedText extends StatelessWidget {
  final String text;
  const RedText({super.key, required this.text});

  @override
  Widget build(BuildContext context) {
    //触发了很多次的build,因为每次的RedText都是重新创建的
    print('red text build4');
    return Container(
        color: Colors.redAccent,
        constraints: const BoxConstraints(maxHeight: 200, maxWidth: 100),
        padding: const EdgeInsets.symmetric(horizontal: 8),
        child: Text(text));
  }
}

要点如下:

  • 我们可以复用widget的引用来避免子树render。
  • 这是利用了flutter在diff的过程中,使用Widget引用比较的原理。当Widget的引用不变的时候,整个Widget就能避免重新build的操作。
  • 注意。在js中并不能实现类似的方法,因为js的引用不变,不代表它的子字段不变。但是在dart语言中,当class是immutable标注的时候,它的所有字段都必须是final的。这意味着class的引用不变,它的所有字段,包括子字段都肯定是不变的。这个实现挺好的,NICE。

3 UI基础组件

代码在这里

3.1 Text

import 'package:flutter/material.dart';

class TextDemo extends StatelessWidget {
  const TextDemo({
    Key? key,
  }) : super(key: key);

  final TextStyle bold24Roboto = const TextStyle(
    color: Colors.red,
    fontSize: 18.0,
    fontWeight: FontWeight.bold,
  );

  // 声明文本样式
  final robotoTextStyle = const TextStyle(
    fontFamily: 'Roboto',
  );

  final robotoTextItalicStyle = const TextStyle(
    fontFamily: 'Roboto',
    fontStyle: FontStyle.italic,
  );

  final robotoTextBoldStyle = const TextStyle(
    fontFamily: 'Roboto',
    fontWeight: FontWeight.w500,
  );

  final qingKeHuangyouTextStyle = const TextStyle(
    fontFamily: 'QingKeHuangyou',
  );

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(children: [
      //文本对齐
      const Text(
        "Hello world",
        textAlign: TextAlign.left,
      ),
      //多行文本
      const Text(
        "Hello world! I'm Jack. ",
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
      //斜体,加粗,字体
      Text(
        "我是Robo字体,普通. ",
        style: robotoTextStyle,
      ),
      Text(
        "我是Robo字体,斜体. ",
        style: robotoTextItalicStyle,
      ),
      Text(
        "我是Robo字体,加粗. ",
        style: robotoTextBoldStyle,
      ),
      Text(
        "我是QingKeHuangyou字体,普通. ",
        style: qingKeHuangyouTextStyle,
      ),
      //样式
      Text(
        "Hello world",
        style: TextStyle(
            color: Colors.blue,
            fontSize: 18.0,
            //行高,是倍数,高度为fontSize*height
            height: 1.2,
            fontFamily: "Courier",
            background: Paint()..color = Colors.yellow,
            decoration: TextDecoration.underline,
            decorationStyle: TextDecorationStyle.dashed),
      ),
      //多文本组合
      RichText(
        text: TextSpan(
          style: bold24Roboto,
          children: const <TextSpan>[
            TextSpan(text: 'Lorem '),
            TextSpan(
              text: 'ipsum',
              style: TextStyle(
                fontWeight: FontWeight.w300,
                fontStyle: FontStyle.italic,
                color: Colors.green,
                fontSize: 48,
              ),
            ),
          ],
        ),
      ),
      //继承文本样式
      const DefaultTextStyle(
          //1.设置文本默认样式
          style: TextStyle(
            color: Colors.purple,
            fontSize: 20.0,
          ),
          textAlign: TextAlign.start,
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text("hello world"),
              Text("I am Jack"),
              Text(
                "I am Jack",
                style: TextStyle(
                    inherit: false, //2.不继承默认样式

                    color: Colors.grey),
              ),
            ],
          )),
    ]));
  }
}

样式如上,没啥好说的。RichText下面放入多个TextSpan就能实现富文本。

3.2 Button

import 'package:flutter/material.dart';

class ButtonDemo extends StatelessWidget {
  const ButtonDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(children: [
      //漂浮"按钮,它默认带有阴影和灰色背景。按
      ElevatedButton(
        child: const Text("normal"),
        onPressed: () {},
      ),
      //文本按钮
      TextButton(
        child: const Text("normal"),
        onPressed: () {},
      ),
      //普通边框按钮
      OutlinedButton(
        child: const Text("normal"),
        onPressed: () {},
      ),
      //带图标按钮
      IconButton(
        icon: const Icon(Icons.thumb_up),
        onPressed: () {},
      ),
      //图片与文字按钮
      ElevatedButton.icon(
        icon: const Icon(Icons.send),
        label: const Text("发送"),
        onPressed: () {},
      ),
      OutlinedButton.icon(
        icon: const Icon(Icons.add),
        label: const Text("添加"),
        onPressed: () {},
      ),
      TextButton.icon(
        icon: const Icon(Icons.info),
        label: const Text("详情"),
        onPressed: () {},
      ),
    ]));
  }
}

按钮也没啥好说的,比较简单。Button准确来说属于Material的组件,不是flutter的自带组件。

3.3 Image

import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart';
import 'package:cached_network_image/cached_network_image.dart';

class ImageDemo extends StatelessWidget {
  const ImageDemo({
    Key? key,
  }) : super(key: key);

  final img = const AssetImage("assets/images/star.webp");

  List<Widget> _buildNetwork() {
    return [
      const Text("image load type..."),
      Image.network(
        "https://picsum.photos/250?image=9",
        width: 100.0,
      ),
      Image.asset(
        "assets/images/star.webp",
        width: 100.0,
      ),
      //占位图和淡入效果
      FadeInImage.memoryNetwork(
        placeholder: kTransparentImage,
        image: 'https://picsum.photos/250?image=9',
      ),
      //cachednetworkimage组件的缓存系统可以将图片缓存到内存和磁盘,并基于使用模式自动清理过期的缓存条目,
      CachedNetworkImage(
        placeholder: (context, url) => const CircularProgressIndicator(),
        imageUrl: 'https://picsum.photos/250?image=9',
      ),
    ];
  }

  List<Widget> _buildImageFill() {
    var widgets = [
      Image(
        image: img,
        height: 50.0,
        width: 100.0,
        fit: BoxFit.fill,
      ),
      Image(
        image: img,
        height: 50,
        width: 50.0,
        fit: BoxFit.contain,
      ),
      Image(
        image: img,
        width: 100.0,
        height: 50.0,
        fit: BoxFit.cover,
      ),
      Image(
        image: img,
        width: 100.0,
        height: 50.0,
        fit: BoxFit.fitWidth,
      ),
      Image(
        image: img,
        width: 100.0,
        height: 50.0,
        fit: BoxFit.fitHeight,
      ),
      Image(
        image: img,
        width: 100.0,
        height: 50.0,
        fit: BoxFit.scaleDown,
      ),
      Image(
        image: img,
        height: 50.0,
        width: 100.0,
        fit: BoxFit.none,
      ),
      Image(
        image: img,
        width: 100.0,
        color: Colors.blue,
        //颜色反色,高对比度
        colorBlendMode: BlendMode.difference,
        fit: BoxFit.fill,
      ),
      Image(
        image: img,
        width: 100.0,
        height: 200.0,
        repeat: ImageRepeat.repeatY,
      )
    ].map((e) {
      return Row(
        children: <Widget>[
          Container(
            decoration: BoxDecoration(
                border: Border.all(width: 1, color: Colors.black)),
            margin: const EdgeInsets.all(16.0),
            child: SizedBox(
              width: 100,
              child: e,
            ),
          ),
          Text(e.fit.toString())
        ],
      );
    }).toList();
    return [const Text("image fit type..."), ...widgets];
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
        child: Column(children: [
      ..._buildNetwork(),
      ..._buildImageFill(),
    ]));
  }
}

image也是没啥好说的,注意一下BoxFit的选择就可以了。

3.4 Icon

import 'package:flutter/material.dart';
import 'package:transparent_image/transparent_image.dart';
import 'package:cached_network_image/cached_network_image.dart';

class IconDemo extends StatelessWidget {
  const IconDemo({
    Key? key,
  }) : super(key: key);

  Widget _buildMaterialIcon() {
    //https://material.io/tools/icons/,在这里可以查到所有的Material icon
    String icons = "";
    // accessible: 0xe03e
    icons += "\uE03e";
    // error:  0xe237
    icons += " \uE237";
    // fingerprint: 0xe287
    icons += " \uE287";

    return Text(icons,
        style: const TextStyle(
          fontFamily: "MaterialIcons",
          fontSize: 24.0,
          color: Colors.green,
        ));
  }

  Widget _buildMaterialIcon2() {
    return const Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(Icons.accessible, color: Colors.green),
        Icon(Icons.error, color: Colors.green),
        Icon(Icons.fingerprint, color: Colors.green),
        Text("icon2"),
      ],
    );
  }

  // book 图标
  final IconData like =
      const IconData(0xe61d, fontFamily: 'myIcon', matchTextDirection: true);
  // 微信图标
  final IconData home =
      const IconData(0xe61e, fontFamily: 'myIcon', matchTextDirection: true);

  Widget _buildMyIcon() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Icon(like, color: Colors.green),
        Icon(home, color: Colors.green),
        const Text("myIcon3"),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
        child: Column(children: [
      _buildMaterialIcon(),
      _buildMaterialIcon2(),
      _buildMyIcon(),
    ]));
  }
}

Icon也是比较简单,注意可以插入自己的图标。

flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true
  fonts:
    - family: Roboto
      fonts:
        - asset: assets/fonts/Roboto-Regular.ttf
        - asset: assets/fonts/Roboto-Medium.ttf
          weight: 500
        - asset: assets/fonts/Roboto-Thin.ttf
          weight: 300
        - asset: assets/fonts/Roboto-Italic.ttf
          style: italic
    - family: QingKeHuangyou
      fonts:
        - asset: assets/fonts/ZCOOLQingKeHuangyou-Regular.ttf
    - family: myIcon
      fonts:
        - asset: assets/icons/iconfont.ttf

在pubspec.yaml中,配置加入自己的字体,命名为myIcon。然后IconData中指定fontFamily和icon对应的unicode值就可以了。

3.5 TextEdit

文本输入框相对较为复杂,需要掌握

3.5.1 样式

import 'package:flutter/material.dart';

class TextEditStyleDemo extends StatelessWidget {
  const TextEditStyleDemo({
    Key? key,
  }) : super(key: key);

  List<Widget> _buildNormalTextField() {
    //边框样式
    const _outlineInputBorder = OutlineInputBorder(
      borderRadius: BorderRadius.zero,
      gapPadding: 0,
      borderSide: BorderSide(
        color: Colors.blue,
      ),
    );

    return [
      const TextField(
        autofocus: true,
        decoration: InputDecoration(
            labelText: "用户名",
            hintText: "用户名或邮箱",
            prefixIcon: Icon(Icons.person)),
      ),
      const TextField(
        decoration: InputDecoration(
            labelText: "密码", hintText: "您的登录密码", prefixIcon: Icon(Icons.lock)),
        //密码
        obscureText: true,
      ),
      const TextField(
        //仅输入数字的输入框,其他的还有
        //TextInputType.datetime
        //TextInputType.emailAddress
        keyboardType: TextInputType.number,
        decoration: InputDecoration(labelText: "年龄", hintText: "您的年龄"),
      ),
      TextField(
        //键盘的输入类型
        textInputAction: TextInputAction.search,
        decoration: const InputDecoration(labelText: "搜索", hintText: "查询"),
        onSubmitted: (data) {
          print('submit! ${data}');
        },
      ),
      const TextField(
        //多行文本
        maxLines: 3,
        decoration: InputDecoration(labelText: "内容"),
      ),
      const TextField(
        //自定义样式
        style: TextStyle(
          color: Colors.blue,
          fontSize: 18.0,
          //行高,是倍数,高度为fontSize*height
          height: 1.2,
          fontFamily: "Courier",
        ),
        decoration: InputDecoration(
          labelText: "手机",
          //labelStyle,未选中的时候,标签样式
          labelStyle: TextStyle(color: Colors.blue, fontSize: 15.0),
          //floatingLabelStyle,选中的时候,标签的样式
          floatingLabelStyle: TextStyle(color: Colors.pink, fontSize: 12.0),
          //图标和前缀文字
          prefixIcon: Icon(Icons.phone),
          prefixText: "+86",
          //placeholder的名称,选中为焦点的时候才会出现
          hintText: "请输入手机号码",
          hintStyle: TextStyle(color: Colors.purple, fontSize: 13.0),
          // 未获得焦点下划线设为黄色
          enabledBorder: UnderlineInputBorder(
            borderSide: BorderSide(color: Colors.yellow),
          ),
          //获得焦点下划线设为绿色
          focusedBorder: UnderlineInputBorder(
            borderSide: BorderSide(color: Colors.green),
          ),
        ),
      ),
      const TextField(
          //常用样式配置
          style: TextStyle(
            color: Colors.red,
            fontSize: 15,
            height: 1,
          ),
          decoration: InputDecoration(
            fillColor: Colors.lightGreen, //背景颜色,必须结合filled: true,才有效
            filled: true, //重点,必须设置为true,fillColor才有效
            isCollapsed: true, //重点,相当于高度包裹的意思,必须设置为true,不然有默认奇妙的最小高度
            contentPadding:
                EdgeInsets.symmetric(vertical: 0, horizontal: 0), //内容内边距,影响高度
            hintText: "电子邮件地址",
            border: _outlineInputBorder,
            focusedBorder: _outlineInputBorder,
            enabledBorder: _outlineInputBorder,
            disabledBorder: _outlineInputBorder,
            errorBorder: _outlineInputBorder,
            focusedErrorBorder: _outlineInputBorder,
          ))
    ];
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
        child: Column(children: [
      ..._buildNormalTextField(),
    ]));
  }
}

要点如下:

  • 输入框的配置主要通过InputDecoration来实现。
  • InputDecoration中的isCollapsed设置了才能去掉默认的padding高度
  • height是行高,指fontSize的倍数,不是一个绝对值

3.5.2 事件

import 'package:flutter/material.dart';

class TextEditChangedAndSetDemo extends StatelessWidget {
  const TextEditChangedAndSetDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Retrieve Text Input',
      home: Scaffold(body: SafeArea(child: MyCustomForm())),
    );
  }
}

// Define a custom Form widget.
class MyCustomForm extends StatefulWidget {
  const MyCustomForm({super.key});

  @override
  State<MyCustomForm> createState() => _MyCustomFormState();
}

class _MyCustomFormState extends State<MyCustomForm> {
  final myController = TextEditingController();

  @override
  void initState() {
    super.initState();
    myController.addListener(_printLatestValue);
  }

  @override
  void dispose() {
    myController.dispose();
    super.dispose();
  }

  void _printLatestValue() {
    final text = myController.text;
    print('Second text field: $text (${text.characters.length})');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Retrieve Text Input'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              onChanged: (text) {
                print('First text field: $text (${text.characters.length})');
              },
            ),
            TextField(
              controller: myController,
            ),
            ElevatedButton(
              child: const Text('设置第二个TextField'),
              onPressed: () {
                //设置默认值,并从第三个字符开始选中后面的字符
                myController.text = "hello world!";
                myController.selection = TextSelection(
                    baseOffset: 2, extentOffset: myController.text.length);
              },
            )
          ],
        ),
      ),
    );
  }
}

要点如下:

  • 使用TextEditingController,来获取text,设置text,设置text的selection
  • 使用TextEditingController,来侦听text的变化

3.5.3 焦点

import 'package:flutter/material.dart';

class TextEditFocus1 extends StatefulWidget {
  @override
  State<TextEditFocus1> createState() => _TextEditFocus1();
}

class _TextEditFocus1 extends State<TextEditFocus1> {
  FocusNode focusNode1 = FocusNode();
  FocusNode focusNode2 = FocusNode();
  FocusScopeNode? focusScopeNode;

  @override
  void initState() {
    super.initState();

    //监听焦点状态改变事件
    focusNode1.addListener(() {
      print('focusNode1 focus info: [${focusNode1.hasFocus}]');
    });

    focusNode2.addListener(() {
      print('focusNode1 focus info: [${focusNode2.hasFocus}]');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(16.0),
      child: Column(
        children: <Widget>[
          TextField(
            autofocus: true,
            focusNode: focusNode1, //关联focusNode1
            decoration: const InputDecoration(labelText: "input1"),
          ),
          TextField(
            focusNode: focusNode2, //关联focusNode2
            decoration: const InputDecoration(labelText: "input2"),
          ),
          Builder(
            builder: (ctx) {
              return Column(
                children: <Widget>[
                  ElevatedButton(
                    child: Text("移动焦点"),
                    onPressed: () {
                      //将焦点从第一个TextField移到第二个TextField
                      // 这是一种写法 FocusScope.of(context).requestFocus(focusNode2);
                      // 这是第二种写法
                      var a = focusScopeNode ?? FocusScope.of(context);
                      focusScopeNode = a;
                      a.requestFocus(focusNode2);
                    },
                  ),
                  ElevatedButton(
                    child: Text("隐藏键盘"),
                    onPressed: () {
                      // 当所有编辑框都失去焦点时键盘就会收起
                      focusNode1.unfocus();
                      focusNode2.unfocus();
                    },
                  ),
                ],
              );
            },
          ),
        ],
      ),
    );
  }
}

要点如下:

  • 给TextField指定focusNode
  • 移动焦点的时候,使用focusNode.focus,或者focusNode.unfocus.

3.5.4 焦点2

import 'package:flutter/material.dart';

class TextEditFocus2 extends StatefulWidget {
  @override
  State<TextEditFocus2> createState() => _TextEditFocus2();
}

class _TextEditFocus2 extends State<TextEditFocus2> {
  FocusNode focusNode1 = FocusNode();
  FocusNode focusNode2 = FocusNode();
  final FocusScopeNode focusScopeNode = FocusScopeNode();

  @override
  void initState() {
    super.initState();

    //监听焦点状态改变事件
    focusNode1.addListener(() {
      print('focusNode1 focus info: [${focusNode1.hasFocus}]');
    });

    focusNode2.addListener(() {
      print('focusNode1 focus info: [${focusNode2.hasFocus}]');
    });
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
        padding: EdgeInsets.all(16.0),
        child: FocusScope(
          node: focusScopeNode,
          child: Column(
            children: <Widget>[
              TextField(
                autofocus: true,
                focusNode: focusNode1, //关联focusNode1
                decoration: const InputDecoration(labelText: "input1"),
              ),
              TextField(
                focusNode: focusNode2, //关联focusNode2
                decoration: const InputDecoration(labelText: "input2"),
              ),
              Builder(
                builder: (ctx) {
                  return Column(
                    children: <Widget>[
                      ElevatedButton(
                        child: const Text("下一个焦点"),
                        onPressed: () {
                          focusScopeNode.nextFocus();
                        },
                      ),
                      ElevatedButton(
                        child: const Text("上一个焦点"),
                        onPressed: () {
                          focusScopeNode.previousFocus();
                        },
                      ),
                      ElevatedButton(
                        child: const Text("取消焦点"),
                        onPressed: () {
                          focusScopeNode.unfocus();
                        },
                      )
                    ],
                  );
                },
              ),
            ],
          ),
        ));
  }
}

要点如下:

  • 直接使用FocusScopeNode.nextFocus,previousFocus, unfocus就可以进行焦点操作了。
  • 而且FocusScopeNode可以以某几个TextEdit作范围来指定。

4 UI布局

代码在这里

4.1 Flex

4.1.1 主轴direction和交叉轴direction

import 'package:flutter/material.dart';

class RowAxisAndDirectionDemo extends StatelessWidget {
  const RowAxisAndDirectionDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Column(
      //测试Row对齐方式,排除Column默认居中对齐的干扰
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(" hello world "),
            Text(" I am Jack "),
          ],
        ),
        Row(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(" hello world "),
            Text(" I am Jack "),
          ],
        ),
        Row(
          //mainAxisAlignment是主轴对齐方向
          mainAxisAlignment: MainAxisAlignment.end,
          //textDirection是水平方向的对齐方式,只有ltr,和rtl两种方式。一般不需要配置
          textDirection: TextDirection.rtl,
          children: <Widget>[
            Text(" hello world "),
            Text(" I am Jack "),
          ],
        ),
        Row(
          //crossAxisAlignment是交叉轴对齐方向
          crossAxisAlignment: CrossAxisAlignment.start,
          //verticalDirection是垂直方向对齐方式,有up和down,两种方式。一般不需要配置
          verticalDirection: VerticalDirection.up,
          children: <Widget>[
            Text(
              " hello world ",
              style: TextStyle(fontSize: 30.0),
            ),
            Text(" I am Jack "),
          ],
        ),
      ],
    );
  }
}

没啥好说的,比较简单

4.1.2 主轴size和交叉轴排列

import 'package:flutter/material.dart';

class ColumnStretchAndMainSizeDemo extends StatelessWidget {
  const ColumnStretchAndMainSizeDemo({
    Key? key,
  }) : super(key: key);

  Widget _buildColumn_mainAxisSize_min() {
    return SizedBox(
      width: 300,
      height: 300,
      child: Container(
          padding: const EdgeInsets.all(16.0),
          color: const Color(0xffdddddd),
          child: Align(
            alignment: Alignment.topLeft,
            child: Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.red, width: 1),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  //maxAxisSize仅当它是loose约束才有效
                  //默认值是min,也就是取子控件的宽高的合并
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    Container(
                      color: Colors.green,
                      child: const Column(
                        children: <Widget>[
                          Text("hello world "),
                          Text("I am Jack "),
                        ],
                      ),
                    )
                  ],
                )),
          )),
    );
  }

  Widget _buildColumn_mainAxisSize_max() {
    return SizedBox(
      width: 300,
      height: 300,
      child: Container(
          padding: const EdgeInsets.all(16.0),
          color: const Color(0xffdddddd),
          child: Align(
            alignment: Alignment.topLeft,
            child: Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.red, width: 1),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  //maxAxisSize仅当它是loose约束才有效
                  //当取max的时候,就是取maxHeight,也就是取父控件的高度
                  mainAxisSize: MainAxisSize.max,
                  children: <Widget>[
                    Container(
                      color: Colors.green,
                      child: const Column(
                        children: <Widget>[
                          Text("hello world "),
                          Text("I am Jack "),
                        ],
                      ),
                    )
                  ],
                )),
          )),
    );
  }

  Widget _buildColumn_mainAxisSize_sub_max() {
    return SizedBox(
      width: 300,
      height: 300,
      child: Container(
          padding: const EdgeInsets.all(16.0),
          color: const Color(0xffdddddd),
          child: Align(
            alignment: Alignment.topLeft,
            child: Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.red, width: 1),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.max,
                  children: <Widget>[
                    Container(
                      color: Colors.green,
                      child: const Column(
                        //Column下面的子Column取max是没有用的,因为row/column下面的约束是Unbounded的,maxHeight = 无穷
                        //子控件,无法使用MainAxisSize.max,只能取子控件的最大值
                        mainAxisSize: MainAxisSize.max,
                        children: <Widget>[
                          Text("hello world "),
                          Text("I am Jack "),
                        ],
                      ),
                    )
                  ],
                )),
          )),
    );
  }

  Widget _buildColumn_mainAxisSize_sub_max_fix() {
    return SizedBox(
      width: 300,
      height: 300,
      child: Container(
          padding: const EdgeInsets.all(16.0),
          color: const Color(0xffdddddd),
          child: Align(
            alignment: Alignment.topLeft,
            child: Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.red, width: 1),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.max,
                  children: <Widget>[
                    //一个简单的办法就是:
                    //Column下面的子Column放在Expanded里面,会得到tight约束,高度就是父Column的高度
                    //子控件,无需使用MainAxisSize.max,因为height是tight约束,无法更改的。
                    Expanded(
                        child: Container(
                      color: Colors.green,
                      child: const Column(
                        children: <Widget>[
                          Text("hello world "),
                          Text("I am Jack "),
                        ],
                      ),
                    ))
                  ],
                )),
          )),
    );
  }

  Widget _buildColumn_mainAxisSize_sub_max_fix2() {
    return SizedBox(
      width: 300,
      height: 300,
      child: Container(
          padding: const EdgeInsets.all(16.0),
          color: const Color(0xffdddddd),
          child: Align(
            alignment: Alignment.topLeft,
            child: Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.red, width: 1),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.max,
                  children: <Widget>[
                    //另外一个办法就是:
                    //Column下面的子Column放在Expanded里面,会得到tight约束,
                    //同时使用Align,将tight约束转换为loose约束
                    //这个时候的子Column就可以使用MainAxisSize.max/MainAxisSize.min来控制自身的高度
                    //这种方法很少用,甚至没有必要,仅仅是演示效果而已。
                    Expanded(
                        child: Align(
                            //注意,没有widthFactor,没有heightFactor的时候,宽高取父的宽高。存在的时候,取子宽高的比例放大。
                            widthFactor: 1,
                            heightFactor: 1,
                            alignment: Alignment.topLeft,
                            child: Container(
                              color: Colors.green,
                              child: const Column(
                                mainAxisSize: MainAxisSize.max,
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: <Widget>[
                                  Text("hello world "),
                                  Text("I am Jack "),
                                ],
                              ),
                            )))
                  ],
                )),
          )),
    );
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
        child: Column(
      children: [
        _buildColumn_mainAxisSize_min(),
        const SizedBox(height: 20),
        _buildColumn_mainAxisSize_max(),
        const SizedBox(height: 20),
        _buildColumn_mainAxisSize_sub_max(),
        const SizedBox(height: 20),
        _buildColumn_mainAxisSize_sub_max_fix(),
        const SizedBox(height: 20),
        _buildColumn_mainAxisSize_sub_max_fix2(),
      ],
    ));
  }
}

要点如下:

  • 当flex的大小是tight约束,设置mainAxisSize是无效的。
  • 当flex的大小是loose约束,mainAxisSize的默认是是min,也就是取子空间的宽高合并。如果mainAxisSize的值为max,就是取父控件的宽高
  • flex的子控件没有Expanded的时候都是Unbounded约束,所以子控件设置MainAxisSize.max是无效的。
  • flex的子控件有Expanded的时候都是tight约束,所以子控件设置MainAxisSize.max是无效的。
  • flex的子控件是Expanded的时候,且再套一层Align,才是loose约束。这个时候,子控件设置MainAxisSize.max才是有效的。

4.1.3 主轴stretch

import 'package:flutter/material.dart';

class flexAndExpandedDemo extends StatelessWidget {
  const flexAndExpandedDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        //Flex的两个子widget按1:2来占据水平空间
        Flex(
          direction: Axis.horizontal,
          children: <Widget>[
            Expanded(
              flex: 1,
              child: Container(
                height: 30.0,
                color: Colors.red,
              ),
            ),
            Expanded(
              flex: 2,
              child: Container(
                height: 30.0,
                color: Colors.green,
              ),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.only(top: 20.0),
          child: SizedBox(
            height: 100.0,
            //Flex的三个子widget,在垂直方向按2:1:1来占用100像素的空间
            child: Flex(
              direction: Axis.vertical,
              children: <Widget>[
                Expanded(
                  flex: 2,
                  child: Container(
                    height: 30.0,
                    color: Colors.red,
                  ),
                ),
                //相当于Expanded(flex:1)
                const Spacer(
                  flex: 1,
                ),
                Expanded(
                  flex: 1,
                  child: Container(
                    height: 30.0,
                    color: Colors.green,
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

要点如下:

  • Expanded取的是剩余主轴大小,子控件是tight约束
  • Spacer相当于Expanded(flex:1)

4.1.4 Wrap

import 'package:flutter/material.dart';

class flexWrapDemo extends StatelessWidget {
  const flexWrapDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //Wrap就是允许wrap的flex而已,flex的属性它都允许使用
    return const Align(
        alignment: Alignment.topCenter,
        child: Wrap(
          spacing: 8.0, // 主轴(水平)方向间距
          runSpacing: 4.0, // 纵轴(垂直)方向间距
          alignment: WrapAlignment.center, //沿主轴方向居中
          children: <Widget>[
            Chip(
              avatar:
                  CircleAvatar(backgroundColor: Colors.blue, child: Text('A')),
              label: Text('Hamilton'),
            ),
            Chip(
              avatar:
                  CircleAvatar(backgroundColor: Colors.blue, child: Text('M')),
              label: Text('Lafayette'),
            ),
            Chip(
              avatar:
                  CircleAvatar(backgroundColor: Colors.blue, child: Text('H')),
              label: Text('Mulligan'),
            ),
            Chip(
              avatar:
                  CircleAvatar(backgroundColor: Colors.blue, child: Text('J')),
              label: Text('Laurens'),
            ),
          ],
        ));
  }
}

没啥好说的,比较简单,比较神奇的是。Wrap不是flex的一个属性,是一个新的布局组件。

4.2 Flow

4.2.1 基础

import 'package:flutter/material.dart';

/*
https://book.flutterchina.club/chapter4/wrap_and_flow.html#_4-5-1-wrap
我们一般很少会使用Flow,因为其过于复杂,需要自己实现子 widget 的位置转换,在很多场景下首先要考虑的是Wrap是否满足需求。Flow主要用于一些需要自定义布局策略或性能要求较高(如动画中)的场景。Flow有如下优点:

性能好;Flow是一个对子组件尺寸以及位置调整非常高效的控件,Flow用转换矩阵在对子组件进行位置调整的时候进行了优化:在Flow定位过后,如果子组件的尺寸或者位置发生了变化,在FlowDelegate中的paintChildren()方法中调用context.paintChild 进行重绘,而context.paintChild在重绘时使用了转换矩阵,并没有实际调整组件位置。
灵活;由于我们需要自己实现FlowDelegate的paintChildren()方法,所以我们需要自己计算每一个组件的位置,因此,可以自定义布局策略。
缺点:

使用复杂。
Flow 不能自适应子组件大小,必须通过指定父容器大小或实现TestFlowDelegate的getSize返回固定大小。
*/
class FlowBasicDemo extends StatelessWidget {
  const FlowBasicDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //Wrap就是允许wrap的flex而已,flex的属性它都允许使用
    return Flow(
      delegate: TestFlowDelegate(margin: EdgeInsets.all(10.0)),
      children: <Widget>[
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.red,
        ),
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.green,
        ),
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.blue,
        ),
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.yellow,
        ),
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.brown,
        ),
        Container(
          width: 80.0,
          height: 80.0,
          color: Colors.purple,
        ),
      ],
    );
  }
}

class TestFlowDelegate extends FlowDelegate {
  EdgeInsets margin;

  TestFlowDelegate({this.margin = EdgeInsets.zero});

  double width = 0;
  double height = 0;

  @override
  void paintChildren(FlowPaintingContext context) {
    var x = margin.left;
    var y = margin.top;
    //计算每一个子widget的位置
    for (int i = 0; i < context.childCount; i++) {
      var w = context.getChildSize(i)!.width + x + margin.right;
      if (w < context.size.width) {
        context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
        x = w + margin.left;
      } else {
        x = margin.left;
        y += context.getChildSize(i)!.height + margin.top + margin.bottom;
        //绘制子widget(有优化)
        context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
        x += context.getChildSize(i)!.width + margin.left + margin.right;
      }
    }
  }

  @override
  Size getSize(BoxConstraints constraints) {
    // 指定Flow的大小,简单起见我们让宽度尽可能大,但高度指定为200,
    // 实际开发中我们需要根据子元素所占用的具体宽高来设置Flow大小
    return Size(double.infinity, 200.0);
  }

  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}

Flow其实是一个自定义的布局组件,可以看这里。我们一般很少会使用Flow,因为其过于复杂,需要自己实现子 widget 的位置转换,在很多场景下首先要考虑的是Wrap是否满足需求。Flow主要用于一些需要自定义布局策略或性能要求较高(如动画中)的场景。Flow有如下优点:

  • 性能好;Flow是一个对子组件尺寸以及位置调整非常高效的控件,Flow用转换矩阵在对子组件进行位置调整的时候进行了优化:在Flow定位过后,如果子组件的尺寸或者位置发生了变化,在FlowDelegate中的paintChildren()方法中调用context.paintChild 进行重绘,而context.paintChild在重绘时使用了转换矩阵,并没有实际调整组件位置。
  • 灵活;由于我们需要自己实现FlowDelegate的paintChildren()方法,所以我们需要自己计算每一个组件的位置,因此,可以自定义布局策略。

缺点:

  • 使用复杂。
  • Flow 不能自适应子组件大小,必须通过指定父容器大小或实现TestFlowDelegate的getSize返回固定大小。

4.2.2 parallel效果

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

//https://docs.flutter.dev/cookbook/effects/parallax-scrolling
/*
该Demo比较复杂,使用了两种方法来实现Parallax,包含了以下内容
* 使用Flow来布局
* 1. 在FlowDelegate中,获取滚动条位置,获取item相对屏幕和滚动区域的位置,然后使用paintChild的transform来转换显示
* 2. Flow布局仅修改了paint阶段,在layout阶段没有任何操作,所以不需要额外侦听scroll的事件
* 使用Parallel控件来实
* 1. 侦听scroll的事件,当scroll事件发生的时候,触发当前widget为dirty.
* 2. 在dirty里面的layout阶段,重写performLayout操作,将子widget进行transform
* 3. 在paint阶段,也进行了重写
*/
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);

class FlowParallaxDemo extends StatelessWidget {
  const FlowParallaxDemo({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          for (final location in locations)
            LocationListItem(
              imageUrl: location.imageUrl,
              name: location.name,
              country: location.place,
            ),
        ],
      ),
    );
  }
}

class LocationListItem extends StatelessWidget {
  LocationListItem({
    super.key,
    required this.imageUrl,
    required this.name,
    required this.country,
  });

  final String imageUrl;
  final String name;
  final String country;
  final GlobalKey _backgroundImageKey = GlobalKey();

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
      child: AspectRatio(
        aspectRatio: 16 / 9,
        child: ClipRRect(
          borderRadius: BorderRadius.circular(16),
          child: Stack(
            children: [
              _buildParallaxBackground(context),
              _buildGradient(),
              _buildTitleAndSubtitle(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildParallaxBackground(BuildContext context) {
    return Flow(
      delegate: ParallaxFlowDelegate(
        scrollable: Scrollable.of(context),
        listItemContext: context,
        backgroundImageKey: _backgroundImageKey,
      ),
      children: [
        Image.network(
          imageUrl,
          key: _backgroundImageKey,
          fit: BoxFit.cover,
        ),
      ],
    );
  }

  Widget _buildGradient() {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Colors.black.withOpacity(0.7)],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.6, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle() {
    return Positioned(
      left: 20,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            name,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 20,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(
            country,
            style: const TextStyle(
              color: Colors.white,
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }
}

class ParallaxFlowDelegate extends FlowDelegate {
  ParallaxFlowDelegate({
    required this.scrollable,
    required this.listItemContext,
    required this.backgroundImageKey,
  }) : super(repaint: scrollable.position);

  final ScrollableState scrollable;
  final BuildContext listItemContext;
  final GlobalKey backgroundImageKey;

  @override
  BoxConstraints getConstraintsForChild(int i, BoxConstraints constraints) {
    return BoxConstraints.tightFor(
      width: constraints.maxWidth,
    );
  }

  @override
  void paintChildren(FlowPaintingContext context) {
    // Calculate the position of this list item within the viewport.
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final listItemBox = listItemContext.findRenderObject() as RenderBox;
    final listItemOffset = listItemBox.localToGlobal(
        listItemBox.size.centerLeft(Offset.zero),
        ancestor: scrollableBox);

    // Determine the percent position of this list item within the
    // scrollable area.
    final viewportDimension = scrollable.position.viewportDimension;
    final scrollFraction =
        (listItemOffset.dy / viewportDimension).clamp(0.0, 1.0);

    // Calculate the vertical alignment of the background
    // based on the scroll percent.
    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    // Convert the background alignment into a pixel offset for
    // painting purposes.
    final backgroundSize =
        (backgroundImageKey.currentContext!.findRenderObject() as RenderBox)
            .size;
    final listItemSize = context.size;
    final childRect =
        verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

    // Paint the background.
    context.paintChild(
      0,
      transform:
          Transform.translate(offset: Offset(0.0, childRect.top)).transform,
    );
  }

  @override
  bool shouldRepaint(ParallaxFlowDelegate oldDelegate) {
    return scrollable != oldDelegate.scrollable ||
        listItemContext != oldDelegate.listItemContext ||
        backgroundImageKey != oldDelegate.backgroundImageKey;
  }
}

class Parallax extends SingleChildRenderObjectWidget {
  const Parallax({
    super.key,
    required Widget background,
  }) : super(child: background);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return RenderParallax(scrollable: Scrollable.of(context));
  }

  @override
  void updateRenderObject(
      BuildContext context, covariant RenderParallax renderObject) {
    renderObject.scrollable = Scrollable.of(context);
  }
}

class ParallaxParentData extends ContainerBoxParentData<RenderBox> {}

class RenderParallax extends RenderBox
    with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin {
  RenderParallax({
    required ScrollableState scrollable,
  }) : _scrollable = scrollable;

  ScrollableState _scrollable;

  ScrollableState get scrollable => _scrollable;

  set scrollable(ScrollableState value) {
    if (value != _scrollable) {
      if (attached) {
        _scrollable.position.removeListener(markNeedsLayout);
      }
      _scrollable = value;
      if (attached) {
        _scrollable.position.addListener(markNeedsLayout);
      }
    }
  }

  @override
  void attach(covariant PipelineOwner owner) {
    super.attach(owner);
    _scrollable.position.addListener(markNeedsLayout);
  }

  @override
  void detach() {
    _scrollable.position.removeListener(markNeedsLayout);
    super.detach();
  }

  @override
  void setupParentData(covariant RenderObject child) {
    if (child.parentData is! ParallaxParentData) {
      child.parentData = ParallaxParentData();
    }
  }

  @override
  void performLayout() {
    size = constraints.biggest;

    // Force the background to take up all available width
    // and then scale its height based on the image's aspect ratio.
    final background = child!;
    final backgroundImageConstraints =
        BoxConstraints.tightFor(width: size.width);
    background.layout(backgroundImageConstraints, parentUsesSize: true);

    // Set the background's local offset, which is zero.
    (background.parentData as ParallaxParentData).offset = Offset.zero;
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // Get the size of the scrollable area.
    final viewportDimension = scrollable.position.viewportDimension;

    // Calculate the global position of this list item.
    final scrollableBox = scrollable.context.findRenderObject() as RenderBox;
    final backgroundOffset =
        localToGlobal(size.centerLeft(Offset.zero), ancestor: scrollableBox);

    // Determine the percent position of this list item within the
    // scrollable area.
    final scrollFraction =
        (backgroundOffset.dy / viewportDimension).clamp(0.0, 1.0);

    // Calculate the vertical alignment of the background
    // based on the scroll percent.
    final verticalAlignment = Alignment(0.0, scrollFraction * 2 - 1);

    // Convert the background alignment into a pixel offset for
    // painting purposes.
    final background = child!;
    final backgroundSize = background.size;
    final listItemSize = size;
    final childRect =
        verticalAlignment.inscribe(backgroundSize, Offset.zero & listItemSize);

    // Paint the background.
    context.paintChild(
        background,
        (background.parentData as ParallaxParentData).offset +
            offset +
            Offset(0.0, childRect.top));
  }
}

class Location {
  const Location({
    required this.name,
    required this.place,
    required this.imageUrl,
  });

  final String name;
  final String place;
  final String imageUrl;
}

const urlPrefix =
    'https://docs.flutter.dev/cookbook/img-files/effects/parallax';
const locations = [
  Location(
    name: 'Mount Rushmore',
    place: 'U.S.A',
    imageUrl: '$urlPrefix/01-mount-rushmore.jpg',
  ),
  Location(
    name: 'Gardens By The Bay',
    place: 'Singapore',
    imageUrl: '$urlPrefix/02-singapore.jpg',
  ),
  Location(
    name: 'Machu Picchu',
    place: 'Peru',
    imageUrl: '$urlPrefix/03-machu-picchu.jpg',
  ),
  Location(
    name: 'Vitznau',
    place: 'Switzerland',
    imageUrl: '$urlPrefix/04-vitznau.jpg',
  ),
  Location(
    name: 'Bali',
    place: 'Indonesia',
    imageUrl: '$urlPrefix/05-bali.jpg',
  ),
  Location(
    name: 'Mexico City',
    place: 'Mexico',
    imageUrl: '$urlPrefix/06-mexico-city.jpg',
  ),
  Location(
    name: 'Cairo',
    place: 'Egypt',
    imageUrl: '$urlPrefix/07-cairo.jpg',
  ),
];

具体解释看这里。该Demo比较复杂,会涉及到滚动条的处理,flow的处理,布局约束等知识,建议看完整个文档以后,再回头看这里。

它使用了两种方法来实现Parallax,包含了以下内容

使用Flow来布局

    1. 在FlowDelegate中,获取滚动条位置,获取item相对屏幕和滚动区域的位置,然后使用paintChild的transform来转换显示
    1. Flow布局仅修改了paint阶段,在layout阶段没有任何操作,所以不需要额外侦听scroll的事件

使用Parallel控件来实

    1. 侦听scroll的事件,当scroll事件发生的时候,触发当前widget为dirty.
    1. 在dirty里面的layout阶段,重写performLayout操作,将子widget进行transform
    1. 在paint阶段,也进行了重写

4.3 Stack

4.3.1 基础

import 'package:flutter/material.dart';

class StackNormalDemo extends StatelessWidget {
  const StackNormalDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints.expand(),
      child: Stack(
        //默认在放在center的位置
        alignment: Alignment.center, //指定未定位或部分定位widget的对齐方式
        children: <Widget>[
          Container(
            //都在center
            color: Colors.red,
            child: const Text("Hello world",
                style: TextStyle(color: Colors.white)),
          ),
          const Positioned(
            //left为18,加上垂直居中
            left: 18.0,
            child: Text("I am Jack"),
          ),
          const Positioned(
            //top为18,加上水平居中
            top: 18.0,
            child: Text("Your friend"),
          )
        ],
      ),
    );
  }
}

要点如下:

  • Stack就是html5中的absolute布局
  • 默认情况下,未指定的轴,都是放在center的位置。可以通过修改alignment来更改这个默认设定。

4.3.2 fit

import 'package:flutter/material.dart';

class StackFitDemo extends StatelessWidget {
  const StackFitDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints.expand(),
      child: Stack(
        //默认放在中间
        alignment: Alignment.center,
        //没有left,没有top的占满整个空间
        fit: StackFit.expand,
        children: <Widget>[
          //left为18,加上垂直居中
          const Positioned(
            left: 18.0,
            child: Text("I am Jack"),
          ),
          //占满整个空间
          Container(
            color: Colors.red,
            child: const Text("Hello world",
                style: TextStyle(color: Colors.white)),
          ),
          //top为18,加上水平居中
          const Positioned(
            top: 18.0,
            child: Text("Your friend"),
          )
        ],
      ),
    );
  }
}

我们也可以通过设置fit,来让没有布局的控件默认覆盖整个控件。

4.4 Align

import 'package:flutter/material.dart';

class AlignAndCenterDemo extends StatelessWidget {
  const AlignAndCenterDemo({
    Key? key,
  }) : super(key: key);

  /*
  * Alignment的偏移计算公式,常用于Widget内部的偏移,以childWidth中间点来偏移。Alignment(this.x, this.y),x和y常取-1和1,0.
  实际偏移 = (Alignment.x * (parentWidth - childWidth) / 2 + (parentWidth - childWidth) / 2,
        Alignment.y * (parentHeight - childHeight) / 2 + (parentHeight - childHeight) / 2)
  */
  Widget _buildAlign() {
    return Container(
      height: 120.0,
      width: 120.0,
      color: Colors.blue.shade50,
      child: const Align(
        alignment: Alignment.topRight,
        child: FlutterLogo(
          size: 60,
        ),
      ),
    );
  }

  /*
  * FractionalOffset 以矩形左侧原点来偏移。
  实际偏移 = (FractionalOffse.x * (parentWidth - childWidth), FractionalOffse.y * (parentHeight - childHeight))
  */
  Widget _buildAlign2() {
    return Container(
      height: 120.0,
      width: 120.0,
      color: Colors.blue.shade50,
      child: const Align(
        alignment: FractionalOffset(0.2, 0.6),
        child: FlutterLogo(
          size: 60,
        ),
      ),
    );
  }

  //center其实就是Align
  Widget _buildCenter() {
    return Container(
      height: 120.0,
      width: 120.0,
      color: Colors.blue.shade50,
      child: const Center(
        child: FlutterLogo(
          size: 60,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints.expand(),
      child: Column(
        children: [
          _buildAlign(),
          const SizedBox(height: 20),
          _buildAlign2(),
          const SizedBox(height: 20),
          _buildCenter(),
        ],
      ),
    );
  }
}

Alignment的偏移计算公式,常用于Widget内部的偏移,以childWidth中间点来偏移。Alignment(this.x, this.y),x和y常取-1和1,0.
实际偏移 = (Alignment.x * (parentWidth - childWidth) / 2 + (parentWidth - childWidth) / 2,Alignment.y * (parentHeight - childHeight) / 2 + (parentHeight - childHeight) / 2)

FractionalOffset 以矩形左侧原点来偏移。
实际偏移 = (FractionalOffse.x * (parentWidth - childWidth), FractionalOffse.y * (parentHeight - childHeight))

要点如下:

  • Align是特有的组件,align/center的实际作用是将tight约束转换为loose约束,这个在《布局约束》这一节有详细介绍。
  • Align的Alignment是以childWidget中间点来计算偏移的。
  • Align的FractionalOffset是以childWidget的左上角原点来计算偏移的。
  • Center其实就是默认在中间的Align节点而已。

4.5 LayoutBuilder

import 'package:flutter/material.dart';

//使用LayoutBuilder来做响应式布局。
//LayoutBuilder可以在运行时获取constraint,根据不同的constraint来做布局
class ResponsiveColumn extends StatelessWidget {
  const ResponsiveColumn({Key? key, required this.children}) : super(key: key);

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    // 通过 LayoutBuilder 拿到父组件传递的约束,然后判断 maxWidth 是否小于200
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth < 200) {
          // 最大宽度小于200,显示单列
          return Column(mainAxisSize: MainAxisSize.min, children: children);
        } else {
          // 大于200,显示双列
          var widgetChildren = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
            if (i + 1 < children.length) {
              widgetChildren.add(Row(
                mainAxisSize: MainAxisSize.min,
                children: [children[i], children[i + 1]],
              ));
            } else {
              widgetChildren.add(children[i]);
            }
          }
          return Column(
              mainAxisSize: MainAxisSize.min, children: widgetChildren);
        }
      },
    );
  }
}

//LayoutBuilder也可以用作运行时的布局调试
class LayoutLogPrint extends StatelessWidget {
  const LayoutLogPrint({
    Key? key,
    required this.child,
  }) : super(key: key);

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (_, constraints) {
      // assert在编译release版本时会被去除
      assert(() {
        print('${key ?? child}: $constraints');
        return true;
      }());
      return child;
    });
  }
}

class LayoutBuilderDemo extends StatelessWidget {
  const LayoutBuilderDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints.expand(),
      child: Column(
        children: [
          Container(
              color: Colors.blue,
              child: const ResponsiveColumn(children: [
                Text("Fish "),
                Text("HelloWorld"),
              ])),
          const SizedBox(height: 20),
          SizedBox(
            width: 180,
            height: 180,
            child: Container(
                color: Colors.green,
                child: const ResponsiveColumn(children: [
                  Text("Fish "),
                  Text("HelloWorld"),
                ])),
          ),
          const SizedBox(height: 20),
          Container(
              color: Colors.yellow,
              child: const LayoutLogPrint(child: Text("uu")))
        ],
      ),
    );
  }
}

LayoutBuilder是相当有用的组件,可以在运行时拿到父级别的constraint来进行按需布局,常用于:

  • 响应式布局
  • 调试布局的时候,打印布局约束的数据

5 UI布局约束

代码在这里

布局约束是flutter的第一大难点,需要重点掌握

5.1 布局约束

import 'package:flutter/material.dart';

const red = Colors.red;
const green = Colors.green;
const blue = Colors.blue;
const big = TextStyle(fontSize: 30);

//////////////////////////////////////////////////

class ConstraintBoxDemo extends StatelessWidget {
  const ConstraintBoxDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return const FlutterLayoutArticle([
      Example1(),
      Example2(),
      Example3(),
      Example4(),
      Example5(),
      Example6(),
      Example7(),
      Example8(),
      Example9(),
      Example10(),
      Example11(),
      Example12(),
      Example13(),
      Example14(),
      Example15(),
      Example16(),
      Example17(),
      Example18(),
      Example19(),
      Example20(),
      Example21(),
      Example22(),
      Example23(),
      Example24(),
      Example25(),
      Example26(),
      Example27(),
      Example28(),
      Example29(),
      Example30(),
    ]);
  }
}

//////////////////////////////////////////////////

abstract class Example extends StatelessWidget {
  const Example({super.key});

  String get code;

  String get explanation;
}

//////////////////////////////////////////////////

class FlutterLayoutArticle extends StatefulWidget {
  const FlutterLayoutArticle(
    this.examples, {
    super.key,
  });

  final List<Example> examples;

  @override
  State<FlutterLayoutArticle> createState() => _FlutterLayoutArticleState();
}

//////////////////////////////////////////////////

class _FlutterLayoutArticleState extends State<FlutterLayoutArticle> {
  late int count;
  late Widget example;
  late String code;
  late String explanation;

  @override
  void initState() {
    count = 1;
    code = const Example1().code;
    explanation = const Example1().explanation;

    super.initState();
  }

  @override
  void didUpdateWidget(FlutterLayoutArticle oldWidget) {
    super.didUpdateWidget(oldWidget);
    var example = widget.examples[count - 1];
    code = example.code;
    explanation = example.explanation;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Layout Article',
      home: SafeArea(
        child: Material(
          color: Colors.black,
          child: FittedBox(
            child: Container(
              width: 400,
              height: 670,
              color: const Color(0xFFCCCCCC),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  Expanded(
                      child: ConstrainedBox(
                          constraints: const BoxConstraints.tightFor(
                              width: double.infinity, height: double.infinity),
                          child: widget.examples[count - 1])),
                  Container(
                    height: 50,
                    width: double.infinity,
                    color: Colors.black,
                    child: SingleChildScrollView(
                      scrollDirection: Axis.horizontal,
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          for (int i = 0; i < widget.examples.length; i++)
                            Container(
                              width: 58,
                              padding: const EdgeInsets.only(left: 4, right: 4),
                              child: button(i + 1),
                            ),
                        ],
                      ),
                    ),
                  ),
                  Container(
                    height: 273,
                    color: Colors.grey[50],
                    child: Scrollbar(
                      child: SingleChildScrollView(
                        key: ValueKey(count),
                        child: Padding(
                          padding: const EdgeInsets.all(10),
                          child: Column(
                            children: [
                              Center(child: Text(code)),
                              const SizedBox(height: 15),
                              Text(
                                explanation,
                                style: TextStyle(
                                    color: Colors.blue[900],
                                    fontStyle: FontStyle.italic),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget button(int exampleNumber) {
    return Button(
      key: ValueKey('button$exampleNumber'),
      isSelected: count == exampleNumber,
      exampleNumber: exampleNumber,
      onPressed: () {
        showExample(
          exampleNumber,
          widget.examples[exampleNumber - 1].code,
          widget.examples[exampleNumber - 1].explanation,
        );
      },
    );
  }

  void showExample(int exampleNumber, String code, String explanation) {
    setState(() {
      count = exampleNumber;
      this.code = code;
      this.explanation = explanation;
    });
  }
}

//////////////////////////////////////////////////

class Button extends StatelessWidget {
  final bool isSelected;
  final int exampleNumber;
  final VoidCallback onPressed;

  const Button({
    super.key,
    required this.isSelected,
    required this.exampleNumber,
    required this.onPressed,
  });

  @override
  Widget build(BuildContext context) {
    return TextButton(
      style: TextButton.styleFrom(
        foregroundColor: Colors.white,
        backgroundColor: isSelected ? Colors.grey : Colors.grey[800],
      ),
      child: Text(exampleNumber.toString()),
      onPressed: () {
        Scrollable.ensureVisible(
          context,
          duration: const Duration(milliseconds: 350),
          curve: Curves.easeOut,
          alignment: 0.5,
        );
        onPressed();
      },
    );
  }
}
//////////////////////////////////////////////////

class Example1 extends Example {
  const Example1({super.key});

  @override
  final code = 'Container(color: red)';

  @override
  final explanation = 'The screen is the parent of the Container, '
      'and it forces the Container to be exactly the same size as the screen.'
      '\n\n'
      'So the Container fills the screen and paints it red.';

  @override
  Widget build(BuildContext context) {
    return Container(color: red);
  }
}

//////////////////////////////////////////////////

class Example2 extends Example {
  const Example2({super.key});

  @override
  final code = 'Container(width: 100, height: 100, color: red)';
  @override
  final String explanation =
      'The red Container wants to be 100x100, but it can\'t, '
      'because the screen forces it to be exactly the same size as the screen.'
      '\n\n'
      'So the Container fills the screen.';

  @override
  Widget build(BuildContext context) {
    return Container(width: 100, height: 100, color: red);
  }
}

//////////////////////////////////////////////////

class Example3 extends Example {
  const Example3({super.key});

  @override
  final code = 'Center(\n'
      '   child: Container(width: 100, height: 100, color: red))';
  @override
  final String explanation =
      'The screen forces the Center to be exactly the same size as the screen, '
      'so the Center fills the screen.'
      '\n\n'
      'The Center tells the Container that it can be any size it wants, but not bigger than the screen.'
      'Now the Container can indeed be 100x100.';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(width: 100, height: 100, color: red),
    );
  }
}

//////////////////////////////////////////////////

class Example4 extends Example {
  const Example4({super.key});

  @override
  final code = 'Align(\n'
      '   alignment: Alignment.bottomRight,\n'
      '   child: Container(width: 100, height: 100, color: red))';
  @override
  final String explanation =
      'This is different from the previous example in that it uses Align instead of Center.'
      '\n\n'
      'Align also tells the Container that it can be any size it wants, but if there is empty space it won\'t center the Container. '
      'Instead, it aligns the Container to the bottom-right of the available space.';

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomRight,
      child: Container(width: 100, height: 100, color: red),
    );
  }
}

//////////////////////////////////////////////////

class Example5 extends Example {
  const Example5({super.key});

  @override
  final code = 'Center(\n'
      '   child: Container(\n'
      '              color: red,\n'
      '              width: double.infinity,\n'
      '              height: double.infinity))';
  @override
  final String explanation =
      'The screen forces the Center to be exactly the same size as the screen, '
      'so the Center fills the screen.'
      '\n\n'
      'The Center tells the Container that it can be any size it wants, but not bigger than the screen.'
      'The Container wants to be of infinite size, but since it can\'t be bigger than the screen, it just fills the screen.';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
          width: double.infinity, height: double.infinity, color: red),
    );
  }
}

//////////////////////////////////////////////////

class Example6 extends Example {
  const Example6({super.key});

  @override
  final code = 'Center(child: Container(color: red))';
  @override
  final String explanation =
      'The screen forces the Center to be exactly the same size as the screen, '
      'so the Center fills the screen.'
      '\n\n'
      'The Center tells the Container that it can be any size it wants, but not bigger than the screen.'
      '\n\n'
      'Since the Container has no child and no fixed size, it decides it wants to be as big as possible, so it fills the whole screen.'
      '\n\n'
      'But why does the Container decide that? '
      'Simply because that\'s a design decision by those who created the Container widget. '
      'It could have been created differently, and you have to read the Container documentation to understand how it behaves, depending on the circumstances. ';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(color: red),
    );
  }
}

//////////////////////////////////////////////////

class Example7 extends Example {
  const Example7({super.key});

  @override
  final code = 'Center(\n'
      '   child: Container(color: red\n'
      '      child: Container(color: green, width: 30, height: 30)))';
  @override
  final String explanation =
      'The screen forces the Center to be exactly the same size as the screen, '
      'so the Center fills the screen.'
      '\n\n'
      'The Center tells the red Container that it can be any size it wants, but not bigger than the screen.'
      'Since the red Container has no size but has a child, it decides it wants to be the same size as its child.'
      '\n\n'
      'The red Container tells its child that it can be any size it wants, but not bigger than the screen.'
      '\n\n'
      'The child is a green Container that wants to be 30x30.'
      '\n\n'
      'Since the red `Container` has no size but has a child, it decides it wants to be the same size as its child. '
      'The red color isn\'t visible, since the green Container entirely covers all of the red Container.';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        color: red,
        child: Container(color: green, width: 30, height: 30),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example8 extends Example {
  const Example8({super.key});

  @override
  final code = 'Center(\n'
      '   child: Container(color: red\n'
      '      padding: const EdgeInsets.all(20),\n'
      '      child: Container(color: green, width: 30, height: 30)))';
  @override
  final String explanation =
      'The red Container sizes itself to its children size, but it takes its own padding into consideration. '
      'So it is also 30x30 plus padding. '
      'The red color is visible because of the padding, and the green Container has the same size as in the previous example.';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        padding: const EdgeInsets.all(20),
        color: red,
        child: Container(color: green, width: 30, height: 30),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example9 extends Example {
  const Example9({super.key});

  @override
  final code = 'ConstrainedBox(\n'
      '   constraints: BoxConstraints(\n'
      '              minWidth: 70, minHeight: 70,\n'
      '              maxWidth: 150, maxHeight: 150),\n'
      '      child: Container(color: red, width: 10, height: 10)))';
  @override
  final String explanation =
      'You might guess that the Container has to be between 70 and 150 pixels, but you would be wrong. '
      'The ConstrainedBox only imposes ADDITIONAL constraints from those it receives from its parent.'
      '\n\n'
      'Here, the screen forces the ConstrainedBox to be exactly the same size as the screen, '
      'so it tells its child Container to also assume the size of the screen, '
      'thus ignoring its \'constraints\' parameter.';

  @override
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: const BoxConstraints(
        minWidth: 70,
        minHeight: 70,
        maxWidth: 150,
        maxHeight: 150,
      ),
      child: Container(color: red, width: 10, height: 10),
    );
  }
}

//////////////////////////////////////////////////

class Example10 extends Example {
  const Example10({super.key});

  @override
  final code = 'Center(\n'
      '   child: ConstrainedBox(\n'
      '      constraints: BoxConstraints(\n'
      '                 minWidth: 70, minHeight: 70,\n'
      '                 maxWidth: 150, maxHeight: 150),\n'
      '        child: Container(color: red, width: 10, height: 10))))';
  @override
  final String explanation =
      'Now, Center allows ConstrainedBox to be any size up to the screen size.'
      '\n\n'
      'The ConstrainedBox imposes ADDITIONAL constraints from its \'constraints\' parameter onto its child.'
      '\n\n'
      'The Container must be between 70 and 150 pixels. It wants to have 10 pixels, so it will end up having 70 (the MINIMUM).';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          minWidth: 70,
          minHeight: 70,
          maxWidth: 150,
          maxHeight: 150,
        ),
        child: Container(color: red, width: 10, height: 10),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example11 extends Example {
  const Example11({super.key});

  @override
  final code = 'Center(\n'
      '   child: ConstrainedBox(\n'
      '      constraints: BoxConstraints(\n'
      '                 minWidth: 70, minHeight: 70,\n'
      '                 maxWidth: 150, maxHeight: 150),\n'
      '        child: Container(color: red, width: 1000, height: 1000))))';
  @override
  final String explanation =
      'Center allows ConstrainedBox to be any size up to the screen size.'
      'The ConstrainedBox imposes ADDITIONAL constraints from its \'constraints\' parameter onto its child'
      '\n\n'
      'The Container must be between 70 and 150 pixels. It wants to have 1000 pixels, so it ends up having 150 (the MAXIMUM).';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          minWidth: 70,
          minHeight: 70,
          maxWidth: 150,
          maxHeight: 150,
        ),
        child: Container(color: red, width: 1000, height: 1000),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example12 extends Example {
  const Example12({super.key});

  @override
  final code = 'Center(\n'
      '   child: ConstrainedBox(\n'
      '      constraints: BoxConstraints(\n'
      '                 minWidth: 70, minHeight: 70,\n'
      '                 maxWidth: 150, maxHeight: 150),\n'
      '        child: Container(color: red, width: 100, height: 100))))';
  @override
  final String explanation =
      'Center allows ConstrainedBox to be any size up to the screen size.'
      'ConstrainedBox imposes ADDITIONAL constraints from its \'constraints\' parameter onto its child.'
      '\n\n'
      'The Container must be between 70 and 150 pixels. It wants to have 100 pixels, and that\'s the size it has, since that\'s between 70 and 150.';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          minWidth: 70,
          minHeight: 70,
          maxWidth: 150,
          maxHeight: 150,
        ),
        child: Container(color: red, width: 100, height: 100),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example13 extends Example {
  const Example13({super.key});

  @override
  final code = 'UnconstrainedBox(\n'
      '   child: Container(color: red, width: 20, height: 50));';
  @override
  final String explanation =
      'The screen forces the UnconstrainedBox to be exactly the same size as the screen.'
      'However, the UnconstrainedBox lets its child Container be any size it wants.';

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      child: Container(color: red, width: 20, height: 50),
    );
  }
}

//////////////////////////////////////////////////

class Example14 extends Example {
  const Example14({super.key});

  @override
  final code = 'UnconstrainedBox(\n'
      '   child: Container(color: red, width: 4000, height: 50));';
  @override
  final String explanation =
      'The screen forces the UnconstrainedBox to be exactly the same size as the screen, '
      'and UnconstrainedBox lets its child Container be any size it wants.'
      '\n\n'
      'Unfortunately, in this case the Container has 4000 pixels of width and is too big to fit in the UnconstrainedBox, '
      'so the UnconstrainedBox displays the much dreaded "overflow warning".';

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      child: Container(color: red, width: 4000, height: 50),
    );
  }
}

//////////////////////////////////////////////////

class Example15 extends Example {
  const Example15({super.key});

  @override
  final code = 'OverflowBox(\n'
      '   minWidth: 0,'
      '   minHeight: 0,'
      '   maxWidth: double.infinity,'
      '   maxHeight: double.infinity,'
      '   child: Container(color: red, width: 4000, height: 50));';
  @override
  final String explanation =
      'The screen forces the OverflowBox to be exactly the same size as the screen, '
      'and OverflowBox lets its child Container be any size it wants.'
      '\n\n'
      'OverflowBox is similar to UnconstrainedBox, and the difference is that it won\'t display any warnings if the child doesn\'t fit the space.'
      '\n\n'
      'In this case the Container is 4000 pixels wide, and is too big to fit in the OverflowBox, '
      'but the OverflowBox simply shows as much as it can, with no warnings given.';

  @override
  Widget build(BuildContext context) {
    return OverflowBox(
      minWidth: 0,
      minHeight: 0,
      maxWidth: double.infinity,
      maxHeight: double.infinity,
      child: Container(color: red, width: 4000, height: 50),
    );
  }
}

//////////////////////////////////////////////////

class Example16 extends Example {
  const Example16({super.key});

  @override
  final code = 'UnconstrainedBox(\n'
      '   child: Container(color: Colors.red, width: double.infinity, height: 100));';
  @override
  final String explanation =
      'This won\'t render anything, and you\'ll see an error in the console.'
      '\n\n'
      'The UnconstrainedBox lets its child be any size it wants, '
      'however its child is a Container with infinite size.'
      '\n\n'
      'Flutter can\'t render infinite sizes, so it throws an error with the following message: '
      '"BoxConstraints forces an infinite width."';

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      child: Container(color: Colors.red, width: double.infinity, height: 100),
    );
  }
}

//////////////////////////////////////////////////

class Example17 extends Example {
  const Example17({super.key});

  @override
  final code = 'UnconstrainedBox(\n'
      '   child: LimitedBox(maxWidth: 100,\n'
      '      child: Container(color: Colors.red,\n'
      '                       width: double.infinity, height: 100));';
  @override
  final String explanation = 'Here you won\'t get an error anymore, '
      'because when the LimitedBox is given an infinite size by the UnconstrainedBox, '
      'it passes a maximum width of 100 down to its child.'
      '\n\n'
      'If you swap the UnconstrainedBox for a Center widget, '
      'the LimitedBox won\'t apply its limit anymore (since its limit is only applied when it gets infinite constraints), '
      'and the width of the Container is allowed to grow past 100.'
      '\n\n'
      'This explains the difference between a LimitedBox and a ConstrainedBox.';

  @override
  Widget build(BuildContext context) {
    return UnconstrainedBox(
      child: LimitedBox(
        maxWidth: 100,
        child: Container(
          color: Colors.red,
          width: double.infinity,
          height: 100,
        ),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example18 extends Example {
  const Example18({super.key});

  @override
  final code = 'FittedBox(\n'
      '   child: Text(\'Some Example Text.\'));';
  @override
  final String explanation =
      'The screen forces the FittedBox to be exactly the same size as the screen.'
      'The Text has some natural width (also called its intrinsic width) that depends on the amount of text, its font size, and so on.'
      '\n\n'
      'The FittedBox lets the Text be any size it wants, '
      'but after the Text tells its size to the FittedBox, '
      'the FittedBox scales the Text until it fills all of the available width.';

  @override
  Widget build(BuildContext context) {
    return const FittedBox(
      child: Text('Some Example Text.'),
    );
  }
}

//////////////////////////////////////////////////

class Example19 extends Example {
  const Example19({super.key});

  @override
  final code = 'Center(\n'
      '   child: FittedBox(\n'
      '      child: Text(\'Some Example Text.\')));';
  @override
  final String explanation =
      'But what happens if you put the FittedBox inside of a Center widget? '
      'The Center lets the FittedBox be any size it wants, up to the screen size.'
      '\n\n'
      'The FittedBox then sizes itself to the Text, and lets the Text be any size it wants.'
      '\n\n'
      'Since both FittedBox and the Text have the same size, no scaling happens.';

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: FittedBox(
        child: Text('Some Example Text.'),
      ),
    );
  }
}

////////////////////////////////////////////////////

class Example20 extends Example {
  const Example20({super.key});

  @override
  final code = 'Center(\n'
      '   child: FittedBox(\n'
      '      child: Text(\'\')));';
  @override
  final String explanation =
      'However, what happens if FittedBox is inside of a Center widget, but the Text is too large to fit the screen?'
      '\n\n'
      'FittedBox tries to size itself to the Text, but it can\'t be bigger than the screen. '
      'It then assumes the screen size, and resizes Text so that it fits the screen, too.';

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: FittedBox(
        child: Text(
            'This is some very very very large text that is too big to fit a regular screen in a single line.'),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example21 extends Example {
  const Example21({super.key});

  @override
  final code = 'Center(\n'
      '   child: Text(\'\'));';
  @override
  final String explanation = 'If, however, you remove the FittedBox, '
      'the Text gets its maximum width from the screen, '
      'and breaks the line so that it fits the screen.';

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text(
          'This is some very very very large text that is too big to fit a regular screen in a single line.'),
    );
  }
}

//////////////////////////////////////////////////

class Example22 extends Example {
  const Example22({super.key});

  @override
  final code = 'FittedBox(\n'
      '   child: Container(\n'
      '      height: 20, width: double.infinity));';
  @override
  final String explanation =
      'FittedBox can only scale a widget that is BOUNDED (has non-infinite width and height).'
      'Otherwise, it won\'t render anything, and you\'ll see an error in the console.';

  @override
  Widget build(BuildContext context) {
    return FittedBox(
      child: Container(
        height: 20,
        width: double.infinity,
        color: Colors.red,
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example23 extends Example {
  const Example23({super.key});

  @override
  final code = 'Row(children:[\n'
      '   Container(color: red, child: Text(\'Hello!\'))\n'
      '   Container(color: green, child: Text(\'Goodbye!\'))]';
  @override
  final String explanation =
      'The screen forces the Row to be exactly the same size as the screen.'
      '\n\n'
      'Just like an UnconstrainedBox, the Row won\'t impose any constraints onto its children, '
      'and instead lets them be any size they want.'
      '\n\n'
      'The Row then puts them side-by-side, and any extra space remains empty.';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Container(color: red, child: const Text('Hello!', style: big)),
        Container(color: green, child: const Text('Goodbye!', style: big)),
      ],
    );
  }
}

//////////////////////////////////////////////////

class Example24 extends Example {
  const Example24({super.key});

  @override
  final code = 'Row(children:[\n'
      '   Container(color: red, child: Text(\'\'))\n'
      '   Container(color: green, child: Text(\'Goodbye!\'))]';
  @override
  final String explanation =
      'Since the Row won\'t impose any constraints onto its children, '
      'it\'s quite possible that the children might be too big to fit the available width of the Row.'
      'In this case, just like an UnconstrainedBox, the Row displays the "overflow warning".';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Container(
          color: red,
          child: const Text(
            'This is a very long text that '
            'won\'t fit the line.',
            style: big,
          ),
        ),
        Container(color: green, child: const Text('Goodbye!', style: big)),
      ],
    );
  }
}

//////////////////////////////////////////////////

class Example25 extends Example {
  const Example25({super.key});

  @override
  final code = 'Row(children:[\n'
      '   Expanded(\n'
      '       child: Container(color: red, child: Text(\'\')))\n'
      '   Container(color: green, child: Text(\'Goodbye!\'))]';
  @override
  final String explanation =
      'When a Row\'s child is wrapped in an Expanded widget, the Row won\'t let this child define its own width anymore.'
      '\n\n'
      'Instead, it defines the Expanded width according to the other children, and only then the Expanded widget forces the original child to have the Expanded\'s width.'
      '\n\n'
      'In other words, once you use Expanded, the original child\'s width becomes irrelevant, and is ignored.';

  @override
  Widget build(BuildContext context) {
    return Row(children: [
      Expanded(
        child: Center(
          child: Container(
            color: red,
            child: const Text(
              'This is a very long text that won\'t fit the line.',
              style: big,
            ),
          ),
        ),
      ),
      Container(color: green, child: const Text('Goodbye!', style: big)),
    ]);
  }
}

//////////////////////////////////////////////////

class Example26 extends Example {
  const Example26({super.key});

  @override
  final code = 'Row(children:[\n'
      '   Expanded(\n'
      '       child: Container(color: red, child: Text(\'\')))\n'
      '   Expanded(\n'
      '       child: Container(color: green, child: Text(\'Goodbye!\'))]';
  @override
  final String explanation =
      'If all of Row\'s children are wrapped in Expanded widgets, each Expanded has a size proportional to its flex parameter, '
      'and only then each Expanded widget forces its child to have the Expanded\'s width.'
      '\n\n'
      'In other words, Expanded ignores the preffered width of its children.';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Expanded(
          child: Container(
            color: red,
            child: const Text(
              'This is a very long text that won\'t fit the line.',
              style: big,
            ),
          ),
        ),
        Expanded(
          child: Container(
            color: green,
            child: const Text(
              'Goodbye!',
              style: big,
            ),
          ),
        ),
      ],
    );
  }
}

//////////////////////////////////////////////////

class Example27 extends Example {
  const Example27({super.key});

  @override
  final code = 'Row(children:[\n'
      '   Flexible(\n'
      '       child: Container(color: red, child: Text(\'\')))\n'
      '   Flexible(\n'
      '       child: Container(color: green, child: Text(\'Goodbye!\'))]';
  @override
  final String explanation =
      'The only difference if you use Flexible instead of Expanded, '
      'is that Flexible lets its child be SMALLER than the Flexible width, '
      'while Expanded forces its child to have the same width of the Expanded.'
      '\n\n'
      'But both Expanded and Flexible ignore their children\'s width when sizing themselves.'
      '\n\n'
      'This means that it\'s IMPOSSIBLE to expand Row children proportionally to their sizes. '
      'The Row either uses the exact child\'s width, or ignores it completely when you use Expanded or Flexible.';

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Flexible(
          child: Container(
            color: red,
            child: const Text(
              'This is a very long text that won\'t fit the line.',
              style: big,
            ),
          ),
        ),
        Flexible(
          child: Container(
            color: green,
            child: const Text(
              'Goodbye!',
              style: big,
            ),
          ),
        ),
      ],
    );
  }
}

//////////////////////////////////////////////////

class Example28 extends Example {
  const Example28({super.key});

  @override
  final code = 'Scaffold(\n'
      '   body: Container(color: blue,\n'
      '   child: Column(\n'
      '      children: [\n'
      '         Text(\'Hello!\'),\n'
      '         Text(\'Goodbye!\')])))';

  @override
  final String explanation =
      'The screen forces the Scaffold to be exactly the same size as the screen, '
      'so the Scaffold fills the screen.'
      '\n\n'
      'The Scaffold tells the Container that it can be any size it wants, but not bigger than the screen.'
      '\n\n'
      'When a widget tells its child that it can be smaller than a certain size, '
      'we say the widget supplies "loose" constraints to its child. More on that later.';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: blue,
        child: const Column(
          children: [
            Text('Hello!'),
            Text('Goodbye!'),
          ],
        ),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example29 extends Example {
  const Example29({super.key});

  @override
  final code = 'Scaffold(\n'
      '   body: Container(color: blue,\n'
      '   child: SizedBox.expand(\n'
      '      child: Column(\n'
      '         children: [\n'
      '            Text(\'Hello!\'),\n'
      '            Text(\'Goodbye!\')]))))';

  @override
  final String explanation =
      'If you want the Scaffold\'s child to be exactly the same size as the Scaffold itself, '
      'you can wrap its child with SizedBox.expand.'
      '\n\n'
      'When a widget tells its child that it must be of a certain size, '
      'we say the widget supplies "tight" constraints to its child. More on that later.';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SizedBox.expand(
        child: Container(
          color: blue,
          child: const Column(
            children: [
              Text('Hello!'),
              Text('Goodbye!'),
            ],
          ),
        ),
      ),
    );
  }
}

//////////////////////////////////////////////////

class Example30 extends Example {
  const Example30({super.key});

  @override
  final code = '''
Scaffold(
  body: Container(
      color: Colors.blue,
      height: 150,
      width: 150,
      padding: const EdgeInsets.all(10),
      child: FractionallySizedBox(
          alignment: Alignment.topLeft,
          widthFactor: 1.5,
          heightFactor: 1.5,
          child: Container(
            color: Colors.red,
            child: const Column(
              children: [
                Text('Hello!'),
                Text('Goodbye!'),
              ],
            ),
          )))
''';

  @override
  final String explanation = '''
FractionallySizedBox的布局行为主要跟它的宽高因子两个参数有关,当参数为null或者有具体数值的时候,布局表现不一样。当然,还有一个辅助参数alignment,作为对齐方式进行布局。

当设置了具体的宽高因子,具体的宽高则根据现有空间宽高 * 因子,有可能会超出父控件的范围,当宽高因子大于1的时候;
当没有设置宽高因子,则填满可用区域;
''';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
          color: Colors.blue,
          height: 150,
          width: 150,
          padding: const EdgeInsets.all(10),
          child: FractionallySizedBox(
              alignment: Alignment.topLeft,
              widthFactor: 1.5,
              heightFactor: 1.5,
              child: Container(
                color: Colors.red,
                child: const Column(
                  children: [
                    Text('Hello!'),
                    Text('Goodbye!'),
                  ],
                ),
              ))),
    );
  }
}

参考资料

了解关键的几步:

Constraints go down. Sizes go up. Parent sets position.

其实就是Android measure, layout的过程。区别在于:

  • Android的measure是允许多次的,对子控件传入match_parent或者wrap_content的约束,从而测量出不同情况下的measure,然后再进行一次layout的操作。
  • Flutter的measure仅仅允许一次,然后子控件只返回一次width/height,最后就layout了。

当前widget的长宽必须在父级constraint的约束下进行得到。

  • 父级constraint不断往下传递。
  • widget结合自己设定(padding,margin等)+ 父级constraint,计算得到下级的constraint,并将这些下级的constraint传递给他们
  • 下级constraint结合自己的宽高(text,image)和constraint,得到宽高返回给widget。
  • widget得到子级宽高,合并计算得到自己的宽高。
  • 父级获得自己的宽高。

constraint的定义:

  • minWidth,maxWidth
  • minHeight,maxHeight

三种情况的Widget的constraints类型

  • Tight, minWidth = maxWidth && minHeight = maxHeight。这种情况下,子级需要计算自身的宽高,宽高必然为constraint
  • Loose, minWidth < maxWidth || minHeight < maxHeight。minWidth和minHeight允许为0,这种情况下,子级才有可能有自己设置宽高的可能性
  • Unbounded , maxWidth = 无穷 || maxHeight = 无穷。这种情况下,子级有设置自己宽高的巨大空间。
  • 但是,1.如果放在ListView是垂直的,(主轴或交叉轴)同时给他一个宽度Unbounded的约束,它就会抱怨无法排版。因为它不知道如何wrap每个item,或者如何显示滚动条
  •      2.在Column里面,(主轴)给他一个高度为Unbounded的约束,同时给一个Expandable的child,它就会抱怨无法排版,因为它不知道空白空间应该分配多少。
  •      3.在Column里面,(交叉轴)给它一个宽度为Unbounded的约束,同时给一个CrossAxisAlignment.stretch,它就会抱怨无法排版,因为无法计算交叉轴应该stretch到一个什么的数值。
  •      4.在Column里面,嵌套一个无Expandable的垂直ListView,它就抱怨无法排版。因为ListView只有高度确定的情况下才能按需显示元素。

实际Widget的常见constraints

  • MaterialApp,Tight约束,宽高都是就是Screen的宽高
  • Center/Align/Scaffold,
    1. 修改子约束,可以将Tight约束转换为Loose约束,不改变maxWidth和maxHeight,但是将minWidth和minHeight设置为0,以保证子组件可以设置自己的宽高。
    1. 确定自身宽高,没有widthFactor,没有heightFactor的时候,宽高取父的宽高。存在的时候,取子宽高的比例放大。
  • SizedBox.expand,将Loose约束转换为Tight约束。将minWidth和minHeight设置为对应的maxWidth和maxHeight。
  • SizedBox.shrink,将Loose约束转换为Tight约束。将maxWidth和maxHeight设置为对应的minWidth和minHeight。
  • SizedBox(),将Loose约束转换为Tight约束。将满足父约束的条件下,将min和max都一起收缩到指定的width和height。
  • Container,无Child的时候,宽高就是constraint的最大值,有Child的时候,宽高就是子Child在宽高(在constraint的计算下)。
  • BoxConstraints,不改变Tight和Loose,仅仅是在父constraint,的条件下加入自己的constraint(如果交集为空,且只取父级的constraint),然后传递到下一级。
  • UnconstrainedBox,Unbounded约束,minWidth = minHeight = 0,maxWidth=maxHeight = 无穷,如果子控件超出了父控件的渲染范围,就会报出overflow warning的错误。如果最终计算的子控件的宽高是无穷的话,就会取消渲染
  • OverflowBox约束,忽略父级约束,直接指定当前约束,如果子控件超出了父控件的渲染范围,也不会报错
  • LimitedBox,将父级的Unbounded约束转换为Loose或Tight约束,如果父级不是Unbounded约束,则不进行转换,常用于UnconstrainedBox下面。
  • FittedBox,将Loose或Tight约束转换为一个Unbounded约束,然后使用scale的手段来显示,返回一个满足上级约束的宽高。如果下级的宽高结果是Unbounded的话,则渲染错误error。
  • FractionallySizedBox,以父级的maxWidth和maxHeight为依据,乘以对应的widthFactor和heightFactor,得到一个tight约束,也就是子控件无法控制宽高。
  • Row/Column,传递下级是(主轴)Unbounded约束,(交叉轴)是将父级的任意约束转换为loose约束。
  • 1.可以使用Expanded来实现传递下级变为Tight约束,分配固定的空白空间。
  • 2.可以使用Flexible来实现传递下级变为Loose约束,maxWidth和maxHeight是空白空间,但是minWidth和minHeight允许为0。这样做的话,相对布局不确定。
  • 3.Expanded/Flexible不能同时与Row/Column自身的主轴是Unbounded约束结合,因为无法计算无穷的空白空间是多少。
  • 4.crossAxisAlignment: CrossAxisAlignment.stretch不能同时与Row/Column自身的交叉轴是Unbounded约束结合,因为无法计算交叉轴应该stretch到一个什么的数值。

5.2 ListView作为子控件

5.2.1 正常的ListView

import 'package:flutter/material.dart';

class ListViewDefaultDemo extends StatefulWidget {
  const ListViewDefaultDemo({
    super.key,
  });

  @override
  State<ListViewDefaultDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewDefaultDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return ListView(
        children: List.generate(100, (index) => Text("Text_${index + 1}")));
  }
}

没啥好说的,比较简单

5.2.2 ListView在Unbounded的宽度

import 'package:flutter/material.dart';

class ListViewInfiniteWidthDemo extends StatefulWidget {
  const ListViewInfiniteWidthDemo({
    super.key,
  });

  @override
  State<ListViewInfiniteWidthDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInfiniteWidthDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return OverflowBox(
        //RenderBox was not laid out报错,ListView需要有限的最大宽度
        maxWidth: double.infinity,
        child: ListView(
            children:
                List.generate(100, (index) => Text("Text_${index + 1}"))));
  }
}

RenderBox was not laid out报错,ListView需要有限的最大宽度

5.2.3 ListView在Unbounded的高度

import 'package:flutter/material.dart';

class ListViewInfiniteHeightDemo extends StatefulWidget {
  const ListViewInfiniteHeightDemo({
    super.key,
  });

  @override
  State<ListViewInfiniteHeightDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInfiniteHeightDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return OverflowBox(
        //RenderBox was not laid out报错,ListView需要有限的最大高度
        maxHeight: double.infinity,
        child: ListView(
            children:
                List.generate(100, (index) => Text("Text_${index + 1}"))));
  }
}

RenderBox was not laid out报错,ListView需要有限的最大高度

5.2.4 ListView在Column的子控件

import 'package:flutter/material.dart';

class ListViewInColumnDemo extends StatefulWidget {
  const ListViewInColumnDemo({
    super.key,
  });

  @override
  State<ListViewInColumnDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInColumnDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    // RenderBox was not laid out,报错
    //Column的每个child在主轴方向默认就是Unbounded约束,导致ListView无法排版
    return Column(children: [
      const Text("ListViewInColumnDemo"),
      ListView(
          children: List.generate(100, (index) => Text("Text_${index + 1}")))
    ]);
  }
}

RenderBox was not laid out,报错,Column的每个child在主轴方向默认就是Unbounded约束,导致ListView无法排版

5.2.5 ListView在Column的子控件修复

import 'package:flutter/material.dart';

class ListViewInColumnFixDemo extends StatefulWidget {
  const ListViewInColumnFixDemo({
    super.key,
  });

  @override
  State<ListViewInColumnFixDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInColumnFixDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    //Column的child设置为Expanded的话,会变成loose约束
    return Column(children: [
      const Text("ListViewInColumnFixDemo"),
      Expanded(
          child: ListView(
              children:
                  List.generate(100, (index) => Text("Text_${index + 1}"))))
    ]);
  }
}

Column的child设置为Expanded的话,会变成loose约束,传递给ListView的高度就是有限的,这个时候就可以排版了

5.2.6 ListView在Row的子控件

import 'package:flutter/material.dart';

class ListViewInRowDemo extends StatefulWidget {
  const ListViewInRowDemo({
    super.key,
  });

  @override
  State<ListViewInRowDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInRowDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    // RenderBox was not laid out,报错
    //Row的每个child在主轴方向默认就是Unbounded约束,导致ListView无法排版
    return Row(children: [
      const Text("ListViewInRowDemo"),
      ListView(
          children: List.generate(100, (index) => Text("Text_${index + 1}")))
    ]);
  }
}

RenderBox was not laid out,报错。Row的每个child在主轴方向默认就是Unbounded约束,导致ListView无法排版

5.2.7 ListView在Row的子控件修复

import 'package:flutter/material.dart';

class ListViewInRowFixDemo extends StatefulWidget {
  const ListViewInRowFixDemo({
    super.key,
  });

  @override
  State<ListViewInRowFixDemo> createState() => _HomePageState();
}

class _HomePageState extends State<ListViewInRowFixDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Row(children: [
      const Text("ListViewInRowFixDemo"),
      Expanded(
          child: ListView(
              children:
                  List.generate(100, (index) => Text("Text_${index + 1}"))))
    ]);
  }
}

Row的child设置为Expanded的话,会变成loose约束,传递给ListView的宽度就是有限的,这个时候就可以排版了

5.3 IntrinsicHeight/IntrinsicWidth

Constraints go down. Sizes go up. Parent sets position.

Flutter的这个布局约束,简单高效,可以实现近线性时间的高速布局。但是,在某些情况下,无法实现特殊的布局。这个时候,flutter提供了IntrinsicHeight/IntrinsicWidth来为布局系统打补丁。

5.3.1 Flex使用Expanded在Unbounded约束下

import 'package:flutter/material.dart';

class IntrinsticHeightExpanedDemo extends StatefulWidget {
  const IntrinsticHeightExpanedDemo({
    super.key,
  });

  @override
  State<IntrinsticHeightExpanedDemo> createState() => _HomePageState();
}

class _HomePageState extends State<IntrinsticHeightExpanedDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
        child: SingleChildScrollView(
            child: Column(
                children: list.map((e) {
      final index = list.indexOf(e);
      return _lineItems(e, index);
    }).toList())));
  }

  List list = [
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': 'IntrinsticHeightExpanedDemo'
    },
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': '可以讲子控件的高度调整至实'
    },
    {
      'title': 'IntrinsicHeight',
      'content': '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,'
    },
    {
      'title': 'IntrinsicHeight',
      'content':
          '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,第一个Container将会撑满整个body的高度,但使用了IntrinsicHeight高度会约束在50。这里Row的高度时需要有子内容的最大高度来决定的,但是第一个Container本身没有高度,有没有子控件,那么他就会去撑满父控件,然后发现父控件Row也是不具有自己的高度的,就撑满了body的高度。IntrinsicHeight就起到了约束Row实际高度的作用'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    }
  ];

  Widget _lineItems(res, index) {
    var isBottom = (index == list.length - 1);
    return Container(
        decoration: const BoxDecoration(
            // color: Colors.cyan,
            border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
        padding: const EdgeInsets.only(left: 15),
        margin: const EdgeInsets.fromLTRB(0, 0, 0, 0),
        child: Row(
          children: <Widget>[
            Column(children: [
              Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                    color: Colors.red, borderRadius: BorderRadius.circular(8)),
              ),
              //提示报错,RenderBox was not laid
              //我们将该组件的Column设置为Expanded,也报错了,Column的高度约束为无限,它无法计算Expanded是多少
              Expanded(
                  child: Container(
                width: 5,
                color: isBottom ? Colors.transparent : Colors.blue,
              )),
            ]),
            Expanded(
              child: rightWidget(res),
            ),
          ],
        ));
  }

  Widget rightWidget(res) {
    return Container(
      // color: Colors.blue,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const SizedBox(height: 15),
          Text(
            res['title'],
            style: const TextStyle(
                color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            res['content'],
            style: const TextStyle(color: Colors.orange, fontSize: 15),
          ),
          const SizedBox(height: 15),
        ],
      ),
    );
  }
}

我们的本意是用Expanded使得,同级的两个Column的高度是一致的。

但是提示报错,RenderBox was not laid,因为ListView的高度是无限的,Column的高度约束为Unbounded,它无法计算Expanded是多少

5.3.2 Flex使用Expanded在Unbounded约束下修复

import 'package:flutter/material.dart';

class IntrinsticHeightExpandedFixDemo extends StatefulWidget {
  const IntrinsticHeightExpandedFixDemo({
    super.key,
  });

  @override
  State<IntrinsticHeightExpandedFixDemo> createState() => _HomePageState();
}

class _HomePageState extends State<IntrinsticHeightExpandedFixDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
        child: SingleChildScrollView(
            child: Column(
                children: list.map((e) {
      final index = list.indexOf(e);
      return _lineItems(e, index);
    }).toList())));
  }

  List list = [
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': 'IntrinsticHeightExpandedFixDemo'
    },
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': '可以讲子控件的高度调整至实'
    },
    {
      'title': 'IntrinsicHeight',
      'content': '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,'
    },
    {
      'title': 'IntrinsicHeight',
      'content':
          '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,第一个Container将会撑满整个body的高度,但使用了IntrinsicHeight高度会约束在50。这里Row的高度时需要有子内容的最大高度来决定的,但是第一个Container本身没有高度,有没有子控件,那么他就会去撑满父控件,然后发现父控件Row也是不具有自己的高度的,就撑满了body的高度。IntrinsicHeight就起到了约束Row实际高度的作用'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    }
  ];

  Widget _lineItems(res, index) {
    var isBottom = (index == list.length - 1);
    return Container(
        decoration: const BoxDecoration(
            // color: Colors.cyan,
            border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
        padding: const EdgeInsets.only(left: 15),
        margin: const EdgeInsets.fromLTRB(0, 0, 0, 0),
        child: IntrinsicHeight(
            //这时候Row的Constraint的高度就是有限的,不再是Unbounded了
            child: Row(
          children: <Widget>[
            Column(children: [
              Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                    color: Colors.red, borderRadius: BorderRadius.circular(8)),
              ),
              //这个时候的maxHeight是有限的,所以可以进行Expanded
              Expanded(
                  child: Container(
                width: 5,
                color: isBottom ? Colors.transparent : Colors.blue,
              )),
            ]),
            Expanded(
              child: rightWidget(res),
            ),
          ],
        )));
  }

  Widget rightWidget(res) {
    return Container(
      // color: Colors.blue,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const SizedBox(height: 15),
          Text(
            res['title'],
            style: const TextStyle(
                color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            res['content'],
            style: const TextStyle(color: Colors.orange, fontSize: 15),
          ),
          const SizedBox(height: 15),
        ],
      ),
    );
  }
}

我们使用IntrinsicHeight就达到我们想要的效果了,参考资料可以看这里

  • IntrinsicHeight的工作,先收集子节点的getMaxIntrinsicHeight来确定高度
  • 从而将Column的Unbounded约束,改为了loose约束。然后才进行Constraints go down. Sizes go up. Parent sets position.的流程

使用IntrinsicHeight的情况下,相当于安卓里面的 2次measure + 1次layout的过程,布局的时间会更慢。最坏情况会导致O(N*N)的时间复杂度,应该尽量避免使用

5.3.3 Flex使用Stretch在Unbounded约束下

import 'package:flutter/material.dart';

class IntrinsticHeightStretchDemo extends StatefulWidget {
  const IntrinsticHeightStretchDemo({
    super.key,
  });

  @override
  State<IntrinsticHeightStretchDemo> createState() => _HomePageState();
}

class _HomePageState extends State<IntrinsticHeightStretchDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
        child: SingleChildScrollView(
            child: Column(
                children: list.map((e) {
      final index = list.indexOf(e);
      return _lineItems(e, index);
    }).toList())));
  }

  List list = [
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': 'IntrinsticHeightStretchDemo'
    },
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': '可以讲子控件的高度调整至实'
    },
    {
      'title': 'IntrinsicHeight',
      'content': '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,'
    },
    {
      'title': 'IntrinsicHeight',
      'content':
          '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,第一个Container将会撑满整个body的高度,但使用了IntrinsicHeight高度会约束在50。这里Row的高度时需要有子内容的最大高度来决定的,但是第一个Container本身没有高度,有没有子控件,那么他就会去撑满父控件,然后发现父控件Row也是不具有自己的高度的,就撑满了body的高度。IntrinsicHeight就起到了约束Row实际高度的作用'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    }
  ];

  Widget _lineItems(res, index) {
    var isBottom = (index == list.length - 1);
    return Container(
        decoration: const BoxDecoration(
            // color: Colors.cyan,
            border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
        padding: const EdgeInsets.only(left: 15),
        margin: const EdgeInsets.fromLTRB(0, 0, 0, 0),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Column(children: [
              Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                    color: Colors.red, borderRadius: BorderRadius.circular(8)),
              ),
              Container(
                width: 5,
                color: isBottom ? Colors.transparent : Colors.blue,
              ),
            ]),
            Expanded(
              child: rightWidget(res),
            ),
          ],
        ));
  }

  Widget rightWidget(res) {
    return Container(
      // color: Colors.blue,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const SizedBox(height: 15),
          Text(
            res['title'],
            style: const TextStyle(
                color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            res['content'],
            style: const TextStyle(color: Colors.orange, fontSize: 15),
          ),
          const SizedBox(height: 15),
        ],
      ),
    );
  }
}

在html5中,当我们希望两个children是在垂直方向是等高的,我们会设置交叉轴为stretch。但是在,flutter中就会报错,因为flutter的交叉轴为stretch是指和父组件等高,而不是与子组件等高

但是提示报错,RenderBox was not laid,因为ListView的高度是无限的,Column的高度约束为Unbounded,它无法计算交叉轴的stretch是多少

5.3.4 Flex使用Stretch在Unbounded约束下修复

import 'package:flutter/material.dart';

class IntrinsticHeightStretchFixDemo extends StatefulWidget {
  const IntrinsticHeightStretchFixDemo({
    super.key,
  });

  @override
  State<IntrinsticHeightStretchFixDemo> createState() => _HomePageState();
}

class _HomePageState extends State<IntrinsticHeightStretchFixDemo> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox.expand(
        child: SingleChildScrollView(
            child: Column(
                children: list.map((e) {
      final index = list.indexOf(e);
      return _lineItems(e, index);
    }).toList())));
  }

  List list = [
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': 'IntrinsticHeightStretchFixDemo'
    },
    {
      'title': '这种情况下Container的宽高铺满,我们给他套上IntrinsicWidth,不设置宽/高步长',
      'content': '可以讲子控件的高度调整至实'
    },
    {
      'title': 'IntrinsicHeight',
      'content': '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,'
    },
    {
      'title': 'IntrinsicHeight',
      'content':
          '可以讲子控件的高度调整至实际高度。下面这个例子如果不使用IntrinsicHeight的情况下,第一个Container将会撑满整个body的高度,但使用了IntrinsicHeight高度会约束在50。这里Row的高度时需要有子内容的最大高度来决定的,但是第一个Container本身没有高度,有没有子控件,那么他就会去撑满父控件,然后发现父控件Row也是不具有自己的高度的,就撑满了body的高度。IntrinsicHeight就起到了约束Row实际高度的作用'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    },
    {
      'title': '可以发现Container宽度被压缩到50,但是高度没有变化。我们再设置宽度步长为11',
      'content': '这里设置步长会影响到子控件最后大小'
    }
  ];

  Widget _lineItems(res, index) {
    var isBottom = (index == list.length - 1);
    return Container(
        decoration: const BoxDecoration(
            // color: Colors.cyan,
            border: Border(bottom: BorderSide(color: Colors.grey, width: 1))),
        padding: const EdgeInsets.only(left: 15),
        margin: const EdgeInsets.fromLTRB(0, 0, 0, 0),
        child: IntrinsicHeight(
            child: Row(
          //这个时候的maxHeight是有限的,所以可以进行stretch
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Column(children: [
              Container(
                width: 16,
                height: 16,
                decoration: BoxDecoration(
                    color: Colors.red, borderRadius: BorderRadius.circular(8)),
              ),
              Container(
                width: 5,
                color: isBottom ? Colors.transparent : Colors.blue,
              ),
            ]),
            Expanded(
              child: rightWidget(res),
            ),
          ],
        )));
  }

  Widget rightWidget(res) {
    return Container(
      // color: Colors.blue,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          const SizedBox(height: 15),
          Text(
            res['title'],
            style: const TextStyle(
                color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold),
          ),
          Text(
            res['content'],
            style: const TextStyle(color: Colors.orange, fontSize: 15),
          ),
          const SizedBox(height: 15),
        ],
      ),
    );
  }
}

我们使用IntrinsicHeight就达到我们想要的效果了,参考资料可以看这里

  • IntrinsicHeight的工作,先收集子节点的getMaxIntrinsicHeight来确定高度
  • 从而将Column的Unbounded约束,改为了loose约束。然后才进行Constraints go down. Sizes go up. Parent sets position.的流程

使用IntrinsicHeight的情况下,相当于安卓里面的 2次measure + 1次layout的过程,布局的时间会更慢。最坏情况会导致O(N*N)的时间复杂度,应该尽量避免使用

5.4 Row/Column作为子控件

5.3.1 Flex使用SpaceBetween在最小屏幕空间下

import 'package:flutter/material.dart';

class ColumnInListViewSpaceBetweenDemo extends StatelessWidget {
  const ColumnInListViewSpaceBetweenDemo({super.key});

  @override
  Widget build(BuildContext context) {
    const items = 4;
    //Column下面有spaceBetween的话,需要的是一个非0的minHeight。
    //当items数目较少的时候,这些items可以均分屏幕的空间。
    return LayoutBuilder(builder: (context, constraints) {
      return SingleChildScrollView(
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: constraints.maxHeight),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: List.generate(
                items, (index) => ItemWidget(text: 'Item $index')),
          ),
        ),
      );
    });
  }
}

class ItemWidget extends StatelessWidget {
  const ItemWidget({
    super.key,
    required this.text,
  });

  final String text;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: SizedBox(
        height: 100,
        child: Center(child: Text(text)),
      ),
    );
  }
}

在SingleChildScrollView的maxHeight是Unbounded,minHeight是0。因此,spaceBetween只会计算为0。但是LayoutBuilder来获取屏幕高度,然后传递Column,指定它的maxHeight是Unbounded,minHeight是screenHeight,这样就能计算spaceBetween了。

5.3.2 Flex使用Spacer在Unbounded下

import 'package:flutter/material.dart';

class ColumnInListViewExpandedDemo extends StatelessWidget {
  const ColumnInListViewExpandedDemo({super.key});

  @override
  Widget build(BuildContext context) {
    const items = 4;

    //Column下面有Spacer,或者Expanded的话,需要的是一个非无穷的maxHeight,所以有IntrinsicHeight
    //当items数目较少的时候,这些items可以均分屏幕的空间。
    return LayoutBuilder(builder: (context, constraints) {
      return const SingleChildScrollView(
        child: IntrinsicHeight(
          child: Column(
            children: [
              ItemWidget(text: 'Item 1'),
              Spacer(),
              ItemWidget(text: 'Item 2'),
              Expanded(
                child: ItemWidget(text: 'Item 3'),
              ),
            ],
          ),
        ),
      );
    });
  }
}

class ItemWidget extends StatelessWidget {
  const ItemWidget({
    super.key,
    required this.text,
  });

  final String text;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: SizedBox(
        height: 100,
        child: Center(child: Text(text)),
      ),
    );
  }
}

类似于在Unbounded约束下使用Expanded,没啥好说的

6 UI装饰组件

代码在这里

在html5中,样式是使用css或者style来表达的,在flutter中,所有的样式都是一个Widget。

6.1 Padding

import 'package:flutter/material.dart';

class PaddingDemo extends StatelessWidget {
  const PaddingDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Padding(
      //上下左右各添加16像素补白
      padding: EdgeInsets.all(16),
      child: Column(
        //显式指定对齐方式为左对齐,排除对齐干扰
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Padding(
            //左边添加8像素补白
            padding: EdgeInsets.only(left: 8),
            child: Text("Hello world"),
          ),
          Padding(
            //上下各添加8像素补白
            padding: EdgeInsets.symmetric(vertical: 8),
            child: Text("I am Jack"),
          ),
          Padding(
            // 分别指定四个方向的补白
            padding: EdgeInsets.fromLTRB(20, 0, 20, 20),
            child: Text("Your friend"),
          )
        ],
      ),
    );
  }
}

比较简单,没啥好说的

6.2 DecorateBox

import 'package:flutter/material.dart';

class DecorateBoxDemo extends StatelessWidget {
  const DecorateBoxDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Align(
        child: DecoratedBox(
            decoration: BoxDecoration(
              gradient: LinearGradient(
                  colors: [Colors.red, Colors.orange.shade700]), //背景渐变
              //边框,仅仅下边框
              border: const Border(
                  bottom: BorderSide(color: Colors.blue, width: 2)),
              //全边框
              //border: Border.all(color: Colors.blue, width: 1),
              //3像素圆角
              borderRadius: BorderRadius.circular(3.0),
              boxShadow: const [
                //阴影
                BoxShadow(
                    color: Colors.black54,
                    offset: Offset(2.0, 2.0),
                    blurRadius: 4.0)
              ],
            ),
            child: const Padding(
              padding: EdgeInsets.symmetric(horizontal: 80.0, vertical: 18.0),
              child: Text(
                "Login",
                style: TextStyle(color: Colors.white),
              ),
            )));
  }
}

decoratedBox表达了

  • color, 背景纯色,
  • gradient,背景渐变
  • border, 边框
  • borderRadius,边框圆角
  • boxShadow,阴影

6.3 Transform

import 'dart:math';

import 'package:flutter/material.dart';

class TransformDemo extends StatelessWidget {
  const TransformDemo({
    Key? key,
  }) : super(key: key);

  //Transform,仅工作在paint阶段,不影响原来widget的layout阶段,不影响原来widget所在排版
  Widget _buildTransformTranslate() {
    return DecoratedBox(
      decoration: const BoxDecoration(color: Colors.red),
      //默认原点为左上角,左移20像素,向上平移5像素
      child: Transform.translate(
        offset: const Offset(-20.0, -5.0),
        child: const Text("Hello world"),
      ),
    );
  }

  Widget _buildTransformRotate() {
    return DecoratedBox(
      decoration: const BoxDecoration(color: Colors.red),
      child: Transform.rotate(
        //旋转90度
        angle: pi / 2,
        child: const Text("Hello world"),
      ),
    );
  }

  Widget _buildTransformScale() {
    return DecoratedBox(
        decoration: const BoxDecoration(color: Colors.red),
        child: Transform.scale(
            scale: 1.5, //放大到1.5倍
            child: const Text("Hello world")));
  }

  Widget _buildTransformDoNotChangeLayout() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        DecoratedBox(
            decoration: const BoxDecoration(color: Colors.red),
            child: Transform.rotate(
              angle: pi / 2, //旋转90度
              child: const Text("Hello world"),
            )),
        const Text(
          "你好",
          style: TextStyle(color: Colors.green, fontSize: 18.0),
        )
      ],
    );
  }

  Widget _buildRoratedBoxDoChangeLayout() {
    return const Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        DecoratedBox(
          decoration: BoxDecoration(color: Colors.red),
          //将Transform.rotate换成RotatedBox
          child: RotatedBox(
            quarterTurns: 1, //旋转90度(1/4圈)
            child: Text("Hello world"),
          ),
        ),
        Text(
          "你好",
          style: TextStyle(color: Colors.green, fontSize: 18.0),
        )
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Align(
        child: Column(
      //显式指定对齐方式为左对齐,排除对齐干扰
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        _buildTransformTranslate(),
        const SizedBox(height: 50),
        _buildTransformRotate(),
        const SizedBox(height: 50),
        _buildTransformScale(),
        const SizedBox(height: 50),
        _buildTransformDoNotChangeLayout(),
        const SizedBox(height: 50),
        _buildRoratedBoxDoChangeLayout(),
        const SizedBox(height: 50),
      ],
    ));
  }
}

transform的注意点:

  • Transform仅执行paint阶段,不影响原来widget的layout阶段,不影响原来widget所在排版。
  • Transform包括有translate, rotate, scale
  • RotatedBox与Transform不同的是,它真实地执行layout和paint阶段,会影响widget的所在排版。

6.4 Container

import 'package:flutter/material.dart';

class ContainerDemo extends StatelessWidget {
  const ContainerDemo({
    Key? key,
  }) : super(key: key);

  //Container同时组合了DecoratedBox,ConstrainedBox,Transform,Padding,Align
  Widget _buildContainerNormal() {
    return Container(
      margin: const EdgeInsets.only(top: 50.0, left: 120.0),
      constraints:
          const BoxConstraints.tightFor(width: 200.0, height: 150.0), //卡片大小
      decoration: const BoxDecoration(
        //背景装饰
        gradient: RadialGradient(
          //背景径向渐变
          colors: [Colors.red, Colors.orange],
          center: Alignment.topLeft,
          radius: .98,
        ),
        boxShadow: [
          //卡片阴影
          BoxShadow(
            color: Colors.black54,
            offset: Offset(2.0, 2.0),
            blurRadius: 4.0,
          )
        ],
      ),
      transform: Matrix4.rotationZ(.2), //卡片倾斜变换
      alignment: Alignment.center, //卡片内文字居中
      child: const Text(
        //卡片文字
        "5.20", style: TextStyle(color: Colors.white, fontSize: 40.0),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      //主轴start
      mainAxisAlignment: MainAxisAlignment.start,
      children: [
        _buildContainerNormal(),
        const SizedBox(height: 50),
        Container(
          margin: const EdgeInsets.all(20.0), //容器外补白
          color: Colors.orange,
          child: const Text("Hello world!"),
        ),
        const SizedBox(height: 50),
        Container(
          padding: const EdgeInsets.all(20.0), //容器内补白
          color: Colors.orange,
          child: const Text("Hello world!"),
        ),
      ],
    );
  }
}

Container是一个组合组件,同时包含了

  • 装饰组件,Padding,DecoratedBox,Transform
  • 布局组件,ConstrainedBox,Align

Container可以说是相当齐全了。相对来说,它的性能也会稍差一点,在简单场景可以直接使用单一的装饰组件。另外,Container的margin其实就是用Padding来实现的而已。区别在于:

  • margin,就是Padding在外,DecoratedBox在内。
  • padding,就是DecoratedBox在外,Padding在内。

6.5 Clip

import 'package:flutter/material.dart';

class ClipDemo extends StatelessWidget {
  const ClipDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 头像
    Widget avatar = Image.asset("assets/images/infinite_star.webp",
        width: 60.0, fit: BoxFit.cover);
    return Center(
      child: Column(
        children: <Widget>[
          avatar, //不剪裁
          ClipOval(child: avatar), //剪裁为圆形
          ClipRRect(
            //剪裁为圆角矩形
            borderRadius: BorderRadius.circular(5.0),
            child: avatar,
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Align(
                //align宽度设置为0.5,导致Align父组件更小,avatar更大。
                alignment: Alignment.topLeft,
                widthFactor: .5,
                child: avatar,
              ),
              const Text(
                "你好世界",
                style: TextStyle(color: Colors.green),
              )
            ],
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              ClipRect(
                //将溢出部分剪裁
                child: Align(
                  alignment: Alignment.topLeft,
                  widthFactor: .5, //宽度设为原来宽度一半
                  child: avatar,
                ),
              ),
              const Text("你好世界", style: TextStyle(color: Colors.green))
            ],
          ),
        ],
      ),
    );
  }
}

clip也比较简单,注意,clip会产生新的paint layer,应该尽少使用。

  • ClipOval,剪裁为圆形
  • ClipRRect,剪裁为圆角矩形
  • ClipRect,剪裁为矩形。

Clip的一个常用法是,将子控件overflow的部分屏蔽掉,仅显示父空间宽高内的部分。

6.6 FittedBox

import 'package:flutter/material.dart';

class FittedBoxDemo extends StatelessWidget {
  const FittedBoxDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        children: [
          //不缩放
          wContainer(BoxFit.none),
          const Text('Wendux'),
          //FittedBox的默认值为container,将子组件缩放到和父组件一样的大小
          wContainer(BoxFit.contain),
          const Text('Flutter中国'),
        ],
      ),
    );
  }

  Widget wContainer(BoxFit boxFit) {
    return Container(
      width: 50,
      height: 50,
      color: Colors.red,
      child: FittedBox(
        fit: boxFit,
        // 子容器超过父容器大小
        child: Container(width: 60, height: 70, color: Colors.blue),
      ),
    );
  }
}

FittedBox的用法:

  • FittedBox将Loose或Tight约束转换为一个Unbounded约束,返回一个满足上级约束的宽高。如果下级的宽高结果是Unbounded的话,则渲染错误error。
  • FittedBox的fit默认值为boxFit,则它会将子组件缩放到和父组件一样的大小。然后使用scale的手段来显示。
  • FittedBox的fit设置为none,则它不会将子组件进行放缩操作,这个时候的FittedBox的行为就像Overflow是一样的了。

7 UI滚动组件

代码在这里

flutter的滚动组件是第二个需要掌握的重点,除了SingleChildScrollView以外,所有的滚动组件都是虚拟滚动实现的,并且采用不同于第5节的,专用滚动组件内部的布局协议。

所以,flutter的滚动性能更好,渲染也更先进。难度会稍高,但必须要重点掌握。

7.1 SingleChildScrollView

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class SingleChildScrollViewDemo extends StatelessWidget {
  const SingleChildScrollViewDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    //默认情况下,没有ScrollBar,可以滚动
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16.0),
      child: Center(
        child: Column(
          //动态创建一个List<Widget>
          children: str
              .split("")
              //每一个字母都用一个Text显示,字体为原来的两倍
              .map((c) => Text(
                    c,
                    textScaler: const TextScaler.linear(2),
                  ))
              .toList(),
        ),
      ),
    );
  }
}

SingleChildScrollView是最简单的滚动组件,由于没有实现Sliver的布局协议,这个组件仅适用于少量组件的情况。

7.2 ScrollBar

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ScrollBarDemo extends StatelessWidget {
  const ScrollBarDemo({
    Key? key,
  }) : super(key: key);

  Widget _buildNormalScrollBar() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    // 在Android和IOS环境下,都会显示普通的深色滚动条
    return Scrollbar(
      child: SingleChildScrollView(
        primary: true,
        padding: const EdgeInsets.all(16.0),
        child: Center(
          child: Column(
            //动态创建一个List<Widget>
            children: str
                .split("")
                //每一个字母都用一个Text显示,字体为原来的两倍
                .map((c) => Text(
                      c,
                      textScaler: const TextScaler.linear(2),
                    ))
                .toList(),
          ),
        ),
      ),
    );
  }

  Widget _buildIosScrollBar() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    // 在IOS环境下,会显示半透明,圆角的滚动条
    // 在Android会显示普通的深色滚动条
    return CupertinoScrollbar(
      child: SingleChildScrollView(
        primary: false,
        padding: const EdgeInsets.all(16.0),
        child: Center(
          child: Column(
            //动态创建一个List<Widget>
            children: str
                .split("")
                //每一个字母都用一个Text显示,字体为原来的两倍
                .map((c) => Text(
                      c,
                      textScaler: const TextScaler.linear(2),
                    ))
                .toList(),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    //如果单个页面有两个Scrollable的话,需要保证只有一个Scrollable的primary设置为true
    //这样才能保证都显示滚动条
    return Column(
      children: [
        Expanded(child: _buildNormalScrollBar()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildIosScrollBar()),
      ],
    );
  }
}

  • Scrollbar,在Android和IOS环境下,都会显示普通的深色滚动条
  • CupertinoScrollbar,在IOS环境下,会显示半透明,圆角的滚动条

这个比较简单

7.3 ScrollPhysis

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class OverScrollBehavior extends ScrollBehavior {
  @override
  Widget buildOverscrollIndicator(
      BuildContext context, Widget child, ScrollableDetails details) {
    switch (getPlatform(context)) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        return GlowingOverscrollIndicator(
          axisDirection: details.direction,
          //不显示头部水波纹
          showLeading: false,
          //不显示尾部水波纹
          showTrailing: false,
          color: Theme.of(context).hoverColor,
          child: child,
        );
      default:
        return super.buildScrollbar(context, child, details);
    }
  }
}

class ScrollPhysisDemo extends StatelessWidget {
  const ScrollPhysisDemo({
    Key? key,
  }) : super(key: key);

  Widget _buildClampingScrollPhysics() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    // 在Android和IOS环境下,都会显示普通的深色滚动条
    return SingleChildScrollView(
      physics: const ClampingScrollPhysics(),
      primary: true,
      padding: const EdgeInsets.all(16.0),
      child: Center(
        child: Column(
          //动态创建一个List<Widget>
          children: str
              .split("")
              //每一个字母都用一个Text显示,字体为原来的两倍
              .map((c) => Text(
                    c,
                    textScaler: const TextScaler.linear(2),
                  ))
              .toList(),
        ),
      ),
    );
  }

  Widget _buildClampingScrollPhysicsAndNoneIndicator() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    // 在Android和IOS环境下,都会显示普通的深色滚动条
    return ScrollConfiguration(
      behavior: OverScrollBehavior(),
      child: SingleChildScrollView(
        physics: const ClampingScrollPhysics(),
        primary: true,
        padding: const EdgeInsets.all(16.0),
        child: Center(
          child: Column(
            //动态创建一个List<Widget>
            children: str
                .split("")
                //每一个字母都用一个Text显示,字体为原来的两倍
                .map((c) => Text(
                      c,
                      textScaler: const TextScaler.linear(2),
                    ))
                .toList(),
          ),
        ),
      ),
    );
  }

  Widget _buildBouncingScrollPhysics() {
    String str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    // BouncingScrollPhysics,边界反弹效果,类似IOS的效果
    return SingleChildScrollView(
      physics: const BouncingScrollPhysics(),
      primary: false,
      padding: const EdgeInsets.all(16.0),
      child: Center(
        child: Column(
          //动态创建一个List<Widget>
          children: str
              .split("")
              //每一个字母都用一个Text显示,字体为原来的两倍
              .map((c) => Text(
                    c,
                    textScaler: const TextScaler.linear(2),
                  ))
              .toList(),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    //如果单个页面有两个Scrollable的话,需要保证只有一个Scrollable的primary设置为true
    //这样才能保证都显示滚动条
    return Column(
      children: [
        Expanded(child: _buildClampingScrollPhysics()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildClampingScrollPhysicsAndNoneIndicator()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildBouncingScrollPhysics()),
      ],
    );
  }
}

要点如下:

  • ClampingScrollPhysics,默认值,滚动到尽头的时候,没有回弹效果,尽头会显示头部和尾部水波纹
  • ClampingScrollPhysics + ScrollConfiguration,滚动到尽头的时候,没有回弹效果,尽头会隐藏头部和尾部水波纹
  • BouncingScrollPhysics,类似IOS的滚动效果,滚动到尽头的时候,有回弹效果,没有头部和尾部水波纹。

7.4 ListView

7.4.1 基础

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ListViewDemo extends StatelessWidget {
  const ListViewDemo({
    Key? key,
  }) : super(key: key);

  Widget _buildNormalListView() {
    //非lazy形式的listView,不推荐使用,会导致所有的Widget都会提前渲染。
    var size = 20;
    return ListView(
      primary: true,
      children: List.generate(size, (index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      }),
    );
  }

  Widget _buildLazyListView() {
    //Lazy形式的listView,推荐使用,Widget按需加载
    return ListView.builder(
      primary: false,
      itemCount: 20,
      itemBuilder: (BuildContext context, int index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      },
    );
  }

  Widget _buildLazyListViewFixHeight() {
    return ListView.builder(
      primary: false,
      itemCount: 20,
      //指定了itemExtent以后,每个项的高度都是高度的,无法变更
      //item项小的话,会提高高度,保证高度为itemExtent
      //item项大的话,会截取高度,保证高度为itemExtent
      itemExtent: 30,
      itemBuilder: (BuildContext context, int index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      },
    );
  }

  Widget _buildLazyListViewPrototypeHeight() {
    return ListView.builder(
      primary: false,
      itemCount: 20,
      //指定了prototypeItem的话,以prototypeItem的高度作为每个项的实际高度
      //item项小的话,会提高高度,保证高度为prototypeItem的高度
      //item项大的话,会截取高度,保证高度为prototypeItem的高度
      prototypeItem: const Text("Text 1"),
      itemBuilder: (BuildContext context, int index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      },
    );
  }

  Widget _buildLazyListViewWithSeperator() {
    //下划线widget预定义以供复用。
    Widget divider1 = const Divider(
      color: Colors.blue,
    );
    Widget divider2 = const Divider(color: Colors.green);
    //Lazy形式的listView,推荐使用,Widget按需加载
    return ListView.separated(
      primary: false,
      itemCount: 20,
      itemBuilder: (BuildContext context, int index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      },
      //分割器构造器
      separatorBuilder: (BuildContext context, int index) {
        return index % 2 == 0 ? divider1 : divider2;
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    //如果单个页面有两个Scrollable的话,需要保证只有一个Scrollable的primary设置为true
    //这样才能保证都显示滚动条
    return Column(
      children: [
        Expanded(child: _buildNormalListView()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildLazyListView()),
        Container(
          height: 10,
          color: Colors.red,
        ),
        Expanded(child: _buildLazyListViewFixHeight()),
        Container(
          height: 10,
          color: Colors.red,
        ),
        Expanded(child: _buildLazyListViewPrototypeHeight()),
        Container(
          height: 10,
          color: Colors.red,
        ),
        Expanded(child: _buildLazyListViewWithSeperator()),
      ],
    );
  }
}

要点如下:

  • 普通ListView,将所有Widget都一次性传入到children字段,不推荐使用,会导致所有的Widget都会提前build。
  • 按需ListView,通过itemBuilder的形式来按需创建Widget,推荐使用,Widget按需加载。

ListView的其他特性:

  • itemExtent,指定了itemExtent以后,每个项的高度都是高度的,无法变更。item项小的话,会提高高度,保证高度为itemExtent。item项大的话,会截取高度,保证高度为itemExtent
  • prototypeItem,指定了prototypeItem的话,以prototypeItem的高度作为每个项的实际高度。item项小的话,会提高高度,保证高度为prototypeItem的高度。item项大的话,会截取高度,保证高度为prototypeItem的高度
  • separatorBuilder,可以指定ListView每一项的分割器,这里的分割器可以重复的Widget,避免重复build。

7.4.2 无限滚动

import 'package:flutter/material.dart';
import 'package:english_words/english_words.dart';

class ListViewInfiniteDemo extends StatefulWidget {
  const ListViewInfiniteDemo({
    Key? key,
  }) : super(key: key);

  @override
  State<ListViewInfiniteDemo> createState() => _InfiniteListViewState();
}

class _InfiniteListViewState extends State<ListViewInfiniteDemo> {
  static const loadingTag = "##loading##"; //表尾标记
  final _words = <String>[loadingTag];

  @override
  void initState() {
    super.initState();
    _retrieveData();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: () {
            setState(() {
              if (_words.length > 2) {
                _words[1] += 'Fish';
              }
            });
          },
          child: const Text("修改第2项"),
        ),
        Expanded(
          child: _buildListView(context),
        ),
      ],
    );
  }

  Widget _buildListView(BuildContext context) {
    return ListView.separated(
      itemCount: _words.length,
      itemBuilder: (context, index) {
        //如果到了表尾
        if (_words[index] == loadingTag) {
          //不足100条,继续获取数据
          if (_words.length - 1 < 100) {
            //获取数据
            _retrieveData();
            //加载时显示loading
            return Container(
              padding: const EdgeInsets.all(16.0),
              alignment: Alignment.center,
              child: const SizedBox(
                width: 24.0,
                height: 24.0,
                child: CircularProgressIndicator(strokeWidth: 2.0),
              ),
            );
          } else {
            //已经加载了100条数据,不再获取数据。
            return Container(
              alignment: Alignment.center,
              padding: const EdgeInsets.all(16.0),
              child: const Text(
                "没有更多了",
                style: TextStyle(color: Colors.grey),
              ),
            );
          }
        }
        //显示单词列表项
        return ListTile(title: Text(_words[index]));
      },
      separatorBuilder: (context, index) => const Divider(height: .0),
    );
  }

  void _retrieveData() {
    Future.delayed(const Duration(seconds: 2)).then((e) {
      setState(() {
        //重新构建列表
        _words.insertAll(
          _words.length - 1,
          //每次生成20个单词
          generateWordPairs().take(20).map((e) => e.asPascalCase).toList(),
        );
      });
    });
  }
}

要点如下:

  • 可以设置最后一项为loading组件,itemBuilder是最后一项,或者侦听scrollListener来触发下一项。
  • 修改某一项的时候,仅需要修改本地数据,然后build一次就可以了。比较简单,甚至连data都不需要传入。

7.5 GridView

7.5.1 基础

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class GridViewDemo extends StatelessWidget {
  GridViewDemo({
    Key? key,
  }) : super(key: key);

  final widgets = [
    Image.asset('assets/images/vertical1.webp', fit: BoxFit.contain),
    Image.asset('assets/images/vertical2.webp', fit: BoxFit.contain),
    Image.asset('assets/images/vertical3.webp', fit: BoxFit.contain),
    Image.asset('assets/images/vertical4.webp', fit: BoxFit.contain),
    Image.asset('assets/images/vertical5.webp', fit: BoxFit.contain),
    Image.asset('assets/images/vertical6.webp', fit: BoxFit.contain)
  ];
  Widget _buildGridViewFixCrossAxis() {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, //横轴三个子widget
        //宽高比是固定的,默认就是1
        //childAspectRatio: 1.0 //宽高比为1时,子widget
      ),
      itemCount: widgets.length,
      itemBuilder: (context, index) => widgets[index],
    );
  }

  Widget _buildGridViewMaxExtentntCrossAxis() {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
        maxCrossAxisExtent: 120, //横轴最宽120
        //宽高比是固定的,默认就是1
        //childAspectRatio: 1.0 //宽高比为1时,子widget
      ),
      itemCount: widgets.length,
      itemBuilder: (context, index) => widgets[index],
    );
  }

  @override
  Widget build(BuildContext context) {
    //如果单个页面有两个Scrollable的话,需要保证只有一个Scrollable的primary设置为true
    //这样才能保证都显示滚动条
    return Column(
      children: [
        Expanded(child: _buildGridViewFixCrossAxis()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildGridViewMaxExtentntCrossAxis()),
      ],
    );
  }
}

要点如下:

  • GridView的宽高比都是固定的,默认就是1。可以通过修改childAspectRatio来配置。如果要支持不同宽高比的widget,需要使用其他的滚动组件。
  • 不推荐使用children形式的提前渲染,推荐使用itemBuilder形式的按需渲染。

GridView的属性

  • gridDelegate为SliverGridDelegateWithFixedCrossAxisCount,固定交叉轴的数量,无论GridView的宽度是多少。
  • gridDelegate为SliverGridDelegateWithMaxCrossAxisExtent,以交叉轴宽度,和每个Widget的maxCrossAxisExtent配置,来决定交叉轴的数量。

无论哪种形式,GridView有两点是不变的

  • 同一个GridView,每个Widget的宽高比都是固定的
  • 同一个GridView,交叉轴的Widget数量都是固定的。

7.5.2 无限滚动

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class GridViewInfiniteDemo extends StatefulWidget {
  const GridViewInfiniteDemo({
    Key? key,
  }) : super(key: key);
  @override
  _InfiniteGridViewState createState() => _InfiniteGridViewState();
}

class _InfiniteGridViewState extends State<GridViewInfiniteDemo> {
  final List<IconData> _icons = []; //保存Icon数据

  @override
  void initState() {
    super.initState();
    // 初始化数据
    _retrieveIcons();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: () {
            setState(() {
              if (_icons.length > 2) {
                _icons[1] = Icons.baby_changing_station;
              }
            });
          },
          child: const Text("修改第2项"),
        ),
        Expanded(
          child: _buildGridView(context),
        ),
      ],
    );
  }

  Widget _buildGridView(BuildContext context) {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, //每行三列
        childAspectRatio: 1.0, //显示区域宽高相等
      ),
      itemCount: _icons.length,
      itemBuilder: (context, index) {
        //如果显示到最后一个并且Icon总数小于200时继续获取数据
        if (index == _icons.length - 1 && _icons.length < 200) {
          _retrieveIcons();
        }
        return Icon(_icons[index]);
      },
    );
  }

  //模拟异步获取数据
  void _retrieveIcons() {
    Future.delayed(const Duration(milliseconds: 200)).then((e) {
      setState(() {
        _icons.addAll([
          Icons.ac_unit,
          Icons.airport_shuttle,
          Icons.all_inclusive,
          Icons.beach_access,
          Icons.cake,
          Icons.free_breakfast,
        ]);
      });
    });
  }
}

要点如下:

  • 可以设置最后一项为loading组件,itemBuilder是最后一项,或者侦听scrollListener来触发下一项。
  • 修改某一项的时候,仅需要修改本地数据,然后build一次就可以了。比较简单,甚至连data都不需要传入。

7.6 AnimatedList

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class AnimatedListDemo extends StatefulWidget {
  const AnimatedListDemo({Key? key}) : super(key: key);

  @override
  _AnimatedListRouteState createState() => _AnimatedListRouteState();
}

class _AnimatedListRouteState extends State<AnimatedListDemo> {
  var data = <String>[];
  int counter = 5;

  final globalKey = GlobalKey<AnimatedListState>();

  @override
  void initState() {
    for (var i = 0; i < counter; i++) {
      data.add('${i + 1}');
    }
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        AnimatedList(
          key: globalKey,
          initialItemCount: data.length,
          itemBuilder: (
            BuildContext context,
            int index,
            Animation<double> animation,
          ) {
            //添加列表项时会执行渐显动画
            return FadeTransition(
              opacity: animation,
              child: buildItem(context, index),
            );
          },
        ),
        buildAddBtn(),
      ],
    );
  }

  // 创建一个 “+” 按钮,点击后会向列表中插入一项
  Widget buildAddBtn() {
    return Positioned(
      bottom: 30,
      left: 0,
      right: 0,
      child: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          // 添加一个列表项
          data.add('${++counter}');
          // 告诉列表项有新添加的列表项
          // 缺少这一句的话,即使放在setState里面也无法刷新数据。
          globalKey.currentState!.insertItem(data.length - 1);
          print('添加 $counter');
        },
      ),
    );
  }

  // 构建列表项
  Widget buildItem(context, index) {
    String char = data[index];
    return ListTile(
      //数字不会重复,所以作为Key
      key: ValueKey(char),
      title: Text(char),
      trailing: IconButton(
        icon: const Icon(Icons.delete),
        // 点击时删除
        onPressed: () => onDelete(context, index),
      ),
    );
  }

  void onDelete(context, index) {
    //缺少这一句的话,即使放在setState里面也无法刷新数据。
    globalKey.currentState!.removeItem(
      index,
      (context, animation) {
        // 删除过程执行的是反向动画,animation.value 会从1变为0
        var item = buildItem(context, index);
        print('删除 ${data[index]}');
        data.removeAt(index);
        // 删除动画是一个合成动画:渐隐 + 收缩列表项
        return FadeTransition(
          opacity: CurvedAnimation(
            parent: animation,
            //让透明度变化的更快一些
            curve: const Interval(0.5, 1.0),
          ),
          // 不断缩小列表项的高度
          child: SizeTransition(
            sizeFactor: animation,
            axisAlignment: 0.0,
            child: item,
          ),
        );
      },
      duration: const Duration(milliseconds: 100), // 动画时间为 100 ms
    );
  }
}

要点如下:

  • itemBuilder,需要传入一个支持animation的Widget,以展示首次出现的动画
  • addItem的时候,不能直接修改本地data,然后setState,这样是没有动画效果的,也不能更新页面数据。需要修改本地data,并且手动执行AnimatedList的insertItem,不需要调用setState,这样才能触发动画和更新页面数据。
  • delItem的时候,不能直接修改本地data,然后setState,这样是没有动画效果的,也不能更新页面数据。需要修改本地data,并且手动执行AnimatedList的removeAt,不需要调用setState,这样才能触发动画和更新页面数据。
  • 在调用removeAt的时候,需要传入离场动画。

评价:

  • 这个设计不太好,不仅使用起来麻烦,而且改成了命令式的修改数据。

7.7 ScorllListener

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ScrollListenerDemo extends StatefulWidget {
  const ScrollListenerDemo({
    Key? key,
  }) : super(key: key);

  @override
  State<ScrollListenerDemo> createState() => _ScrollListenerDemo();
}

class _ScrollListenerDemo extends State<ScrollListenerDemo> {
  final ScrollController _controller = ScrollController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      print('listView1 offset ${_controller.offset}');
    });
  }

  Widget _buildNormalListView() {
    var size = 20;
    return ListView(
      controller: _controller,
      children: List.generate(size, (index) {
        index++;
        var str = "";
        for (var i = 0; i != index; i++) {
          str += "[Text $index]";
        }
        return Text(str);
      }),
    );
  }

  Widget _buildNormalListView2() {
    /*
    在接收到滚动事件时,参数类型为ScrollNotification,它包括一个metrics属性,它的类型是ScrollMetrics,该属性包含当前ViewPort及滚动位置等信息:
    pixels:当前滚动位置。
    maxScrollExtent:最大可滚动长度。
    extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
    extentInside:ViewPort内部长度;此示例中屏幕显示的列表部分的长度。
    extentAfter:列表中未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
    atEdge:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)。
    ScrollMetrics还有一些其他属性,读者可以自行查阅API文档。
    */
    var size = 20;
    //使用事件冒泡的方式来做滚动监听,可以得到较为丰富的事件消息。
    return NotificationListener<ScrollNotification>(
      /*
      /// Return true to cancel the notification bubbling. Return false to
      /// allow the notification to continue to be dispatched to further ancestors.
      */
      onNotification: (ScrollNotification notification) {
        print(
            'listView2 offset ${notification.metrics.pixels} ${notification.metrics.maxScrollExtent} ${notification.metrics.atEdge}');
        return false;
      },
      child: ListView(
        children: List.generate(size, (index) {
          index++;
          var str = "";
          for (var i = 0; i != index; i++) {
            str += "[Text $index]";
          }
          return Text(str);
        }),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    //如果单个页面有两个Scrollable的话,需要保证只有一个Scrollable的primary设置为true
    //这样才能保证都显示滚动条
    return Column(
      children: [
        ElevatedButton(
            onPressed: () {
              _controller.animateTo(
                0,
                duration: const Duration(milliseconds: 200),
                curve: Curves.ease,
              );
            },
            child: const Text('返回顶部')),
        Expanded(child: _buildNormalListView()),
        Container(
          height: 30,
          color: Colors.red,
        ),
        Expanded(child: _buildNormalListView2()),
      ],
    );
  }
}

要点如下:

  • 可以使用ScrollController来获取Scroll变化的事件,并且可以设置Scroll offset的位置。
  • 可以使用NotificationListener来获取Scroll变化的事件,这样的话,事件的内容也比较详细。但是不能设置scroll offset的位置。

7.8 ScrollOffsetStorage

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class ScrollOffsetStorageDemo extends StatefulWidget {
  const ScrollOffsetStorageDemo({super.key});

  @override
  State<ScrollOffsetStorageDemo> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<ScrollOffsetStorageDemo> {
  final List<Widget> pages = const <Widget>[
    ColorBoxPage(
      //PageStorageKey属于localKey的范畴
      //但是PageStorage检查到widget退出的时候,都会保存scrollOffset
      //新widget进来的时候,就会恢复scrollOffset
      key: PageStorageKey<String>('pageOne'),
    ),
    ColorBoxPage(
        //这个页面有PageStorageKey,所以每次滚动位置都会丢失
        //key: PageStorageKey<String>('pageTwo'),
        ),
  ];
  int currentTab = 0;
  final PageStorageBucket _bucket = PageStorageBucket();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      //body每次,仅显示当前Widget,另外一个Widget就会销毁
      //PageStorage在顶层记录每个PageStorageKey对应的位置
      body: PageStorage(
        bucket: _bucket,
        child: pages[currentTab],
      ),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentTab,
        onTap: (int index) {
          setState(() {
            currentTab = index;
          });
        },
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'page 1',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'page2',
          ),
        ],
      ),
    );
  }
}

class ColorBoxPage extends StatelessWidget {
  const ColorBoxPage({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemExtent: 250.0,
      itemBuilder: (BuildContext context, int index) => Container(
        padding: const EdgeInsets.all(10.0),
        child: Material(
          color: index.isEven ? Colors.cyan : Colors.deepOrange,
          child: Center(
            child: Text(index.toString()),
          ),
        ),
      ),
    );
  }
}

要点如下:

  • Scaffold的body,默认是没有做页面缓存的,也就是每次页面都是重新创建Element实例的。在默认的情况下,滚动组件的滚动位置会丢失。
  • 我们可以使用PageStorage来包裹滚动组件,而每个滚动组件使用PageStorageKey来指定。这样的话,当widget退出的时候,PageStorage会自动保存当前滚动组件的offset,下次重新创建Widget的时候,会自动恢复滚动组件的offset。
  • 在Demo里面,Page1是设置有PageSorageKey,所以每次切换tab的时候,滚动位置都能恢复。Page2是没有设置PageStorageKey,所以每次切换tab的时候,滚动位置都没有恢复。
  • 比较神奇的是,PageStorageKey不需要必须设置在滚动组件上,只需要设置在滚动组件的父组件上也可以。这可能是跟PageStorage侦听了ScrollNotification事件有关。

7.9 PageView

7.9.1 基础

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

//PageView常用来实现 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能
class PageViewDemo extends StatelessWidget {
  const PageViewDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var children = <Widget>[];
    // 生成 6 个 Tab 页
    for (int i = 0; i < 6; ++i) {
      children.add(PageViewCounter(text: 'Counter_$i'));
    }

    return PageView(
      //keepAlive的意义:
      //1. PageView只能缓存前后一页,超出的会丢失状态
      //2. ListView里面有一个类似的配置,addAutomaticKeepAlives,配置为true的时候,滑出viewport以后,依然会缓存widget。
      //   但是如果滑出viewPort的距离太远,依然会丢失状态
      //是否开启keepAlive:
      // 开启keepAlive能避免重复刷新页面状态,但是消耗更多的内存
      // 不开启keepAlive,切换页面需要重新刷新,状态需要移到父级来保存,否则会丢失状态。
      allowImplicitScrolling: true,
      // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
      children: children,
    );
  }
}

class PageViewCounter extends StatefulWidget {
  const PageViewCounter({Key? key, required this.text}) : super(key: key);

  final String text;

  @override
  _PageViewCounter createState() => _PageViewCounter();
}

class _PageViewCounter extends State<PageViewCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(
      child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        Center(child: Text("${widget.text}_$_counter")),
        ElevatedButton(
            onPressed: () {
              setState(() {
                _counter++;
              });
            },
            child: const Text("递增counter"))
      ]),
    );
  }
}

要点如下:

  • PageView就是一个大号的ListView而已,它默认的大小就是整页的大小。它常用来实现 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能
  • PageView在默认情况下,不缓存页面的Widget,在切换页面以后,旧Widget的State就会丢失。
  • PageView即使打开了allowImplicitScrolling,而是仅缓存了前后3页的页面Widget的State。当滑动到第4页的时候,Widget的State就会丢失。

是否缓存Widget的State,我们称为KeepAlive,在ListView中也有类似的设定:

  • ListView里面有一个类似的配置,addAutomaticKeepAlives,配置为true的时候,滑出viewport以后,依然会缓存widget。
  • ListView中如果widget滑出viewPort的距离太远,依然会丢失State。

7.9.2 Widget缓存

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

//PageView常用来实现 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能
class PageViewCacheExtentDemo extends StatelessWidget {
  const PageViewCacheExtentDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    var children = <Widget>[];
    // 生成 6 个 Tab 页
    for (int i = 0; i < 6; ++i) {
      children.add(
        //使用true的话,则在ListView或者PageView等Scroll组件中,无论什么时候都会缓存,无论滑出viewport有多远。
        //需谨慎使用
        KeepAliveWrapper(
          keepAlive: true,
          child: PageViewCounter(text: 'Counter_$i'),
        ),
      );
    }

    return PageView(
      //只能缓存前后一页,超出的会丢失状态
      allowImplicitScrolling: true,
      // scrollDirection: Axis.vertical, // 滑动方向为垂直方向
      children: children,
    );
  }
}

class PageViewCounter extends StatefulWidget {
  const PageViewCounter({Key? key, required this.text}) : super(key: key);

  final String text;

  @override
  _PageViewCounter createState() => _PageViewCounter();
}

class _PageViewCounter extends State<PageViewCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(
      child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
        Center(child: Text("${widget.text}_$_counter")),
        ElevatedButton(
            onPressed: () {
              setState(() {
                _counter++;
              });
            },
            child: const Text("递增counter"))
      ]),
    );
  }
}

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;

  @override
  _KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}

/*
* 由flutter来询问我们要不要keepAlive,混入AutomaticKeepAliveClientMixin就可以了
*/
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }

  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if (oldWidget.keepAlive != widget.keepAlive) {
      // keepAlive 状态需要更新,实现在 AutomaticKeepAliveClientMixin 中
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }

  //返回要不要keepAlive
  @override
  bool get wantKeepAlive => widget.keepAlive;
}

要点如下:

  • 在滚动组件的里面,如果我们需要强制指定每个Widget都必须缓存,可以通过继承AutomaticKeepAliveClientMixin来实现,在wantKeepAlive属性中永远返回true就可以了。
  • KeepAliveWrapper,需要谨慎使用,所有组件都keepAlive的话,对于内存是不少的压力。

7.10 TabBarView

import 'package:demo/scroll/pageViewCacheExtent.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class TabBarViewDemo extends StatefulWidget {
  const TabBarViewDemo({
    Key? key,
  }) : super(key: key);

  @override
  _TabViewRoute1State createState() => _TabViewRoute1State();
}

//TabBarView其实就是PageView和TabBar的组合使用而已
class _TabViewRoute1State extends State<TabBarViewDemo>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;
  List tabs = ["新闻", "历史", "图片"];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: tabs.length, vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("App Name"),
        bottom: TabBar(
          //这里绑定同一个_tabController
          controller: _tabController,
          tabs: tabs.map((e) => Tab(text: e)).toList(),
        ),
      ),
      body: TabBarView(
        //这里绑定同一个_tabController
        controller: _tabController,
        children: tabs.map((e) {
          return KeepAliveWrapper(
            child: Container(
              alignment: Alignment.center,
              child: Text(e, textScaleFactor: 5),
            ),
          );
        }).toList(),
      ),
    );
  }

  @override
  void dispose() {
    // 释放资源
    _tabController.dispose();
    super.dispose();
  }
}

要点如下:

  • TabBarView其实就是PageView和TabBar的组合使用而已。
  • TabBarView既可以通过移动PageView来切换Tab,也可以点击TabBar来切换Tab.
  • 为了让TabBar和TabBarView保存同步,他们需要绑定到同一个TabController上。

8 UI滚动Sliver

代码在这里

参考资料在这里

8.0 基础

Sliver名称 功能 对应的可滚动组件
SliverList 列表 ListView
SliverFixedExtentList 高度固定的列表 ListView,指定itemExtent时
SliverAnimatedList 添加/删除列表项可以执行动画 AnimatedList
SliverGrid 网格 GridView
SliverPrototypeExtentList 根据原型生成高度固定的列表 ListView,指定prototypeItem 时
SliverFillViewport 包含多个子组件,每个都可以填满屏幕 PageView

除了和列表对应的 Sliver 之外还有一些用于对 Sliver 进行布局、装饰的组件,它们的子组件必须是 Sliver,我们列举几个常用的:

Sliver名称 对应RenderBox
SliverPadding Padding
SliverVisibility、SliverOpacity Visibility、Opacity
SliverFadeTransition FadeTransition
SliverLayoutBuilder LayoutBuilder

还有一些其他常用的 Sliver:

Sliver名称 说明
SliverAppBar 对应 AppBar,主要是为了在 CustomScrollView 中使用。
SliverToBoxAdapter 一个适配器,可以将 RenderBox 适配为 Sliver,后面介绍。
SliverPersistentHeader 滑动到顶部时可以固定住,后面介绍。

Sliver是flutter滚动组件的底层实现组件,所有的虚拟滚动组件(ListView,GridView,PageView)底层有对应的Sliver组件。有些时候,我们需要单独使用Sliver,而不是直接使用ListView,例如,我们需要将多个ListView, GridView, PageView放在同一个滚动列表中。或者,我们需要自定义的Sliver组件,使得它可以与现有的Sliver组件协调一起工作。

Flutter 中的可滚动组件主要由三个角色组成:Scrollable、Viewport 和 Sliver:

  • Scrollable :用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport 。
  • Viewport:显示的视窗,即列表的可视区域;
  • Sliver:视窗里显示的元素。

具体布局过程:

  • Scrollable 监听到用户滑动行为后,根据最新的滑动偏移构建 Viewport 。
  • Viewport 将当前视口信息和配置信息通过 SliverConstraints 传递给 Sliver。
  • Sliver 中对子组件(RenderBox)按需进行构建和布局,然后确认自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。

8.1 Sliver基础

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

//ListView,GridView,PageView,AnimatedList其实底层都是用CustomScrollView + Silver来实现。
//我们可以直接用CustomScrollView + 多个silver一起做,实现多个滚动组件共用一个viewport和scrollable。
class SliverDemo extends StatelessWidget {
  const SliverDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: <Widget>[
        // AppBar,包含一个导航栏.
        SliverAppBar(
          pinned: true, // 滑动到顶端时会固定住
          expandedHeight: 250.0,
          flexibleSpace: FlexibleSpaceBar(
            title: const Text('Demo'),
            background: Image.asset(
              "assets/images/star.webp",
              fit: BoxFit.cover,
            ),
          ),
        ),
        //SilverPadding下面依然需要用silver
        SliverPadding(
          padding: const EdgeInsets.all(8.0),
          sliver: SliverGrid(
            //Grid
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2, //Grid按两列显示
              mainAxisSpacing: 10.0,
              crossAxisSpacing: 10.0,
              childAspectRatio: 4.0,
            ),
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                //创建子widget
                return Container(
                  alignment: Alignment.center,
                  color: Colors.cyan[100 * (index % 9)],
                  child: Text('grid item $index'),
                );
              },
              childCount: 20,
            ),
          ),
        ),
        //DecorateSilverList下面依然需要用silver
        DecoratedSliver(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(20),
            boxShadow: const [
              BoxShadow(
                  color: Color(0xFF111133),
                  blurRadius: 2,
                  offset: Offset(-2, -1))
            ],
            gradient: const LinearGradient(
              colors: <Color>[
                Color(0xFFEEEEEE),
                Color(0xFF111133),
              ],
              stops: <double>[0.1, 1.0],
            ),
          ),
          sliver: SliverGrid(
            //Grid
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2, //Grid按两列显示
              mainAxisSpacing: 10.0,
              crossAxisSpacing: 10.0,
              childAspectRatio: 4.0,
            ),
            delegate: SliverChildBuilderDelegate(
              (BuildContext context, int index) {
                //创建子widget
                return Container(
                  alignment: Alignment.center,
                  color: Colors.cyan[100 * (index % 9)],
                  child: Text('grid item $index'),
                );
              },
              childCount: 20,
            ),
          ),
        ),
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              //创建列表项
              return Container(
                alignment: Alignment.center,
                color: Colors.lightBlue[100 * (index % 9)],
                child: Text('list item $index'),
              );
            },
            childCount: 20,
          ),
        ),

        SliverToBoxAdapter(
          //可以嵌套一个不同轴方向的滚动控件,例如是水平方向的ListView,GridView,PageView
          //如果是嵌套相同轴方向的滚动控件,要么是使用silver组件,要么是使用renderBox组件,父组件用NestedScrollView
          child: SizedBox(
            height: 300,
            child: PageView(
              children: const [Text("1"), Text("2")],
            ),
          ),
        ),
        SliverToBoxAdapter(
          //可以嵌套任意非silver组件。
          child: Container(
            padding: const EdgeInsets.symmetric(vertical: 30),
            alignment: Alignment.center,
            decoration: BoxDecoration(
              border: Border.all(color: Colors.red, width: 1),
              color: Colors.yellow,
            ),
            child: const Text("Hello World"),
          ),
        ),
      ],
    );
  }
}

要点如下:

  • 使用CustomScrollView,作为Sliver的容器。在CustomScrollView下只能存放Sliver组件,不能存放普通的RenderBox组件。因为两者的布局协议是不一样的。
  • SliverAppBar,一个允许吸顶和floating的header
  • SliverPadding,在Sliver容器下Padding组件,它的childWidget也只能是Sliver
  • DecoratedSliver,在Sliver容器下的DecoratedBox组件,它的childWidget也只能是Sliver
  • SliverGrid,在Sliver容器下的GridView组件。
  • SliverList,在Sliver容器下的ListView组件。
  • SliverToBoxAdapter,可以嵌套普通的RenderBox组件,但是注意嵌套的RenderBox组件的滚动轴方向必须是不相同的。例如在垂直方向的CustomScrollView可以嵌套水平方向的PageView,但是不能嵌套垂直方向的PageView,否则会导致滑动手势冲突。

8.2 SliverPersistHeader

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class SliverPersistHeaderDemo extends StatelessWidget {
  const SliverPersistHeaderDemo({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      //sticky 的Header,最小高度为50,初始高度为80,列表下拉的时候,会缩小为高度50
      const Expanded(
          child: PersistentHeaderRoute(
              pinned: true, floating: false, minHeight: 50, maxHeight: 80)),
      Container(
        height: 10,
        color: Colors.red,
      ),
      //sticky 的Header,最小高度为50,初始高度为50,列表下拉的时候,高度不变
      const Expanded(
          child: PersistentHeaderRoute(
              pinned: true, floating: false, minHeight: 50, maxHeight: 50)),
      Container(
        height: 10,
        color: Colors.red,
      ),
      //floating 的Header,最小高度为50,初始高度为80,列表下拉的时候,Header会消失,当稍微向上的时候,Header会重新出现,直至Header完全出现以后(高度80),才能下拉body
      const Expanded(
          child: PersistentHeaderRoute(
              pinned: false, floating: true, minHeight: 50, maxHeight: 80)),
      Container(
        height: 10,
        color: Colors.red,
      ),
      //普通的滚动Header,最小高度为50,初始高度为80,列表下拉的时候,Header会消失,当稍微向上的时候,Header会重新出现
      const Expanded(
          child: PersistentHeaderRoute(
              pinned: false, floating: false, minHeight: 50, maxHeight: 80)),
    ]);
  }
}

class PersistentHeaderRoute extends StatelessWidget {
  final bool pinned;

  final bool floating;

  final double minHeight;

  final double maxHeight;

  const PersistentHeaderRoute(
      {super.key,
      required this.pinned,
      required this.floating,
      required this.minHeight,
      required this.maxHeight});

  @override
  Widget build(BuildContext context) {
    print('$pinned, $floating, $minHeight,$maxHeight');
    return CustomScrollView(
      slivers: [
        SliverPersistentHeader(
          pinned: pinned,
          floating: floating,
          delegate: SliverHeaderDelegate(
            //有最大和最小高度
            pinned: pinned,
            minHeight: minHeight,
            maxHeight: maxHeight,
            child: buildHeader(1),
          ),
        ),
        buildSliverList(),
      ],
    );
  }

  // 构建固定高度的SliverList,count为列表项属相
  Widget buildSliverList([int count = 100]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }

  // 构建 header
  Widget buildHeader(int i) {
    return Container(
      color: Colors.lightBlue.shade200,
      alignment: Alignment.centerLeft,
      child: Text("PersistentHeader $i"),
    );
  }
}

typedef SliverHeaderBuilder = Widget Function(
    BuildContext context, double shrinkOffset, bool overlapsContent);

class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {
  // child 为 header
  SliverHeaderDelegate({
    required this.maxHeight,
    this.minHeight = 0,
    required this.pinned,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        assert(minHeight <= maxHeight && minHeight >= 0);

  final bool pinned;
  final double maxHeight;
  final double minHeight;
  final SliverHeaderBuilder builder;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    Widget child = builder(context, shrinkOffset, overlapsContent);
    //shrinkOffset为0,代表收缩程度最小,header处于最大的展开状态。
    //shrinkOffset为maxHeight,代表收缩程度最大,header处于最小的展开状态。
    //shrinkOffset读数最大为maxHeight,但是实际渲染的时候shrink只需要取maxHeight-minHeight就可以了。
    //因为body的开始渲染位置就是beginPaint = maxHeight - shrinkOffset。

    var headerExtent = maxHeight - shrinkOffset;
    var headerShowHeight = maxHeight - shrinkOffset;
    if (pinned && headerShowHeight < minHeight) {
      headerShowHeight = minHeight;
    }

    print(
        '${child.key}: shrink: $shrinkOffset,headerExtent: $headerExtent,headerHeight: $headerShowHeight, overlaps:$overlapsContent');
    // 让 header 尽可能充满限制的空间;宽度为 Viewport 宽度,
    // 高度随着用户滑动在[minHeight,maxHeight]之间变化。
    return SizedBox.expand(child: child);
  }

  @override
  double get maxExtent => maxHeight;

  @override
  double get minExtent => minHeight;

  @override
  bool shouldRebuild(SliverHeaderDelegate oldDelegate) {
    return oldDelegate.maxExtent != maxExtent ||
        oldDelegate.minExtent != minExtent;
  }
}

SliverPersistHeader是SliverAppBar的底层实现。要点如下:

  • pinned为true的时候,是滚动Header一直存在。在滚动条最顶端的时候处于maxHeight高度,否则处于minHeight的高度。如果minHeight = maxHeight,则header保持大小不变。也就是说,header只有在顶部才会出现,高度处于[minHeight,maxHeight]的状态。
  • floating为true的时候,是滚动Header按需显示。当滚动下拉的时候,header可以处于完全消失的状态。当滚动稍微往上方向的时候,header就会逐渐出现。也就是说,header随时都可以出现,高度处于[0,maxHeight]的状态。
  • pinned和floating都是为false的时候,是滚动Header仅在滚动条顶部出现,minHeight是没有意义的。也就是说,header只有在顶部才会出现,高度处于[0,maxHeight]的状态。

SliverPersistentHeader需要一个SliverHeaderDelegate

  • 返回一个minHeight,和maxHeight的高度。
  • 在build回调里面,返回Header组件。

SliverHeaderDelegate在build回调的时候,也能获取得到Sliver的布局约束。

  • shrinkOffset为0,代表收缩程度最小,header处于最大的展开状态。
  • shrinkOffset为maxHeight,代表收缩程度最大,header处于最小的展开状态。
  • header需要渲染的高度就是maxHeight - shrinkOffset。但是在pinned的情况下,实际渲染的时候header的渲染高度最小是minHeight,而不是0。

8.3 SliverMainAxisGroup

import 'package:demo/sliver/sliverPersistHeader.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

//看这里
//https://cloud.tencent.com/developer/article/2321484?areaId=106001
class ItemData {
  final String groupName;
  final List<String> users;

  ItemData({required this.groupName, this.users = const []});

  static List<ItemData> get testData => [
        ItemData(groupName: '幻将术士', users: ['梦小梦', '梦千']),
        ItemData(
            groupName: '幻将剑客', users: ['捷特', '龙少', '莫向阳', '何解连', '浪封', '梦飞烟']),
        ItemData(groupName: '幻将弓者', users: ['巫缨', '巫妻孋', '摄王', '裔王', '梦童']),
        ItemData(
            groupName: '其他', users: List.generate(20, (index) => '小兵$index')),
      ];
}

//SliverMainAxisGroup包含一个SliverPersistentHeader和SliverList就可以轻松做到分组吸顶的效果
class SliverMainAxisGroupDemo extends StatelessWidget {
  const SliverMainAxisGroupDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: ItemData.testData.map(_buildGroup).toList(),
    );
  }

  Widget _buildGroup(ItemData itemData) {
    return SliverMainAxisGroup(slivers: [
      SliverPersistentHeader(
        pinned: true,
        delegate: SliverHeaderDelegate(
          minHeight: 40,
          maxHeight: 40,
          pinned: true,
          child: Container(
            alignment: Alignment.centerLeft,
            color: const Color(0xffF6F6F6),
            padding: const EdgeInsets.only(left: 20),
            height: 40,
            child: Text(itemData.groupName),
          ),
        ),
      ),
      SliverList(
        delegate: SliverChildBuilderDelegate(
          (_, index) => _buildItemByUser(itemData.users[index]),