这篇文章讲的是制作一个比较简单的自定义View---图片裁减器,为了降低难度,我只实现了矩形的裁减。
先看看效果图:
在开始码代码之前先想一下我们的裁减器TailorView都需要实现哪些功能:
1.打开系统相册,从中选中一张图片交给我们的Activity.
2.将图片加载到一个ImageView上.
3.显示一个矩形裁减框,并且可以放大、缩小、拖动.
4.将图片中在裁减框范围之外的部分加上一个黑色滤镜.
5.获取并生成裁减器框内部分的图片并生成Bitmap.
好了,然后我们就开始码代码喽~
首先是打开相册并选择一张图片,这件事我们交个一个Activity:MainActivity,这也是我们的入口Activity。注意由于我们需要取得位于SD卡上的图片文件,故需要再AndroidManifest文件中声明如下权限:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
同时由于我的测试机型是Android6.0.1的,故还需要再运行时动态监测权限并申请权限。方便起见,我们打开应用的第一时间就申请权限:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}
,READ_EXTERNAL_STORAGE_REQUEST_CODE);
}
这就是在运行时动态申请权限的代码,这里我只申请了READ_EXTERNAL_STROAGE,其他权限同样处理方法。
那么我们怎样才知道用户是否授予了权限呢?很简单,只需要重写Activity的onRequestPermissionsResult方法即可,然后在里面判断用户是否给予了权限:
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if(requestCode==READ_EXTERNAL_STORAGE_REQUEST_CODE&&grantResults.length>0){
//do something
}
}
注意这里的READ_EXTERNAL_STORAGE_REQUEST_CODE和申请权限时的是同一个,都是我们自定义的常量。
好了,权限的问题我们已经搞定了,接下来就是调用系统相册了,很简单,只需一个Intent即可:
Intent intent = new Intent(Intent.ACTION_PICK,
android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
startActivityForResult(intent, IMAGE_CODE);
然后就会自动打开系统相册,并且当你选择了一张图片的时候,就会返回。
接下来就是获取这张我们选择的图片,这里需要重写onActivityResult方法,因为我们调用系统相册时利用的是startActivityForResult(),故当Activity返回时会调用onActivityResult方法,连同返回的数据。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode==IMAGE_CODE&&resultCode==RESULT_OK&&data!=null){
Uri selectedImage=data.getData();
String[] filePathColumn={MediaStore.Images.Media.DATA};
Cursor cursor=getContentResolver().query(selectedImage,
filePathColumn,null,null,null);
if(cursor!=null){
cursor.moveToFirst();
String picturePath=
cursor.getString(cursor.getColumnIndex(filePathColumn[0]));
cursor.close();
Intent intent=new Intent(this,TailorActivity.class);
intent.putExtra("picturePath",picturePath);
startActivityForResult(intent,IMAGE_TAILORED);
}
}
}
同样,这里的IMAGE_CODE就是startActivityForResylt中的IMAGE_CODE.这样我们就获取了图片的绝对路径,接下来就是加载这个图片到ImageView中了。这里我新建了一个Activity:TailorActivity,并在其布局里放了一个占据全屏的ImageView,我们将图片路径一并传给Activity:TailorActivity即可。
Intent intent=new Intent(this,TailorActivity.class);
intent.putExtra("picturePath",picturePath);
startActivityForResult(intent,IMAGE_TAILORED);
在TailorActivity先从Intent中取出路径,并加载图片:
mBitmap= BitmapFactory.decodeFile(imagePath);
mImageView.setImageBitmap(mBitmap);
接下来就是今天的主角:自定义View----TailorView
我先说一下这个View的结构:TailorView内部维护四个Point类,每个Point很简单只包含一个坐标x和y,这四个Point对应着我们要在屏幕上绘制的裁减框的四个角。我们每次draw的时候先根据这四个Point的位置画出四条线,就是裁减框的边界,然后绘制四个小的实心圆,当用户拖拽任意个=一个实心圆时,我们的裁剪框做出相应的大小改变。当用户点击裁剪框中间的部分时,裁剪框跟随者用户手指左右平移。
其中裁剪框放大、缩小、平移的逻辑都是在View的onTouchEvent中实现的。
先看看我们的onDraw:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//drawLines
mPaint.setStyle(Paint.Style.STROKE);
for (int i=0;i<4;i++){
canvas.drawLine(points[i].getX(),points[i].getY()
,points[(i+1)%4].getX(),points[(i+1)%4].getY(),mPaint);
}
mPaint.setStyle(Paint.Style.FILL);
//draw points
for (int i=0;i<4;i++){
canvas.drawCircle(points[i].getX(),points[i].getY(),mRadius,mPaint);
}
}
这样很轻松的就绘制除了四个边和四个实心角。
由于我们的TailorView有一个默认初始大小,因此在xml布局文件中使用的时候都不需要指定宽高了,我们也不用重写onMeasure()方法了,算是偷了个懒。
我们要对我们的TailorView做出一些限制:
1.四个点的顺序不能乱,因为后面我们还要靠这四个点的顺序来构造一个Rect,这个Rect就是不会被黑色滤镜覆盖的范围。
2.我们的TailorView所能移动的地方存在一个边界,就是ImageView中显示的图片的实际边界。
四个点的顺序不能乱,那就在onTouchEvent中检测四个点的相对坐标即可:
//check if points gets collide
private boolean pointsCross(int x, int y, int selectedIndex) {
if ((selectedIndex==0&&points[1].x-x<=mTouchSlop)
||(selectedIndex==0&&points[3].y-y<=mTouchSlop)) return true;
if ((selectedIndex==1&&x-points[0].x<=mTouchSlop)
||(selectedIndex==1&&points[2].y-y<=mTouchSlop)) return true;
if ((selectedIndex==2&&y-points[1].y<=mTouchSlop)
||(selectedIndex==2&&x-points[3].x<=mTouchSlop)) return true;
if ((selectedIndex==3&&points[2].x-x<=mTouchSlop)
||(selectedIndex==3&&y-points[0].y<=mTouchSlop)) return true;
return false;
}
可以看出,我的做法是当相邻两个点的某一方向上的距离小于mTouchSlop时,即视为冲突,就无法再任由用户移动了。
然后是我自定义了一个Bounds类,也很简单,就是包含x1,x2,y1,y1,即为边界的左上和右下点的坐标,在初始化的时候我们利用ImageView中Bitmp的实际边界,给我们的mBound进行赋值。然后同样在onTouchEvent中进行移动的时候,检测是否和边界冲突,倘若冲突,就停止移动:
private boolean checkOutBounds(int x, int y) {
return mBounds.x1<=x&&mBounds.x2>=x&&mBounds.y1<=y&&mBounds.y2>=y;
}
然后就是判断点击的点是在裁剪框内部还是在四个边角上:
private boolean inCenter(int x,int y){
for(int i=0;i<4;i++){
if (inCircle(x,y,points[i])) return false;
}
return mRect.contains(x,y);
}
private boolean inCircle(int x,int y,Point p){
return Math.hypot(x-p.getX(),y-p.getY())<=checkLength;
}
当用户点击的是边框内部的时候,我们就随用户的移动移动整个裁剪框:
//move the whole rect
private void moveWhole(int delX,int delY){
for (int i=0;i<4;i++){
if (!checkOutBounds(points[i].x+delX,points[i].y+delY)) return;
}
for (int i=0;i<4;i++){
points[i].x+=delX;
points[i].y+=delY;
}
mRect=new Rect(points[0].getX(),points[0].getY(),points[2].getX(),points[2].getY());
mListener.onRectPositionChange();
}
当用户点击的是某个边角时,相应的需要改变其相邻的两个Point的x或y:
if (shouldMove){
p.setX(x).setY(y);
//change the relative two circle's position
if (selectedIndex%2==0){
points[(selectedIndex+1)%4].setY(y);
points[(selectedIndex+3)%4].setX(x);
}else{
points[(selectedIndex+1)%4].setX(x);
points[(selectedIndex+3)%4].setY(y);
}
if (mListener!=null){
mListener.onRectPositionChange();
}
mRect=new Rect(points[0].getX(),points[0].getY(),points[2].getX(),points[2].getY());
}
这样,裁剪框的逻辑基本就实现了,完整的代码我会放在后面。
然后是我们的另一个自定义View---FilterView,其功能就是给出了裁剪框以外的部分加上一个黑色滤镜,这个就很好实现了,我们只需要让FilterView充满屏幕,然后绘制一个半透明黑色背景色即可。
直接看onDraw方法吧:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//draw filter color except the rect consists the four points
mPaint.setAlpha(alpha);
canvas.drawRect(0,0,mWidth,mRect.top,mPaint);
canvas.drawRect(0,mRect.bottom,mWidth,mHeight,mPaint);
canvas.drawRect(0,mRect.top,mRect.left,mRect.bottom,mPaint);
canvas.drawRect(mRect.right,mRect.top,mWidth,mRect.bottom,mPaint);
}
这里的alpha我的取值是0xAA。
这里的mRect就是从TailorView那里取得的Rect,注意我们的TailorView的Rect在不停的改变,因此我们需要定义一个接口Listener对其实现监听:
public interface RectPositionChangeListener{
void onRectPositionChange();
}
然后我们给TailorView设置一个Listener,在其内部每当Rect变化时就调用onRectPOsitionChange()方法即可。
最后就是我们截取图片的功能了,思路是先利用View的getDrawingCache方法获取屏幕截图,然后再利用Rect的坐标从截图中生成一张新的图片:
//return a screen capture bitmap unscaled
public static Bitmap getScreenCapture(Activity activity){
View view=activity.getWindow().getDecorView();
view.setDrawingCacheEnabled(true);
view.buildDrawingCache();
Bitmap bitmap=view.getDrawingCache();
int []wh=getScreenWidthAndHeight(activity);
Bitmap scaled=Bitmap.createBitmap(bitmap,0,0,wh[0],wh[1]);
view.setDrawingCacheEnabled(false);
view.destroyDrawingCache();
return scaled;
}
//this screen bitmap contain the statusBar, remember to minus its height.
Bitmap screen=ScreenUtils.getScreenCapture(this);
Rect rect=mTailorView.getRect();
Bitmap bitmap=Bitmap.createBitmap(screen,rect.left,rect.top+mTailorView.statusBarHeight
,rect.right-rect.left,rect.bottom-rect.top);
注意这个截图是包含状态栏的,故需要对坐标进行一下处理。
至此,我们的自定义图片裁减器就基本完成了,当然有很多缺点和bug,但作为一个自定义View学习Demo还是足够了。
文章中都是抽离出了部分代码,可能阅读起来不太容易理解,完整的源代码见此,加上注释理解起来应该好很多。
网友评论