小菜之前嘗試過 Flutter 自帶的 DropdownButton 下拉框,簡單方便;但僅單純的原生效果不足以滿足各類個性化設計;於是小菜以 DropdownButton 為基礎,調整部分原始碼,擴充套件為 ACEDropdownButton 自定義下拉框元件;
- 新增 backgroundColor 設定下拉框背景色;
- 新增 menuRadius 設定下拉框邊框效果;
- 新增 isChecked 設定下拉框中預設選中狀態及 iconChecked 選中圖示;
- 下拉框在展示時不會遮擋 DropdownButton 按鈕,預設在按鈕頂部或底部展示;
- 下拉框展示效果調整為預設由上而下;
對於 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 圖示,其中 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());
複製程式碼
4. 避免遮擋
小菜選擇自定義 ACEDropdownButton 下拉框最重要的原因是,Flutter 自帶的 DropdownButton 在下拉框展示時會預設遮擋按鈕,小菜預期的效果是:
- 若按鈕下部分螢幕空間足夠展示所有下拉 items,則在按鈕下部分展示,且不遮擋按鈕;
- 若按鈕下部分高度不足以展示下拉 items,檢視按鈕上半部分螢幕空間是否足以展示所有下拉 items,若足夠則展示,且不遮擋按鈕;
- 若按鈕上半部分和下半部分螢幕空間均不足以展示所有下拉 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)),
複製程式碼
小菜對於原始碼的理解還不夠深入,僅對需要的效果修改了部分原始碼,對於所有測試場景可能不夠全面;如有錯誤,請多多指導!
來源: 阿策小和尚