【Flutter 專題】104 圖解自定義 ACEDropdownButton 下拉框

阿策小和尚發表於2021-07-05

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

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

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

DropdownButton 原始碼

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

      以小菜的理解,整個下拉框包括三個核心元件,分別是 DropdownButton_DropdownMenu_DropdownRoute

      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());
複製程式碼

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());
複製程式碼

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

      小菜想實現在下拉框展示時,突顯出選中狀態 item,於是在對應 item 位置新增一個 iconChecked 圖示,其中 isCheckedtrue 時,會展示選中圖示,否則正常不展示;

      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());
複製程式碼

4. 避免遮擋

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

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

      分析原始碼,下拉框展示位置是通過 _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;
複製程式碼

5. Animate 下拉框展示動畫

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

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

getSelectedItemOffset: () => route.getItemOffset(0)),
複製程式碼


      ACEDropdownButton 案例原始碼


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

來源: 阿策小和尚

相關文章