03_Flutter自定义下拉菜单
在Flutter的内置api中,可以使用showMenu实现类似下拉菜单的效果,或者使用PopupMenuButton组件,PopupMenuButton内部也是使用了showMenu这个api,但是使用showMenu时,下拉面板的显示已经被约定死了,只能放一个简单的列表,没有办法定制下来面板的ui,并且下拉面板的宽高需要通过指定constraints进行限制,下面是一个简单的showMenu的用法:
Container(
height: 44,
margin: EdgeInsetsDirectional.only(top: 30, start: 30, end: 30),
color: Colors.red,
child: Builder(
builder: (context) {
return GestureDetector(
onTap: () {
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
Offset offset = Offset(0.0, button.size.height);
RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(offset, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
),
Offset.zero & overlay.size,
);
showMenu(
context: context,
position: position,
constraints: BoxConstraints(maxWidth: 315, maxHeight: 200),
items: List.generate(5, (index) => PopupMenuItem(
child: Container(
width: 375,
height: 44,
alignment: AlignmentDirectional.center,
child: Text("item"),
)
))
);
},
);
},
),
)
接下来,我们将参照showMenu的源码,依葫芦画个瓢,自定义一个下拉菜单的api,并可自由定制下拉面板的布局内容,篇幅有点长,请耐心观看。
一.确定下拉面板的起始位置
查看PopupMenuButton的源码,可以知道,PopupMenuButton在确定下拉面板的起始位置时,是先获取下拉面板依赖的按钮的边界位置和整个页面的显示区域边界,通过这两个边界计算得到一个RelativeRect,这个RelativeRect就是用来描述下拉面板的起始位置的。
showPopup(BuildContext context) {
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
Offset offset = Offset(0.0, button.size.height);
RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(offset, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
),
Offset.zero & overlay.size,
);
}
注:上述代码中用的的context对象,必须是下拉面板依赖的按钮对应的context,否则最后计算出来的RelativeRect是不对的。计算过程不做过多解释了,直接上图:
二.确定下拉面板的布局约束
- 水平方向确定最大宽度,比较简单,下拉面板的最大宽度和它所依赖的按钮的宽度一致即可
- 垂直方向上的最大高度,上一步已经确定了position的值,垂直方向上的最大高度可以取position.top - buttonHeight - padding.top - kToolbarHeight和constraints.biggest.height - position.top - padding.bottom的最大值,padding为安全区域的大小
- 使用CustomSingleChildLayout作为下拉面板的父容器,并实现一个SingleChildLayoutDelegate,重写getConstraintsForChild,确定约束
EdgeInsets padding = MediaQuery.paddingOf(context);
class _CustomPopupRouteLayout extends SingleChildLayoutDelegate {
final RelativeRect position;
_CustomPopupRouteLayout(this.position);
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Size buttonSize = position.toSize(constraints.biggest);
double constraintsWidth = buttonSize.width;
double constraintsHeight = max(position.top - buttonSize.height - padding.top - kToolbarHeight, constraints.biggest.height - position.top - padding.bottom);
return BoxConstraints.loose(Size(constraintsWidth, constraintsHeight));
}
bool shouldRelayout(covariant _CustomPopupRouteLayout oldDelegate) {
return position != oldDelegate.position;
}
}
三.显示下拉面板
我们先把下拉面板显示出来看看效果,这里的下拉面板其实是一个弹出层,而在Flutter中,所有的弹出层的显示和页面路由是一样的,都是通过Navigator.push进行显示,参照showMenu的源码,这里的弹出层我们让其继承PopupRoute
class _CustomPopupRoute<T> extends PopupRoute<T> {
final RelativeRect position;
final String? barrierLabel;
_CustomPopupRoute({
required this.position,
required this.barrierLabel,
});
Color? get barrierColor => null;
bool get barrierDismissible => true;
Duration get transitionDuration => Duration(milliseconds: 200);
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _CustomPopupRouteLayout(position),
child: Material(
child: Container(
color: Colors.yellow,
width: double.infinity,
height: double.infinity,
alignment: AlignmentDirectional.center,
child: Text("popup content"),
),
),
);
}
}
showPopup(BuildContext context) {
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
Offset offset = Offset(0.0, button.size.height);
RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(offset, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
),
Offset.zero & overlay.size,
);
Navigator.of(context).push(_CustomPopupRoute(
position: position, barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel
));
}
如图,黄色区域就是下拉面板,可以看到,点击按钮下拉面板显示,点击下拉面板以外的区域,下拉面板关闭,但是位置好像不对,因为我们根本就没去确定下拉面板的位置。
四.确定下拉面板的位置
Offset getPositionForChild(Size size, Size childSize) {
return super.getPositionForChild(size, childSize);
}
只需要重写SingleChildLayoutDelegate的getPositionForChild方法,返回一个Offset对象,Offset的x、y的值就代表下拉面板左上角的位置,那么问题来了,x、y的值怎么确定?
-
确定x
x = position.left
-
确定y
position.top + constraintsHeight > size.height - paddingBottom
时
position.top + constraintsHeight <= size.height - paddingBottom
时
EdgeInsets padding = MediaQuery.paddingOf(context);
class _CustomPopupRouteLayout extends SingleChildLayoutDelegate {
final RelativeRect position;
EdgeInsets padding;
_CustomPopupRouteLayout(this.position, this.padding);
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Size buttonSize = position.toSize(constraints.biggest);
double constraintsWidth = buttonSize.width;
double constraintsHeight = max(position.top - buttonSize.height - padding.top - kToolbarHeight, constraints.biggest.height - position.top - padding.bottom);
return BoxConstraints.loose(Size(constraintsWidth, constraintsHeight));
}
Offset getPositionForChild(Size size, Size childSize) {
double x = position.left;
double y = position.top;
final double buttonHeight = size.height - position.top - position.bottom;
double constraintsHeight = max(position.top - buttonHeight - padding.top - kToolbarHeight, size.height - position.top - padding.bottom);
if(position.top + constraintsHeight > size.height - padding.bottom) {
y = position.top - childSize.height - buttonHeight;
}
return Offset(x, y);
}
bool shouldRelayout(covariant _CustomPopupRouteLayout oldDelegate) {
return position != oldDelegate.position || padding != oldDelegate.padding;
}
}
六.下拉动画实现
创建动画插值器,其值从0 ~ 1之间变化,动画时长为PopupRoute中重写的transitionDuration,及200ms时间内,从0变到1,或者从1变到0
final CurveTween heightFactorTween = CurveTween(curve: const Interval(0.0, 1.0));
使用AnimatedBuilder改造PopupRoute的布局结构,根据heightFactorTween的动画执行值 * 下拉菜单内容容器的高度,改变拉菜单内容的高度即可,这里暂时将高度设置为固定值300。
class _CustomPopupRoute<T> extends PopupRoute<T> {
...
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
EdgeInsets padding = MediaQuery.paddingOf(context);
final CurveTween heightFactorTween = CurveTween(curve: const Interval(0.0, 1.0));
return MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: CustomSingleChildLayout(
delegate: _CustomPopupRouteLayout(position, padding),
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Material(
child: Container(
height: 300*heightFactorTween.evaluate(animation),
child: child,
)
);
},
child: Container(
color: Colors.yellow,
width: double.infinity,
height: 300,
alignment: AlignmentDirectional.center,
child: Text("popup content"),
),
),
),
);
}
}
下拉动画效果已经出来了,但是实际情况下,下拉面板的高度是不能直接在组件层固定写死的,所以这里需要动态计算出下拉面板的高度。
七.下拉面板动态高度,支持下拉动画
想要获取组件的高度,需要等到组件的layout完成后,才能获取到组件的大小,因此,我们需要自定义一个RenderObject,重写其performLayout,在子控件第一次layout完后,获取到子控件的初始高度,子控件的初始化高度结合动画的高度比例系数来最终确定自身的大小。
class _RenderHeightFactorBox extends RenderShiftedBox {
double _heightFactor;
_RenderHeightFactorBox({
RenderBox? child,
double? heightFactor,
}):_heightFactor = heightFactor ?? 1.0, super(child);
double get heightFactor => _heightFactor;
set heightFactor(double value) {
if (_heightFactor == value) {
return;
}
_heightFactor = value;
markNeedsLayout();
}
void performLayout() {
final BoxConstraints constraints = this.constraints;
if (child == null) {
size = constraints.constrain(Size.zero);
return;
}
child!.layout(constraints, parentUsesSize: true);
size = constraints.constrain(Size(
child!.size.width,
child!.size.height,
));
child!.layout(constraints.copyWith(maxWidth: size.width, maxHeight: size.height * heightFactor), parentUsesSize: true);
size = constraints.constrain(Size(
child!.size.width,
child!.size.height,
));
}
}
接着定义一个SingleChildRenderObjectWidget,并引用_RenderHeightFactorBox
class _HeightFactorBox extends SingleChildRenderObjectWidget {
final double? heightFactor;
const _HeightFactorBox({
super.key,
this.heightFactor,
super.child,
});
RenderObject createRenderObject(BuildContext context) => _RenderHeightFactorBox(heightFactor: heightFactor);
void updateRenderObject(BuildContext context, _RenderHeightFactorBox renderObject) {
renderObject.heightFactor = heightFactor ?? 1.0;
}
}
最后把下拉面板中,执行动画的child使用_HeightFactorBox包裹,并传入heightFactorTween的执行结果即可。
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
EdgeInsets padding = MediaQuery.paddingOf(context);
final CurveTween heightFactorTween = CurveTween(curve: const Interval(0.0, 1.0));
return MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: CustomSingleChildLayout(
delegate: _CustomPopupRouteLayout(position, padding),
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Material(
child: _HeightFactorBox(
heightFactor: heightFactorTween.evaluate(animation),
child: child,
)
);
},
child: Container(
color: Colors.yellow,
width: double.infinity,
height: double.infinity,
alignment: AlignmentDirectional.center,
child: Text("popup content"),
),
),
),
);
}
八.完整代码
class TestPage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("下拉菜单"),
backgroundColor: Colors.blue,
),
body: Container(
width: 375,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 44,
margin: const EdgeInsetsDirectional.only(top: 30, start: 30, end: 30),
color: Colors.red,
child: Builder(
builder: (context) {
return GestureDetector(
onTap: () {
showPopup(context: context, builder: (context) {
return Container(
height: 400,
decoration: const BoxDecoration(
color: Colors.yellow
),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List<Widget>.generate(29, (index) {
int itemIndex = index ~/ 2;
if(index.isEven) {
return Container(
height: 44,
alignment: AlignmentDirectional.center,
child: Text("item$itemIndex"),
);
} else {
return Container(
height: 1,
color: Colors.grey,
);
}
}),
),
),
);
});
},
);
},
),
),
],
),
),
);
}
}
showPopup({
required BuildContext context,
required WidgetBuilder builder,
double? elevation,
Color? shadowColor,
Duration animationDuration = const Duration(milliseconds: 200)
}) {
final RenderBox button = context.findRenderObject()! as RenderBox;
final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
Offset offset = Offset(0.0, button.size.height);
RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(offset, ancestor: overlay),
button.localToGlobal(button.size.bottomRight(Offset.zero) + offset, ancestor: overlay),
),
Offset.zero & overlay.size,
);
Navigator.of(context).push(_CustomPopupRoute(
position: position,
builder: builder,
elevation: elevation,
shadowColor: shadowColor,
animationDuration: animationDuration,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel
));
}
class _CustomPopupRoute<T> extends PopupRoute<T> {
final WidgetBuilder builder;
final RelativeRect position;
final double? elevation;
final Color? shadowColor;
final String? barrierLabel;
final Duration animationDuration;
_CustomPopupRoute({
required this.builder,
required this.position,
required this.barrierLabel,
this.elevation,
this.shadowColor,
Duration? animationDuration
}): animationDuration = animationDuration ?? const Duration(milliseconds: 200),
super(traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop);
Color? get barrierColor => null;
bool get barrierDismissible => true;
Duration get transitionDuration => animationDuration;
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
EdgeInsets padding = MediaQuery.paddingOf(context);
final CurveTween heightFactorTween = CurveTween(curve: const Interval(0.0, 1.0));
return MediaQuery.removePadding(
context: context,
removeTop: true,
removeBottom: true,
removeLeft: true,
removeRight: true,
child: CustomSingleChildLayout(
delegate: _CustomPopupRouteLayout(position, padding),
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Material(
child: _HeightFactorBox(
heightFactor: heightFactorTween.evaluate(animation),
child: child,
)
);
},
child: builder(context),
),
),
);
}
}
class _CustomPopupRouteLayout extends SingleChildLayoutDelegate {
final RelativeRect position;
EdgeInsets padding;
double childHeightMax = 0;
_CustomPopupRouteLayout(this.position, this.padding);
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
Size buttonSize = position.toSize(constraints.biggest);
double constraintsWidth = buttonSize.width;
double constraintsHeight = max(position.top - buttonSize.height - padding.top - kToolbarHeight, constraints.biggest.height - position.top - padding.bottom);
return BoxConstraints.loose(Size(constraintsWidth, constraintsHeight));
}
Offset getPositionForChild(Size size, Size childSize) {
double x = position.left;
double y = position.top;
final double buttonHeight = size.height - position.top - position.bottom;
double constraintsHeight = max(position.top - buttonHeight - padding.top - kToolbarHeight, size.height - position.top - padding.bottom);
if(position.top + constraintsHeight > size.height - padding.bottom) {
y = position.top - childSize.height - buttonHeight;
}
return Offset(x, y);
}
bool shouldRelayout(covariant _CustomPopupRouteLayout oldDelegate) {
return position != oldDelegate.position || padding != oldDelegate.padding;
}
}
class _RenderHeightFactorBox extends RenderShiftedBox {
double _heightFactor;
_RenderHeightFactorBox({
RenderBox? child,
double? heightFactor,
}):_heightFactor = heightFactor ?? 1.0, super(child);
double get heightFactor => _heightFactor;
set heightFactor(double value) {
if (_heightFactor == value) {
return;
}
_heightFactor = value;
markNeedsLayout();
}
void performLayout() {
final BoxConstraints constraints = this.constraints;
if (child == null) {
size = constraints.constrain(Size.zero);
return;
}
child!.layout(constraints, parentUsesSize: true);
size = constraints.constrain(Size(
child!.size.width,
child!.size.height,
));
child!.layout(constraints.copyWith(maxWidth: size.width, maxHeight: size.height * heightFactor), parentUsesSize: true);
size = constraints.constrain(Size(
child!.size.width,
child!.size.height,
));
}
}
class _HeightFactorBox extends SingleChildRenderObjectWidget {
final double? heightFactor;
const _HeightFactorBox({
super.key,
this.heightFactor,
super.child,
});
RenderObject createRenderObject(BuildContext context) => _RenderHeightFactorBox(heightFactor: heightFactor);
void updateRenderObject(BuildContext context, _RenderHeightFactorBox renderObject) {
renderObject.heightFactor = heightFactor ?? 1.0;
}
}