从零开始制作图片裁减器

这篇文章讲的是制作一个比较简单的自定义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还是足够了。

文章中都是抽离出了部分代码,可能阅读起来不太容易理解,完整的源代码见此,加上注释理解起来应该好很多。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,874评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,102评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,676评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,911评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,937评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,935评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,860评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,660评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,113评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,363评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,506评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,238评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,861评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,486评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,674评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,513评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,426评论 2 352

推荐阅读更多精彩内容