Flutter 彈窗菜單實(shí)現(xiàn) 2023-08-12 周六

簡(jiǎn)介

彈窗菜單用到的場(chǎng)景還是蠻多的,比如這樣的:

image.png

實(shí)現(xiàn)方案

  • 采用PopupMenuButton組件,配合PopupMenuItem,可以非常方便地實(shí)現(xiàn)這種彈出菜單效果。

  • 實(shí)踐下來(lái),基本的功能實(shí)現(xiàn)是足夠用了,但是如果需要自定義視圖,就顯得力不從心;

  • 彈窗菜單基本上是一種OverlayEntry,所以如果需要自定義,應(yīng)該用這個(gè);

  • OverlayEntry彈窗菜單與目標(biāo)組件是緊挨著的,如何定位是個(gè)問(wèn)題。

  • CompositedTransformFollower 與 CompositedTransformTarget的組合可以解決目標(biāo)組件與彈出OverlayEntry的定位問(wèn)題。

封裝

  • 模仿PopupMenuButton,定義接口參數(shù)。為了增加自定義的自由度,彈出部分也只是個(gè)Widget,而不是一個(gè)數(shù)組。
/// 模仿PopupMenuButton寫的彈窗菜單
class PandaPopupMenu extends StatelessWidget {
  const PandaPopupMenu({
    Key? key,
    required this.targetWiget,
    required this.menuWiget,
  }) : super(key: key);

  final Widget targetWiget;
  final Widget menuWiget;

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text(
        'PandaPopupMenu is working',
        style: TextStyle(fontSize: 20),
      ),
    );
  }
}
  • 內(nèi)部變量:組件對(duì)所需要的連線;彈窗用一個(gè)OverlayEntry
  /// 內(nèi)部變量
  final LayerLink _layerLink = LayerLink();
  OverlayEntry? _overlayEntry;

Target布局

這個(gè)就是PopupMenuButton一直顯示的部分,外面套一個(gè)Container,可以帶來(lái)很大的方便。

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        _showOverlay(context);
      },
      child: Container(
        margin: margin,
        padding: padding,
        color: color,
        width: width,
        height: height,
        decoration: decoration,
        child: CompositedTransformTarget(
          link: _layerLink,
          child: targetWidget,
        ),
      ),
    );
  }

菜單代碼

  /// 顯示浮層
  void _showOverlay(BuildContext context) {
    /// 防止重復(fù)創(chuàng)建,不然失去句柄的OverlayEntry將無(wú)法消除
    if (_overlayEntry == null) {
      _overlayEntry = _createOverlayEntry();
      if (_overlayEntry != null) {
        Overlay.of(context).insert(_overlayEntry!);
      }
    }
  }

  /// 隱藏浮層
  void _hideOverlay() {
    /// 防止null調(diào)用異常
    if (_overlayEntry != null) {
      _overlayEntry?.remove();
      _overlayEntry = null;
    }
  }

  /// 創(chuàng)建浮層
  OverlayEntry _createOverlayEntry() {
    return OverlayEntry(
      builder: (BuildContext context) {
        return GestureDetector(
          onTap: () {
            _hideOverlay();
          },
          child: UnconstrainedBox(
            child: CompositedTransformFollower(
              link: _layerLink,
              targetAnchor: Alignment.bottomCenter,
              followerAnchor: Alignment.topCenter,
              offset: const Offset(0, 10),
              child: Material(
                child: menuWigdet,
              ),
            ),
          ),
        );
      },
    );
  }
  • OverlayEntry默認(rèn)是全屏充滿的,PopupMenuButton就是這樣的情況。不過(guò),在很多時(shí)候我們不希望這樣。比如,我們現(xiàn)在的設(shè)想是點(diǎn)擊Target部分,顯示菜單;點(diǎn)擊菜單部分,隱藏菜單。要去掉全屏,只要在外面套一個(gè)UnconstrainedBox就可以了。

  • OverlayEntry不能反復(fù)創(chuàng)建,不然的話,丟失句柄的OverlayEntry會(huì)無(wú)法消除;所以創(chuàng)建和消除方法需要做好判空處理。

  • 默認(rèn)錨點(diǎn)都在左上角,這樣導(dǎo)致彈出的菜單蓋住了Target,失去“跟隨”的意義。這個(gè)只要改一下targetAnchor,followerAnchor取值就可以了。

  • 偏移量:Target和菜單是緊挨著的,如果需要間隔,那么只要修改offset參數(shù)就可以了。

調(diào)用代碼

PandaPopupMenu(
  targetWigdet: Container(
    color: Colors.yellow,
    width: 30,
    height: 30,
  ),
  menuWigdet: Container(
    color: Colors.blue,
    width: 200,
    height: 300,
  ),
),

代碼很簡(jiǎn)單,就是兩個(gè)不同顏色的矩形

效果

  • 默認(rèn)狀態(tài):就是一個(gè)黃色矩形
image.png
  • 展開狀態(tài):點(diǎn)擊黃色矩形,添加一個(gè)浮層,目前是一個(gè)藍(lán)色矩形;跟隨狀態(tài),并且有間隔。點(diǎn)擊彈出的藍(lán)色矩形,可以隱藏。
image.png
  • 由于去掉了OverlayEntry的全屏屬性,其他地方都能正常點(diǎn)擊,響應(yīng)也正常,頁(yè)面跳轉(zhuǎn)也不受影響。

  • 問(wèn)題:由于這里頁(yè)面的底部是Tab,切換Tab的時(shí)候,失去了“跟隨”目標(biāo),所以彈出的藍(lán)色就只能居中顯示。

![image.png](https://upload-images.jianshu.io/upload_images/1186939-35473caf95611854.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

變換

作為一個(gè)整體,變換之后,也能很好地跟隨。

  • 旋轉(zhuǎn)
Transform(
  transform: Matrix4.rotationZ(-15 / 180 * 3.14),
  alignment: Alignment.center,
  child: PandaPopupMenu(
    targetWigdet: Container(
      color: Colors.yellow,
      width: 30,
      height: 30,
    ),
    menuWigdet: Container(
      color: Colors.blue,
      width: 200,
      height: 300,
    ),
  ),
),
image.png
  • 縮放
Transform(
  transform: Matrix4.diagonal3Values(0.5, 0.5, 1),
  alignment: Alignment.center,
  child: PandaPopupMenu(
    targetWigdet: Container(
      color: Colors.yellow,
      width: 30,
      height: 30,
    ),
    menuWigdet: Container(
      color: Colors.blue,
      width: 200,
      height: 300,
    ),
  ),
),
image.png
  • 斜切
Transform(
  transform: Matrix4.skewX(15 / 180 * 3.14),
  alignment: Alignment.center,
  child: PandaPopupMenu(
    targetWigdet: Container(
      color: Colors.yellow,
      width: 30,
      height: 30,
    ),
    menuWigdet: Container(
      color: Colors.blue,
      width: 200,
      height: 300,
    ),
  ),
),
image.png
  • 平移
Transform(
  transform: Matrix4.translationValues(30, 10, 0),
  alignment: Alignment.center,
  child: PandaPopupMenu(
    targetWigdet: Container(
      color: Colors.yellow,
      width: 30,
      height: 30,
    ),
    menuWigdet: Container(
      color: Colors.blue,
      width: 200,
      height: 300,
    ),
  ),
),
image.png

代碼最后的樣子

// ignore_for_file: must_be_immutable

import 'package:flutter/material.dart';

/// 模仿PopupMenuButton寫的彈窗菜單
class PandaPopupMenu extends StatelessWidget {
  PandaPopupMenu({
    Key? key,
    required this.targetWigdet,
    required this.menuWigdet,
    this.margin,
    this.padding,
    this.color,
    this.width,
    this.height,
    this.decoration,
    this.offset = const Offset(0, 10),
    this.targetAnchor = Alignment.bottomCenter,
    this.followerAnchor = Alignment.topCenter,
  }) : super(key: key);

  final Widget targetWigdet;
  final Widget menuWigdet;
  final EdgeInsetsGeometry? margin;
  final EdgeInsetsGeometry? padding;
  final Color? color;
  final double? width;
  final double? height;
  final Decoration? decoration;
  final Offset offset;
  final Alignment targetAnchor;
  final Alignment followerAnchor;

  /// 內(nèi)部變量
  final LayerLink _layerLink = LayerLink();
  OverlayEntry? _overlayEntry;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: () {
        _showOverlay(context);
      },
      child: Container(
        margin: margin,
        padding: padding,
        color: color,
        width: width,
        height: height,
        decoration: decoration,
        child: CompositedTransformTarget(
          link: _layerLink,
          child: targetWigdet,
        ),
      ),
    );
  }

  /// 顯示浮層
  void _showOverlay(BuildContext context) {
    /// 防止重復(fù)創(chuàng)建,不然失去句柄的OverlayEntry將無(wú)法消除
    if (_overlayEntry == null) {
      _overlayEntry = _createOverlayEntry();
      if (_overlayEntry != null) {
        Overlay.of(context).insert(_overlayEntry!);
      }
    }
  }

  /// 隱藏浮層
  void _hideOverlay() {
    /// 防止null調(diào)用異常
    if (_overlayEntry != null) {
      _overlayEntry?.remove();
      _overlayEntry = null;
    }
  }

  /// 創(chuàng)建浮層
  OverlayEntry _createOverlayEntry() {
    return OverlayEntry(
      builder: (BuildContext context) {
        return GestureDetector(
          onTap: () {
            _hideOverlay();
          },
          child: UnconstrainedBox(
            child: CompositedTransformFollower(
              link: _layerLink,
              targetAnchor: Alignment.bottomCenter,
              followerAnchor: Alignment.topCenter,
              offset: const Offset(0, 10),
              child: Material(
                child: menuWigdet,
              ),
            ),
          ),
        );
      },
    );
  }
}

參考文章

Flutter 組件 | 手牽手,一起走 CompositedTransformFollower 與 CompositedTransformTarget

Flutter 帶指示器的懸浮窗口

CompositedTransformTarget + OverlayEntry實(shí)現(xiàn)懸浮窗

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 彈窗的概念如今已被泛化,主要功能:信息傳遞與用戶反饋。從是否強(qiáng)制用戶交互的角度分類:模態(tài)彈窗和非模態(tài)彈窗,都位于當(dāng)...
    日暮山主人閱讀 1,980評(píng)論 0 2
  • 用兩張圖告訴你,為什么你的 App 會(huì)卡頓? - Android - 掘金 Cover 有什么料? 從這篇文章中你...
    hw1212閱讀 14,047評(píng)論 2 59
  • lzyprime 博客 (github) 創(chuàng)建時(shí)間:2020.08.20qq及郵箱:2383518170 λ: ...
    lzyprime閱讀 7,271評(píng)論 0 6
  • Flutter里面的布局都是由各種widget組成的,所以有必要熟悉一下各種widget 基礎(chǔ) Widgets 名...
    Observer_觀者閱讀 793評(píng)論 0 3
  • 用到的組件 1、通過(guò)CocoaPods安裝 2、第三方類庫(kù)安裝 3、第三方服務(wù) 友盟社會(huì)化分享組件 友盟用戶反饋 ...
    SunnyLeong閱讀 15,196評(píng)論 1 180

友情鏈接更多精彩內(nèi)容