【Flutter實戰】六大布局組件及半圓菜單案例

老孟導讀:Flutter中布局組件有水平 / 垂直布局組件( RowColumn )、疊加布局組件( StackIndexedStack )、流式布局組件( Wrap )和 自定義布局組件(Flow)。

水平、垂直布局組件

Row 是將子組件以水平方式布局的組件, Column 是將子組件以垂直方式布局的組件。項目中 90% 的頁面布局都可以通過 Row 和 Column 來實現。

將3個組件水平排列:

Row(
  children: <Widget>[
    Container(
      height: 50,
      width: 100,
      color: Colors.red,
    ),
    Container(
      height: 50,
      width: 100,
      color: Colors.green,
    ),
    Container(
      height: 50,
      width: 100,
      color: Colors.blue,
    ),
  ],
)

將3個組件垂直排列:

Column(
  mainAxisSize: MainAxisSize.min,
  children: <Widget>[
    Container(
      height: 50,
      width: 100,
      color: Colors.red,
    ),
    Container(
      height: 50,
      width: 100,
      color: Colors.green,
    ),
    Container(
      height: 50,
      width: 100,
      color: Colors.blue,
    ),
  ],
)

在 Row 和 Column 中有一個非常重要的概念:主軸( MainAxis )交叉軸( CrossAxis ),主軸就是與組件布局方向一致的軸,交叉軸就是與主軸方向垂直的軸。

具體到 Row 組件,主軸 是水平方向,交叉軸 是垂直方向。而 Column 與 Row 正好相反,主軸 是垂直方向,交叉軸 是水平方向。

明白了 主軸 和 交叉軸 概念,我們來看下 mainAxisAlignment 屬性,此屬性表示主軸方向的對齊方式,默認值為 start,表示從組件的開始處布局,此處的開始位置和 textDirection 屬性有關,textDirection 表示文本的布局方向,其值包括 ltr(從左到右) 和 rtl(從右到左),當 textDirection = ltr 時,start 表示左側,當 textDirection = rtl 時,start 表示右側,

Container(
  decoration: BoxDecoration(border: Border.all(color: Colors.black)),
  child: Row(
    children: <Widget>[
      Container(
        height: 50,
        width: 100,
        color: Colors.red,
      ),
      Container(
        height: 50,
        width: 100,
        color: Colors.green,
      ),
      Container(
        height: 50,
        width: 100,
        color: Colors.blue,
      ),
    ],
  ),
)

黑色邊框是Row控件的範圍,默認情況下Row鋪滿父組件。

主軸對齊方式有6種,效果如下圖:

spaceAround 和 spaceEvenly 區別是:

  • spaceAround :第一個子控件距開始位置和最後一個子控件距結尾位置是其他子控件間距的一半。
  • spaceEvenly : 所有間距一樣。

和主軸對齊方式相對應的就是交叉軸對齊方式 crossAxisAlignment ,交叉軸對齊方式默認是居中。Row控件的高度是依賴子控件高度,因此子控件高都一樣時,Row的高和子控件高相同,此時是無法體現交叉軸對齊方式,修改3個顏色塊高分別為50,100,150,這樣Row的高是150,代碼如下:

Container(
      decoration: BoxDecoration(border: Border.all(color: Colors.black)),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Container(
            height: 50,
            width: 100,
            color: Colors.red,
          ),
          Container(
            height: 100,
            width: 100,
            color: Colors.green,
          ),
          Container(
            height: 150,
            width: 100,
            color: Colors.blue,
          ),
        ],
      ),
    )

主軸對齊方式效果如下圖:

mainAxisSize 表示主軸尺寸,有 min 和 max 兩種方式,默認是 maxmin 表示盡可能小,max 表示盡可能大。

Container(
	decoration: BoxDecoration(border: Border.all(color: Colors.black)),
	child: Row(
		mainAxisSize: MainAxisSize.min,
		...
	)
)

看黑色邊框,正好包裹子組件,而 max 效果如下:

textDirection 表示子組件主軸布局方向,值包括 ltr(從左到右) 和 rtl(從右到左)

Container(
  decoration: BoxDecoration(border: Border.all(color: Colors.black)),
  child: Row(
    textDirection: TextDirection.rtl,
    children: <Widget>[
      ...
    ],
  ),
)

verticalDirection 表示子組件交叉軸布局方向:

  • up :從底部開始,並垂直堆疊到頂部,對齊方式的 start 在底部,end 在頂部。
  • down: 與 up 相反。
Container(
  decoration: BoxDecoration(border: Border.all(color: Colors.black)),
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.start,
    verticalDirection: VerticalDirection.up,
    children: <Widget>[
      Container(
        height: 50,
        width: 100,
        color: Colors.red,
      ),
      Container(
        height: 100,
        width: 100,
        color: Colors.green,
      ),
      Container(
        height: 150,
        width: 100,
        color: Colors.blue,
      ),
    ],
  ),
)

想一想這種效果完全可以通過對齊方式實現,那麼為什麼還要有 textDirectionverticalDirection 這兩個屬性,官方API文檔已經解釋了這個問題:

This is also used to disambiguate start and end values (e.g. [MainAxisAlignment.start] or [CrossAxisAlignment.end]).

用於消除 MainAxisAlignment.start 和 CrossAxisAlignment.end 值的歧義的。

疊加布局組件

疊加布局組件包含 StackIndexedStack,Stack 組件將子組件疊加显示,根據子組件的順利依次向上疊加,用法如下:

Stack(
  children: <Widget>[
    Container(
      height: 200,
      width: 200,
      color: Colors.red,
    ),
    Container(
      height: 170,
      width: 170,
      color: Colors.blue,
    ),
    Container(
      height: 140,
      width: 140,
      color: Colors.yellow,
    )
  ],
)

Stack 對未定位(不被 Positioned 包裹)子組件的大小由 fit 參數決定,默認值是 StackFit.loose ,表示子組件自己決定,StackFit.expand 表示盡可能的大,用法如下:

Stack(
  fit: StackFit.expand,
  children: <Widget>[
    Container(
      height: 200,
      width: 200,
      color: Colors.red,
    ),
    Container(
      height: 170,
      width: 170,
      color: Colors.blue,
    ),
    Container(
      height: 140,
      width: 140,
      color: Colors.yellow,
    )
  ],
)

效果只有黃色(最後一個組件的顏色),並不是其他組件沒有繪製,而是另外兩個組件被黃色組件覆蓋。

Stack 對未定位(不被 Positioned 包裹)子組件的對齊方式由 alignment 控制,默認左上角對齊,用法如下:

Stack(
  alignment: AlignmentDirectional.center,
  children: <Widget>[
    Container(
      height: 200,
      width: 200,
      color: Colors.red,
    ),
    Container(
      height: 170,
      width: 170,
      color: Colors.blue,
    ),
    Container(
      height: 140,
      width: 140,
      color: Colors.yellow,
    )
  ],
)

通過 Positioned 定位的子組件:

Stack(
  alignment: AlignmentDirectional.center,
  children: <Widget>[
    Container(
      height: 200,
      width: 200,
      color: Colors.red,
    ),
    Container(
      height: 170,
      width: 170,
      color: Colors.blue,
    ),
    Positioned(
      left: 30,
      right: 40,
      bottom: 50,
      top: 60,
      child: Container(
        color: Colors.yellow,
      ),
    )
  ],
)

topbottomleftright 四種定位屬性,分別表示距離上下左右的距離。

如果子組件超過 Stack 邊界由 overflow 控制,默認是裁剪,下面設置總是显示的用法:

Stack(
  overflow: Overflow.visible,
  children: <Widget>[
    Container(
      height: 200,
      width: 200,
      color: Colors.red,
    ),
    Positioned(
      left: 100,
      top: 100,
      height: 150,
      width: 150,
      child: Container(
        color: Colors.green,
      ),
    )
  ],
)

IndexedStack 是 Stack 的子類,Stack 是將所有的子組件疊加显示,而 IndexedStack 通過 index 只显示指定索引的子組件,用法如下:

class IndexedStackDemo extends StatefulWidget {
  @override
  _IndexedStackDemoState createState() => _IndexedStackDemoState();
}

class _IndexedStackDemoState extends State<IndexedStackDemo> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        SizedBox(height: 50,),
        _buildIndexedStack(),
        SizedBox(height: 30,),
        _buildRow(),
      ],
    );
  }

  _buildIndexedStack() {
    return IndexedStack(
      index: _index,
      children: <Widget>[
        Center(
          child: Container(
            height: 300,
            width: 300,
            color: Colors.red,
            alignment: Alignment.center,
            child: Icon(
              Icons.fastfood,
              size: 60,
              color: Colors.blue,
            ),
          ),
        ),
        Center(
          child: Container(
            height: 300,
            width: 300,
            color: Colors.green,
            alignment: Alignment.center,
            child: Icon(
              Icons.cake,
              size: 60,
              color: Colors.blue,
            ),
          ),
        ),
        Center(
          child: Container(
            height: 300,
            width: 300,
            color: Colors.yellow,
            alignment: Alignment.center,
            child: Icon(
              Icons.local_cafe,
              size: 60,
              color: Colors.blue,
            ),
          ),
        ),
      ],
    );
  }

  _buildRow() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        IconButton(
          icon: Icon(Icons.fastfood),
          onPressed: () {
            setState(() {
              _index = 0;
            });
          },
        ),
        IconButton(
          icon: Icon(Icons.cake),
          onPressed: () {
            setState(() {
              _index = 1;
            });
          },
        ),
        IconButton(
          icon: Icon(Icons.local_cafe),
          onPressed: () {
            setState(() {
              _index = 2;
            });
          },
        ),
      ],
    );
  }
}

流式布局組件

Wrap 為子組件進行水平或者垂直方向布局,且當空間用完時,Wrap 會自動換行,也就是流式布局。

創建多個子控件做為 Wrap 的子控件,代碼如下:

Wrap(
  children: List.generate(10, (i) {
    double w = 50.0 + 10 * i;
    return Container(
      color: Colors.primaries[i],
      height: 50,
      width: w,
      child: Text('$i'),
    );
  }),
)

direction 屬性控制布局方向,默認為水平方向,設置方向為垂直代碼如下:

Wrap(
  direction: Axis.vertical,
  children: List.generate(4, (i) {
    double w = 50.0 + 10 * i;
    return Container(
      color: Colors.primaries[i],
      height: 50,
      width: w,
      child: Text('$i'),
    );
  }),
)

alignment 屬性控制主軸對齊方式,crossAxisAlignment 屬性控制交叉軸對齊方式,對齊方式只對有剩餘空間的行或者列起作用,例如水平方向上正好填充完整,則不管設置主軸對齊方式為什麼,看上去的效果都是鋪滿。

說明 :主軸就是與當前組件方向一致的軸,而交叉軸就是與當前組件方向垂直的軸,如果Wrap的布局方向為水平方向 Axis.horizontal,那麼主軸就是水平方向,反之布局方向為垂直方向 Axis.vertical ,主軸就是垂直方向。

Wrap(
	alignment: WrapAlignment.spaceBetween,
	...
)

主軸對齊方式有6種,效果如下圖:

spaceAroundspaceEvenly 區別是:

  • spaceAround:第一個子控件距開始位置和最後一個子控件距結尾位置是其他子控件間距的一半。
  • spaceEvenly:所有間距一樣。

設置交叉軸對齊代碼如下:

Wrap(
	crossAxisAlignment: WrapCrossAlignment.center,
	...
)

如果 Wrap 的主軸方向為水平方向,交叉軸方向則為垂直方向,如果想要看到交叉軸對齊方式的效果需要設置子控件的高不一樣,代碼如下:

Wrap(
  spacing: 5,
  runSpacing: 3,
  crossAxisAlignment: WrapCrossAlignment.center,
  children: List.generate(10, (i) {
    double w = 50.0 + 10 * i;
    double h = 50.0 + 5 * i;
    return Container(
      color: Colors.primaries[i],
      height: h,
      alignment: Alignment.center,
      width: w,
      child: Text('$i'),
    );
  }),
)

runAlignment 屬性控制 Wrap 的交叉抽方向上每一行的對齊方式,下面直接看 runAlignment 6中方式對應的效果圖,

runAlignmentalignment 的區別:

  • alignment :是主軸方向上對齊方式,作用於每一行。
  • runAlignment :是交叉軸方向上將每一行看作一個整體的對齊方式。

spacingrunSpacing 屬性控制Wrap主軸方向和交叉軸方向子控件之間的間隙,代碼如下:

Wrap(
	spacing: 5,
    runSpacing: 2,
	...
)

textDirection 屬性表示 Wrap 主軸方向上子組件的方向,取值範圍是 ltr(從左到右) 和 rtl(從右到左),下面是從右到左的代碼:

Wrap(
	textDirection: TextDirection.rtl,
	...
)

verticalDirection 屬性表示 Wrap 交叉軸方向上子組件的方向,取值範圍是 up(向上) 和 down(向下),設置代碼如下:

Wrap(
	verticalDirection: VerticalDirection.up,
	...
)

注意:文字為0的組件是在下面的。

自定義布局組件

大部分情況下,不會使用到 Flow ,但 Flow 可以調整子組件的位置和大小,結合Matrix4繪製出各種酷炫的效果。

Flow 組件對使用轉換矩陣操作子組件經過系統優化,性能非常高效。

基本用法如下:

Flow(
  delegate: SimpleFlowDelegate(),
  children: List.generate(5, (index) {
    return Container(
      height: 100,
      color: Colors.primaries[index % Colors.primaries.length],
    );
  }),
)

delegate 控制子組件的位置和大小,定義如下 :

class SimpleFlowDelegate extends FlowDelegate {
  @override
  void paintChildren(FlowPaintingContext context) {
    for (int i = 0; i < context.childCount; ++i) {
      context.paintChild(i);
    }
  }

  @override
  bool shouldRepaint(SimpleFlowDelegate oldDelegate) {
    return false;
  }
}

delegate 要繼承 FlowDelegate,重寫 paintChildrenshouldRepaint 函數,上面直接繪製子組件,效果如下:

只看到一種顏色並不是只繪製了這一個,而是疊加覆蓋了,和 Stack 類似,下面讓每一個組件有一定的偏移,SimpleFlowDelegate 修改如下:

class SimpleFlowDelegate extends FlowDelegate {
  @override
  void paintChildren(FlowPaintingContext context) {
    for (int i = 0; i < context.childCount; ++i) {
      context.paintChild(i,transform: Matrix4.translationValues(0,i*30.0,0));
    }
  }

  @override
  bool shouldRepaint(SimpleFlowDelegate oldDelegate) {
    return false;
  }
}

每一個子組件比上一個組件向下偏移30。

仿 掘金-我的效果

效果如下:

到拿到一個頁面時,先要將其拆分,上面的效果拆分如下:

總體分為3個部分,水平布局,紅色區域圓形頭像代碼如下:

_buildCircleImg() {
  return Container(
    height: 60,
    width: 60,
    decoration: BoxDecoration(
        shape: BoxShape.circle,
        image: DecorationImage(image: AssetImage('assets/images/logo.png'))),
  );
}

藍色區域代碼如下:

_buildCenter() {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      Text('老孟Flutter', style: TextStyle(fontSize: 20),),
      Text('Flutter、Android', style: TextStyle(color: Colors.grey),)
    ],
  );
}

綠色區域是一個圖標,代碼如下:

Icon(Icons.arrow_forward_ios,color: Colors.grey,size: 14,),

將這3部分組合在一起:

Container(
  color: Colors.grey.withOpacity(.5),
  alignment: Alignment.center,
  child: Container(
    height: 100,
    color: Colors.white,
    child: Row(
      children: <Widget>[
        SizedBox(
          width: 15,
        ),
        _buildCircleImg(),
        SizedBox(
          width: 25,
        ),
        Expanded(
          child: _buildCenter(),
        ),
        Icon(Icons.arrow_forward_ios,color: Colors.grey,size: 14,),
        SizedBox(
          width: 15,
        ),
      ],
    ),
  ),
)

最終的效果就是開始我們看到的效果圖。

水平展開/收起菜單

使用Flow實現水平展開/收起菜單的功能,代碼如下:

class DemoFlowPopMenu extends StatefulWidget {
  @override
  _DemoFlowPopMenuState createState() => _DemoFlowPopMenuState();
}

class _DemoFlowPopMenuState extends State<DemoFlowPopMenu>
    with SingleTickerProviderStateMixin {
  //動畫必須要with這個類
  AnimationController _ctrlAnimationPopMenu; //定義動畫的變量
  IconData lastTapped = Icons.notifications;
  final List<IconData> menuItems = <IconData>[
    //菜單的icon
    Icons.home,
    Icons.new_releases,
    Icons.notifications,
    Icons.settings,
    Icons.menu,
  ];

  void _updateMenu(IconData icon) {
    if (icon != Icons.menu) {
      setState(() => lastTapped = icon);
    } else {
      _ctrlAnimationPopMenu.status == AnimationStatus.completed
          ? _ctrlAnimationPopMenu.reverse() //展開和收攏的效果
          : _ctrlAnimationPopMenu.forward();
    }
  }

  @override
  void initState() {
    super.initState();
    _ctrlAnimationPopMenu = AnimationController(
      //必須初始化動畫變量
      duration: const Duration(milliseconds: 250), //動畫時長250毫秒
      vsync: this, //SingleTickerProviderStateMixin的作用
    );
  }

//生成Popmenu數據
  Widget flowMenuItem(IconData icon) {
    final double buttonDiameter =
        MediaQuery.of(context).size.width * 2 / (menuItems.length * 3);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8.0),
      child: RawMaterialButton(
        fillColor: lastTapped == icon ? Colors.amber[700] : Colors.blue,
        splashColor: Colors.amber[100],
        shape: CircleBorder(),
        constraints: BoxConstraints.tight(Size(buttonDiameter, buttonDiameter)),
        onPressed: () {
          _updateMenu(icon);
        },
        child: Icon(icon, color: Colors.white, size: 30.0),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Flow(
        delegate: FlowMenuDelegate(animation: _ctrlAnimationPopMenu),
        children: menuItems
            .map<Widget>((IconData icon) => flowMenuItem(icon))
            .toList(),
      ),
    );
  }
}

FlowMenuDelegate 定義如下:

class FlowMenuDelegate extends FlowDelegate {
  FlowMenuDelegate({this.animation}) : super(repaint: animation);
  final Animation<double> animation;

  @override
  void paintChildren(FlowPaintingContext context) {
    double x = 50.0; //起始位置
    double y = 50.0; //橫向展開,y不變
    for (int i = 0; i < context.childCount; ++i) {
      x = context.getChildSize(i).width * i * animation.value;
      context.paintChild(
        i,
        transform: Matrix4.translationValues(x, y, 0),
      );
    }
  }

  @override
  bool shouldRepaint(FlowMenuDelegate oldDelegate) =>
      animation != oldDelegate.animation;
}

半圓菜單展開/收起

代碼如下:

import 'dart:math';

import 'package:flutter/material.dart';

class DemoFlowMenu extends StatefulWidget {
  @override
  _DemoFlowMenuState createState() => _DemoFlowMenuState();
}

class _DemoFlowMenuState extends State<DemoFlowMenu>
    with TickerProviderStateMixin {
  //動畫需要這個類來混合
  //動畫變量,以及初始化和銷毀
  AnimationController _ctrlAnimationCircle;

  @override
  void initState() {
    super.initState();
    _ctrlAnimationCircle = AnimationController(
        //初始化動畫變量
        lowerBound: 0,
        upperBound: 80,
        duration: Duration(milliseconds: 300),
        vsync: this);
    _ctrlAnimationCircle.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    _ctrlAnimationCircle.dispose(); //銷毀變量,釋放資源
    super.dispose();
  }

  //生成Flow的數據
  List<Widget> _buildFlowChildren() {
    return List.generate(
        5,
        (index) => Container(
              child: Icon(
                index.isEven ? Icons.timer : Icons.ac_unit,
                color: Colors.primaries[index % Colors.primaries.length],
              ),
            ));
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned.fill(
          child: Flow(
            delegate: FlowAnimatedCircle(_ctrlAnimationCircle.value),
            children: _buildFlowChildren(),
          ),
        ),
        Positioned.fill(
          child: IconButton(
            icon: Icon(Icons.menu),
            onPressed: () {
              setState(() {
                //點擊后讓動畫可前行或回退
                _ctrlAnimationCircle.status == AnimationStatus.completed
                    ? _ctrlAnimationCircle.reverse()
                    : _ctrlAnimationCircle.forward();
              });
            },
          ),
        ),
      ],
    );
  }
}

FlowAnimatedCircle 代碼如下:

class FlowAnimatedCircle extends FlowDelegate {
  final double radius; //綁定半徑,讓圓動起來
  FlowAnimatedCircle(this.radius);

  @override
  void paintChildren(FlowPaintingContext context) {
    if (radius == 0) {
      return;
    }
    double x = 0; //開始(0,0)在父組件的中心
    double y = 0;
    for (int i = 0; i < context.childCount; i++) {
      x = radius * cos(i * pi / (context.childCount - 1)); //根據數學得出坐標
      y = radius * sin(i * pi / (context.childCount - 1)); //根據數學得出坐標
      context.paintChild(i, transform: Matrix4.translationValues(x, -y, 0));
    } //使用Matrix定位每個子組件
  }

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

交流

老孟Flutter博客地址(330個控件用法):http://laomengit.com

歡迎加入Flutter交流群(微信:laomengit)、關注公眾號【老孟Flutter】:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

※台北網頁設計公司全省服務真心推薦

※想知道最厲害的網頁設計公司“嚨底家”!

※推薦評價好的iphone維修中心

聚甘新