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

??????小菜之前嘗試過(guò) Flutter 自帶的 DropdownButton 下拉框,簡(jiǎn)單方便;但僅單純的原生效果不足以滿(mǎn)足各類(lèi)個(gè)性化設(shè)計(jì);于是小菜以 DropdownButton 為基礎(chǔ),調(diào)整部分源碼,擴(kuò)展為 ACEDropdownButton 自定義下拉框組件;

  1. 添加 backgroundColor 設(shè)置下拉框背景色;
  2. 添加 menuRadius 設(shè)置下拉框邊框效果;
  3. 添加 isChecked 設(shè)置下拉框中默認(rèn)選中狀態(tài)及 iconChecked 選中圖標(biāo);
  4. 下拉框在展示時(shí)不會(huì)遮擋 DropdownButton 按鈕,默認(rèn)在按鈕頂部或底部展示;
  5. 下拉框展示效果調(diào)整為默認(rèn)由上而下;

??????對(duì)于 DropdownButton 整體的功能是非常完整的,包括路由管理,已經(jīng)動(dòng)畫(huà)效果等;小菜僅站在巨人的肩膀上進(jìn)行一點(diǎn)小擴(kuò)展,學(xué)習(xí)源碼真的對(duì)我們自己的編碼很有幫助;

DropdownButton 源碼

??????DropdownButton 源碼整合在一個(gè)文件中,文件中有很多私有類(lèi),不會(huì)影響其它組件;

??????以小菜的理解,整個(gè)下拉框包括三個(gè)核心組件,分別是 DropdownButton_DropdownMenu_DropdownRoute

??????DropdownButton 是開(kāi)發(fā)人員最直接面對(duì)的 StatefulWidget 有狀態(tài)的組件,包含眾多屬性,基本框架是一個(gè)方便于視力障礙人員的 Semantics 組件,而其核心組件是一個(gè)層級(jí)遮罩 IndexedStack;其中在進(jìn)行背景圖標(biāo)等各種樣式繪制;

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 點(diǎn)擊 _handleTap() 操作中,主要通過(guò) _DropdownRoute 來(lái)完成的,_DropdownRoute 是一個(gè) PopupRoute 路由;小菜認(rèn)為最核心的是 getMenuLimits 對(duì)于下拉框的尺寸位置,各子 item 位置等一系列位置計(jì)算;在這里可以確定下拉框展示的起始位置以及與屏幕兩端距離判斷,指定具體的約束條件;DropdownButton 同時(shí)還起到了銜接 _DropdownMenu 展示作用;

??????在 _DropdownMenuRouteLayout 中還有一點(diǎn)需要注意,通過(guò)計(jì)算 Menu 最大高度與屏幕差距,設(shè)置 Menu 最大高度比屏幕高度最少差一個(gè) item 容器空間,用來(lái)用戶(hù)點(diǎn)擊時(shí)關(guān)閉下拉框;

_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 也是一個(gè) StatefulWidget 有狀態(tài)組件,在下拉框展示的同時(shí)設(shè)置了一系列的動(dòng)畫(huà),展示動(dòng)畫(huà)分為三個(gè)階段,[0-0.25s] 先淡入選中 item 所在的矩形容器,[0.25-0.5s] 以選中 item 為中心向兩端擴(kuò)容直到容納所有的 item[0.5-1.0s] 由上而下淡入展示 item 內(nèi)容;

??????_DropdownMenu 通過(guò) _DropdownMenuPainter_DropdownMenuItemContainer 分別對(duì)下拉框以及子 item 的繪制,小菜主要是在此進(jìn)行下拉框樣式的擴(kuò)展;

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))

??????源碼有太多需要學(xué)習(xí)的地方,小菜強(qiáng)烈建議多閱讀源碼;

ACEDropdownButton 擴(kuò)展

1. backgroundColor 下拉框背景色

??????根據(jù) DropdownButton 源碼可得,下拉框的背景色可以通過(guò) _DropdownMenu 中繪制 _DropdownMenuPainter 時(shí)處理,默認(rèn)的背景色為 Theme.of(context).canvasColor;當(dāng)然我們也可以手動(dòng)設(shè)置主題中的 canvasColor 來(lái)更新下拉框背景色;

??????小菜添加 backgroundColor 屬性,并通過(guò) ACEDropdownButton -> _DropdownRoute -> _DropdownMenu 中轉(zhuǎn)設(shè)置下拉框背景色;

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 相同,設(shè)置 menuRadius 下拉框?qū)傩裕⑼ㄟ^(guò) _DropdownRoute 中轉(zhuǎn)一下,其中需要在 _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 下拉框選中狀態(tài)及圖標(biāo)

??????小菜想實(shí)現(xiàn)在下拉框展示時(shí),突顯出選中狀態(tài) item,于是在對(duì)應(yīng) item 位置添加一個(gè) iconChecked 圖標(biāo),其中 isCheckedtrue 時(shí),會(huì)展示選中圖標(biāo),否則正常不展示;

??????item 的繪制是在 _DropdownMenuItemButton 中加載的,可以通過(guò) _DropdownMenuItemButton 添加屬性設(shè)置,小菜為了統(tǒng)一管理,依舊通過(guò) _DropdownRoute 進(jìn)行中轉(zhuǎn);

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 在下拉框展示時(shí)會(huì)默認(rèn)遮擋按鈕,小菜預(yù)期的效果是:

  1. 若按鈕下部分屏幕空間足夠展示所有下拉 items,則在按鈕下部分展示,且不遮擋按鈕;
  2. 若按鈕下部分高度不足以展示下拉 items,查看按鈕上半部分屏幕空間是否足以展示所有下拉 items,若足夠則展示,且不遮擋按鈕;
  3. 若按鈕上半部分和下半部分屏幕空間均不足以展示所有下拉 items 時(shí),此時(shí)以屏幕頂部或底部為邊界,展示可滑動(dòng) items 下拉框;

??????分析源碼,下拉框展示位置是通過(guò) _MenuLimits getMenuLimits 計(jì)算的,默認(rèn)的 menuTop 是通過(guò)按鈕頂部與選中 item 所在位置以及下拉框整體高度等綜合計(jì)算獲得的,因此展示的位置優(yōu)先以選中 item 覆蓋按鈕位置,再向上向下延展;

??????小菜簡(jiǎn)化計(jì)算方式,僅判斷屏幕剩余空間與按鈕高度差是否能容納下拉框高度;從而確定 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 下拉框展示動(dòng)畫(huà)

??????DropdownButton 下拉框展示動(dòng)畫(huà)默認(rèn)是以選中 item 為起點(diǎn),分別向上下兩端延展;

??????小菜修改了下拉框展示位置,因?yàn)閯?dòng)畫(huà)會(huì)顯得很突兀,于是小菜調(diào)整動(dòng)畫(huà)起始位置,在 getSelectedItemOffset 設(shè)為 route.getItemOffset(0) 第一個(gè) item 位即可;小菜同時(shí)也測(cè)試過(guò)若在按鈕上半部分展示下拉框時(shí),由末尾 item 向首位 item 動(dòng)畫(huà),修改了很多方法,結(jié)果的效果卻很奇怪,不符合日常動(dòng)畫(huà)展示效果,因此無(wú)論從何處展示下拉框,均是從第一個(gè) item 位置開(kāi)始展示動(dòng)畫(huà);

getSelectedItemOffset: () => route.getItemOffset(0)),

??????ACEDropdownButton 案例源碼


??????小菜對(duì)于源碼的理解還不夠深入,僅對(duì)需要的效果修改了部分源碼,對(duì)于所有測(cè)試場(chǎng)景可能不夠全面;如有錯(cuò)誤,請(qǐng)多多指導(dǎo)!

來(lái)源: 阿策小和尚

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

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