前言
最近这段时间,项目中有通过蒙版作功能说明的需求,看了UI效果图后,最终决定使用DialogFragment+自定义view来实现。我尽量封装的好一些,这样调用起来就会比较方便。
最终实现效果
最终实现效果如上:点击标题栏右上角图片显示蒙版的第一个内容,点击蒙版显示蒙版的第二个内容,再点击蒙版蒙版消失。
核心思路及代码实现
什么是蒙版及如何实现?
所谓“蒙版”,其实就是在应用当前界面覆盖一层半透明的蒙层,然后掏几个矩形的洞,在洞里显示要进行功能说明的控件,在矩形洞周围画图片显示对该控件的功能说明。如下图所示:
综合来看,这个蒙版有全透明部分(矩形洞),也有半透明部分(除矩形洞外的区域)。那怎么实现这种效果呢?我采用的实现方式是:在当前界面覆盖一层全屏且透明的界面,然后在此界面上画矩形,使矩形内部为透明,外部为不透明。
怎么在当前界面覆盖一层全屏且透明的界面呢?我想到的实现有两种:透明且全屏的activity、透明且全屏的Dialog。如果使用Activity实现,那就需要在清单文件注册此Activity,而使用Dialog实现就可省略这一步。另外,android官方比较推荐在使用Dialog时,用DialogFragment来实现,所以,最终选择的实现方式就是使用透明且全屏的DialogFragment了。
怎么做出一个透明且全屏的DialogFragment呢?网上的实现比较多,我最终选择的是在onCreate方法中设置style的方式:
public class MaskDialog extends DialogFragment{
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(DialogFragment.STYLE_NORMAL, R.style.MaskDialog);
}
}
R.style.MaskDialog的声明:
<!--透明全屏Dialog-->
<!--继承主题中的Dialog样式,下面的属性都是对Dialog所在Window的设置-->
<style name="MaskDialog" parent="Theme.AppCompat.Light.Dialog">
<!--设置成无标题栏的-->
<item name="android:windowNoTitle">true</item>
<!--设置成全屏-->
<item name="android:windowFullscreen">true</item>
<!--控制window是否全屏,false,就是不悬浮、全屏;true,就是悬浮、不全屏-->
<item name="android:windowIsFloating">false</item>
<!--设置window背景色-->
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:background">@android:color/transparent</item>
<!--如果设置成true,整个窗口会显得更暗,设置成fasle就不会更暗了-->
<item name="android:backgroundDimEnabled">false</item>
</style>
怎么创建内部为透明、外部为填充色的矩形?
上述就已经做出了一个透明且全屏的界面了,下一步来看怎么画出一个或多个矩形,并使矩形内部为全透明,其余部分均为半透明。
矩形是画出来的,所以需要一个控件的画板来承载此矩形。而且此控件应该是填充整个DialogFragment的,这样矩形外的半透明区域就可以覆盖这个界面了。控件填充整个DialogFragment这个就相当简单了,只需要在onCreateView中填充一个布局,且此布局的android:layout_width和android:layout_height属性均为match_parent就可以,如下:
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
View inflateView = inflater.inflate(R.layout.activity_mask_index, container);
return inflateView;
}
布局文件activity_mask_index内容如下:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.example.yizhan.maskdemo.mask.MaskRectView
android:id="@+id/mask_react_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</RelativeLayout>
MaskRectView,就是上面说的要画出矩形的自定义控件,下面会主要围绕它来说。
怎么在自定义控件中画出矩形呢?如果只是画一个矩形,那大可以在此自定义控件的onDraw方法调用canvas.drawRect即可。但我们的需求是不一定就只画一个矩形,那就需要使用到Path了。path可以做到,add多个Rect到Path后通过canvas.drawPath一次性绘制出所有矩形。另外,画矩形是需要先给出矩形的left、top、right、bottom才可以的,所以我们就需要在此自定义控件中提供一个bean数组用来提供矩形的位置信息。首先来看下,bean的定义
public class MaskBean implements Serializable {
public float mLeft = 0;
public float mTop = 0;
public float mRight = 0;
public float mBottom = 0;
}
就是定义了四个变量用于标识所画矩形的位置信息。其次,来看下自定义控件中此bean的数组以及对此数组的使用、还有就是画出多个矩形:
public class MaskRectView extends View {
//画笔
private Paint mPaint;
//控件的宽高
private int mWidth;
private int mHeight;
//用于记录矩形位置信息的bean数组
private MaskBean[] mMaskBeans;
//画笔颜色
private int fillColor = Color.BLACK;
private void init() {
//初始化画笔
mPaint = new Paint();
mPaint.setStyle(Paint.Style.FILL);//画笔模式为填充
mPaint.setAntiAlias(true);//抗锯齿
mPaint.setColor(fillColor);//默认颜色
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//就是用来获取控件的宽高的
mWidth = w;
mHeight = h;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//bean数组的判空
if (mMaskBeans == null || mMaskBeans.length == 0) {
return;
}
//给画笔设置上颜色
mPaint.setColor(fillColor);
//**画出一个或多个矩形(圆角矩形)
Path path = new Path();
for (MaskBean bean : mMaskBeans) {
RectF rectF = new RectF();
rectF.left = bean.mLeft;
rectF.top = bean.mTop;
rectF.right = bean.mRight;
rectF.bottom = bean.mBottom;
path.addRoundRect(rectF, 8, 8, Path.Direction.CW);
}
//一次性画出所有矩形
canvas.drawPath(path, mPaint);
}
//画笔颜色的设置暴露出来,有外界决定填充色
public void setFillColor(int color) {
this.fillColor = color;
invalidate();
}
//设置bean数组,也就是说,在什么位置显示矩形是外部的Dialog决定的
public void setMaskBean(MaskBean[] maskBeans) {
this.mMaskBeans = maskBeans;
invalidate();
}
}
一下子贴了N多的代码,但都很好理解。说几个点:
- 初始化Paint时,画笔模式设置成了Paint.Style.FILL,是因为只关注矩形图形而不关注其内部填充色或外部填充色时,可以用Paint.Style.STROKE,但这里是关注填充色的;
- 覆写的onSizeChanged就一个作用:获取当前控件的宽高。而上述已经在DialogFragment的onCreateView方法中将此控件的宽高设置成全屏了,所以这个宽高其实就是屏幕的宽高;
- 画笔颜色fillColor为控件的成员变量,初始值为黑色,并提供了setFillColor方法给外界的DialogFragment。画笔模式已经设置成了填充模式,所以这里的画笔颜色其实就是填充色。这样就能由外界的DialogFragment来决定填充色了;
- onDraw方法中的逻辑是,遍历bean数组中的所有bean,取出bean中的所要画的矩形的位置信息,add到path中,一次性画出;
- bean数组也暴露给外界的DialogFragment,由其决定要显示的矩形位置;
- 上述所画的其实并非矩形而是圆角矩形,因为圆角矩形更美观。
- 上述代码中将位置信息add到Path时传入了一个Path.Direction.CW,Path中的图形都是有方向的,这里的cw**表示顺时针方向。这个参数主要会影响到Path的填充模式setFillType。
上述代码自造一些数据运行后你会发现,填充色只是填充了矩形内部,外部为透明。我们需求正好相反:填充色填充矩形外部,矩形内部应为透明。那怎么做到呢?这里就要修改Path的填充模式了,使用Path的setFillType方法。关于这个方法及其传入的参数,稍微有些复杂,因为会涉及到计算机识别某个点是在图形内部或外部的基本原理(奇偶规则和非零环绕数规则),这里不作表述。这里直接给解决办法,很简单,只一句代码:在Path path = new Path()代码下,直接加一句:path.setFillType(Path.FillType.INVERSE_EVEN_ODD)就可以了。
上述的代码是没有问题的,但真正运行后发现还是有问题,运行结果并没有像我们期望的内部透明、外部填充。为什么呢?因为使Path的填充类型生效,还需要关闭硬件加速才可以。而硬件加速可以在整个应用级别关闭,也可以在Activity、Window以及View级别关闭。为了使硬件加速的关闭影响范围尽量小,这里使用了view级别的硬件加速关闭:在上述init方法中添加setLayerType(View.LAYER_TYPE_SOFTWARE, null)即可。
怎么在矩形的周边画出图片?
现在在控件中已经画出一个或多个矩形,并且已做到“内部透明、外部填充”,那下一步就是在这些矩形周边画图片了。画图片之前需要确定这几个信息:所要画的图片Bitmap(画什么)、图片的位置信息(在哪里画)。这些信息我依旧把它放在bean数组中。bean中需要添加如下代码:
public class MaskBean implements Serializable {
public static final int RECT_LEFT = 0;
public static final int RECT_TOP = 1;
public static final int RECT_RIGHT = 2;
public static final int RECT_BOTTOM = 3;
...//省略内容为矩形的位置信息:left、top、right、bottom
public Bitmap mBitmap = null;
//矩形与该方向上的图片的间距
//方向为RECT_LEFT:图片右上角与矩形左上角的间距
//方向为RECT_TOP:图片左下角与矩形左上角的间距
//方向为RECT_RIGHT:图片左上角与矩形右上角的间距
//方向为RECT_BOTTOM:图片左上角与矩形左下角的间距
//间距均为正值
public float mBitmapDx = 0;
public float mBitmapDy = 0;
//图片在矩形的那个方向上
public int bitmapType = RECT_LEFT;
}
图片可能的位置,就是矩形的左边、上边、右边、下边四个方向。上述bean中就定义了图片的四个方向RECT_LEFT、RECT_TOP、RECT_RIGHT、RECT_BOTTOM及标识当前图片在哪个方向的bitmapType,还定义了图片与矩形的位置间距,因为矩形的位置已知,根据此间距及图片bitmap的宽高就可以算出bitmap左上角的坐标,而有了左上角的坐标就可以使用canvas.drawBitmap画出图片。那为什么不直接在bean中直接定义bitmapX、bitmapY直接给出bitmap的左上角坐标不是很好吗?看蒙版效果图你能知道图片在矩形的哪个方向上以及大致估算出(或UI同事给出)该图片的某个角距矩形某个角的间距,但根据这些信息你还要计算出图片的宽高才能算出图片的左上角坐标,而获取图片的宽高在控件内就可以做,如果在控件内做的话,那控件外只需要提供图片在哪个方向bitmapType上并按照bean中“矩形与该方向上图片的间距”的规定给出mBitmapDx、mBitmapDy的值,控件内就可以做图片左上角坐标的计算了。通过这种设定,简化控件外调用方的处理,还是很不错的。
上述说的是bitmap位置信息的传递,那看一下在onDraw方法内怎么画出该图片
@Override
protected void onDraw(Canvas canvas) {
...//省略的是上面讲到的使用Path画出多个矩形的代码
mPaint.setColor(0xff000000);
for (MaskBean bean : mMaskBeans) {
//画bitmap
if (bean.mBitmap != null) {
//图片的宽高
int width = bean.mBitmap.getWidth();
int height = bean.mBitmap.getHeight();
//将要画的bitmap的左上角坐标:(bitmapLeft,bitmapTop)
float bitmapLeft = 0;
float bitmapTop = 0;
switch (bean.bitmapType) {
case MaskBean.RECT_LEFT://左边,图片右上角与矩形左上角的间距
bitmapLeft = bean.mLeft - bean.mBitmapDx - width;
bitmapTop = bean.mTop + bean.mBitmapDy;
break;
case MaskBean.RECT_TOP://上边,图片左下角与矩形左上角的间距
bitmapLeft = bean.mLeft + bean.mBitmapDx;
bitmapTop = bean.mTop - bean.mBitmapDy - height;
break;
case MaskBean.RECT_RIGHT://右边,图片左上角与矩形右上角的间距
bitmapLeft = bean.mRight + bean.mBitmapDx;
bitmapTop = bean.mTop + bean.mBitmapDy;
break;
case MaskBean.RECT_BOTTOM://下边,图片左上角与矩形左下角的间距
bitmapLeft = bean.mLeft + bean.mBitmapDx;
bitmapTop = bean.mBottom + bean.mBitmapDy;
break;
}
Rect src = new Rect(0, 0, width, height);
int destLeft = (int) bitmapLeft;
int destTop = (int) bitmapTop;
int destRight = (int) (bitmapLeft + width);
int destBottom = (int) (bitmapTop + height);
//Bitmap目标矩形
Rect dest = new Rect(destLeft, destTop, destRight, destBottom);
canvas.drawBitmap(bean.mBitmap, src, dest, mPaint);
}
}
...
}
又是贴出了好些代码,再说几个点:
- 在开始画bitmap之前设置了画笔颜色:mPaint.setColor(0xff000000),这里其实有一个坑:画多个矩形时,其实已经给画笔设置了颜色mPaint.setColor(fillColor),而此处的fillColor一般是半透明的,如果此处不再次设置下画笔颜色为不透明的话,会导致画出来的图片颜色有点儿不那么亮,所以,这里设置画笔颜色是必需的;
- 画图片的基本逻辑是:根据设定的图片相对于矩形的方向和间距,算出图片左上角坐标,然后再根据图片的宽高,算出图片右下角坐标,然后调用canvas的drawBitmap将图片画到指定的位置
上述的代码画出了图片,但实际使用中,还是会遇到一些问题,比如说,图片是根据其宽高获取的,图片太大就可能超出屏幕边界。那怎么避免这种情况呢?这就需要对算出的destLeft、destTop、destRight、destBottom做一些处理:在Rect dest = new Rect(destLeft, destTop, destRight, destBottom)代码前添加如下代码即可:
//超出边界的处理
if (destLeft < 0) {//图片的左边界超出了屏幕左侧
if (destRight < 10) {//在图片的左边界超出屏幕左侧的前提下,图片的右边界在屏幕左侧10像素内,那就不显示了
break;
} else {
destLeft = 10;//这里的"10"和上面的"10",是我手动设置的图片距离屏幕左侧的间距
destBottom = destTop + (destRight - destLeft) * height / width;//为了等比例显示,调整高度
}
}
if (destTop < 0) {
if (destBottom < 10) {
break;
} else {
destTop = 10;
destRight = destLeft + (destBottom - destTop) * width / height;
}
}
if (destRight > mWidth) {
if (destLeft > mWidth - 10) {
break;
} else {
destRight = mWidth - 10;
destBottom = destTop + (destRight - destLeft) * height / width;
}
}
if (destBottom > mHeight) {
if (destTop > mHeight - 10) {
break;
} else {
destBottom = mHeight - 10;
destRight = destLeft + (destBottom - destTop) * width / height;
}
}
到此,在矩形周边画图片的处理已经基本完毕了。
画出阴影
其实仔细观察的话,你会发现,上述矩形周边是有阴影的。那怎么画出阴影呢?这里就要用到Paint的setMaskFilter方法了。
public class MaskRectView extends View {
...
private BlurMaskFilter mBlurMaskFilter = new BlurMaskFilter(10, BlurMaskFilter.Blur.OUTER);
...
@Override
protected void onDraw(Canvas canvas) {
...//省略的内容是上述画矩形的代码
...//省略的内容是上述画图片的代码
Path path2 = new Path();
mPaint.setColor(fillColor);//默认颜色
mPaint.setMaskFilter(mBlurMaskFilter);
for (MaskBean bean : mMaskBeans) {
RectF rectF = new RectF();
rectF.left = bean.mLeft;
rectF.top = bean.mTop;
rectF.right = bean.mRight;
rectF.bottom = bean.mBottom;
path2.addRoundRect(rectF, 8, 8, Path.Direction.CW);
}
canvas.drawPath(path2, mPaint);
mPaint.setMaskFilter(null);
}
}
画阴影使用到了BlurMaskFilter对象,以及Paint的setMaskFilter方法。具体原理本篇文章并不准备展开说明,有兴趣的话可以自己去查一下。涉及到一个问题这里要说明一下,此处的代码和上面画矩形的代码很像,都是通过创建path对象,然后将一个个矩形添加到path中,不同的是,此处的代码设置了maskFilter,那为什么不把此处的代码与上述画矩形的代码进行合并,一次性画出“内部透明、外部填充”的矩形及其阴影呢?这个我试过了之后,发现这两处代码兼容后并不能实现我们预期的效果,所以此处分为了两处。
怎么获取view在屏幕中的位置?
上述已经画出了“内部为透明、外部为填充”的矩形、矩形周围的图片及矩形的阴影。接下来,看怎么调用此自定义控件。因为后面会给出完整的源码,不需处理直接的数据传递不再表述。这里只看一下,怎么获取view在屏幕中的位置。
DialogFragment只需把需要显示的控件传递过来,由DialogFragment自身把获取控件在屏幕中的位置:
int[] locations = new int[2];
view.getLocationOnScreen(locations);
left = locations[0];
top = locations[1];
right = maskBean.mLeft + view.getWidth();
bottom = maskBean.mTop + view.getHeight();
其实也挺简单的,就是调用了一个api而已。剩余的诸如自定义控件的点击事件、bitmap数据的传递,就没什么好说的了。
总结
本文通过DialogFragment+自定义控件做了一个简单的蒙版效果,整体的思路其实挺简单的,从一开始有一个想法,之后有什么问题解决什么问题,有什么需求就想办法做出什么样的需求。如看这篇博客的同行,还有问题,可通过邮箱(zjz710605775@163.com)联系到我。
这里是完整的源码