Android 用户引导库 MaterialIntroView 使用及源码分析

1,380 阅读8分钟
原文链接: www.jianshu.com

0x00 背景

在最近一次迭代开发中,团队尝试提升部门间的沟通效率。迭代初期,Android开发小组提到了一个经常会遇到的痛点:在过去,曾把大量时间花在实现“新功能引导”上。

由于新功能引导在各个发布版间表现各异,几乎难以统筹。其次,功能引导具有塑造艺术的可能,直接导致每一个版本都需要单独沟通,而且变更几率较大,难以一次性审校通过。为此,团队期望寻找一种能够快速实现,方便维护,并尽可能减少与设计师之间的冗余沟通的解决方案。

经过研究,最终决定使用第三方库 MaterialIntroView 作为日后维护新功能引导的标准库。

0x01 效果预览

在此处引用官方图片展示该项目带来的效果:


art_drawer.png

art_gravity_left.png

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当中。注意,在MaterialIntroViewsetXXX()方法中,并没有真正的对属性值进行使用,仅仅是保存值而已。

下一步,调用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
该属性决定了用户点击白圈的时候,是否调用目标viewperformClick()方法。

重写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工作还未完成”的情况发生。

这是工作后写的第一篇技术博客,耗时大约两个工作日夜晚。该库本身并不复杂。书写本文的目的在于,尝试了解自己能够以什么样的方式表达自己的想法,并转化为文字给予他人理解。团队协作中,高效的交流至关重要,前人不止一次强调此话。

喜欢我的文章?还请不吝打赏~~