作者:SoulQw
链接:https://blog.csdn.net/u014626094/article/details/105430981
导读:
文章很快速的接验证了方案的原理,验证原理后,其实剩下的就是针对高亮这个需求,如何更好的设计;所以如果跟着作者的思路走下去,可以了解到这个库一步步设计出来的样子。
在我们的开发过程中,常常遇到这样的问题,我们的APP开发中要在某个页面去加一些新功能的引导,最常用的就是将整个页面做成一个类似于Dialog背景的蒙层,然后将想提示用户的位置高亮出来,最后加一些元素在上面,那么大概效果就是这样:
乍一看很简单嘛,设计师切个纯图展示不就好了嘛?
其实我们之前的功能都是这么做的:
需要展示用户引导页的时候用一个设计师给的纯图覆盖在当前页面.
但是这样虽然又不是不能用,但其实一直会存在几个问题:
设计师一套16:9的图无法适配所有比例的屏幕,其他纵横比的机型会出现拉伸的情况.
高分辨率手机但一套图模糊,但是多图又会增大APk包大小
带着这个问题,我们去和设计师沟通了一番,后来设计无意间一句话引起了我的思考“既然多图适配这么麻烦,你是否可以把那块控件抠出来呢?”
预期效果:
在不使用纯图的前提下实现一个全屏的蒙层上制定的一个或者多个View的高亮
可行性分析
最初尝试的方案A:
首先在整个界面画出一个半透明的全屏蒙层
通过View.getDrawingCache() 获取该目标View的bitmap缓存
获取该View在屏幕中的位置,在该位置放置一个ImageView去展示之前拿到的Bitmap缓存,即达到了高亮View的效果
效果: 发现部分View是可以通过该方案实现高亮的.
但是会有几个的问题:
很多时候,我们看到的View 其实是层叠的,它自己本身没背景颜色,而背景就绘制在它的Parent中,我们获取它的DrawingCache 只能拿到一个没有背景的View缓存图,而这个结果肯定不是我们那想要的.
如果View通过Shape指定了背景的话,通过这个方式无法获取背景的圆角或者圆形,只能得到一个矩形的图
这个获取View,bitmap的方法在不同机型下有些兼容性问题,部分低端机型下会出现卡顿的情况
最终选择的方案B:
首先在整个界面画出一个半透明的全屏蒙层;
找到View在屏幕中的位置,和它当前的大小,直接在蒙层上绘出这个大小的矩形,如果它是有设置背景的,根据它背景的类型,获取到相关的ShapeDrawable,然后判断它当前的形状然后我们绘制跟它背景一模一样的形状,然后将这块区域“镂空”即可!
那如何镂空呢?
我们先来看看最终实现效果,后面我们来讲实现原理:
而实现上述效果,仅仅需要一行代码:
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();
}
大致能实现如下功能:
一行代码完成某个View,或者多个View的高亮展示
同样支持基于AapterView(如ListView、GridView等) 或RecyclerView 的item以及item中元素的高亮
自动识别圆角背景,也可以自定义任何你想要的形状
如果依次按顺序去高亮一些列View,提供流式操作
原理
接下来我来分解一下主要设计思路,一步步达到我们想要的效果:
在蒙层上“镂空一块区域”
回想一下:
我们最开始通过接触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,下面我们来一步步设计实现它:
因为我们是要把View镂空,所以,我们需要写一个类,包含我们的View,以及它的大小和区域,我们叫他HollowInfo:
public class HollowInfo {
/**
* 目标View 用于定位透明区域
*/ public View targetView;
/**
* 可自定义区域大小
*/ public Rect targetBound;
}
这边列出了最核心的两个属性,第一个是我们核心的的View,我们需要根据它在屏幕上的位置确定我们绘制的起点,第二个是绘制的区域,我们可以使用View自己的的宽高,也可以自定义它的大小.
有了我们的基本绘制实体类,我来定义我们的画板,它主要做两件事:
根据指定颜色绘制整个屏幕大小的半透明蒙层
在蒙层上绘制指定大小的镂空区域
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之外,还需要添加一些文字,或者可交互的元素(比如按钮)怎么办呢?
很简单嘛! 我们在我们的蒙层View中再盖上一层去展示额外的元素不就好了!,现在我们只需要给这些元素找一个载体即可~
寻找合适的载体因为我们是一个引导页的蒙层,所以我第一时间想到的就是Dialog,
第一方面,dialog构建方便,我们只需要自己构建View填充给它,然后将dialog设为全屏切透明即可
第二方面,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中再展示出来~
只有两个细节点我提一下:
DialogFragment 源码的show中默认使用commit提交Fragment的事务,在一些Activity界面重建的情况下可能出现状态丢失的异常,我们try/catch住并重新实现保证逻辑的正常执行:
@Override
public void show(FragmentManager manager, String tag) {
try {
super.show(manager, tag);
} catch (Exception e) {
manager.beginTransaction()
.add(this, tag)
.commitAllowingStateLoss();
}
}
全屏透明的Dialog我们使用Theme即可实现:
<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了:
设计调用API最终我们交给用户使用的时候无非就只剩下这么几件事了:
指定要高亮的View,如果有特殊需求形状等也一并加入
指定显示在顶层的布局
像Dialog一样按需设置回调,设置回退键等
代码细节我精简了一下,大致就是一个构建者模式:
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中设置点击事件等等,细节上可以看看没有精简过的源码,这里就不贴出来了~
CurtainFlow:多个高亮步骤
上面实现了我们一次高亮一个或者多个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管理起来,然后依次取出展示,大家有兴趣可以看看源码~
总结:
一行代码完成某个View,或者多个View的高亮展示
同样支持基于AapterView(如ListView、GridView等) 或RecyclerView 的item以及item中元素的高亮
自动识别圆角背景,也可以自定义任何你想要的形状
如果依次按顺序去高亮一些列View,提供流式操作
Github地址
https://github.com/soulqw/Curtain
关注我获取更多知识或者投稿
如有任何疑问可在文章底部留言。为了防止恶意评论,本博客现已开启留言审核功能。但是博主会在后台第一时间看到您的留言,并会在第一时间对您的留言进行回复!欢迎交流!
本文链接: https://leetcode.jp/更优雅的androidui界面控件高亮的实现/