思路

开发过程中常见这样的需求,页面中有几个按钮,用户首次进入时需要对这几个按钮高亮展示并加上文字提示。常见的一种方案是找UI切图,那如何完全使用代码来实现呢?

就以Flutter原始Demo页面为例,如果我们需要对中间展示区域以及右下角按钮进行一个引导提示。

我们需要做到的效果是除了红色框内的Widget,其余部分要盖上一层半透明黑色浮层,相当于是全屏浮层,红色区域镂空。

首先是黑色浮层,这个比较容易,Flutter中的Overlay可以轻易实现,它可以浮在任意的Widget之上,包括Dialog

那么如何镂空呢?

一种思路是首先拿到对应的Widget与其宽高xy偏移量,然后在Overlay中先铺一层浮层后,把该WidgetOverlay的对应位置中再绘制一遍。也就是说该Widget存在两份,一份是原本的Widget,另一份是在Overlay之上又绘制一层,并且不会被浮层所覆盖,即为高亮。这是一种思路,但如果你需要进行引导提示的Widget自身有透明度,那么这个方案就略有问题,因为你的浮层即为半透明,那么用户就可以穿过顶层的Widget看到下面的内容,略有瑕疵。

那么另一种思路就是我们不去在Overlay之上盖上另一个克隆Widget,而是将Overlay半透明黑色涂层对应位置进行镂空即可,就不存在任何问题了。

Flutter BlendMode

既然需要镂空,我们需要了解一下Flutter中的图层混合模式概念

在画布上绘制形状或图像时,可以使用不同的算法来混合像素,每个算法都存在两个输入,即源(正在绘制的图像 src)和目标(要合成源图像的图像 dst)

我们把半透明黑色涂层 和 需要进行高亮的Widget 理解为src和dst。

接下来我们通过下面的图例可知,如果我们需要实现镂空效果,需要的混合模式为SrcOutDstOut,因为他们的混合模式为一个源展示,且该源与另一个源有非透明像素交汇部分完全剔除。

ColorFiltered

Flutter中为我们提供了ColorFiltered,这是一个官方为我们封装的一个以Color作为源的混合模式Widget。其接收两个参数,colorFilterchild,前者我们可以理解为上述的src,后者则为dst

下面以一段简单的代码说明

class TestColorFilteredPage extends StatelessWidget {
  const TestColorFilteredPage({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return ColorFiltered(
      colorFilter: const ColorFilter.mode(Colors.yellow, BlendMode.srcOut),
      child: Stack(
        children: [
          Positioned.fill(
              child: Container(
            color: Colors.transparent,
          )),
          Positioned(
              top: 100,
              left: 100,
              child: Container(
                color: Colors.black,
                height: 100,
                width: 100,
              ))
        ],
      ),
    );
  }
}

效果:

可以看到作为srccolorFiler除了与作为dstStack非透明像素交汇的地方被镂空了,其他地方均正常显示。

此处需要说明一下,作为dstchild,要实现蒙版的效果,必须要与src有所交汇,所以Stack中使用了透明的Positioned.fill填充,之所以要用透明色,是因为我们使用的混合模式srcOut的算法会剔除非透明像素交互部分

实现

上述部分思路已经足够支持我们写出想要的效果了,接下来我们来进行实现

获取镂空位置

首先我需要拿到对应Widgetkey,就可以拿到对应的宽高与xy偏移量

RenderObject? promptRenderObject =
    promptWidgetKey.currentContext?.findRenderObject();
double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
double widgetTop = 0;
double widgetLeft = 0;
if (promptRenderObject is RenderBox) {
  Offset offset = promptRenderObject.localToGlobal(Offset.zero);
  widgetTop = offset.dy;
  widgetLeft = offset.dx;
}

ColorFiltered child

lastOverlay = OverlayEntry(builder: (ctx) {
  return GestureDetector(
    onTap: () {
      // 点击后移除当前展示的overlay
      _removeCurrentOverlay();
      // 准备展示下一个overlay
      _prepareToPromptSingleWidget();
    },
    child: Stack(
      children: [
        Positioned.fill(
            child: ColorFiltered(
          colorFilter: ColorFilter.mode(
              Colors.black.withOpacity(0.7), BlendMode.srcOut),
          child: Stack(
            children: [
              // 透明色填充背景,作为蒙版
              Positioned.fill(
                  child: Container(
                color: Colors.transparent,
              )),
              // 镂空区域
              Positioned(
                  left: l,
                  top: t,
                  child: Container(
                    width: w,
                    height: h,
                    decoration: decoration ??
                        const BoxDecoration(color: Colors.black),
                  )),
            ],
          ),
        )),
        // 文字提示,需要放在ColorFiltered的外层
        Positioned(
            left: l - 40,
            top: t - 40,
            child: Material(
              color: Colors.transparent,
              child: Text(
                tips,
                style: const TextStyle(fontSize: 14, color: Colors.white),
              ),
            ))
      ],
    ),
  );
});
Overlay.of(context)?.insert(lastOverlay!);

其中的文字偏移量,可以自己通过代码来设置,展示在中心,或者判断位置跟随Widget展示均可,此处不再赘述。

最后我们把Overlay添加到屏幕上展示即可。

完整代码

这里我将逻辑封装在静态工具类中,鉴于单个页面可能会有不止一个引导Widget,所以对于这个静态工具类,我们需要传入需要进行高亮引导的Widget和提示语的集合。

class PromptItem {
  GlobalKey promptWidgetKey;
  String promptTips;
  PromptItem(this.promptWidgetKey, this.promptTips);
}
class PromptBuilder {
  static List<PromptItem> toPromptWidgetKeys = [];
  static OverlayEntry? lastOverlay;
  static promptToWidgets(List<PromptItem> widgetKeys) {
    toPromptWidgetKeys = widgetKeys;
    _prepareToPromptSingleWidget();
  }
  static _prepareToPromptSingleWidget() async {
    if (toPromptWidgetKeys.isEmpty) {
      return;
    }
    PromptItem promptItem = toPromptWidgetKeys.removeAt(0);
    RenderObject? promptRenderObject =
        promptItem.promptWidgetKey.currentContext?.findRenderObject();
    double widgetHeight = promptRenderObject?.paintBounds.height ?? 0;
    double widgetWidth = promptRenderObject?.paintBounds.width ?? 0;
    double widgetTop = 0;
    double widgetLeft = 0;
    if (promptRenderObject is RenderBox) {
      Offset offset = promptRenderObject.localToGlobal(Offset.zero);
      widgetTop = offset.dy;
      widgetLeft = offset.dx;
    }
    if (widgetHeight != 0 &&
        widgetWidth != 0 &&
        widgetTop != 0 &&
        widgetLeft != 0) {
      _buildNextPromptOverlay(
          promptItem.promptWidgetKey.currentContext!,
          widgetWidth,
          widgetHeight,
          widgetLeft,
          widgetTop,
          null,
          promptItem.promptTips);
    }
  }
  static _buildNextPromptOverlay(BuildContext context, double w, double h,
      double l, double t, Decoration? decoration, String tips) {
    _removeCurrentOverlay();
    lastOverlay = OverlayEntry(builder: (ctx) {
      return GestureDetector(
        onTap: () {
          // 点击后移除当前展示的overlay
          _removeCurrentOverlay();
          // 准备展示下一个overlay
          _prepareToPromptSingleWidget();
        },
        child: Stack(
          children: [
            Positioned.fill(
                child: ColorFiltered(
              colorFilter: ColorFilter.mode(
                  Colors.black.withOpacity(0.7), BlendMode.srcOut),
              child: Stack(
                children: [
                  // 透明色填充背景,作为蒙版
                  Positioned.fill(
                      child: Container(
                    color: Colors.transparent,
                  )),
                  // 镂空区域
                  Positioned(
                      left: l,
                      top: t,
                      child: Container(
                        width: w,
                        height: h,
                        decoration: decoration ??
                            const BoxDecoration(color: Colors.black),
                      )),
                ],
              ),
            )),
            // 文字提示,需要放在ColorFiltered的外层
            Positioned(
                left: l - 40,
                top: t - 40,
                child: Material(
                  color: Colors.transparent,
                  child: Text(
                    tips,
                    style: const TextStyle(fontSize: 14, color: Colors.white),
                  ),
                ))
          ],
        ),
      );
    });
    Overlay.of(context)?.insert(lastOverlay!);
  }
  static _removeCurrentOverlay() {
    if (lastOverlay != null) {
      lastOverlay!.remove();
      lastOverlay = null;
    }
  }
}
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  int _counter = 0;
  GlobalKey centerWidgetKey = GlobalKey();
  GlobalKey bottomWidgetKey = GlobalKey();
  void _incrementCounter() {
    setState(() {
      _counter  ;
    });
  }
  @override
  void initState() {
    super.initState();
    // 页面展示时进行prompt绘制,在此添加observer监听等待渲染完成后挂载prompt
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      List<PromptItem> prompts = [];
      prompts.add(PromptItem(centerWidgetKey, "这是中心Widget"));
      prompts.add(PromptItem(bottomWidgetKey, "这是底部Button"));
      PromptBuilder.promptToWidgets(prompts);
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          // 需要高亮展示的widget,需要声明其GlobalKey
          key: centerWidgetKey,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // 需要高亮展示的widget,需要声明其GlobalKey
        key: bottomWidgetKey,
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

最终效果

小结

本文仅总结代码实现思路,对于具体细节并未处理,可以在PromptItemPromptBuilder进行更多的属性声明以更加灵活的展示prompt,比如圆角等参数。有任何问题欢迎大家随时讨论。

最后附上github地址:github.com/slowguy/flu…

以上就是Flutter使用Overlay与ColorFiltered新手引导实现示例的详细内容,更多关于Flutter使用Overlay ColorFiltered的资料请关注Devmax其它相关文章!

Flutter使用Overlay与ColorFiltered新手引导实现示例的更多相关文章

  1. Flutter中文教程-Cookbook

    Flutter中文网的Cookbook中包含了在编写Flutter应用程序时常见问题及示例。设计基础使用主题共享颜色和字体样式Images显示来自网上的图片用占位符淡入图片使用缓存图Lists创建一个基本list创建一个水平list使用长列表创建不同类型子项的List创建一个gridList处理手势处理点击添加Material触摸水波效果实现滑动关闭导航导航到新页面并返回给新页面传值从新页面返回数据给上一个页面网络从网上获取数据进行认证请求使用WebSockets

  2. android-studio – 未配置Dart SDK

    Initializinggradle…

  3. 安卓 – 从一个扑动的应用程序拨打电话

    或者有更好的选择从我的应用程序拨打电话?

  4. android – 如何在Flutter中添加Webview?

    我知道可以将WebView添加为整页,但找不到任何示例代码.我假设你可以使用PageView作为它的基础,但不知道如何调用本机androidWebView并将其添加到PageView.谁能指出我正确的方向?

  5. android – 如何将消息从Flutter传递给Native?

    如果需要与特定的API/硬件组件进行交互,您如何将Flutter的信息传递回Android/Native代码?是否有任何事件频道可以通过其他方式发送信息或类似于回调?

  6. android – 如何在Flutter App中处理onPause / onResume?

    我是否过于复杂的事情?即使我的用例似乎不需要它,我仍然想知道:如何自己处理onPause/onResume事件?

  7. android – 如何使用Flutter构建Augment Reality应用程序?

    我对Android开发有一些基础知识.最近听说过Flutter并且非常有兴趣研究它.我想知道是否有可能使用颤振构建增强现实应用程序以及要实现此目的的方法?请帮忙.解决方法截至目前,颤振不支持3D.Flutter现在专注于2D,团队长期计划为颤振提供优化的3Dapi.你读了常见问题here.

  8. Flutter 网络请求框架封装详解

    这篇文章主要介绍了Flutter 网络请求框架封装详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  9. Flutter StreamBuilder实现局部刷新实例详解

    这篇文章主要为大家介绍了Flutter StreamBuilder实现局部刷新实例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

  10. Flutter 首页必用组件NestedScrollView的示例详解

    今天介绍的组件是NestedScrollView,大部分的App首页都会用到这个组件。对Flutter 首页必用组件NestedScrollView的相关知识感兴趣的一起看看吧

随机推荐

  1. Flutter 网络请求框架封装详解

    这篇文章主要介绍了Flutter 网络请求框架封装详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧

  2. Android单选按钮RadioButton的使用详解

    今天小编就为大家分享一篇关于Android单选按钮RadioButton的使用详解,小编觉得内容挺不错的,现在分享给大家,具有很好的参考价值,需要的朋友一起跟随小编来看看吧

  3. 解决android studio 打包发现generate signed apk 消失不见问题

    这篇文章主要介绍了解决android studio 打包发现generate signed apk 消失不见问题,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧

  4. Android 实现自定义圆形listview功能的实例代码

    这篇文章主要介绍了Android 实现自定义圆形listview功能的实例代码,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  5. 详解Android studio 动态fragment的用法

    这篇文章主要介绍了Android studio 动态fragment的用法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下

  6. Android用RecyclerView实现图标拖拽排序以及增删管理

    这篇文章主要介绍了Android用RecyclerView实现图标拖拽排序以及增删管理的方法,帮助大家更好的理解和学习使用Android,感兴趣的朋友可以了解下

  7. Android notifyDataSetChanged() 动态更新ListView案例详解

    这篇文章主要介绍了Android notifyDataSetChanged() 动态更新ListView案例详解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下

  8. Android自定义View实现弹幕效果

    这篇文章主要为大家详细介绍了Android自定义View实现弹幕效果,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

  9. Android自定义View实现跟随手指移动

    这篇文章主要为大家详细介绍了Android自定义View实现跟随手指移动,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

  10. Android实现多点触摸操作

    这篇文章主要介绍了Android实现多点触摸操作,实现图片的放大、缩小和旋转等处理,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下

返回
顶部