0x00 背景
在最近一次迭代开发中,团队尝试提升部门间的沟通效率。迭代初期,Android开发小组提到了一个经常会遇到的痛点:在过去,曾把大量时间花在实现“新功能引导”上。
由于新功能引导在各个发布版间表现各异,几乎难以统筹。其次,功能引导具有塑造艺术的可能,直接导致每一个版本都需要单独沟通,而且变更几率较大,难以一次性审校通过。为此,团队期望寻找一种能够快速实现,方便维护,并尽可能减少与设计师之间的冗余沟通的解决方案。
经过研究,最终决定使用第三方库 MaterialIntroView 作为日后维护新功能引导的标准库。
0x01 效果预览
在此处引用官方图片展示该项目带来的效果:


Nice!简洁而不失风度。怎么样,很不错吧。
MaterialIntroView注重元素聚焦引导。通过全屏阴影覆盖及锁定点击区域,促使用户尝试新功能。
0x02 如何使用
由于该项目使用自定义仓库jitpack。因此需要配置 Project 目录下的build.gradle:
repositories {
maven {
url "https://jitpack.io"
}
}
随后在Module目录下的build.gradle添加库引用:
dependencies {
compile 'com.github.iammert:MaterialIntroView:1.5.2'
}
点击 Sync Now,使 Gradle 生效即可。
最后,在需要添加引导代码的地方添加:
new MaterialIntroView.Builder(mContext)
.enableDotAnimation(true)
.enableIcon(false)
.setFocusGravity(FocusGravity.CENTER)
.setFocusType(Focus.MINIMUM)
.setDelayMillis(500)
.enableFadeAnimation(true)
.performClick(true)
.setInfoText("Hi There! Click this card and see what happens.")
.setTarget(mButton)
.setUsageId("unique_id")
.show();
即可使用。
如果需要统一保存参数,在多个引导动画中使用,可以使用MaterialIntroConfiguration保存参数:
MaterialIntroConfiguration config = new MaterialIntroConfiguration();
config.setDelayMillis(800);
config.setDismissOnTouch(true);
config.setFadeAnimationEnabled(true);
...
最后,
new MaterialIntroView.Builder(mContext)
.setTarget(mButton)
.setConfiguration(config)
.show();
即可统一引导视图样式。
0x03 参数表
在MaterialIntroView.Builder中,各个方法含义如下:
| 方法 | 用途 |
|---|---|
| enableDotAnimation | 是否显示焦点中央的动态白点。样式请看上文效果图 |
| enableIcon | 是否显示提示图标。即:上文效果图中的绿色问号 |
| setFocusGravity | 焦点处于目标 View 什么方位 |
| setFocusType | 焦点覆盖面积。最小,一般,亦或是尽可能将整个目标 view 全覆盖 |
| setDelayMillis | 遮罩引导延迟多久后出现 |
| enableFadeAnimation | 是否启用渐变 |
| performClick | 点击焦点的时候,是否将单击事件传递给目标 view |
| setInfoText | 引导文字 |
| setTextColor | 引导文字颜色 |
| setInfoTextSize | 引导文字大小 |
| setMaskColor | 遮罩颜色 |
| setTarget | 引导动画所关注的目标 view |
| setTargetPadding | 在原本焦点圈的基础上,增加焦点半径 |
| dismissOnTouch | 是否触摸任意区域遮罩消失 |
| setListener | 焦点内有效点击监听器 |
| setConfiguration | 统一设置参数。该方法将会覆盖上述部分属性 |
| setUsageId | 该事件的唯一ID,开源库自带事件记录 SharedPreferences。如果该引导动画成功显示并消失,则第二次调用 show() 方法将不会显示引导遮罩。 |
0x04 源码分析
Builder:构造入口
虽然该库的最终效果是创建了View并进行全局遮罩,但实际上作者并不打算将该View暴露出来。因此,从一开始,就使用了Builder模式隐藏了View的细节本身。
查看Builder代码:
public static class Builder {
private MaterialIntroView materialIntroView;
private Activity activity;
private Focus focusType = Focus.MINIMUM;
public Builder(Activity activity) {
this.activity = activity;
materialIntroView = new MaterialIntroView(activity);
}
public Builder setMaskColor(int maskColor) {
materialIntroView.setMaskColor(maskColor);
return this;
}
public Builder setXXX() {
...
}
public MaterialIntroView build() {
Circle circle = new Circle(
materialIntroView.targetView,
materialIntroView.focusType,
materialIntroView.focusGravity,
materialIntroView.padding);
materialIntroView.setCircle(circle);
return materialIntroView;
}
public MaterialIntroView show() {
build().show(activity);
return materialIntroView;
}
}
此段代码位于co/mobiwise/materialintro/view/MaterialIntroView.java
观察发现,在构造方法中,通过new MaterialIntroView(activity)创建了一个MaterialIntroView对象引用。该Builder类中具有多个setXXX()方法,通过同名方法桥接,将属性设置到真正的MaterialIntroView当中。注意,在MaterialIntroView的setXXX()方法中,并没有真正的对属性值进行使用,仅仅是保存值而已。
下一步,调用Builder中的show()方法。从这里开始,MaterialIntroView开始为绘制做准备。
Circle:标记亮圈
Builder.show()方法会调用Builder.build(),在Builder.build()中,创建了一个Circle:
public MaterialIntroView build() {
Circle circle = new Circle(
materialIntroView.targetView,
materialIntroView.focusType,
materialIntroView.focusGravity,
materialIntroView.padding);
materialIntroView.setCircle(circle);
return materialIntroView;
}
Circle用于标记遮罩层中的亮圈的范围。
Circle类位于co/mobiwise/materialintro/shape/Circle.java
Circle类中有两个重要的方法:calculateRadius(),getFocusPoint()。并且都在Circle构造方法中得以执行:
public Circle(Target target, Focus focus, FocusGravity focusGravity, int padding) {
this.target = target;
this.focus = focus;
this.focusGravity = focusGravity;
this.padding = padding;
circlePoint = getFocusPoint();
calculateRadius(padding);
}
第一个重要方法:getFocusPoint():
private Point getFocusPoint(){
if(focusGravity == FocusGravity.LEFT){
// 居左
int xLeft = target.getRect().left + (target.getPoint().x - target.getRect().left) / 2;
return new Point(xLeft, target.getPoint().y);
}
else if(focusGravity == FocusGravity.RIGHT){
// 居右
int xRight = target.getPoint().x + (target.getRect().right - target.getPoint().x) / 2;
return new Point(xRight, target.getPoint().y);
}
else
// 居中
return target.getPoint();
}
该方法用于构造并返回亮圈Point,并在其中确定Point的绘制位置。
第二个重要方法:calculateRadius():
private void calculateRadius(int padding){
int side;
if(focus == Focus.MINIMUM)
// 最短边的一半
side = Math.min(target.getRect().width() / 2, target.getRect().height() / 2);
else if(focus == Focus.ALL)
// 最长边的一半
side = Math.max(target.getRect().width() / 2, target.getRect().height() / 2);
else{
// (大边的一半+小边的一半)/ 2
int minSide = Math.min(target.getRect().width() / 2, target.getRect().height() / 2);
int maxSide = Math.max(target.getRect().width() / 2, target.getRect().height() / 2);
side = (minSide + maxSide) / 2;
}
// 在最后的基础上,加上额外的半径
radius = side + padding;
}
该方法用计算亮圈的半径。半径受两方面的影响:第一,Focus类的枚举值。不同的枚举值,有不同的大小期望;第二,额外的padding。该padding在计算的最后加上。
以上两个方法执行完毕,构造方法也结束了。至此,Circle生成完毕。
MaterialIntroView.show():绘制前准备
在Builder.show(),最终调用了MaterialIntroView.show()。
查看MaterialIntroView.show()代码:
private void show(Activity activity) {
// 判断是否需要显示该view
if (preferencesManager.isDisplayed(materialIntroViewId))
return;
((ViewGroup) activity.getWindow().getDecorView()).addView(this);
setReady(true);
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (isFadeAnimationEnabled)
AnimationFactory.animateFadeIn(MaterialIntroView.this, fadeAnimationDuration, new AnimationListener.OnAnimationStartListener() {
@Override
public void onAnimationStart() {
setVisibility(VISIBLE);
}
});
else
setVisibility(VISIBLE);
}
},delayMillis);
}
第一件事,通过id判断是否需要显示该view。此id则是Builder.setUsageId()中的id。与判断id相对应的,对id赋值的代码在MaterialIntroView.dismiss()中:
private void dismiss() {
preferencesManager.setDisplayed(materialIntroViewId);
...
}
MaterialIntroView.dismiss()会在MaterialIntroView消失时调用。
PreferencesManager位于co/mobiwise/materialintro/prefs/PreferencesManager.java中。
随后,将当前MaterialIntroView附加到DecorView(为什么是DecorView?BruceVan的简书)中:
((ViewGroup) activity.getWindow().getDecorView()).addView(this);
最后,通过isFadeAnimationEnabled判断是否需要动画。最终执行setVisibility(VISIBLE),开始让view显示出来,进入绘制流程。
绘制灰色背景及亮圈
onDraw()方法被重写:
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
...
// 清除画布,绘制指定的颜色
this.canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
this.canvas.drawColor(maskColor);
...
// 用橡皮擦擦出一个圆
circleShape.draw(this.canvas, eraser, padding);
...
}
maskColor即自定义的遮罩颜色。此处思想很简单:先将画布整张涂色,然后在需要亮圈的地方,擦干净。擦亮圈的代码在Circle类中定义:
public void draw(Canvas canvas, Paint eraser, int padding){
calculateRadius(padding);
circlePoint = getFocusPoint();
canvas.drawCircle(circlePoint.x, circlePoint.y, radius, eraser);
}
其中calculateRadius()和getFocusPoint()方法在上文介绍Circle时已经提到过。通过计算出的值,画(擦除)圆。注意,在Android的定义中,“擦除”也是“画”的一种。
何时布局动态白点及文字框
在上述的MaterialIntroView绘制流程中,并没有提及动态白点和文字框的绘制。因为这两个元素的生成并不是在MaterialIntroView的绘制周期中完成的。
MaterialIntroView的构造方法调用了init()方法,init()方法如下:
private void init(Context context) {
...
// 实例化文字layout
View layoutInfo = LayoutInflater.from(getContext()).inflate(R.layout.material_intro_card, null);
// 保存引用,稍后用于add到父view
infoView = layoutInfo.findViewById(R.id.info_layout);
// 实例化白点view并保存引用,稍后用于add到父view
dotView = LayoutInflater.from(getContext()).inflate(R.layout.dotview, null);
getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
...
if (isInfoEnabled)
setInfoLayout();
if (isDotViewEnabled)
setDotViewLayout();
...
}
});
}
观察发现,对于白点view及文字框,早期就准备好了。通过设置OnGlobalLayoutListener,那么当MaterialIntroView出现布局变动,亦或者视图可见性发生变化的时候,就会调用setInfoLayout()及setDotViewLayout(),分别对这两个view本身进行设置。
布局动态白点
setDotViewLayout()方法代码:
private void setDotViewLayout() {
...
// 使白点位于白圈中央
dotViewLayoutParams.setMargins(
circleShape.getPoint().x - (dotViewLayoutParams.width / 2),
circleShape.getPoint().y - (dotViewLayoutParams.height / 2),
0,
0);
dotView.setLayoutParams(dotViewLayoutParams);
dotView.postInvalidate();
// 添加到 MaterialIntroView
addView(dotView);
// 动画
AnimationFactory.performAnimation(dotView);
}
先将白点放在白圈中央,随后执行循环动画。动画代码位于co/mobiwise/materialintro/animation/AnimationFactory.java。此处相对简单,不展开细说。
布局文字框
setInfoLayout()用于布局文字框,此方法与setDotViewLayout()可相提并论,因为这两个方法结构极为相似。
该方法需要注意的地方:
private void setInfoLayout() {
...
if (circleShape.getPoint().y < height / 2) {
// 白圈上方位置不够,文字框需要布局在白圈下方
((RelativeLayout) infoView).setGravity(Gravity.TOP);
infoDialogParams.setMargins(
0,
circleShape.getPoint().y + circleShape.getRadius(),
0,
0);
} else {
// 文字框布局在白圈上方
((RelativeLayout) infoView).setGravity(Gravity.BOTTOM);
infoDialogParams.setMargins(
0,
0,
0,
height - (circleShape.getPoint().y + circleShape.getRadius()) + 2 * circleShape.getRadius());
}
...
}
此段代码决定了文字框应当放置在何处。
触摸事件传递
在MaterialIntroView.Builder中,有一个属性:performClick。
该属性决定了用户点击白圈的时候,是否调用目标view的performClick()方法。
重写onTouchEvent():
public boolean onTouchEvent(MotionEvent event) {
// 此处省略计算点击处是否位于白圈
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (isTouchOnFocus && isPerformClick) {
// 触发目标view按下效果
targetView.getView().setPressed(true);
targetView.getView().invalidate();
}
return true;
case MotionEvent.ACTION_UP:
if (isTouchOnFocus || dismissOnTouch)
dismiss();
if (isTouchOnFocus && isPerformClick) {
// 执行performClick()方法,并实现按下及抬起效果
targetView.getView().performClick();
targetView.getView().setPressed(true);
targetView.getView().invalidate();
targetView.getView().setPressed(false);
targetView.getView().invalidate();
}
...
}
...
}
通过计算点击处是否位于有效区域(白圈内),以触发目标view的单击事件。该库未实现长按。基于同理,实现长按并不困难,按下执行延时Runnable,MotionEvent.CANCEL事件抬起则不执行Runnable.run()方法。
0x05 后记
至此,该项目核心源码分析完毕。
值得注意的是,该库在进行view操作的时候,大量使用handler.post(Runnable)方法,即使许多地方没有必要。这种方法可以通过Runnable,将操作插入到MainThread message queue的末端。以保证每一次view操作都是分离的事件,而不会发生“依赖当前view而当前view工作还未完成”的情况发生。
这是工作后写的第一篇技术博客,耗时大约两个工作日夜晚。该库本身并不复杂。书写本文的目的在于,尝试了解自己能够以什么样的方式表达自己的想法,并转化为文字给予他人理解。团队协作中,高效的交流至关重要,前人不止一次强调此话。
喜欢我的文章?还请不吝打赏~~