Flutter悬浮控件

一、背景:

其实我之前做项目写过这么个控件 Flutter 带指示器的悬浮窗口
我总结了一下,跟接本文控件的区别:
1.我之前写的悬浮窗的宽度是动态的,根据内容变化的。但是本次的悬浮控件的宽度是固定的。
2.我之前的控件屏幕越界没有处理的很好,跟第一点也有些关系,因为悬浮窗的宽度不固定,也没法动态去计算。
3.我之前的控件点击屏幕消失弹窗也做的不够优雅。

二、看下越界和正常情况下的显示
左边越界处理之后的显示.png
正常的显示.png
三、核心思路:

1.最核心的就是控制弹窗的位置,根据控件的位置来计算。
2.计算弹窗的三角形相对于弹窗最左边的距离。
3.然后就是判断越界,这个可以看看代码,只要思路对,就是一点数学。然后越界要重新计算前两点。

四、重写中感悟

关于控件的松约束和紧约束, BoxConstraints.tight和 BoxConstraints.loose的理解。这个关于控件的大小。本文的弹窗的空白区域取消,需要我们对常用空间是松约束还是紧约束有了解。

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:wrapper/wrapper.dart';

class OverlayPage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return HomePageState();
  }
}

class HomePageState extends State<OverlayPage> {
  final List<String> books = ['《诗经》', '《资治通鉴》', '《史记》', '《狂人日记》'];

  Map<String, String> introductionMap = {
    '《诗经》': '《诗经》,是中国古代诗歌开端,最早的一部诗歌总集,收集了西周初年至春秋中叶(前11世纪至前6世纪)的诗歌,共311篇。',
    '《资治通鉴》': '《资治通鉴》,是由北宋史学家司马光主编的一部多卷本编年体史书,共294卷,历时十九年完成。',
    '《史记》':
    '《史记》,二十四史之一,最初称为《太史公书》或《太史公记》、《太史记》,是西汉史学家司马迁撰写的纪传体史书,是中国历史上第一部纪传体通史。',
    '《狂人日记》':
    '《狂人日记》是鲁迅创作的第一个短篇白话文日记体小说,也是中国第一部现代白话小说,写于1918年4月。该文首发于1918年5月15日4卷5号的《新青年》月刊,后收入《呐喊》集。',
    '《西游记》':
    '《西游记》是中国古代第一部浪漫主义章回体长篇神魔小说。现存明刊百回本《西游记》均无作者署名,清代学者吴玉搢等首先提出《西游记》作者是明代吴承恩。',
    '《红楼梦》':
    '《红楼梦》是中国古代章回体长篇小说,中国古典四大名著之一,通行本共120回,一般认为前80回是清代作家曹雪芹所著,后40回作者为无名氏,整理者为程伟元、高鹗。',
    '《三国演义》':
    '《三国演义》是元末明初小说家罗贯中根据陈寿《三国志》和裴松之注解以及民间三国故事传说经过艺术加工创作而成的长篇章回体历史演义小说。',
    '《水浒传》': '《水浒传》是元末明初施耐庵(现存刊本署名大多有施耐庵、罗贯中两人中的一人,或两人皆有)编著的章回体长篇小说。',
  };

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("悬浮窗的显示"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(10),
        child: Wrap(
          spacing: 15,
          children: books.map((e) => Alertable( message: e,introduction: introductionMap[e]??"暂无简介",)).toList(),
        ),
      ),
    );
  }
}

class Alertable extends StatefulWidget {
  String message;
  String introduction;

  @override
  State<StatefulWidget> createState() {
    return AlertableState();
  }

  Alertable({
    Key? key,
    required this.message,
    required this.introduction,
  }):super(key: key);
}

class AlertableState extends State<Alertable> {
  OverlayEntry? _entry;

  @override
  Widget build(BuildContext context) {
    return  _buildTextWidget(widget.message,widget.introduction);
  }

  Widget _buildTextWidget(String book,String introduction){
    return GestureDetector(
      onTap: () => _onTapItem(book,introduction),
      child: Container(
          padding: const EdgeInsets.all(3),
          decoration: BoxDecoration(
              color: Colors.blue,
              boxShadow: const [
                BoxShadow(
                    offset: Offset(.5, .5),
                    blurRadius: .5,
                    spreadRadius: .1,
                    color: Colors.blueAccent)
              ],
              borderRadius: BorderRadius.circular(6)),
          child: Text(
            book,
            style: const TextStyle(color: Colors.white),
          )),
    );
  }


 void  _onTapItem(String book,String introduction){
    _createNewEntry(book,introduction);
  }

  ///首先:OverlayEntry#builder 下的约束是 紧约束 ,所以像 Container 、SizedBox 这种会被父级紧约束锁死尺寸的组件需要先解除紧约束,才能指定尺寸。
  /// 其次:Positioned 可以作为 builder 返回的 顶层 组件。因为 Overlay 中使用的 _Theatre 组件对应的渲染对象 _RenderTheatre 子级数据也是 StackParentData 。
  /// 最后:Overlay 在单独的栈中维护,显示文字时需要在上层提供 Material 组件。
  void _createNewEntry(String book,String introduction){
      ///获取目标尺寸、偏移量信息
    Size targetSize = Size.zero;
    Offset targetOffset = Offset.zero;
    double boxWidget = 250;
    double marginTop = 10;
    const EdgeInsets padding =  EdgeInsets.symmetric(horizontal: 8,vertical: 4);

    double spannerOffset = (boxWidget - 6) / 2;
    double winWidth = MediaQuery.of(context).size.width;

    RenderObject? rederObject = context.findRenderObject();
    if(rederObject != null && rederObject is RenderBox){
      targetSize = rederObject.size;
      Offset topLeftOffset = rederObject.localToGlobal(Offset.zero);

      Offset offset = Offset( targetSize.width/2 - boxWidget /2 , targetSize.height + marginTop);

      bool isLeftOut =  topLeftOffset.dx
          + targetSize.width / 2 - boxWidget /2 < 0;
      bool isRightOut = winWidth - topLeftOffset.dx - targetSize.width /2 < boxWidget /2;
      if(isLeftOut){
        targetOffset = Offset(topLeftOffset.dx,  topLeftOffset.dy + offset.dy);
        spannerOffset = targetSize.width / 2;
      }else if(isRightOut){
        ///这个算法很复杂,但是越界会只留出右边的padding
        // targetOffset = Offset(topLeftOffset.dx - ( boxWidget - (winWidth -
        //     topLeftOffset.dx  - padding.right)),
        //     topLeftOffset.dy + offset.dy);
        // spannerOffset =  boxWidget - (winWidth -
        //     topLeftOffset.dx  - padding.right) + targetSize.width /2;
        ///这种算法就是悬浮窗和控件的右边对齐,简单
        targetOffset = Offset(topLeftOffset.dx + targetSize.width - boxWidget,  topLeftOffset.dy + offset.dy);
        spannerOffset = boxWidget - targetSize.width /2;

      }else{
        targetOffset = topLeftOffset + offset;
        spannerOffset = boxWidget /  2;
      }
    }


    ///获取overlayState
    OverlayState? overlayState = Overlay.of(context);
    if (overlayState == null)  return;

    _entry = OverlayEntry
      (builder: (BuildContext context)
      => GestureDetector(
        ///这个地方这么设置可以让事件不往下透传
        behavior: HitTestBehavior.opaque,
        onTap: removeEntry,
        child: Stack(
          ///postioned 这个让内容显示很小,但是Stack还是整个屏幕。
          children :[ Positioned(
              width: boxWidget,
              ///这个是相对原点的位置
              left: targetOffset.dx,
              top: targetOffset.dy,
              child: GestureDetector(
                onTap: removeEntry,
                child: Material(child:
                    Wrapper(
                      padding: padding,
                      spineType: SpineType.top,
                      offset: spannerOffset,
                      angle: 60,
                      spineHeight: 6,
                      color: const Color(0xff95EC69),
                      child: Text(introduction) ,
                    )
                // ColoredBox(color:Colors.red,child: Text(introduction))),
                )))],
        ),
      )
    );
    overlayState.insert(_entry!);

  }

 void removeEntry(){
    if(_entry != null){
      _entry!.remove();
      _entry == null;
    }
  }


}



最后,这个是 张风捷特烈大佬写,我只是照着大佬的脚步又走了一遍,还是有不少的收获的。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,701评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,649评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,037评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,994评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,018评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,796评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,481评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,370评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,868评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,014评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,153评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,832评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,494评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,039评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,156评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,437评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,131评论 2 356

推荐阅读更多精彩内容