Flutter 嵌套地獄?不存在的,ConstraintLayout 來解救!

從 Android 轉(zhuǎn)向 Flutter 后,受不了沒有 ConstraintLayout 的日子,更反感嵌套地獄,網(wǎng)上的優(yōu)化方法都治標(biāo)不治本。于是繼 2016 年開源 MagicIndicator(9100+ star) 以后,我只能再次發(fā)力了。

https://github.com/hackware1993/Flutter_ConstraintLayout,歡迎試用并給我反饋。

Flutter ConstraintLayout

一個(gè)超級強(qiáng)大的 Stack,使用約束構(gòu)建極為靈活的布局,和 Android 下的 ConstraintLayout 和 iOS 下的 AutoLayout 類似。但代碼實(shí)現(xiàn)卻高效得多,它具有 O(n)
的布局時(shí)間復(fù)雜度,無需線性方程求解。

它在性能、靈活性、開發(fā)速度、可維護(hù)性方面全面超越傳統(tǒng)嵌套寫法。它幾乎否定了固有特性測量這種 O(2n) 的布局算法。

它是一個(gè)布局,也是一個(gè)更現(xiàn)代化的通用布局框架。

大幅提高 Flutter 的開發(fā)體驗(yàn)和效率,并提升應(yīng)用性能

不管布局有多復(fù)雜,約束有多深,它始終有媲美單一 Flex 或 Stack
的性能,在面對復(fù)雜的布局時(shí),它能提供更好的性能,更大的靈活性,更少的代碼,以及非常扁平的代碼層次結(jié)構(gòu),大大提升代碼的可維護(hù)性。對”嵌套地獄“說不。

總之一句話,用了就回不去了。

改善”嵌套地獄“是我開發(fā) Flutter ConstraintLayout 的初衷之一,但我不推崇極致地追求一層嵌套,這是不必要的。因此像鏈這種特性,F(xiàn)lex 本身已經(jīng)很好的支持了,因此
ConstraintLayout 不會(huì)積極支持它。

查看 Flutter Web 在線示例

Flutter ConstraintLayout 有極高的布局性能。它不基于 Cassowary 算法,無需線性方程求解。 任何時(shí)候,每一個(gè)子元素都只會(huì)被 layout
一次,當(dāng)自身的寬或高被設(shè)置為 wrapContent 時(shí),部分子元素可能會(huì)計(jì)算兩次 offset。約束布局的布局過程包含以下三個(gè)步驟:

  1. 約束計(jì)算
  2. 布局
  3. 繪制

其中布局和繪制的性能幾乎與單一 Flex 或 Stack 相當(dāng),約束計(jì)算的性能大致為 0.01 毫秒(一般復(fù)雜度的布局,20 個(gè)子元素)。只有在約束變化后才會(huì)重新計(jì)算約束。

約束布局自身可以被任意嵌套而不帶來性能問題,渲染樹中的每個(gè)子元素都只會(huì)被 layout 一次,時(shí)間復(fù)雜度為 O(n),而不是 O(2n) 或更糟糕的復(fù)雜度。

更小的 Widget 樹帶來了更少的 build 耗時(shí)和更小的 Element 樹。非常扁平的布局結(jié)構(gòu)帶來了更小的 RenderObject 樹和更少的渲染耗時(shí)。大多數(shù)人容易忽略的事情是復(fù)雜嵌套導(dǎo)致
build 耗時(shí)有時(shí)甚至超過渲染耗時(shí)。

推薦在頂層使用 ConstraintLayout。對于極端復(fù)雜的布局(1000 個(gè)子元素,2000 個(gè)約束),非首幀布局和繪制的總耗時(shí)在 5 毫秒內(nèi)(在 Windows 10
調(diào)試模式下,發(fā)布模式耗時(shí)更少),理論上首幀優(yōu)勢會(huì)更明顯。對于常規(guī)復(fù)雜布局(50 個(gè)子元素,100 個(gè)約束),幀率可輕松達(dá)到 200 fps。

如非必要,盡量相對于 parent 布局,這樣可以定義更少的 id,或者使用相對 id。

警告:
為了布局性能的考慮,約束總是單向的,不允許存在兩個(gè)子元素相互約束對方(比如 A 的右邊約束在 B 的左邊,而 B 的左邊又反過來約束在 A
的右邊)。每一個(gè)約束都應(yīng)該確切的描述子元素是如何定位的。盡管約束只能單向,但你仍然能更好的處理以前雙向約束才能做到的事情,比如鏈(暫時(shí)還未支持,請結(jié)合 Flex 使用)。

特性

  1. 基本約束
    1. left
      1. toLeft
      2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
      3. toRight
    2. right
      1. toLeft
      2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
      3. toRight
    3. top
      1. toTop
      2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
      3. toBottom
    4. bottom
      1. toTop
      2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
      3. toBottom
    5. baseline
      1. toTop
      2. toCenter(默認(rèn)偏移量為 0.5,代表中心)
      3. toBaseline
      4. toBottom
  2. margin and goneMargin(當(dāng)依賴的元素的可見性為 gone 或者其某一邊的實(shí)際大小為 0 時(shí),goneMargin 就會(huì)生效,否則 margin 會(huì)生效,即便其自身的可見性為
    gone)
  3. clickPadding(
    快速擴(kuò)大子元素的點(diǎn)擊區(qū)域而無需改變子元素的實(shí)際大小。這意味著你可以完全遵照視覺稿來布局,而不用為了考慮點(diǎn)擊區(qū)域而做額外的事情,這會(huì)提升一定的開發(fā)效率。這也意味著子元素之間可以在不增加嵌套的情況下共享點(diǎn)擊區(qū)域,有時(shí)可能需要結(jié)合
    e-index 使用)
  4. 可見性控制
    1. visible
    2. invisible
    3. gone(有時(shí)更好的做法是使用條件表達(dá)式來避免創(chuàng)建子元素,使用 gone 的好處是可以保留狀態(tài))
  5. 完善的約束缺失、非法、冗余提示
  6. 偏移(當(dāng)同時(shí)設(shè)置了上下或左右約束時(shí),可以使用 horizontalBias 和 verticalBias 來調(diào)整偏移。默認(rèn)值是 0.5,代表居中)
  7. z-index(繪制順序,默認(rèn)是子元素的順序)
  8. 平移、旋轉(zhuǎn)
  9. 百分比布局(當(dāng)大小被設(shè)置為 matchConstraint 時(shí),就會(huì)啟用百分比布局,默認(rèn)的百分比是 1(100%)。相關(guān)的屬性是
    widthPercent,heightPercent,widthPercentageAnchor,heightPercentageAnchor)
  10. 引導(dǎo)線
  11. 約束和 Widget 分離
  12. 柵欄
  13. 比例布局
    1. widthHeightRatio: 1 / 3,
    2. ratioBaseOnWidth: true, (默認(rèn)值是 null,代表自動(dòng)推斷,未確定邊的大小會(huì)根據(jù)確定邊的大小和 widthHeightRatio
      計(jì)算出來。未確定邊的大小必須設(shè)置為 matchConstraint,確定邊的大小可以為 matchParent,固定大小(>=0),matchConstraint)
  14. 相對 id(這是為懶癌患者設(shè)計(jì)的,因?yàn)槊莻€(gè)麻煩事。如果已經(jīng)為子元素定義了 id,則不能再使用相對 id 來引用他們)
    1. rId(3) 代表第三個(gè)子元素,以此類推
    2. sId(-1) 代表上一個(gè)兄弟元素,以此類推
    3. sId(1) 代表下一個(gè)兄弟元素,以此類推
  15. 包裝約束,是對基本約束的封裝,便于使用,最終會(huì)轉(zhuǎn)化成基本約束
    1. topLeftTo
    2. topCenterTo
    3. topRightTo
    4. centerLeftTo
    5. centerTo
    6. centerRightTo
    7. bottomLeftTo
    8. bottomCenterTo
    9. bottomRightTo
    10. centerHorizontalTo
    11. centerVerticalTo
    12. outTopLeftTo
    13. outTopCenterTo
    14. outTopRightTo
    15. outCenterLeftTo
    16. outCenterRightTo
    17. outBottomLeftTo
    18. outBottomCenterTo
    19. outBottomRightTo
    20. centerTopLeftTo
    21. centerTopCenterTo
    22. centerTopRightTo
    23. centerCenterLeftTo
    24. centerCenterRightTo
    25. centerBottomLeftTo
    26. centerBottomCenterTo
    27. centerBottomRightTo
  16. 瀑布流、網(wǎng)格、列表(列表是一個(gè)特殊的瀑布流,網(wǎng)格也是一個(gè)特殊的瀑布流)
  17. 圓形定位
  18. 圖釘定位
  19. 隨意定位
  20. e-index(事件分發(fā)順序,默認(rèn)是 z-index,一般用來處理點(diǎn)擊區(qū)域)
  21. 子元素的大小可以被設(shè)置為:
    1. 固定大小(>=0)
    2. matchParent
    3. wrapContent(默認(rèn)值,支持最大、最小設(shè)置)
    4. matchConstraint
  22. 自身的大小可以被設(shè)置為:
    1. 固定大小(>=0)
    2. matchParent(default)
    3. wrapContent(暫不支持最大、最小設(shè)置)
  23. 布局調(diào)試
    1. showHelperWidgets
    2. showClickArea
    3. showZIndex
    4. showChildDepth
    5. debugPrintConstraints
    6. showLayoutPerformanceOverlay

后續(xù)開發(fā)計(jì)劃:

  1. 約束可視化
  2. 提供可視化編輯器,通過拖拽創(chuàng)建布局
  3. 自動(dòng)將設(shè)計(jì)稿轉(zhuǎn)成代碼
  4. 更多...

支持的平臺:

  1. Android
  2. iOS
  3. Mac
  4. Windows
  5. Linux
  6. Web

導(dǎo)入

支持空安全

dependencies:
  flutter_constraintlayout:
    git:
      url: 'https://github.com/hackware1993/Flutter-ConstraintLayout.git'
      ref: 'v1.5.1-stable'
dependencies:
  flutter_constraintlayout: ^1.5.1-stable
import 'package:flutter_constraintlayout/flutter_constraintlayout.dart';

示例 Flutter Web Online Example

effect.gif
class SummaryExampleState extends State<SummaryExample> {
  double x = 0;
  double y = 0;

  ConstraintId box0 = ConstraintId('box0');
  ConstraintId box1 = ConstraintId('box1');
  ConstraintId box2 = ConstraintId('box2');
  ConstraintId box3 = ConstraintId('box3');
  ConstraintId box4 = ConstraintId('box4');
  ConstraintId box5 = ConstraintId('box5');
  ConstraintId box6 = ConstraintId('box6');
  ConstraintId box7 = ConstraintId('box7');
  ConstraintId box8 = ConstraintId('box8');
  ConstraintId box9 = ConstraintId('box9');
  ConstraintId box10 = ConstraintId('box10');
  ConstraintId box11 = ConstraintId('box11');
  ConstraintId barrier = ConstraintId('barrier');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const CustomAppBar(
        title: 'Summary',
        codePath: 'example/summary.dart',
      ),
      backgroundColor: Colors.black,
      body: ConstraintLayout(
        // Constraints can be separated from widgets
        childConstraints: [
          Constraint(
            id: box0,
            size: 200,
            bottomLeftTo: parent,
            zIndex: 20,
          )
        ],
        children: [
          Container(
            color: Colors.redAccent,
            alignment: Alignment.center,
            child: const Text('box0'),
          ).applyConstraintId(
            id: box0, // Constraints can be separated from widgets
          ),
          Container(
            color: Colors.redAccent,
            alignment: Alignment.center,
            child: const Text('box1'),
          ).apply(
            constraint: Constraint(
              // Constraints set with widgets
              id: box1,
              width: 200,
              height: 100,
              topRightTo: parent,
            ),
          ),
          Container(
            color: Colors.blue,
            alignment: Alignment.center,
            child: const Text('box2'),
          ).applyConstraint(
            // Constraints set with widgets easy way
            id: box2,
            size: matchConstraint,
            centerHorizontalTo: box3,
            top: box3.bottom,
            bottom: parent.bottom,
          ),
          Container(
            color: Colors.orange,
            width: 200,
            height: 150,
            alignment: Alignment.center,
            child: const Text('box3'),
          ).applyConstraint(
            id: box3,
            right: box1.left,
            top: box1.bottom,
          ),
          Container(
            color: Colors.redAccent,
            alignment: Alignment.center,
            child: const Text('box4'),
          ).applyConstraint(
            id: box4,
            size: 50,
            bottomRightTo: parent,
          ),
          GestureDetector(
            child: Container(
              color: Colors.pink,
              alignment: Alignment.center,
              child: const Text('box5 draggable'),
            ),
            onPanUpdate: (details) {
              setState(() {
                x += details.delta.dx;
                y += details.delta.dy;
              });
            },
          ).applyConstraint(
            id: box5,
            width: 120,
            height: 100,
            centerTo: parent,
            zIndex: 100,
            translate: Offset(x, y),
            translateConstraint: true,
          ),
          Container(
            color: Colors.lightGreen,
            alignment: Alignment.center,
            child: const Text('box6'),
          ).applyConstraint(
            id: box6,
            size: 120,
            centerVerticalTo: box2,
            verticalBias: 0.8,
            left: box3.right,
            right: parent.right,
          ),
          Container(
            color: Colors.lightGreen,
            alignment: Alignment.center,
            child: const Text('box7'),
          ).applyConstraint(
            id: box7,
            size: matchConstraint,
            left: parent.left,
            right: box3.left,
            centerVerticalTo: parent,
            margin: const EdgeInsets.all(50),
          ),
          Container(
            color: Colors.cyan,
            alignment: Alignment.center,
            child: const Text('child[7] pinned to the top right'),
          ).applyConstraint(
            width: 200,
            height: 100,
            left: box5.right,
            bottom: box5.top,
          ),
          const Text(
            'box9 baseline to box7',
            style: TextStyle(
              color: Colors.white,
            ),
          ).applyConstraint(
            id: box9,
            baseline: box7.baseline,
            left: box7.left,
          ),
          Container(
            color: Colors.yellow,
            alignment: Alignment.bottomCenter,
            child: const Text(
                'percentage layout\nwidth: 50% of parent\nheight: 30% of parent'),
          ).applyConstraint(
            size: matchConstraint,
            widthPercent: 0.5,
            heightPercent: 0.3,
            horizontalBias: 0,
            verticalBias: 0,
            centerTo: parent,
          ),
          Barrier(
            id: barrier,
            direction: BarrierDirection.left,
            referencedIds: [box6, box5],
          ),
          Container(
            color: const Color(0xFFFFD500),
            alignment: Alignment.center,
            child: const Text('align to barrier'),
          ).applyConstraint(
            width: 100,
            height: 200,
            top: box5.top,
            right: barrier.left,
          )
        ],
      ),
    );
  }
}

高級用法

  1. 引導(dǎo)線 Flutter Web 在線示例
guideline.png
class GuidelineExample extends StatelessWidget {
  const GuidelineExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ConstraintId guideline = ConstraintId('guideline');
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          children: [
            Container(
              color: const Color(0xFF005BBB),
            ).applyConstraint(
              width: matchParent,
              height: matchConstraint,
              top: parent.top,
              bottom: guideline.top,
            ),
            Guideline(
              id: guideline,
              horizontal: true,
              guidelinePercent: 0.5,
            ),
            Container(
              color: const Color(0xFFFFD500),
            ).applyConstraint(
              width: matchParent,
              height: matchConstraint,
              top: guideline.bottom,
              bottom: parent.bottom,
            ),
            const Text(
              'Align to Guideline',
              style: TextStyle(
                fontSize: 40,
                color: Colors.white,
              ),
            ).applyConstraint(
              width: wrapContent,
              height: wrapContent,
              centerHorizontalTo: parent,
              bottom: guideline.bottom,
            )
          ],
        ),
      ),
    );
  }
}
  1. 柵欄 Flutter Web 在線示例
barrier.gif
class BarrierExample extends StatelessWidget {
  const BarrierExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ConstraintId leftChild = ConstraintId('leftChild');
    ConstraintId rightChild = ConstraintId('rightChild');
    ConstraintId barrier = ConstraintId('barrier');
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          debugShowGuideline: true,
          children: [
            Container(
              color: const Color(0xFF005BBB),
            ).applyConstraint(
              id: leftChild,
              width: 200,
              height: 200,
              top: parent.top,
              left: parent.left,
            ),
            Container(
              color: const Color(0xFFFFD500),
            ).applyConstraint(
              id: rightChild,
              width: 200,
              height: matchConstraint,
              right: parent.right,
              top: parent.top,
              bottom: parent.bottom,
              heightPercent: 0.5,
              verticalBias: 0,
            ),
            Barrier(
              id: barrier,
              direction: BarrierDirection.bottom,
              referencedIds: [leftChild, rightChild],
            ),
            const Text(
              'Align to barrier',
              style: TextStyle(
                fontSize: 40,
                color: Colors.blue,
              ),
            ).applyConstraint(
              width: wrapContent,
              height: wrapContent,
              centerHorizontalTo: parent,
              top: barrier.bottom,
              goneMargin: const EdgeInsets.only(top: 20),
            )
          ],
        ),
      ),
    );
  }
}
  1. 角標(biāo) Flutter Web 在線示例
badge.png
class BadgeExample extends StatelessWidget {
  const BadgeExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ConstraintId anchor = ConstraintId('anchor');
    return Scaffold(
      body: ConstraintLayout(
        children: [
          Container(
            color: Colors.yellow,
          ).applyConstraint(
            width: 200,
            height: 200,
            centerTo: parent,
            id: anchor,
          ),
          Container(
            color: Colors.green,
            child: const Text(
              'Indeterminate badge size',
              style: TextStyle(
                color: Colors.black,
                fontSize: 20,
              ),
            ),
          ).applyConstraint(
            left: anchor.right,
            bottom: anchor.top,
            translate: const Offset(-0.5, 0.5),
            percentageTranslate: true,
          ),
          Container(
            color: Colors.green,
          ).applyConstraint(
            width: 100,
            height: 100,
            left: anchor.right,
            right: anchor.right,
            top: anchor.bottom,
            bottom: anchor.bottom,
          )
        ],
      ),
    );
  }
}
  1. 網(wǎng)格 Flutter Web 在線示例
grid.png
class GridExample extends StatelessWidget {
  const GridExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    List<Color> colors = [
      Colors.redAccent,
      Colors.greenAccent,
      Colors.blueAccent,
      Colors.orangeAccent,
      Colors.yellow,
      Colors.pink,
      Colors.lightBlueAccent
    ];
    return Scaffold(
      body: ConstraintLayout(
        children: [
          ...constraintGrid(
              id: ConstraintId('grid'),
              left: parent.left,
              top: parent.top,
              itemCount: 50,
              columnCount: 8,
              itemWidth: 50,
              itemHeight: 50,
              itemBuilder: (index) {
                return Container(
                  color: colors[index % colors.length],
                );
              },
              itemMarginBuilder: (index) {
                return const EdgeInsets.only(
                  left: 10,
                  top: 10,
                );
              })
        ],
      ),
    );
  }
}
  1. 瀑布流 Flutter Web 在線示例
staggered_grid.gif
class StaggeredGridExample extends StatelessWidget {
  const StaggeredGridExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    List<Color> colors = [
      Colors.redAccent,
      Colors.greenAccent,
      Colors.blueAccent,
      Colors.orangeAccent,
      Colors.yellow,
      Colors.pink,
      Colors.lightBlueAccent
    ];
    const double smallestSize = 40;
    const int columnCount = 8;
    Random random = Random();
    return Scaffold(
      body: ConstraintLayout(
        children: [
          TextButton(
            onPressed: () {
              (context as Element).markNeedsBuild();
            },
            child: const Text(
              'Upset',
              style: TextStyle(
                fontSize: 32,
                height: 1.5,
              ),
            ),
          ).applyConstraint(
            left: ConstraintId('horizontalList').right,
            top: ConstraintId('horizontalList').top,
          ),
          ...constraintGrid(
              id: ConstraintId('horizontalList'),
              left: parent.left,
              top: parent.top,
              margin: const EdgeInsets.only(
                left: 100,
              ),
              itemCount: 50,
              columnCount: columnCount,
              itemBuilder: (index) {
                return Container(
                  color: colors[index % colors.length],
                  alignment: Alignment.center,
                  child: Text('$index'),
                );
              },
              itemSizeBuilder: (index) {
                if (index == 0) {
                  return const Size(
                      smallestSize * columnCount + 35, smallestSize);
                }
                if (index == 6) {
                  return const Size(smallestSize * 2 + 5, smallestSize);
                }
                if (index == 7) {
                  return const Size(smallestSize * 6 + 25, smallestSize);
                }
                if (index == 19) {
                  return const Size(smallestSize * 2 + 5, smallestSize);
                }
                if (index == 29) {
                  return const Size(smallestSize * 3 + 10, smallestSize);
                }
                return Size(
                    smallestSize, (2 + random.nextInt(4)) * smallestSize);
              },
              itemSpanBuilder: (index) {
                if (index == 0) {
                  return columnCount;
                }
                if (index == 6) {
                  return 2;
                }
                if (index == 7) {
                  return 6;
                }
                if (index == 19) {
                  return 2;
                }
                if (index == 29) {
                  return 3;
                }
                return 1;
              },
              itemMarginBuilder: (index) {
                return const EdgeInsets.only(
                  left: 5,
                  top: 5,
                );
              })
        ],
      ),
    );
  }
}
  1. 圓形定位 Flutter Web 在線示例
circle_position.gif
class CirclePositionExampleState extends State<CirclePositionExample> {
  late Timer timer;
  late int hour;
  late int minute;
  late int second;

  double centerTranslateX = 0;
  double centerTranslateY = 0;

  @override
  void initState() {
    super.initState();
    calculateClockAngle();
    timer = Timer.periodic(const Duration(seconds: 1), (_) {
      calculateClockAngle();
    });
  }

  void calculateClockAngle() {
    setState(() {
      DateTime now = DateTime.now();
      hour = now.hour;
      minute = now.minute;
      second = now.second;
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ConstraintLayout(
        children: [
          GestureDetector(
            child: Container(
              decoration: const BoxDecoration(
                color: Colors.red,
                borderRadius: BorderRadius.all(
                  Radius.circular(1000),
                ),
              ),
            ),
            onPanUpdate: (details) {
              setState(() {
                centerTranslateX += details.delta.dx;
                centerTranslateY += details.delta.dy;
              });
            },
          ).applyConstraint(
            width: 20,
            height: 20,
            centerTo: parent,
            zIndex: 100,
            translate: Offset(centerTranslateX, centerTranslateY),
            translateConstraint: true,
          ),
          for (int i = 0; i < 12; i++)
            Text(
              '${i + 1}',
              style: const TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 25,
              ),
            ).applyConstraint(
              centerTo: rId(0),
              translate: circleTranslate(
                radius: 205,
                angle: (i + 1) * 30,
              ),
            ),
          for (int i = 0; i < 60; i++)
            if (i % 5 != 0)
              Transform.rotate(
                angle: pi + pi * (i * 6 / 180),
                child: Container(
                  color: Colors.grey,
                  margin: const EdgeInsets.only(
                    top: 405,
                  ),
                ),
              ).applyConstraint(
                width: 1,
                height: 415,
                centerTo: rId(0),
              ),
          Transform.rotate(
            angle: pi + pi * (hour * 30 / 180),
            alignment: Alignment.topCenter,
            child: Container(
              color: Colors.green,
            ),
          ).applyConstraint(
            width: 5,
            height: 80,
            centerTo: rId(0),
            translate: const Offset(0, 0.5),
            percentageTranslate: true,
          ),
          Transform.rotate(
            angle: pi + pi * (minute * 6 / 180),
            alignment: Alignment.topCenter,
            child: Container(
              color: Colors.pink,
            ),
          ).applyConstraint(
            width: 5,
            height: 120,
            centerTo: rId(0),
            translate: const Offset(0, 0.5),
            percentageTranslate: true,
          ),
          Transform.rotate(
            angle: pi + pi * (second * 6 / 180),
            alignment: Alignment.topCenter,
            child: Container(
              color: Colors.blue,
            ),
          ).applyConstraint(
            width: 5,
            height: 180,
            centerTo: rId(0),
            translate: const Offset(0, 0.5),
            percentageTranslate: true,
          ),
          Text(
            '$hour:$minute:$second',
            style: const TextStyle(
              fontSize: 40,
            ),
          ).applyConstraint(
            outTopCenterTo: rId(0),
            margin: const EdgeInsets.only(
              bottom: 250,
            ),
          )
        ],
      ),
    );
  }
}
  1. margin Flutter Web 在線示例
margin.png
class MarginExample extends StatelessWidget {
  const MarginExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ConstraintLayout(
        children: [
          Container(
            color: const Color(0xFF005BBB),
          ).applyConstraint(
            size: 50,
            topLeftTo: parent,
            margin: const EdgeInsets.only(
              left: 20,
              top: 100,
            ),
          ),
          Container(
            color: const Color(0xFFFFD500),
          ).applyConstraint(
            size: 100,
            top: sId(-1).bottom,
            right: parent.right.margin(100),
          ),
          Container(
            color: Colors.pink,
          ).applyConstraint(
            size: 50,
            topRightTo: parent.rightMargin(20).topMargin(50),
          ),
        ],
      ),
    );
  }
}
  1. 圖釘定位 Flutter Web 在線示例
pinned_position.gif
class PinnedPositionExampleState extends State<PinnedPositionExample> {
  late Timer timer;
  double angle = 0;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
      setState(() {
        angle++;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    ConstraintId anchor = ConstraintId('anchor');
    return Scaffold(
      appBar: const CustomAppBar(
        title: 'Pinned Position',
        codePath: 'example/pinned_position.dart',
      ),
      body: ConstraintLayout(
        children: [
          Container(
            color: Colors.yellow,
          ).applyConstraint(
            id: anchor,
            size: 200,
            centerTo: parent,
          ),
          Container(
            color: Colors.cyan,
          ).applyConstraint(
            size: 100,
            pinnedInfo: PinnedInfo(
              anchor,
              Anchor(0.2, AnchorType.percent, 0.2, AnchorType.percent),
              Anchor(1, AnchorType.percent, 1, AnchorType.percent),
              angle: angle,
            ),
          ),
          Container(
            color: Colors.orange,
          ).applyConstraint(
            size: 60,
            pinnedInfo: PinnedInfo(
              anchor,
              Anchor(1, AnchorType.percent, 1, AnchorType.percent),
              Anchor(0, AnchorType.percent, 0, AnchorType.percent),
              angle: 360 - angle,
            ),
          ),
          Container(
            color: Colors.black,
          ).applyConstraint(
            size: 60,
            pinnedInfo: PinnedInfo(
              anchor,
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              angle: angle,
            ),
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.red,
              borderRadius: BorderRadius.all(Radius.circular(10)),
            ),
          ).applyConstraint(
            size: 10,
            centerBottomRightTo: anchor,
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.red,
              borderRadius: BorderRadius.all(Radius.circular(10)),
            ),
          ).applyConstraint(
            size: 10,
            centerTopLeftTo: anchor,
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.red,
              borderRadius: BorderRadius.all(Radius.circular(10)),
            ),
          ).applyConstraint(
            size: 10,
            centerTo: anchor,
          )
        ],
      ),
    );
  }
}
  1. 平移 Flutter Web 在線示例
translate.gif
class TrackPainter extends CustomPainter {
  Queue<Offset> points = Queue();
  Paint painter = Paint();

  TrackPainter(this.points);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPoints(PointMode.polygon, points.toList(), painter);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

class TranslateExampleState extends State<TranslateExample> {
  late Timer timer;
  double angle = 0;
  double earthRevolutionAngle = 0;
  Queue<Offset> points = Queue();

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
      setState(() {
        angle += 1;
        earthRevolutionAngle += 0.1;
      });
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    ConstraintId anchor = ConstraintId('anchor');
    return Scaffold(
      appBar: const CustomAppBar(
        title: 'Translate',
        codePath: 'example/translate.dart',
      ),
      body: ConstraintLayout(
        children: [
          CustomPaint(
            painter: TrackPainter(points),
          ).applyConstraint(
            width: matchParent,
            height: matchParent,
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.redAccent,
              borderRadius: BorderRadius.all(Radius.circular(1000)),
            ),
            child: const Text('----'),
            alignment: Alignment.center,
          ).applyConstraint(
            id: cId('sun'),
            size: 200,
            pinnedInfo: PinnedInfo(
              parent,
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              Anchor(0.3, AnchorType.percent, 0.5, AnchorType.percent),
              angle: earthRevolutionAngle * 365 / 25.4,
            ),
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.blue,
              borderRadius: BorderRadius.all(Radius.circular(1000)),
            ),
            child: const Text('----'),
            alignment: Alignment.center,
          ).applyConstraint(
            id: cId('earth'),
            size: 100,
            pinnedInfo: PinnedInfo(
              cId('sun'),
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              angle: earthRevolutionAngle * 365,
            ),
            translate: circleTranslate(
              radius: 250,
              angle: earthRevolutionAngle,
            ),
            translateConstraint: true,
          ),
          Container(
            decoration: const BoxDecoration(
              color: Colors.grey,
              borderRadius: BorderRadius.all(Radius.circular(1000)),
            ),
            child: const Text('----'),
            alignment: Alignment.center,
          ).applyConstraint(
            id: cId('moon'),
            size: 50,
            pinnedInfo: PinnedInfo(
              cId('earth'),
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
              angle: earthRevolutionAngle * 365 / 27.32,
            ),
            translate: circleTranslate(
              radius: 100,
              angle: earthRevolutionAngle * 365 / 27.32,
            ),
            translateConstraint: true,
            paintCallback: (_, __, ____, offset, ______) {
              points.add(offset!);
              if (points.length > 2000) {
                points.removeFirst();
              }
            },
          ),
          Text('Sun rotates ${(earthRevolutionAngle * 365 / 25.4) ~/ 360} times')
              .applyConstraint(
            outTopCenterTo: cId('sun'),
          ),
          Text('Earth rotates ${earthRevolutionAngle * 365 ~/ 360} times')
              .applyConstraint(
            outTopCenterTo: cId('earth'),
          ),
          Text('Moon rotates ${(earthRevolutionAngle * 365 / 27.32) ~/ 360} times')
              .applyConstraint(
            outTopCenterTo: cId('moon'),
          ),
          Container(
            color: Colors.yellow,
          ).applyConstraint(
            id: anchor,
            size: 250,
            centerRightTo: parent.rightMargin(300),
          ),
          Container(
            color: Colors.red,
            child: const Text('pinned translate'),
          ).applyConstraint(
            centerTo: anchor,
            translate: PinnedTranslate(
              PinnedInfo(
                null,
                Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
                null,
                angle: angle,
              ),
            ),
          ),
          Container(
            color: Colors.blue,
            child: const Text('circle translate'),
          ).applyConstraint(
            size: wrapContent,
            centerTo: anchor,
            translate: circleTranslate(
              radius: 100,
              angle: angle,
            ),
          ),
          Container(
            color: Colors.cyan,
            child: const Text('pinned & circle translate'),
          ).applyConstraint(
            centerTo: anchor,
            translate: PinnedTranslate(
              PinnedInfo(
                null,
                Anchor(0.5, AnchorType.percent, 0.5, AnchorType.percent),
                null,
                angle: angle,
              ),
            ) +
                circleTranslate(
                  radius: 150,
                  angle: angle,
                ),
          ),
          Container(
            color: Colors.orange,
            child: const Text('normal translate'),
          ).applyConstraint(
            size: wrapContent,
            outBottomCenterTo: anchor,
            translate: Offset(0, angle / 5),
          )
        ],
      ),
    );
  }
}

性能優(yōu)化

  1. 當(dāng)布局復(fù)雜時(shí),如果子元素需要頻繁重繪,可以考慮使用 RepaintBoundary。當(dāng)然合成 Layer 也有開銷,所以需要合理使用。
class OffPaintExample extends StatelessWidget {
  const OffPaintExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          children: [
            Container(
              color: Colors.orangeAccent,
            ).offPaint().applyConstraint(
              width: 200,
              height: 200,
              topRightTo: parent,
            )
          ],
        ),
      ),
    );
  }
}
  1. 盡量使用 const Widget。如果你沒法將子元素聲明為 const 而它自身又不會(huì)改變。可以使用內(nèi)置的 OffBuildWidget 來避免子元素重復(fù) build。
class OffBuildExample extends StatelessWidget {
  const OffBuildExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ConstraintLayout(
          children: [

            /// subtrees that do not change
            Container(
              color: Colors.orangeAccent,
            ).offBuild(id: 'id').applyConstraint(
              width: 200,
              height: 200,
              topRightTo: parent,
            )
          ],
        ),
      ),
    );
  }
}
  1. 子元素會(huì)自動(dòng)成為 RelayoutBoundary 除非它的寬或高是 wrapContent。可以酌情的減少 wrapContent 的使用,因?yàn)楫?dāng) ConstraintLayout
    自身的大小發(fā)生變化時(shí)(通常是窗口大小發(fā)生變化,移動(dòng)端幾乎不存在此類情況),所有寬或高為 wrapContent
    的子元素都會(huì)被重新布局。而其他元素由于傳遞給它們的約束未發(fā)生變化,不會(huì)觸發(fā)真正的布局。

  2. 如果你在 children 列表中使用 Guideline 或 Barrier, Element 和 RenderObject 將不可避免的被創(chuàng)建,它們會(huì)被布局但不會(huì)繪制。此時(shí)你可以使用
    GuidelineDefine 或 BarrierDefine 來優(yōu)化, Element 和 RenderObject 就不會(huì)再創(chuàng)建了。

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

  @override
  Widget build(BuildContext context) {
    ConstraintId leftChild = ConstraintId('leftChild');
    ConstraintId rightChild = ConstraintId('rightChild');
    ConstraintId barrier = ConstraintId('barrier');
    return Scaffold(
      body: ConstraintLayout(
        childConstraints: [
          BarrierDefine(
            id: barrier,
            direction: BarrierDirection.bottom,
            referencedIds: [leftChild, rightChild],
          ),
        ],
        children: [
          Container(
            color: const Color(0xFF005BBB),
          ).applyConstraint(
            id: leftChild,
            width: 200,
            height: 200,
            topLeftTo: parent,
          ),
          Container(
            color: const Color(0xFFFFD500),
          ).applyConstraint(
            id: rightChild,
            width: 200,
            height: matchConstraint,
            centerRightTo: parent,
            heightPercent: 0.5,
            verticalBias: 0,
          ),
          const Text(
            'Align to barrier',
            style: TextStyle(
              fontSize: 40,
              color: Colors.blue,
            ),
          ).applyConstraint(
            centerHorizontalTo: parent,
            top: barrier.bottom,
          )
        ],
      ),
    );
  }
}   
  1. 每一幀,ConstraintLayout 會(huì)比對參數(shù)并決定以下事情:
    1. 是否需要重新計(jì)算約束?
    2. 是否需要重新布局?
    3. 是否需要重新繪制?
    4. 是否需要重排繪制順序?
    5. 是否需要重排事件分發(fā)順序?

這些比對不會(huì)成為性能瓶頸,但會(huì)提高 CPU 占用率。如果你對 ConstraintLayout 內(nèi)部原理足夠了解,你可以使用 ConstraintLayoutController
來手動(dòng)觸發(fā)這些操作,停止參數(shù)比對。

class ConstraintControllerExampleState extends State<ConstraintControllerExample> {
  double x = 0;
  double y = 0;
  ConstraintLayoutController controller = ConstraintLayoutController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const CustomAppBar(
        title: 'Constraint Controller',
        codePath: 'example/constraint_controller.dart',
      ),
      body: ConstraintLayout(
        controller: controller,
        children: [
          GestureDetector(
            child: Container(
              color: Colors.pink,
              alignment: Alignment.center,
              child: const Text('box draggable'),
            ),
            onPanUpdate: (details) {
              setState(() {
                x += details.delta.dx;
                y += details.delta.dy;
                controller.markNeedsPaint();
              });
            },
          ).applyConstraint(
            size: 200,
            centerTo: parent,
            translate: Offset(x, y),
          )
        ],
      ),
    );
  }
}

擴(kuò)展

ConstraintLayout 基于約束的布局算法極其強(qiáng)大和靈活,似乎可以成為了一個(gè)通用的布局框架。你只需要生成約束,將布局的任務(wù)交給 ConstraintLayout
即可。部分內(nèi)置功能比如圓形定位、瀑布流、網(wǎng)格、列表以擴(kuò)展的形式提供。

在線示例中的圖表就是一個(gè)典型的擴(kuò)展:

charts.gif

歡迎為 ConstraintLayout 開發(fā)擴(kuò)展。

支持我

如果它對你幫助很大,可以考慮贊助我一杯奶茶,或者給個(gè) star。你的支持是我繼續(xù)維護(hù)的動(dòng)力。

聯(lián)系方式

hackware1993@gmail.com

協(xié)議

MIT License

Copyright (c) 2022 hackware1993

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,750評論 1 375
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內(nèi)容