??????小菜之前嘗試過(guò) Flutter 自帶的 DropdownButton 下拉框,簡(jiǎn)單方便;但僅單純的原生效果不足以滿(mǎn)足各類(lèi)個(gè)性化設(shè)計(jì);于是小菜以 DropdownButton 為基礎(chǔ),調(diào)整部分源碼,擴(kuò)展為 ACEDropdownButton 自定義下拉框組件;
- 添加 backgroundColor 設(shè)置下拉框背景色;
- 添加 menuRadius 設(shè)置下拉框邊框效果;
- 添加 isChecked 設(shè)置下拉框中默認(rèn)選中狀態(tài)及 iconChecked 選中圖標(biāo);
- 下拉框在展示時(shí)不會(huì)遮擋 DropdownButton 按鈕,默認(rèn)在按鈕頂部或底部展示;
- 下拉框展示效果調(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),其中 isChecked 為 true 時(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ù)期的效果是:
- 若按鈕下部分屏幕空間足夠展示所有下拉 items,則在按鈕下部分展示,且不遮擋按鈕;
- 若按鈕下部分高度不足以展示下拉 items,查看按鈕上半部分屏幕空間是否足以展示所有下拉 items,若足夠則展示,且不遮擋按鈕;
- 若按鈕上半部分和下半部分屏幕空間均不足以展示所有下拉 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)源: 阿策小和尚