最近需要实现一个小需求包含如下功能点:1. 点击某个区域,高亮此区域,其他地方灰度显示;2. 高亮的同时,底部弹出菜单按钮;3. 点击菜单按钮执行相应操作,点击灰度地方高亮和底部弹出菜单消失。如下图所示:
刚开始考虑到使用BottomSheet来做,但是BottomSheet弹出后,其他地方不会高亮,后来又想到是否可以使用CustomPainter画出来,后面发现比较难以实现。接着就网上搜索了一下有没有类似方案,发现了的确有人做了非常类似的东西,参考这里。
看了一遍之后发现思路非常简单(PS:我做的时候完全没有往这方面想,可能是刚接触Flutter思路想法还没有转过来吧~_~),所以我们的主要思路就是,获取我们点击的区域(我们这里是BankCardBox Widget)Widget,拿到这个BankCardBox Widget传到新的页面,同时在新的页面我们要保证这个Widget的位置要和原来屏幕上面的位置是一样的,这样在新页面其他地方设置透明度,达到我们需要的效果 --- 即点击屏幕区域,高亮此区域,并且其他地方置灰。基于此,我们主要需要做以下几件事:
1. 获取原屏幕页面上面的BankCardBox Widget的位置和大小,保证打开新屏幕页面后完全覆盖之前的BankCardBox Widget;
首先我们想到Flutter的UI渲染是一个Widgets tree,那么tree的特性使得一个节点可以通过context很轻易的拿到它的字节点的相关信息,所以我们这里如果需要获取Widget的位置,我们何不把这个Widget通过一个Stateful Widget包裹起来,然后通过Global key拿到这个Widget的位置,这样我们编码如下:
class FocusedMenuHolder extends StatefulWidget {
final Widget child,menuContent;
const FocusedMenuHolder({Key key, @required this.child,@required this.menuContent});
@override
_FocusedMenuHolderState createState() => _FocusedMenuHolderState();
}
class _FocusedMenuHolderState extends State<FocusedMenuHolder> {
GlobalKey containerKey = GlobalKey();
Offset childOffset = Offset(0, 0);
Size childSize;
getOffset() {
RenderBox renderBox = containerKey.currentContext.findRenderObject();
Size size = renderBox.size;
Offset offset = renderBox.localToGlobal(Offset.zero);
setState(() {
this.childOffset = Offset(offset.dx, offset.dy);
childSize = size;
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
key: containerKey,
onLongPress: () async {
getOffset();
},
child: widget.child);
}
}
可以看到,Stateful Widget里面的child属性就是我们需要包裹的Widget,menuContent就是我们点击Widget时候需要在底部弹出的菜单按钮。我们在这里是通过getOffset方法拿到Widget的位置和大小的。
2. 包裹每个BankCardBox;
上面我们实现了这个Stateful Widget,接着我们就可以通过它来包裹我们的BankCardBox Widget了。我们通过ListView.builder方法构建了一个卡片列表,卡片列表的每个卡片就是我们的BandCardBox。
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CommonWidget.appBar(
context,
'Cards',
Icons.arrow_back,
Colors.black,
),
body: Container(
margin: EdgeInsets.all(8.0),
height: SizeConfig().screenHeight * .7,
child: ListView.builder(
shrinkWrap: true,
itemBuilder: (context, index) {
BankCard card = cards[index];
return FocusedMenuHolder(
child: BankCardBox(
cardType: card.cardBrand,
cardNum: card.cardNumber,
),
menuContent: _buildMenuItems(card),
);
},
itemCount: cards.length,
),
),
);
}
3. 获取BankCardBox Widget跳转新页面;
点击BankCardBox Widget之后,跳转到新页面,这里我们为了实现菜单弹出的效果,我们不用传统的MaterialPageRoute,使用PageRouteBuilder来实现这个路由。
@override
Widget build(BuildContext context) {
return GestureDetector(
key: containerKey,
onTap: () async {
getOffset();
await Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration(milliseconds: 100),
pageBuilder: (context, animation, secondaryAnimation) {
animation = Tween(begin: 0.0, end: 1.0).animate(animation);
return FadeTransition(
opacity: animation,
child: FocusedMenuDetails(
menuContent: widget.menuContent,
child: widget.child,
childOffset: childOffset,
childSize: childSize,
),
);
},
fullscreenDialog: true,
opaque: false,
),
);
},
child: widget.child,
);
}
4. 弹出菜单新页面实现
点击BankCardBox之后,我们跳转到新页面,新页面实现如下,整体上使用Stack布局,使得弹出菜单展示在底部,BankCardBox Widget根据传入的位置和大小布局到指定的位置,并且使用Backdrop Filter来调节页面的透明度。同时我们使用GestureDetector来实现点击其他地方pop当前弹出页面。
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../shared.dart';
class FocusedMenuDetails extends StatelessWidget {
final Offset childOffset;
final Size childSize;
final Widget menuContent;
final Widget child;
const FocusedMenuDetails({
Key key,
@required this.menuContent,
@required this.childOffset,
@required this.childSize,
@required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final sw = SizeConfig().screenWidth;
final sh = SizeConfig().screenHeight;
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
child: Stack(
fit: StackFit.expand,
children: [
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: Container(
color: Colors.black.withOpacity(0.3),
),
),
),
Positioned(
bottom: 20.0,
left: 15.0,
child: TweenAnimationBuilder(
duration: Duration(milliseconds: 200),
builder: (BuildContext context, value, Widget child) {
return Transform.scale(
scale: value,
alignment: Alignment.center,
child: child,
);
},
tween: Tween(begin: 0.0, end: 1.0),
child: Container(
width: sw - 30.0,
height: sh * .2,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius:
const BorderRadius.all(Radius.circular(5.0)),
boxShadow: [
const BoxShadow(
color: Colors.black38,
blurRadius: 10,
spreadRadius: 1)
]),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(5.0)),
child: menuContent,
),
),
),
),
Positioned(
top: childOffset.dy,
left: childOffset.dx,
child: AbsorbPointer(
absorbing: true,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
width: childSize.width,
height: childSize.height,
child: child,
),
),
),
],
),
),
);
}
}
5. 总结。
主要是这种思路,使用Widgets tree包裹获取子Widget的大小和位置,使用了PageRouteBuilder来实现路由效果,GestureDetector检测点击区域等等。源码。