在很多APP中都会出现第一次打开某些页面时,会把整个页面都蒙上一层蒙版只突出显示重点介绍的内容。在Android原生中的实现基本上都是在根view上添加一层引导的layout,在这个layout上添加和删除引导的view,知道完成之后在移除这个layout,这样对原有页面没有任何影响。基于flutter的特殊性想采用这种方式显然不太合适。这里我们采用另一种方式,在需要引导的页面push一个新的widget,这个widget负责实现引导类似于上面我们介绍的layout。我们先来看看本文要实现的效果。
原始页面
引导页1
引导页2
引导页3
引导页4
前置知识
在正式开始之前我们还有几个小问题需要解决:由于是采用push新的widget,我们必须让其透明;新的widget的push时机。
-透明:其实系统已经给我们提供好了,我们在构建蒙版layout(GuideLayout)时,在他的build方法中只需返回一个Material组件即可,当然需要配置一个属性type;比如我们这里的,
@override
Widget build(BuildContext context) {
Size screenSize = MediaQuery.of(context).size;
return Material(
color: Color(0x00ffffff),
type: MaterialType.transparency,
child: ...
}
}
之所以配置这个属性是因为在Material的介绍中注释已经说明了。 To create a transparent piece of material, use [MaterialType.transparency].
-时机:如果是需要网络请求后显示,肯定是网络请求回来直接push。但如果是静态页的话,我们通常会在initState中注册监听器实现。flutter中为我们提供了监听器。这些监听器都在WidgetsBinding类中。他们可以监听当前页面处于前台还是后台,绘制完成的监听。这里我们采用绘制完成的监听。
WidgetsBinding.instance.addPostFrameCallback();
这个方法会在当前帧结束之后回调,而且只会回调一次。如果熟悉Android,那么他就类似于view.post。至此前置工作完毕,正式开始弹出页面的逻辑。
效果分析及准备
在开始之前我们先看看需要哪些东西能实现这个效果。首先弹出页面整个有蒙层,只有需要引导的部分没有蒙层,而且需要引导的部分没蒙层的形状为矩形,圆角矩形,椭圆,圆形。其次需要引导的widget附近有用于说明的widget。最后每次只显示一个需要引导的widget,最后一个显示完成之后pop这个widget。对于这三大部分内容其实最为关键的还是在蒙版上“挖孔”,也就是在蒙版上扣除特定区域,这就需要用到曲线的拟合。其实这个实现在之前的Flutter自定义view的实现中有所介绍,只不过没有深入。这里重点介绍一下。
准备之曲线拟合
曲线拟合我们采用的是Path result = Path.combine(PathOperation.difference, path1, path2);
这里最重要的是第一个参数,他决定了最终曲线保留两条曲线的区域,一共有五个值,下面采用示意图来表示每种值的拟合效果
这里的path1是一个圆形,path2是一个长方形。要想实现蒙版“挖孔”效果,其实path1可以是全屏的矩形,画笔颜色为蒙版颜色,需要挖孔的区域为path2,对比几种拟合效果,曲线拟合的第一个参数应该是PathOperation.difference。至此蒙版挖孔也实现了。
准备之引导数据类
对比引导的效果我们不难看出,要想添加引导需要的东西主要有,
- “挖孔”的区域以及挖孔的位置:childSize表示大小,Offset表示距离屏幕左侧及上册的偏移量。
- ”说明文本”以及位置:之所以是带引号的说明文本,是因为这里的说明不仅仅是一段文字,有可能还配有图片,所以说明文本定义为一个Widget。
- 点击的回调:对于点了某一个引导往往需要存储相关数据,因此需要将点击事件传递出去,因为是与单个引导绑定我们只需传递一个function即可,所以采用系统定义好的
typedef GestureTapCallback = void Function();
- 点击组件可关闭:引导页往往是点击任何位置都可关闭,但有些时候我们希望用户只能点击“引导区域“才可关闭,所以我们需要定义一个bool值来表示,默认是点击整个区域关闭。
- 引导区域的形状:通过开篇的4张引导图可以看出,引导区域有四种类型,所以我们可以定义一个枚举来表示
enum ChildShape {
CIRCLE, //圆形
RECTANGLE, //矩形
OVAL, //椭圆
ROUND_RECTANGLE //圆角矩形
}
数据类完整定义如下
class GuideChild {
//突出显示的widget的大小
Size childSize;
//突出显示widget的位置(偏移量)
Offset offset;
//突出显示widget的形状
ChildShape childShape = ChildShape.RECTANGLE;
//用于解释说明突出显示widget的组件
Widget descWidget;
//用于解释说明突出显示widget的组件位置
Offset descOffset;
//点击组件的回调
GestureTapCallback callback;
//仅点击组件可关闭
bool closeByClickChild = false;
double padding = 5;
}
准备工作至此结束。
实现
页面背景,既然我们采用曲线拟合的方式来画,所以我们采用CustomPainter来绘制背景。因为是整个蒙上一层,所以Material的child就应该是CustomPaint,又因为整个页面都可点击,所以Material的child应该是GestureDetector,而CustomPaint则作为GestureDetector的child。这个CustomPaint的大小应该是整个屏幕,所以需要展示的”说明文本“就可以是他的child。因为我们给定了child的位置,所以CustomPaint的child应该是一个Stack+Positioned组件,从而可以按指定位置显示。所以build代码为
@override
Widget build(BuildContext context) {
Size screenSize = MediaQuery.of(context).size;
return Material(
color: Color(0x00ffffff),
type: MaterialType.transparency,
child: GestureDetector(
onTapUp: tapUp,
child: CustomPaint(
size: screenSize,
painter: BgPainter(
offset: widget.children.first.offset,
childSize: widget.children.first.childSize,
shape: widget.children.first.childShape,
padding: widget.children.first.padding),
child: Stack(
children: [
Positioned(
child: widget.children.first.descWidget,
left: widget.children.first.descOffset.dx,
top: widget.children.first.descOffset.dy,
)
],
),
),
),
);
}
下面来分析一下大框的实现。在GestureDetector中我们监听了onTapUp事件,主要是因为在这个方法中我们可以拿到手指抬起时的位置,从而当要求仅可点击”引导区域”关闭时我们判断点击位置。点击的逻辑如下
void tapUp(TapUpDetails details) {
print("tapUp==>>${details.globalPosition}");
if (widget.children.first.closeByClickChild) {
Path path = new Path();
path.addRect(Rect.fromLTWH(
widget.children.first.offset.dx,
widget.children.first.offset.dy,
widget.children.first.childSize.width,
widget.children.first.childSize.height));
if (!path.contains(details.globalPosition)) return;
}
widget.children.first.callback?.call();
widget.children.removeAt(0);
if (widget.children.length == 0) {
widget.onCompete?.call();
Navigator.of(context).pop();
} else {
setState(() {});
}
print("length==>>${widget.children.length}");
}
}
当设置了仅可点击“引导区域“关闭时,我们会判断是否在区域中页面。其他情况我们会回调传递过来的点击回调,并且让当前页面显示下一个需要显示的引导。如果没有需要显示的引导就关闭这个GuideLayout。(这里只判断了矩形区域的点击,感兴趣的可以补充一下其他几种样式的区域)剩下的就是背景的绘制
class BgPainter extends CustomPainter {
Offset offset;
Size childSize;
Path path1;
Path path2;
Paint _paint;
ChildShape shape;
double padding;
BgPainter({this.offset, this.childSize, this.shape, this.padding}) {
path1 = Path();
path2 = Path();
_paint = Paint()
..color = Color(0x90000000)
..style = PaintingStyle.fill
..isAntiAlias = true;
}
@override
void paint(Canvas canvas, Size size) {
path1.reset();
path2.reset();
path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height));
switch (shape) {
case ChildShape.RECTANGLE:
path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding,
childSize.width + padding * 2, childSize.height + padding * 2));
break;
case ChildShape.CIRCLE:
double length;
double left;
double top;
double radius = sqrt(childSize.width * childSize.width +
childSize.height * childSize.height);
length = radius + padding * 2;
left = offset.dx - (radius - childSize.width) / 2 - padding;
top = offset.dy - (radius - childSize.height) / 2 - padding;
path2.addOval(Rect.fromLTWH(left, top, length, length));
break;
case ChildShape.OVAL:
double length;
double left;
double top;
double radius = sqrt(childSize.width * childSize.width +
childSize.height * childSize.height);
length = radius + padding * 2;
left =
offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding;
top = offset.dy - (radius - childSize.height) / 2 - padding;
path2.addOval(Rect.fromLTWH(
left, top, length + padding * 6, length + padding * 2));
break;
case ChildShape.ROUND_RECTANGLE:
path2.addRRect(RRect.fromRectXY(
Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2));
break;
}
Path result = Path.combine(PathOperation.difference, path1, path2);
canvas.drawPath(result, _paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
其实重点就是通过path来绘制圆形,椭圆和圆角矩形。绘制的内容大致结束了。那么这个layout还要给外部提供调用,这里采用静态方法
static void showGuide(BuildContext context, List<GuideChild> children,
GestureTapCallback onComplete) {
Navigator.of(context).push(PageRouteBuilder(
pageBuilder: (context, animation, secAnim) {
return FadeTransition(
///渐变过渡 0.0-1.0
opacity: Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
///动画样式
parent: animation,
///动画曲线
curve: Curves.fastOutSlowIn,
),
),
child: GuideLayout(
children,
onCompete: onComplete,
),
);
},
opaque: false));
}
}
之所以需要context则是因为他需要压栈,children则是需要引导的所有,onComplete则是所有引导都完事时候提供给调用者的回调。至此整个定义完成,完整代码
import 'dart:math';
import 'package:flutter/material.dart';
enum ChildShape {
CIRCLE, //圆形
RECTANGLE, //矩形
OVAL, //椭圆
ROUND_RECTANGLE //圆角矩形
}
class GuideChild {
//突出显示的widget的大小
Size childSize;
//突出显示widget的位置(偏移量)
Offset offset;
//突出显示widget的形状
ChildShape childShape = ChildShape.RECTANGLE;
//用于解释说明突出显示widget的组件
Widget descWidget;
//用于解释说明突出显示widget的组件位置
Offset descOffset;
//点击组件的回调
GestureTapCallback callback;
//仅点击组件可关闭
bool closeByClickChild = false;
double padding = 5;
}
class GuideLayout extends StatefulWidget {
final List<GuideChild> children;
final GestureTapCallback onCompete;
GuideLayout(this.children, {this.onCompete});
@override
State<StatefulWidget> createState() {
return GuideLayoutState();
}
static void showGuide(BuildContext context, List<GuideChild> children,
GestureTapCallback onComplete) {
Navigator.of(context).push(PageRouteBuilder(
pageBuilder: (context, animation, secAnim) {
return FadeTransition(
///渐变过渡 0.0-1.0
opacity: Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
///动画样式
parent: animation,
///动画曲线
curve: Curves.fastOutSlowIn,
),
),
child: GuideLayout(
children,
onCompete: onComplete,
),
);
},
opaque: false));
}
}
class GuideLayoutState extends State<GuideLayout> {
@override
Widget build(BuildContext context) {
Size screenSize = MediaQuery.of(context).size;
return Material(
color: Color(0x00ffffff),
type: MaterialType.transparency,
child: GestureDetector(
onTapUp: tapUp,
child: CustomPaint(
size: screenSize,
painter: BgPainter(
offset: widget.children.first.offset,
childSize: widget.children.first.childSize,
shape: widget.children.first.childShape,
padding: widget.children.first.padding),
child: Stack(
children: [
Positioned(
child: widget.children.first.descWidget,
left: widget.children.first.descOffset.dx,
top: widget.children.first.descOffset.dy,
)
],
),
),
),
);
}
void tapChild() {
widget.children.first.callback?.call();
setState(() {
if (widget.children.length == 1) {
widget.onCompete?.call();
Navigator.of(context).pop();
} else if (widget.children.length > 1) {
widget.children.removeAt(0);
}
});
}
void tapUp(TapUpDetails details) {
print("tapUp==>>${details.globalPosition}");
if (widget.children.first.closeByClickChild) {
Path path = new Path();
path.addRect(Rect.fromLTWH(
widget.children.first.offset.dx,
widget.children.first.offset.dy,
widget.children.first.childSize.width,
widget.children.first.childSize.height));
if (!path.contains(details.globalPosition)) return;
}
widget.children.first.callback?.call();
widget.children.removeAt(0);
if (widget.children.length == 0) {
widget.onCompete?.call();
Navigator.of(context).pop();
} else {
setState(() {});
}
print("length==>>${widget.children.length}");
}
}
class BgPainter extends CustomPainter {
Offset offset;
Size childSize;
Path path1;
Path path2;
Paint _paint;
ChildShape shape;
double padding;
BgPainter({this.offset, this.childSize, this.shape, this.padding}) {
path1 = Path();
path2 = Path();
_paint = Paint()
..color = Color(0x90000000)
..style = PaintingStyle.fill
..isAntiAlias = true;
}
@override
void paint(Canvas canvas, Size size) {
path1.reset();
path2.reset();
path1.addRect(Rect.fromLTWH(0, 0, size.width, size.height));
switch (shape) {
case ChildShape.RECTANGLE:
path2.addRect(Rect.fromLTWH(offset.dx - padding, offset.dy - padding,
childSize.width + padding * 2, childSize.height + padding * 2));
break;
case ChildShape.CIRCLE:
double length;
double left;
double top;
double radius = sqrt(childSize.width * childSize.width +
childSize.height * childSize.height);
length = radius + padding * 2;
left = offset.dx - (radius - childSize.width) / 2 - padding;
top = offset.dy - (radius - childSize.height) / 2 - padding;
path2.addOval(Rect.fromLTWH(left, top, length, length));
break;
case ChildShape.OVAL:
double length;
double left;
double top;
double radius = sqrt(childSize.width * childSize.width +
childSize.height * childSize.height);
length = radius + padding * 2;
left =
offset.dx - (radius + padding * 4 - childSize.width) / 2 - padding;
top = offset.dy - (radius - childSize.height) / 2 - padding;
path2.addOval(Rect.fromLTWH(
left, top, length + padding * 6, length + padding * 2));
break;
case ChildShape.ROUND_RECTANGLE:
path2.addRRect(RRect.fromRectXY(
Rect.fromLTWH(offset.dx - padding, offset.dy - padding, childSize.width + padding * 2, childSize.height + padding * 2), padding * 2, padding * 2));
break;
}
Path result = Path.combine(PathOperation.difference, path1, path2);
canvas.drawPath(result, _paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
使用
使用定义的GuideLayout方式为
GuideLayout.showGuide(context, children, onComplete);
这里的children是一个list,它是GuideChild的集合,onComplete则是引导展示完成的回调。这里需要特别注明的一点是如何构造这个GuideChild。因为我们需要child的size和offset等信息,所以我们给需要添加引导的widget的key赋值,当然这里是globalKey。这个globalKey可以拿到的信息如下
RenderBox renderBox = _globalKey.currentContext.findRenderObject();
if (!renderBox.size.isEmpty) {
Offset childOffset = renderBox.localToGlobal(Offset.zero);
Size childSize = renderBox.size;
}
通过globalKey拿到了RenderBox,renderBox.size
就是绑定key的widget的size,renderBox.localToGlobal(Offset.zero);
可以拿到绑定key的widget的偏移量。剩下的就是构建说明widget,这个根据具体的需求而定,它的显示位置也是需要根据需要而定,至此构建需要的Child也完成了。实现开篇效果的调用代码如下,感兴趣的可以直接粘贴把玩一下。
import 'package:flutter/material.dart';
import 'package:myapp/guide_layout.dart';
class GuideTest extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _GuideTestState();
}
}
class _GuideTestState extends State<GuideTest> {
GlobalKey<_GuideTestState> _globalKey = new GlobalKey();
GlobalKey<_GuideTestState> _globalKey2 = new GlobalKey();
GlobalKey<_GuideTestState> _globalKey3 = new GlobalKey();
GlobalKey<_GuideTestState> _globalKey4 = new GlobalKey();
List<GuideChild> children = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
print("timeStamp==>>$timeStamp");
RenderBox renderBox = _globalKey.currentContext.findRenderObject();
RenderBox renderBox2 = _globalKey2.currentContext.findRenderObject();
RenderBox renderBox3 = _globalKey3.currentContext.findRenderObject();
RenderBox renderBox4 = _globalKey4.currentContext.findRenderObject();
if (!renderBox.size.isEmpty) {
Offset childOffset = renderBox.localToGlobal(Offset.zero);
print(childOffset);
Offset descOffset =
Offset(10, childOffset.dy + renderBox.size.height + 10);
children.add(new GuideChild()
..offset = childOffset
..childSize = renderBox.size
..descOffset = descOffset
..descWidget = getDescWidget()
..callback = callback1
..closeByClickChild = true
..childShape = ChildShape.RECTANGLE);
Offset childOffset2 = renderBox2.localToGlobal(Offset.zero);
Offset descOffset2 = Offset(100, childOffset2.dy - 50);
children.add(new GuideChild()
..offset = childOffset2
..childSize = renderBox2.size
..descOffset = descOffset2
..descWidget = getDescWidget()
..callback = callback2
..childShape = ChildShape.ROUND_RECTANGLE);
Offset childOffset3 = renderBox3.localToGlobal(Offset.zero);
Offset descOffset3 =
Offset(50, childOffset3.dy + renderBox3.size.height +50);
children.add(new GuideChild()
..offset = childOffset3
..childSize = renderBox3.size
..descOffset = descOffset3
..descWidget = getDescWidget()
..callback = callback3
..childShape = ChildShape.OVAL..padding=3);
Offset childOffset4 = renderBox4.localToGlobal(Offset.zero);
Offset descOffset4 =
Offset(180, childOffset4.dy + renderBox4.size.height + 30);
children.add(new GuideChild()
..offset = childOffset4
..childSize = renderBox4.size
..descOffset = descOffset4
..descWidget = getDescWidget()
..callback = callback4
..childShape = ChildShape.CIRCLE);
GuideLayout.showGuide(context, children, onComplete);
}
});
}
Widget getDescWidget() {
return Container(
child: DecoratedBox(
child: Text(
'这是一个引导文本的说明',
style: TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w800),
),
decoration: BoxDecoration(color: Colors.greenAccent),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("测试引导"),
),
body: Center(
child: Stack(
children: [
Positioned(
left: 30,
top: 30,
child: Container(
height: 100,
width: 100,
key: _globalKey,
color: Colors.blue,
child: Center(
child: Text(
"测试文本1",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
)),
Positioned(
left: 100,
top: 600,
child: Container(
key: _globalKey2,
height: 100,
width: 200,
color: Colors.red,
child: Center(
child: Text(
"测试文本2",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
),
Positioned(
left: 50,
top: 400,
child: Container(
key: _globalKey3,
height: 100,
width: 150,
color: Colors.purple,
child: Center(
child: Text(
"测试文本3",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
),
Positioned(
left: 230,
top: 250,
child: Container(
key: _globalKey4,
height: 120,
width: 100,
color: Colors.lightGreenAccent,
child: Center(
child: Text(
"测试文本4",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
)
],
),
),
);
}
void callback1() {
print("点击了第一个引导");
}
void callback2() {
print("点击了第二个引导");
}
void callback3() {
print("点击了第三个引导");
}
void callback4() {
print("点击了第四个引导");
}
void onComplete() {
print("都点完了");
}
}
不断点击引导log如下
可以看到基本满足了我们的需求