天天看點

Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

      小菜之前嘗試過 Flutter 自帶的 DropdownButton 下拉框,簡單友善;但僅單純的原生效果不足以滿足各類個性化設計;于是小菜以 DropdownButton 為基礎,調整部分源碼,擴充為 ACEDropdownButton 自定義下拉框元件;

  1. 添加 backgroundColor 設定下拉框背景色;
  2. 添加 menuRadius 設定下拉框邊框效果;
  3. 添加 isChecked 設定下拉框中預設選中狀态及 iconChecked 選中圖示;
  4. 下拉框在展示時不會遮擋 DropdownButton 按鈕,預設在按鈕頂部或底部展示;
  5. 下拉框展示效果調整為預設由上而下;

      對于 DropdownButton 整體的功能是非常完整的,包括路由管理,已經動畫效果等;小菜僅站在巨人的肩膀上進行一點小擴充,學習源碼真的對我們自己的編碼很有幫助;

Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

DropdownButton 源碼

      DropdownButton 源碼整合在一個檔案中,檔案中有很多私有類,不會影響其它元件;

      以小菜的了解,整個下拉框包括三個核心元件,分别是 DropdownButton、_DropdownMenu 和 _DropdownRoute;

Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

      DropdownButton 是開發人員最直接面對的 StatefulWidget 有狀态的元件,包含衆多屬性,基本架構是一個友善于視力障礙人員的 Semantics 元件,而其核心元件是一個層級遮罩 IndexedStack;其中在進行背景圖示等各種樣式繪制;

Widget innerItemsWidget;
if (items.isEmpty) {
  innerItemsWidget = Container();
} else {
  innerItemsWidget = IndexedStack(
      index: index, alignment: AlignmentDirectional.centerStart,
      children: widget.isDense ? items : items.map((Widget item) {
              return widget.itemHeight != null ? SizedBox(height: widget.itemHeight, child: item) : Column(mainAxisSize: MainAxisSize.min, children: <Widget>[item]);
            }).toList());
}           

      在 DropdownButton 點選 _handleTap() 操作中,主要通過 _DropdownRoute 來完成的,_DropdownRoute 是一個 PopupRoute 路由;小菜認為最核心的是 getMenuLimits 對于下拉框的尺寸位置,各子 item 位置等一系列位置計算;在這裡可以确定下拉框展示的起始位置以及與螢幕兩端距離判斷,指定具體的限制條件;DropdownButton 同時還起到了銜接 _DropdownMenu 展示作用;

      在 _DropdownMenuRouteLayout 中還有一點需要注意,通過計算 Menu 最大高度與螢幕差距,設定 Menu 最大高度比螢幕高度最少差一個 item 容器空間,用來使用者點選時關閉下拉框;

_MenuLimits getMenuLimits(Rect buttonRect, double availableHeight, int index) {
  final double maxMenuHeight = availableHeight - 2.0 * _kMenuItemHeight;
  final double buttonTop = buttonRect.top;
  final double buttonBottom = math.min(buttonRect.bottom, availableHeight);
  final double selectedItemOffset = getItemOffset(index);
  final double topLimit = math.min(_kMenuItemHeight, buttonTop);
  final double bottomLimit = math.max(availableHeight - _kMenuItemHeight, buttonBottom);
  double menuTop = (buttonTop - selectedItemOffset) - (itemHeights[selectedIndex] - buttonRect.height) / 2.0;
  double preferredMenuHeight = kMaterialListPadding.vertical;
  if (items.isNotEmpty)  preferredMenuHeight += itemHeights.reduce((double total, double height) => total + height);
  final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight);
  double menuBottom = menuTop + menuHeight;
  if (menuTop < topLimit) menuTop = math.min(buttonTop, topLimit);

  if (menuBottom > bottomLimit) {
    menuBottom = math.max(buttonBottom, bottomLimit);
    menuTop = menuBottom - menuHeight;
  }

  final double scrollOffset = preferredMenuHeight <= maxMenuHeight ? 0 : math.max(0.0, selectedItemOffset - (buttonTop - menuTop));
  return _MenuLimits(menuTop, menuBottom, menuHeight, scrollOffset);
}           

      _DropdownMenu 也是一個 StatefulWidget 有狀态元件,在下拉框展示的同時設定了一系列的動畫,展示動畫分為三個階段,[0-0.25s] 先淡入選中 item 所在的矩形容器,[0.25-0.5s] 以選中 item 為中心向兩端擴容直到容納所有的 item,[0.5-1.0s] 由上而下淡入展示 item 内容;

      _DropdownMenu 通過 _DropdownMenuPainter 和 _DropdownMenuItemContainer 分别對下拉框以及子 item 的繪制,小菜主要是在此進行下拉框樣式的擴充;

CustomPaint(
  painter: _DropdownMenuPainter(
      color: route.backgroundColor ?? Theme.of(context).canvasColor,
      menuRadius: route.menuRadius,
      elevation: route.elevation,
      selectedIndex: route.selectedIndex,
      resize: _resize,
      getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex))           

      源碼有太多需要學習的地方,小菜強烈建議多閱讀源碼;

ACEDropdownButton 擴充

1. backgroundColor 下拉框背景色

      根據 DropdownButton 源碼可得,下拉框的背景色可以通過 _DropdownMenu 中繪制 _DropdownMenuPainter 時處理,預設的背景色為 Theme.of(context).canvasColor;當然我們也可以手動設定主題中的 canvasColor 來更新下拉框背景色;

      小菜添加 backgroundColor 屬性,并通過 ACEDropdownButton -> _DropdownRoute -> _DropdownMenu 中轉設定下拉框背景色;

class _DropdownMenuState<T> extends State<_DropdownMenu<T>> {
    ...
    @override
    Widget build(BuildContext context) {
    return FadeTransition(
        opacity: _fadeOpacity,
        child: CustomPaint(
            painter: _DropdownMenuPainter(
                color: route.backgroundColor ?? Theme.of(context).canvasColor,
                elevation: route.elevation,
                selectedIndex: route.selectedIndex,
                resize: _resize,
                getSelectedItemOffset: () => route.getItemOffset(route.selectedIndex)),
        ...
    }
    ...
}

return ACEDropdownButton<String>(
    value: dropdownValue,
    backgroundColor: Colors.green.withOpacity(0.8),
    onChanged: (String newValue) => setState(() => dropdownValue = newValue),
    items: <String>['北京市', '天津市', '河北省', '其它'].map<ACEDropdownMenuItem<String>>((String value) {
      return ACEDropdownMenuItem<String>(value: value, child: Text(value));
    }).toList());           
Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

2. menuRadius 下拉框邊框效果

      下拉框的邊框需要在 _DropdownMenuPainter 中繪制,跟 backgroundColor 相同,設定 menuRadius 下拉框屬性,并通過 _DropdownRoute 中轉一下,其中需要在 _DropdownMenuPainter 中添加 menuRadius;

class _DropdownMenuPainter extends CustomPainter {
  _DropdownMenuPainter(
      {this.color, this.elevation,
      this.selectedIndex, this.resize,
      this.getSelectedItemOffset,
      this.menuRadius})
      : _painter = BoxDecoration(
          color: color,
          borderRadius: menuRadius ?? BorderRadius.circular(2.0),
          boxShadow: kElevationToShadow[elevation],
        ).createBoxPainter(),
        super(repaint: resize);
}

return ACEDropdownButton<String>(
    value: dropdownValue,
    backgroundColor: Colors.green.withOpacity(0.8),
    menuRadius: const BorderRadius.all(Radius.circular(15.0)),
    onChanged: (String newValue) => setState(() => dropdownValue = newValue),
    items: <String>['北京市', '天津市', '河北省', '其它'].map<ACEDropdownMenuItem<String>>((String value) {
      return ACEDropdownMenuItem<String>(value: value, child: Text(value));
    }).toList());           
Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

3. isChecked & iconChecked 下拉框選中狀态及圖示

      小菜想實作在下拉框展示時,突顯出選中狀态 item,于是在對應 item 位置添加一個 iconChecked 圖示,其中 isChecked 為 true 時,會展示選中圖示,否則正常不展示;

      item 的繪制是在 _DropdownMenuItemButton 中加載的,可以通過 _DropdownMenuItemButton 添加屬性設定,小菜為了統一管理,依舊通過 _DropdownRoute 進行中轉;

class _DropdownMenuItemButtonState<T> extends State<_DropdownMenuItemButton<T>> {
    @override
    Widget build(BuildContext context) {
        ...
        Widget child = FadeTransition(
        opacity: opacity,
        child: InkWell(
            autofocus: widget.itemIndex == widget.route.selectedIndex,
            child: Container(
                padding: widget.padding,
                child: Row(children: <Widget>[
                  Expanded(child: widget.route.items[widget.itemIndex]),
                  widget.route.isChecked == true && widget.itemIndex == widget.route.selectedIndex
                      ? (widget.route.iconChecked ?? Icon(Icons.check, size: _kIconCheckedSize))
                      : Container()
                ])),
        ...
    }
}

return ACEDropdownButton<String>(
    value: dropdownValue,
    backgroundColor: Colors.green.withOpacity(0.8),
    menuRadius: const BorderRadius.all(Radius.circular(15.0)),
    isChecked: true,
    iconChecked: Icon(Icons.tag_faces),
    onChanged: (String newValue) => setState(() => dropdownValue = newValue),
    items: <String>['北京市', '天津市', '河北省', '其它'].map<ACEDropdownMenuItem<String>>((String value) {
      return ACEDropdownMenuItem<String>(value: value, child: Text(value));
    }).toList());           
Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

4. 避免遮擋

      小菜選擇自定義 ACEDropdownButton 下拉框最重要的原因是,Flutter 自帶的 DropdownButton 在下拉框展示時會預設遮擋按鈕,小菜預期的效果是:

  1. 若按鈕下部分螢幕空間足夠展示所有下拉 items,則在按鈕下部分展示,且不遮擋按鈕;
  2. 若按鈕下部分高度不足以展示下拉 items,檢視按鈕上半部分螢幕空間是否足以展示所有下拉 items,若足夠則展示,且不遮擋按鈕;
  3. 若按鈕上半部分和下半部分螢幕空間均不足以展示所有下拉 items 時,此時以螢幕頂部或底部為邊界,展示可滑動 items 下拉框;
    Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

      分析源碼,下拉框展示位置是通過 _MenuLimits getMenuLimits 計算的,預設的 menuTop 是通過按鈕頂部與選中 item 所在位置以及下拉框整體高度等綜合計算獲得的,是以展示的位置優先以選中 item 覆寫按鈕位置,再向上向下延展;

      小菜簡化計算方式,僅判斷螢幕剩餘空間與按鈕高度差是否能容納下拉框高度;進而确定 menuTop 起始位置,在按鈕上半部分或按鈕下半部分展示;

final double menuHeight = math.min(maxMenuHeight, preferredMenuHeight);
if (bottomLimit - buttonRect.bottom < menuHeight) {
    menuTop = buttonRect.top - menuHeight;
} else {
    menuTop = buttonRect.bottom;
}
double menuBottom = menuTop + menuHeight;           
Flutter 104: 圖解自定義 ACEDropdownButton 下拉框

5. Animate 下拉框展示動畫

      DropdownButton 下拉框展示動畫預設是以選中 item 為起點,分别向上下兩端延展;

      小菜修改了下拉框展示位置,因為動畫會顯得很突兀,于是小菜調整動畫起始位置,在 getSelectedItemOffset 設為 route.getItemOffset(0) 第一個 item 位即可;小菜同時也測試過若在按鈕上半部分展示下拉框時,由末尾 item 向首位 item 動畫,修改了很多方法,結果的效果卻很奇怪,不符合日常動畫展示效果,是以無論從何處展示下拉框,均是從第一個 item 位置開始展示動畫;

Flutter 104: 圖解自定義 ACEDropdownButton 下拉框
getSelectedItemOffset: () => route.getItemOffset(0)),           
Flutter 104: 圖解自定義 ACEDropdownButton 下拉框
ACEDropdownButton 案例源碼

      小菜對于源碼的了解還不夠深入,僅對需要的效果修改了部分源碼,對于所有測試場景可能不夠全面;如有錯誤,請多多指導!

來源: 阿策小和尚

繼續閱讀