Flutter - 上下文弹出菜单

1,902 阅读4分钟

最近需要实现一个小需求包含如下功能点: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检测点击区域等等。源码