版权声明:本文为作者原创书籍。转载请注明作者和出处,未经授权,严禁私自转载,侵权必究!!!
情感语录: 自己想要的东西,要么奋力直追,要么干脆放弃。别总是逢人就喋喋不休的表决心或者哀怨不断,做别人茶余饭后的笑点
欢迎来到本章节,上一章节介绍了数据库
的使用,知识点回顾 戳这里 Flutter基础第十三章
前面章节介绍了有关弹窗的(Dialog)的使用,Dialog在发开中能解决大部分的弹出选择类问题,但是有些特殊的定位弹窗场景就不太友好了,本章主要介绍另外一种弹窗 PopupMenuButton和DropdownButton
它们的效果如同Android 原生中的 PopWindow 一样。
本章简要:
1、PopupMenuButton
组件
2、DropdownButton
组件
3、拓展 PopupMenuDivider
组件
4、拓展 DropdownButton
组件
一、PopupMenuButton 组件
PopupMenuButton 常和 PopupMenuItem 、 PopupMenuEntry 或者 继承自 PopupMenuEntry 的子类使用。
构造函数:
const PopupMenuButton({
Key key,
@required this.itemBuilder,
this.initialValue,
this.onSelected,
this.onCanceled,
this.tooltip,
this.elevation = 8.0,
this.padding = const EdgeInsets.all(8.0),
this.child,
this.icon,
this.offset = Offset.zero,
this.enabled = true,
})
常用属性介绍:
属性 描述
itemBuilder item子项
initialValue 初始值
onSelected 选中时的回调
onCanceled 取消时回调
tooltip 提示
elevation 阴影
child 子控件,不能和icon都设置
icon IconButton子控件, 不能和child都设置
offset 可设置控件偏移位置
enabled 是否启用(false 表示不能点击)
简单运用:
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_learn/util/ToastUtil.dart';
class PopViewPage extends StatefulWidget {
PopViewPage({Key key}) : super(key: key);
_PopViewPageState createState() => _PopViewPageState();
}
class _PopViewPageState extends State<PopViewPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("PopViewPage"),
actions: <Widget>[
_NormalPopMenu(),
],
),
body: Text(""));
}
Widget _NormalPopMenu() {
return PopupMenuButton<String>(
icon: Icon(Icons.settings),
itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[
PopupMenuItem<String>(value: 'Item01', child: Text('Item One')),
PopupMenuItem<String>(value: 'Item02', child: Text('Item Two')),
PopupMenuItem<String>(value: 'Item03', child: Text('Item Three')),
PopupMenuItem<String>(value: 'Item04', child: Text('Item Four'))
],
onSelected: (String value) {
ToastUtil.show(value.toString());
});
}
}
效果如下:
可以看到点击设置按钮后弹出了一个 pop 窗口,选择某一行后也能接受到选择的信息;但有一个不好的地方弹出窗口后我们的菜单按钮都被遮挡了,它直接在屏幕的最右顶端就显示了,这种体验并不好,我期望的是它在该按钮下面显示出来,或者是在我们的导航栏下面显示。如果要在导航栏下显示,肯定就要将 pop 窗口往下移动一段距离,而移动多少距离合适呢?可以看出这段距离就是 AppBar的高度,当然这还需要考虑到横竖屏甚至是平板上的AppBar 高度都不一致的情况,下面追溯到 AppBar 中去看看:
// TODO(eseidel): Toolbar needs to change size based on orientation:
// https://material.io/design/components/app-bars-top.html#specs
// Mobile Landscape: 48dp
// Mobile Portrait: 56dp
// Tablet/Desktop: 64dp
在 AppBar 中看到有这么一段描述:横屏 AppBar 高度为 48dp,竖屏 AppBar 高度为 56dp,平板的 AppBar 为64dp。为了同时适用于手机和平板那就需要将 pop 窗口往下偏移64个单位。下面来试试:
Widget _NormalPopMenu() {
return PopupMenuButton<String>(
//dy 方向向下移动64个单位
offset: Offset(0, 64),
icon: Icon(Icons.settings),
itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[
PopupMenuItem<String>(value: 'Item01', child: Text('Item One')),
PopupMenuItem<String>(value: 'Item02', child: Text('Item Two')),
PopupMenuItem<String>(value: 'Item03', child: Text('Item Three')),
PopupMenuItem<String>(value: 'Item04', child: Text('Item Four'))
],
onSelected: (String value) {
ToastUtil.show(value.toString());
});
}
效果如下:
可以看到 pop 弹出后确实在 AppBar 下面了,说明Offset
设置的补偿值生效了,而这种效果应该才是开发中常见的吧!
PopupMenuButton 还可以配置一些简单的样式,如分割线,是否选中等。下面结合 PopupMenuDivider 和 CheckedPopupMenuItem
组件使用下。
//添加分割线和选中样式
Widget _DividerPopMenu() {
return PopupMenuButton<String>(
offset: Offset(0, 64),
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'Item01',
child: CheckedPopupMenuItem<String>(
checked: false, value: 'Item01', child: Text('Item One'))),
PopupMenuDivider(height: 1.0),
PopupMenuItem<String>(
value: 'Item02',
child: CheckedPopupMenuItem<String>(
checked: true, value: 'Item02', child: Text('Item Two'))),
PopupMenuDivider(height: 1.0),
PopupMenuItem<String>(
value: 'Item03',
child: CheckedPopupMenuItem<String>(
checked: false,
value: 'Item03',
child: Text('Item Three'))),
PopupMenuDivider(height: 1.0),
PopupMenuItem<String>(
value: 'Item04',
child: CheckedPopupMenuItem<String>(
checked: false,
value: 'Item04',
child: Text('Item Four')))
],
onSelected: (String value) {
ToastUtil.show(value.toString());
});
}
效果如下:
模拟器效果有点差哈,分割线截图后看不到了;真机上不会存在这样的问题。但可以看到配置的默认选中项是生效了的。Flutter 中提供的 PopupMenuDivider 和 CheckedPopupMenuItem 两个控件还是不够人性化。PopupMenuDivider 只能设置高度,不可以设置颜色... ;CheckedPopupMenuItem 显示的图标和文字之间的间距又那么大,还不能修改。总的来说效果并不理想,现在怎么解决上面的这些问题呢? 首先要解决 PopupMenuDivider 可以自定义分割线颜色问题,其次解决 CheckedPopupMenuItem 中的一些间距问题。
自定义 PopupMenuDivider
要自定义 PopupMenuDivider 首先得看看它内部是怎么实现的!! 跟进源码内部,发现 PopupMenuDivider 代码极其简单,且内部也是再构建的一个 Divider 组件 ,如下:
而 Divider 组件正好可以设置分割线颜色。真是太好了,看来这个自定义就没什么难度了;直接依葫芦画瓢即可。
新建一个 ExpandPopupMenuDivider 类 同样也去继承 PopupMenuEntry 类, 然后添加我们的颜色属性。
import 'package:flutter/material.dart';
const double _kMenuDividerHeight = 16.0;
class ExpandPopupMenuDivider<T> extends PopupMenuEntry<T> {
/// Creates a horizontal divider for a popup menu.
///
/// By default, the divider has a height of 16 logical pixels.
const ExpandPopupMenuDivider({
Key key,
this.height = _kMenuDividerHeight,
this.color,
})
: super(key: key);
/// The height of the divider entry.
///
/// Defaults to 16 pixels.
final double height;
final Color color;
@override
bool represents(void value) => false;
@override
_ExpandPopupMenuDividerState createState() => _ExpandPopupMenuDividerState(height,color);
}
class _ExpandPopupMenuDividerState extends State<ExpandPopupMenuDivider> {
double height;
Color color;
_ExpandPopupMenuDividerState(this.height,this.color);
@override
Widget build(BuildContext context) => Divider(height: height,color: color);
}
这样分割线颜色问题解决掉O(∩_∩)O~ ,间距问题就更简单了 因为 PopupMenuItem 中 child
属性接收的是一个 Widget ,那自然可以换成其他布局方式了,如:ListTile 、Row 等,为了灵活性这里使用 Row 布局方式,下面来看自定义一个效果。
//自定义分割线 和 Item 样式
Widget _CustomPopMenu() {
return PopupMenuButton<String>(
offset: Offset(0, 64),
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'Item01',
child: Row(children: <Widget>[
Padding( padding: EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: Icon(Icons.search,color: Colors.blueAccent)),
Text('Item One')
]),),
ExpandPopupMenuDivider<String>(height: 2.0,color: Colors.red),
PopupMenuItem<String>(
value: 'Item02',
child: Row(children: <Widget>[
Padding( padding: EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: Icon(Icons.home,color: Colors.blueAccent)),
Text('Item Two')
])),
ExpandPopupMenuDivider<String>(height: 2.0,color: Colors.red),
PopupMenuItem<String>(
value: 'Item03',
child: Row(children: <Widget>[
Padding( padding: EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: Icon(Icons.person,color: Colors.blueAccent)),
Text('Item Three')
])),
ExpandPopupMenuDivider<String>(height: 2.0,color: Colors.red),
PopupMenuItem<String>(
value: 'Item04',
child: Row(children: <Widget>[
Padding( padding: EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 0.0),
child: Icon(Icons.local_grocery_store,color: Colors.blueAccent)),
Text('Item Four')
]))
],
onSelected: (String value) {
ToastUtil.show(value.toString());
});
}
效果如下:
完美,O(∩_∩)O哈哈~,模拟器分割线颜色看起来不均匀(真机上不会有该问题)
PopupMenu 默认的弹框位置都是在右上角,如果有需要在其他位置弹框就需要借助 showMenu,通过 position 属性定位弹框,我觉得Flutter 中的提供的这种方式并没很好的支持,弹出的位置会根据内容的长度也会有变化。它并没原生中的 PopWind 通过参照物 View 实现定位的方式好用。
var raisedButton = RaisedButton(
child: Text('Pop弹出窗'),
onPressed: () {
showMenu(
context: context,
position: RelativeRect.fromSize(Rect.fromLTRB(150, 130, 0, 0),Size(100, 200)),
elevation: 10,
items: <PopupMenuItem<String>>[
PopupMenuItem<String>(value: 'Item01', child: Text('Item One'),
),
PopupMenuItem<String>(value: 'Item02', child: Text('Item Two')),
PopupMenuItem<String>(
value: 'Item03', child: Text('Item Three')),
PopupMenuItem<String>(value: 'Item04', child: Text('Item Four'))
]).then((value) {
if (null == value) {
return;
}
ToastUtil.show(value.toString());
});
});
效果如下:
showMenu 这里提一下就好了,因为我觉得它是真心不好用,如果你有更多好的玩法可以留言讨论哦O(∩_∩)O哈哈~
二、DropdownButton 组件
DropdownButton 也可以实现一个弹窗效果,一些简单的点击选择 业务场景使用它的情况相对较多。它常常和 DropdownMenuItem 配合一起使用。
构造函数:
DropdownButton({
Key key,
@required this.items,
this.value,
this.hint,
this.disabledHint,
@required this.onChanged,
this.elevation = 8,
this.style,
this.underline,
this.icon,
this.iconDisabledColor,
this.iconEnabledColor,
this.iconSize = 24.0,
this.isDense = false,
this.isExpanded = false,
})
常用属性
属性 描述
items 下拉列表 Item
value 当前选中的值
hint value = null 时显示
disabledHint 禁用时提示
onChanged 选项发生变化时回调
elevation 阴影值
style 字体样式
icon 右侧图标
iconSize 右侧图标大小
isDense 是否添加稠密效果,false会添加 8个单位值的距离
isExpanded 是否填充父组件
iconEnabledColor 启用时图标的颜色
iconDisabledColor 未启用时图标的颜色
underline 底部下划线
简单运用:
//使用一个父组件包裹 DropdownButton
Container(
width: 100,
child: DropdownButton<String>(
value: content,
//填充父组件
isExpanded: true,
iconEnabledColor: Colors.yellow,
//添加下划线
underline: Container(
color: Colors.red,
height: 1,
),
//监听改变的值
onChanged: (String value) {
setState(() {
content = value;
});
},
items: <String>["One", 'Two', 'Free', 'Four']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
)),
效果如下:
这段代码是直接从官方例子中拿出来简单修改了下,可以看到 DropdownButton 确实能实现一个简单的弹窗效果。但是有个问题你肯定发现了,在我每次选择不同 Item 后,DropdownButton 弹出的位置都发生了变化......
很显然这种效果是我们大多数情况不能接受的,我期望的是每次弹出总是在 下划线以下显示。面对这样的需求那该怎么办呢? 一顿源码狂找,发现Flutter 中并没有提供相应的解决方案......
分析问题,每次点击不同的 Item 后弹出的位置都在变化,点击同一 Item 时弹出的位置则在上一次的位置,很明显内部在维护一个选中时的索引值,然后再通过选中的索引值后计算显示的位置。
带着疑问进入 DropdownButton 内部源码,发现 DropdownButton 继承的是 StatefulWidget
组件,既然继承的是 StatefulWidget 那直接跳转到 createState()
方法去查看 它创建了一个什么 State!!!!
发现它创建了 _DropdownButtonState ,其内部源码如下:
class _DropdownButtonState<T> extends State<DropdownButton<T>> with WidgetsBindingObserver {
int _selectedIndex;
_DropdownRoute<T> _dropdownRoute;
@override
void initState() {
super.initState();
_updateSelectedIndex();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_removeDropdownRoute();
super.dispose();
}
// Typically called because the device's orientation has changed.
// Defined by WidgetsBindingObserver
@override
void didChangeMetrics() {
_removeDropdownRoute();
}
void _removeDropdownRoute() {
_dropdownRoute?._dismiss();
_dropdownRoute = null;
}
@override
void didUpdateWidget(DropdownButton<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_updateSelectedIndex();
}
void _updateSelectedIndex() {
if (!_enabled) {
return;
}
assert(widget.value == null ||
widget.items.where((DropdownMenuItem<T> item) => item.value == widget.value).length == 1);
_selectedIndex = null;
for (int itemIndex = 0; itemIndex < widget.items.length; itemIndex++) {
if (widget.items[itemIndex].value == widget.value) {
_selectedIndex = itemIndex;
return;
}
}
}
TextStyle get _textStyle => widget.style ?? Theme.of(context).textTheme.subhead;
void _handleTap() {
final RenderBox itemBox = context.findRenderObject();
final Rect itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size;
final TextDirection textDirection = Directionality.of(context);
final EdgeInsetsGeometry menuMargin = ButtonTheme.of(context).alignedDropdown
?_kAlignedMenuMargin
: _kUnalignedMenuMargin;
assert(_dropdownRoute == null);
_dropdownRoute = _DropdownRoute<T>(
items: widget.items,
buttonRect: menuMargin.resolve(textDirection).inflateRect(itemRect),
padding: _kMenuItemPadding.resolve(textDirection),
selectedIndex: _selectedIndex ?? 0,
elevation: widget.elevation,
theme: Theme.of(context, shadowThemeOnly: true),
style: _textStyle,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
);
Navigator.push(context, _dropdownRoute).then<void>((_DropdownRouteResult<T> newValue) {
_dropdownRoute = null;
if (!mounted || newValue == null)
return;
if (widget.onChanged != null)
widget.onChanged(newValue.result);
});
}
// When isDense is true, reduce the height of this button from _kMenuItemHeight to
// _kDenseButtonHeight, but don't make it smaller than the text that it contains.
// Similarly, we don't reduce the height of the button so much that its icon
// would be clipped.
double get _denseButtonHeight {
final double fontSize = _textStyle.fontSize ?? Theme.of(context).textTheme.subhead.fontSize;
return math.max(fontSize, math.max(widget.iconSize, _kDenseButtonHeight));
}
Color get _iconColor {
// These colors are not defined in the Material Design spec.
if (_enabled) {
if (widget.iconEnabledColor != null) {
return widget.iconEnabledColor;
}
switch(Theme.of(context).brightness) {
case Brightness.light:
return Colors.grey.shade700;
case Brightness.dark:
return Colors.white70;
}
} else {
if (widget.iconDisabledColor != null) {
return widget.iconDisabledColor;
}
switch(Theme.of(context).brightness) {
case Brightness.light:
return Colors.grey.shade400;
case Brightness.dark:
return Colors.white10;
}
}
assert(false);
return null;
}
bool get _enabled => widget.items != null && widget.items.isNotEmpty && widget.onChanged != null;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMaterial(context));
assert(debugCheckHasMaterialLocalizations(context));
// The width of the button and the menu are defined by the widest
// item and the width of the hint.
final List<Widget> items = _enabled ? List<Widget>.from(widget.items) : <Widget>[];
int hintIndex;
if (widget.hint != null || (!_enabled && widget.disabledHint != null)) {
final Widget emplacedHint =
_enabled ? widget.hint : DropdownMenuItem<Widget>(child: widget.disabledHint ?? widget.hint);
hintIndex = items.length;
items.add(DefaultTextStyle(
style: _textStyle.copyWith(color: Theme.of(context).hintColor),
child: IgnorePointer(
child: emplacedHint,
ignoringSemantics: false,
),
));
}
final EdgeInsetsGeometry padding = ButtonTheme.of(context).alignedDropdown
? _kAlignedButtonPadding
: _kUnalignedButtonPadding;
// If value is null (then _selectedIndex is null) or if disabled then we
// display the hint or nothing at all.
final int index = _enabled ? (_selectedIndex ?? hintIndex) : hintIndex;
Widget innerItemsWidget;
if (items.isEmpty) {
innerItemsWidget = Container();
} else {
innerItemsWidget = IndexedStack(
index: index,
alignment: AlignmentDirectional.centerStart,
children: items,
);
}
const Icon defaultIcon = Icon(Icons.arrow_drop_down);
Widget result = DefaultTextStyle(
style: _textStyle,
child: Container(
padding: padding.resolve(Directionality.of(context)),
height: widget.isDense ? _denseButtonHeight : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
widget.isExpanded ? Expanded(child: innerItemsWidget) : innerItemsWidget,
IconTheme(
data: IconThemeData(
color: _iconColor,
size: widget.iconSize,
),
child: widget.icon ?? defaultIcon,
),
],
),
),
);
if (!DropdownButtonHideUnderline.at(context)) {
final double bottom = widget.isDense ? 0.0 : 8.0;
result = Stack(
children: <Widget>[
result,
Positioned(
left: 0.0,
right: 0.0,
bottom: bottom,
child: widget.underline ?? Container(
height: 1.0,
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFFBDBDBD), width: 0.0))
),
),
),
],
);
}
return Semantics(
button: true,
child: GestureDetector(
onTap: _enabled ? _handleTap : null,
behavior: HitTestBehavior.opaque,
child: result,
),
);
}
}
源码较多,下面挑关键的说,首先 看到 内部维护两个属性:
int _selectedIndex;
_DropdownRoute<T> _dropdownRoute;
果不其然 发现内部有一个 选中时的索引值 _selectedIndex
,而选中时 对这个 _selectedIndex 做了些什么工作呢? 如下:
发现点击选中后 将 _selectedIndex 传入了 _DropdownRoute 中。那持续跟进。
class _DropdownRoute<T> extends PopupRoute<_DropdownRouteResult<T>> {
_DropdownRoute({
this.items,
this.padding,
this.buttonRect,
this.selectedIndex,
this.elevation = 8,
this.theme,
@required this.style,
this.barrierLabel,
}) : assert(style != null);
.............. 省略部分代码
final int selectedIndex;
..............省略部分代码
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return _DropdownRoutePage<T>(
..............省略部分代码
selectedIndex: selectedIndex,
elevation: elevation,
theme: theme,
style: style,
);
}
);
}
void _dismiss() {
navigator?.removeRoute(this);
}
}
_DropdownRoute 在构建页面时 又将 selectedIndex 传入了 _DropdownRoutePage 中。层次这么深,没办法继续跟进 _DropdownRoutePage 中。在 build 方法中终于看到了 selectedIndex 的计算方式,如下:
selectedItemOffset 就是 item 的偏移量,也就是说只要改掉这个值 就能改变弹出的位置。那具体该怎么改呢? 我想要放在 DropdownButton 底部显示,并且想可以控制它和DropdownButton 之间的间距。 首先肯定的拿到 DropdownButton 的高度,其中正好有一个对象 buttonRect
就能拿到 DropdownButton 在屏幕上位置的高度 ,而间距的大小最好是从外部能够传入。最后得出以下公式计算:
selectedItemOffset = (buttonRect.height + marginTop) * -1;
这里为啥要 乘 -1
呢 ? 经过一轮测试发现 selectedItemOffset 的值大于等于 0 弹出窗都会显示在 DropdownButton 之上,乘 -2 又在底部太远,-1 刚好合适。
内部源码我们不能修改,因此也只能依葫芦画瓢的再造一个 DropdownButton 组件,新建 ExpandDropdownButton 类,其它基本直接复制进来,主要修改上面的计算公式:
double selectedItemOffset;
if(alwaysBottom){
selectedItemOffset = (buttonRect.height + marginTop) * -1;
}else{
selectedItemOffset = selectedIndex * _kMenuItemHeight + kMaterialListPadding.top;
}
为了保证原来的算方式也可以使用,这里添加了一个 alwaysBottom
属性 在外部控制弹出方式。因为添加了属性内部源码修改的地方还是相对较多这里就不贴出来了,感兴趣的可以见文末源码。
ExpandDropdownButton 中的拓展属性:
属性 描述
alwaysBottom 是否总是在底部显示,false 为Flutter 自带方式
marginTop 弹出窗距离button 距离
drawPadding 和右侧图标的内距值(使用该属性尽量不要把父容器限制宽度)
下面来使用下拓展后的 ExpandDropdownButton 组件.
Container(
child: ExpandDropdownButton<String>(
value: content1,
// 总是在底部显示
alwaysBottom: true,
//距离 button 10个单位间距
marginTop: 10,
//文字和图标内距40个单位
drawPadding: 40,
icon: Icon(
Icons.arrow_drop_down,
size: 20,
),
iconDisabledColor: Colors.blue,
iconEnabledColor: Colors.yellow,
underline: Container(
color: Colors.red,
height: 1,
),
onChanged: (value) {
setState(() {
content1 = value;
ToastUtil.show(content);
});
},
items: <String>["One", 'Two', 'Free', 'Four']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
)),
效果如下:
拓展后的 ExpandDropdownButton 能满足绝大部分的场景需求了,如果还有别的要求就需要你自己去拓展了。总的来说效果还是比较给力O(∩_∩)O
至此 Flutter 中的有关弹窗的组件就全部介绍完了,根据业务需求选择适当的弹窗 (Dialog 或者本章介绍的Pop)才是合理利用。
实例源码地址:https://github.com/zhengzaihong/flutter_learn/blob/master/lib/page/pop/PopViewPage.dart