一、背景:
其实我之前做项目写过这么个控件 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;
}
}
}