在咱们的开发过程当中,经常遇到这样的问题,咱们的APP开发中要在某个页面去加一些新功能的引导,最经常使用的就是将整个页面作成一个相似于Dialog背景的蒙层,而后将想提示用户的位置高亮出来,最后加一些元素在上面,那么大概效果就是这样:java
乍一看很简单嘛,设计师切个纯图展现不就行了嘛? 其实咱们以前的功能都是这么作的: 须要展现用户引导页的时候用一个设计师给的纯图覆盖在当前页面.android
可是这样虽然又不是不能用,但其实一直会存在几个问题:git
带着这个问题,咱们去和设计师沟通了一番,后来设计无心间一句话引发了个人思考“既然多图适配这么麻烦,你是否能够把那块控件抠出来呢?”github
在不使用纯图的前提下实现一个全屏的蒙层上制定的一个或者多个View的高亮canvas
效果: 发现部分View是能够经过该方案实现高亮的,可是会有几个的问题:数组
那如何镂空呢? 咱们先来看看最终实现效果,后面咱们来说实现原理:缓存
而实现上述效果,仅仅须要一行代码:bash
private void showInitGuide() { new Curtain(SimpleGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .with(findViewById(R.id.btn_shape_circle)) .with(findViewById(R.id.btn_shape_custom)) .show(); } 复制代码
大体能实现以下功能:markdown
接下来我来分解一下主要设计思路,一步步达到咱们想要的效果:app
回想一下: 咱们最开始经过接触CircleImageView,了解到View绘制过程当中,图层层叠有16种叠加效果:
那么咱们绘制的图层1不就是半透明的背景,而图层2就是咱们的View的形状区域,咱们只要找到一个叠加公共区域透明的效果是否是就是实现了镂空的效果了?因此这边我选择了DstOut效果,因此核心代码以下:
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawBackGround(canvas); drawHollowFields(canvas); } /** * 画一个半透明的背景 */ private void drawBackGround(Canvas canvas) { mPaint.setXfermode(null); mPaint.setColor(mCurtainColor); canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint); } /** * 画出透明区域 */ private void drawHollowFields(Canvas canvas) { mPaint.setColor(Color.WHITE); mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); //测试画一个圆 canvas.drawCircle(getWidth()/2,getHeight()/4,300, mPaint); } 复制代码
效果以下,是否是已经镂空了?
固然,这里就是核心的逻辑了,实际上咱们须要高亮的是咱们的View,下面咱们来一步步设计实现它:
public class HollowInfo { /** * 目标View 用于定位透明区域 */ public View targetView; /** * 可自定义区域大小 */ public Rect targetBound; } 复制代码
这边列出了最核心的两个属性,第一个是咱们核心的的View,咱们须要根据它在屏幕上的位置肯定咱们绘制的起点,第二个是绘制的区域,咱们可使用View本身的的宽高,也能够自定义它的大小.
2.有了咱们的基本绘制实体类,我来定义咱们的画板,它主要作两件事:
public class GuideView extends View { private HollowInfo[] mHollows; private int mCurtainColor = 0x88000000; private Paint mPaint; public GuideView(@NonNull Context context) { super(context, null); init(); } private void init() { mPaint = new Paint(ANTI_ALIAS_FLAG); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //固然是全屏大小 setMeasuredDimension(getScreenWidth(getContext()), getScreenHeight(getContext())); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int count; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null); } else { count = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.ALL_SAVE_FLAG); } drawBackGround(canvas); drawHollowFields(canvas); canvas.restoreToCount(count); } private void drawBackGround(Canvas canvas) { mPaint.setXfermode(null); mPaint.setColor(mCurtainColor); canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint); } /** * 绘制全部镂空区域 */ private void drawHollowFields(Canvas canvas) { mPaint.setColor(Color.WHITE); mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT)); //可能有多个View 须要高亮, 因此遍历数组 for (HollowInfo mHollow : mHollows) { drawSingleHollow(mHollow, canvas); } } private void drawSingleHollow(HollowInfo info, Canvas canvas) { if (mHollows.length <= 0) { return; } info.targetBound = new Rect(); //获取View的边界方框 info.targetView.getDrawingRect(info.targetBound); int[] viewLocation = new int[2]; info.targetView.getLocationOnScreen(viewLocation); info.targetBound.left = viewLocation[0]; info.targetBound.top = viewLocation[1]; info.targetBound.right += info.targetBound.left; info.targetBound.bottom += info.targetBound.top; //要减去状态栏的高度 info.targetBound.top -= getStatusBarHeight(getContext()); info.targetBound.bottom -= getStatusBarHeight(getContext()); //绘制镂空区域 realDrawHollows(info, canvas); } private void realDrawHollows(HollowInfo info, Canvas canvas) { canvas.drawRect(info.targetBound, mPaint); } } 复制代码
效果以下:
到目前咱们已经把图片ImageView高亮了,彷佛已经完成了,可是咱们细看一下,它下面有两个设置了Shape的按钮,分别是圆形和圆角的,而咱们代码中只绘制了矩形,因此确定是没办法适配圆角的,那怎么办呢?
对!,咱们能够从View的backGround入手,由于咱们能设置各类shape的Drawable实际上就是GradientDrawable,咱们能够同过判断它的类型,而后经过反射获取咱们想要的属性,咱们修改realDrawHollows代码以下:
/** * 绘制镂空区域 */ private void realDrawHollows(HollowInfo info, Canvas canvas) { if (!drawHollowSpaceIfMatched(info, canvas)) { //没有匹配上,默认降级方案:画一个矩形 canvas.drawRect(info.targetBound, mPaint); } } private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } return false; } private void drawGradientHollow(HollowInfo info, Canvas canvas, Drawable drawable) { Field fieldGradientState; Object mGradientState = null; int shape = GradientDrawable.RECTANGLE; try { fieldGradientState = Class.forName("android.graphics.drawable.GradientDrawable").getDeclaredField("mGradientState"); fieldGradientState.setAccessible(true); mGradientState = fieldGradientState.get(drawable); Field fieldShape = mGradientState.getClass().getDeclaredField("mShape"); fieldShape.setAccessible(true); shape = (int) fieldShape.get(mGradientState); } catch (Exception e) { e.printStackTrace(); } float mRadius = 0; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { mRadius = ((GradientDrawable) drawable).getCornerRadius(); } else { try { Field fieldRadius = mGradientState.getClass().getDeclaredField("mRadius"); fieldRadius.setAccessible(true); mRadius = (float) fieldRadius.get(mGradientState); } catch (Exception e) { e.printStackTrace(); } } if (shape == GradientDrawable.OVAL) { canvas.drawOval(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), mPaint); } else { float rad = Math.min(mRadius, Math.min(info.targetBound.width(), info.targetBound.height()) * 0.5f); canvas.drawRoundRect(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), rad, rad, mPaint); } } 复制代码
在获取到背景类型时候,我若是肯定了是咱们想要的GradientDrawable以后,咱们就去获取它的形状实际类型,是椭圆仍是圆角,再获取它的圆角度数,能拿到直接拿,拿不到经过反射的方式,最后绘制出相应的形状便可.
固然,咱们View的背景多是一个Selector,因此咱们须要外加一层判断:取它当前的第一个:
private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } //android selector backGround if (drawable instanceof StateListDrawable) { if (drawable.getCurrent() instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable.getCurrent()); return true; } } return false; } 复制代码
咱们再来看看这么作以后的效果:
虽然咱们能本身适配View的背景,可能不能包含全部Drawable的,好比RippleDrawable,并且实际业务场景确定很复杂,也许产品须要特别的高亮形状?一个好的代码确定要有拓展的能力,咱们可否将图形的方法自定义?,接下来咱们自定义一个Shape:
public interface Shape { /** * 画你想要的任何形状 */ void drawShape(Canvas canvas, Paint paint, HollowInfo info); } 复制代码
在HolloInfo中增长Shape,由用户在构建HolloInfo时候传入:
public class HollowInfo { /** * 目标View 用于定位透明区域 */ public View targetView; /** * 可自定义区域大小 */ public Rect targetBound; /** * 指定的形状 */ public Shape shape; } 复制代码
再来补充咱们的drawHollowSpaceIfMatched方法:若是用户指定了形状的话,咱们优先画形状,不然再自动适配它的背景:
private boolean drawHollowSpaceIfMatched(HollowInfo info, Canvas canvas) { //user custom shape if (null != info.shape) { info.shape.drawShape(canvas, mPaint, info); return true; } //android shape backGround Drawable drawable = info.targetView.getBackground(); if (drawable instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable); return true; } //android selector backGround if (drawable instanceof StateListDrawable) { if (drawable.getCurrent() instanceof GradientDrawable) { drawGradientHollow(info, canvas, drawable.getCurrent()); return true; } } return false; } 复制代码
我如今自定义一个圆角的形状:
public class RoundShape implements Shape { private float radius; public RoundShape(float radius) { this.radius = radius; } @Override public void drawShape(Canvas canvas, Paint paint, HollowInfo info) { canvas.drawRoundRect(new RectF(info.targetBound.left, info.targetBound.top, info.targetBound.right, info.targetBound.bottom), radius, radius, paint); } } private void showInitGuide() { new Curtain(SimpleGuideActivity.this) //自定义高亮形状 .withShape(findViewById(R.id.btn_shape_circle), new RoundShape(12)).show(); } 复制代码
咱们设置给一个圆形的View 那么效果以下:
因此,只要自定义了Shape,形状交给你,想怎么自定义都行~
到这里有朋友问了...那我除了高亮View以外,还须要添加一些文字,或者可交互的元素(好比按钮)怎么办呢?
由于咱们是一个引导页的蒙层,因此我第一时间想到的就是Dialog,
固然构建Dialog,咱们固然推荐DialogFragment,方便管理横竖屏的状况,也是谷歌推荐的作法, 那么核心代码以下:
public class GuideDialogFragment extends DialogFragment { private static final int MAX_CHILD_COUNT = 2; private static final int GUIDE_ID = 0x3; private FrameLayout contentView; private Dialog dialog; private int topLayoutRes = 0; private GuideView guideView; public void show() { FragmentActivity activity = (FragmentActivity) guideView.getContext(); guideView.setId(GUIDE_ID); this.contentView = new FrameLayout(activity); this.contentView.addView(guideView); if (topLayoutRes != 0) { updateTopView(); } //定义一个全透明主题的Dialog dialog = new AlertDialog.Builder(activity, R.style.TransparentDialog) .setView(contentView) .create(); show(activity.getSupportFragmentManager(), GuideDialogFragment.class.getSimpleName()); } void updateContent() { contentView.removeAllViews(); contentView.addView(guideView); if (contentView.getChildCount() == MAX_CHILD_COUNT) { contentView.removeViewAt(1); } //将自定义的View 布局加载入contentView的顶层达到层叠的效果 LayoutInflater.from(contentView.getContext()).inflate(topLayoutRes, contentView, true); } /** * 防止出现状态丢失 */ @Override public void show(FragmentManager manager, String tag) { try { super.show(manager, tag); } catch (Exception e) { manager.beginTransaction() .add(this, tag) .commitAllowingStateLoss(); } } @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { return dialog; } @Override public void onDestroyView() { super.onDestroyView(); if (dialog != null) { dialog = null; } } private void updateTopView() { if (contentView.getChildCount() == MAX_CHILD_COUNT) { contentView.removeViewAt(1); } LayoutInflater.from(contentView.getContext()).inflate(topLayoutRes, contentView, true); } } 复制代码
代码很简单,核心就是建立一个Dialog,将咱们的透明的View和顶层包含其余元素的TopView放入Dialog的contentView中再展现出来~
只有两个细节点我提一下:
@Override public void show(FragmentManager manager, String tag) { try { super.show(manager, tag); } catch (Exception e) { manager.beginTransaction() .add(this, tag) .commitAllowingStateLoss(); } } 复制代码
<style name="TransparentDialog" parent="@android:style/Theme.Dialog"> <item name="android:windowIsFloating">false</item> <item name="android:windowNoTitle">true</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowAnimationStyle">@null</item> <item name="android:windowBackground">@android:color/transparent</item> </style> 复制代码
载体咱们就作好了,接下来就是设计调用API了:
最终咱们交给用户使用的时候无非就只剩下这么几件事了:
代码细节我精简了一下,大体就是一个构建者模式:
public class Curtain { SparseArray<HollowInfo> hollows; boolean cancelBackPressed = true; int topViewId; FragmentActivity activity; public Curtain(Fragment fragment) { this(fragment.getActivity()); } public Curtain(FragmentActivity activity) { this.activity = activity; this.hollows = new SparseArray<>(); } /** * @param which 页面上任一要高亮的view */ public Curtain with(@NonNull View which) { getHollowInfo(which); return this; } /** * 设置自定义形状 * * @param which 目标view * @param shape 形状 */ public Curtain withShape(@NonNull View which, Shape shape) { getHollowInfo(which).setShape(shape); return this; } /** * 自定义的引导页蒙层上层的元素 */ public Curtain setTopView(@LayoutRes int layoutId) { this.topViewId = layoutId; return this; } public void show() { //载体dialog GuideDialogFragment guider = new GuideDialogFragment(); guider.setTopViewRes(topViewId); //半透明蒙层View GuideView guideView = new GuideView(activity); //将透明区域设置蒙层VIew addHollows(guideView); guider.setGuideView(guideView); guider.show(); } void addHollows(GuideView guideView) { HollowInfo[] tobeDraw = new HollowInfo[hollows.size()]; for (int i = 0; i < hollows.size(); i++) { tobeDraw[i] = hollows.valueAt(i); } guideView.setHollowInfo(tobeDraw); } private HollowInfo getHollowInfo(View which) { HollowInfo info = hollows.get(which.hashCode()); if (null == info) { info = new HollowInfo(which); info.targetView = which; hollows.append(which.hashCode(), info); } return info; } } 复制代码
咱们能够看到经过构建者模式将一个个View封装为咱们最开始定义的HollowInfo,放入SparseArray,而后经过Show方法建立咱们的蒙层View,再构建咱们的载体,将他们合并起来.
咱们来个最终版调用: 先写一个顶部修饰TopView布局: view_guide_1.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:background="#66000000" tools:ignore="HardcodedText"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="140dp" android:layout_marginTop="300dp" android:text="自动识别View背景形状,也能够本身指定和定义高亮形状" android:textColor="#FFFFFF" android:textSize="18sp" android:textStyle="bold" /> </FrameLayout> 复制代码
而后结合起来使用:
private void showInitGuide() { new Curtain(SimpleGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .setTopView(R.layout.view_guide_1) .show(); } 复制代码
效果以下:
固然也能支持展现回调,在TopView中设置点击事件等等,细节上能够看看没有精简过的源码,这里就不贴出来了~
curtain实现了咱们一次高亮一个或者多个View的状况,可是实际业务场景每每很复杂,须要第一次高亮ViewA ,结束以后高亮ViewB,和ViewC,而后每次描述的文字或者元素都不同,以下:
咱们将每一步的Curtain对象放入一个流对象来管理,能够灵活进退,自由惯例,能够有效减小方法嵌套:
定义接口:
public interface CurtainFlowInterface { /** * 到下个 * 若是下个没有,即等于 finish() */ void push(); /** * 回到上个 */ void pop(); /** * 按照id 去某个节点 * * @param curtainId */ void toCurtainById(int curtainId); /** * 找到当前展现curtain 中到view元素 */ <T extends View> T findViewInCurrentCurtain(@IdRes int id); /** * 结束 */ void finish(); } 复制代码
定了接口咱们大体知道能提供什么功能了,实现的话,咱们只须要吧Curtain对象放入其中进行管理便可,咱们看下使用流程:
/** * 第一步 高亮一个View */ private static final int ID_STEP_1 = 1; /** * 第二步 高亮一个带圆形的View */ private static final int ID_STEP_2 = 2; /** * 第三步 为一个View指定自定义的透明形状 */ private static final int ID_STEP_3 = 3; private Curtain getStepOneGuide() { return new Curtain(CurtainFlowGuideActivity.this) .with(findViewById(R.id.iv_guide_first)) .setTopView(R.layout.view_guide_flow1); } private Curtain getStepTwoGuide() { return new Curtain(CurtainFlowGuideActivity.this) .with(findViewById(R.id.btn_shape_circle)) .setTopView(R.layout.view_guide_flow2); } private Curtain getStepThreeGuide() { return new Curtain(CurtainFlowGuideActivity.this) //自定义高亮形状 .withShape(findViewById(R.id.btn_shape_custom), new RoundShape(12)) //自定义高亮形状的Padding .withPadding(findViewById(R.id.btn_shape_custom), 24) .setTopView(R.layout.view_guide_flow3); } 复制代码
配合咱们的FLow:
private void showInitGuide() { new CurtainFlow.Builder() .with(ID_STEP_1, getStepOneGuide()) .with(ID_STEP_2, getStepTwoGuide()) .with(ID_STEP_3, getStepThreeGuide()) .create() .start(new CurtainFlow.CallBack() { @Override public void onProcess(int currentId, final CurtainFlowInterface curtainFlow) { switch (currentId) { case ID_STEP_2: //回到上个 curtainFlow.findViewInCurrentCurtain(R.id.tv_to_last) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.pop(); } }); break; case ID_STEP_3: curtainFlow.findViewInCurrentCurtain(R.id.tv_to_last) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.pop(); } }); //从新来一遍,即回到第一步 curtainFlow.findViewInCurrentCurtain(R.id.tv_retry) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.toCurtainById(ID_STEP_1); } }); break; } //去下一个 curtainFlow.findViewInCurrentCurtain(R.id.tv_to_next) .setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { curtainFlow.push(); } }); } @Override public void onFinish() { Toast.makeText(CurtainFlowGuideActivity.this, "all flow ended", Toast.LENGTH_SHORT).show(); } }); } 复制代码
CurtainFlow的实现源码我就不贴出来具体分析了,大体就是吧Curtain对象按照经过咱们在静态常量中定义的ID和和Curtain对象经过SparseArray管理起来,而后依次取出展现,你们有兴趣能够看看源码~