Android图片裁剪工具封装

笔者从零开始开发Android,而且是跳过java直接使用kotlin开发,这其中的好处是可以避开java这门传统语言诸多的潜规则,难处是相比资深Android开发者少了许多可以现用的工具库。比如Android对图片的支持就非常开放,换言之就是非常依赖一个成熟的工具库(比如Glide),(相比web里标签就安全易用很多)。

包括本文将实现的工具在内,笔者目前也收集了整整2个成熟好用的图片相关工具类,一个就是Glide,另一个是Subsampling Scale Image View,并且膨胀地以为不再需要其他任何图片相关的工具库了。这个想法差点被动摇的一次就是现在需要加一个图片裁剪功能了,一想到网上现有的那些酷炫的工具库,就担心起要不要用和用哪个的问题,但是当笔者看了这篇文章——How We Created uCrop,就对引入第三方图片裁剪库更加抵触了,思来想去自己内心的想法都是:

为了【传一张图片然后确定参数挖出一张新图片来】这样一个需求而依赖【别人开发的自己无法控制或灵活定制的】库是不值得的

本文将纯依赖SubsamplingScaleImageView来实现一个支持缩放和旋转的图片裁剪组件,SubsamplingScaleImageView帮助我完成了其中图片缩放、旋转、拖拽的底层触摸交互以及图片的内存管理工作,这让我可以专心的利用这些动作来确定一件事:我需要裁剪出一张图片如何旋转/缩放后的哪个部分。然后就可以根据这些信息从原图创建出需要的裁剪图。

依靠SubsamplingScaleImageView已经具备的能力,这个图片裁剪组件的kotlin代码目前包含非关键代码在内也只有300行出头。

实现过程总览

纵观整个图片裁剪工具,其实现分这么几步:

  • 自定义View的基本结构和布局

  • 拖拽位置边界的保护

  • 旋转能力和执行裁剪

自定义View

首先给这个图片裁剪工具起个难听的名字叫做YmageCutterView,其布局基于ConstraintLayout中间放一个指定宽高的矩形作为裁剪框,四周是四个半透明矩形作为遮罩,底下是原图。设想就是缩放和拖拽原图,再以中间裁剪框作为边界裁剪出目标图。像这样:




    

    

    

    

    

    



然后为了使裁剪尺寸支持自定义,需要设置一个自定义参数叫ratio,用来传入需要裁剪图片的宽高比,比如支持这么使用:




上面这样中间的裁剪框就是个4:3的矩形。至于用户通过拖拽实时更改这个裁剪框尺寸这个需求,本文还未去实现,讲真其使用场景也不多,后续再考虑补足。由于使用的是ConstraintLayout,只需要将传入的ratio属性设置给中间裁剪框的layoutParams.dimensionRatio就完成裁剪图片宽高比的更新了。

拖拽位置边界的保护

SubsamplingScaleImageView本身已经实现了较完整的触摸交互,包括拖拽和缩放,在它的基础上我们先加一个边界保护:


originIV.setOnTouchListener { _, event ->

    when (event.action) {

        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {

            inDrag = false

            inCheck = false

            lazyCheck?.cancel()

            lazyCheck = null

            lazyCheck = Timer().schedule(100) {

                if (!inDrag && !inCheck) {

                    inCheck = true

                    resolvePositionCheck()

                }

            }

        }

        MotionEvent.ACTION_DOWN -> {

            inDrag = true

        }

    }

    return@setOnTouchListener false

}

以上代码做了这么几件事情:

  1. 手指按下时指示正在拖拽

  2. 手指抬起后100毫秒内若未再次按下,则执行边界检查

边界检查实现如下:


private fun resolvePositionCheck() {

    val center = originIV.center ?: return

    val x: Float

    val y: Float

    if (originIV.orientation == rotation0 || originIV.orientation == rotation180) {

        val left = frameV.width/2/originIV.scale

        val top = frameV.height/2/originIV.scale

        val right = left + (originIV.sWidth*originIV.scale - frameV.width)/originIV.scale

        val bottom = top + (originIV.sHeight*originIV.scale - frameV.height)/originIV.scale

        limitRect.set(left, top, right, bottom)

        x = if (center.x < limitRect.left) {

            if (originIV.sWidth*originIV.scale < frameV.width) {

                if (center.x < limitRect.right) {

                    limitRect.right

                } else {

                    center.x

                }

            } else {

                limitRect.left

            }

        } else if (center.x > limitRect.right) {

            if (originIV.sWidth*originIV.scale < frameV.width) {

                limitRect.left

            } else {

                limitRect.right

            }

        } else {

            center.x

        }

        y = if (center.y < limitRect.top) {

            if (originIV.sHeight*originIV.scale < frameV.height) {

                if (center.y < limitRect.bottom) {

                    limitRect.bottom

                } else {

                    center.y

                }

            } else {

                limitRect.top

            }

        } else if (center.y > limitRect.bottom) {

            if (originIV.sHeight*originIV.scale < frameV.height) {

                limitRect.top

            } else {

                limitRect.bottom

            }

        } else {

            center.y

        }

    } else {

        val left = frameV.width/2/originIV.scale

        val top = frameV.height/2/originIV.scale

        val right = left + (originIV.sHeight*originIV.scale - frameV.width)/originIV.scale

        val bottom = top + (originIV.sWidth*originIV.scale - frameV.height)/originIV.scale

        limitRect.set(left, top, right, bottom)

        x = if (center.x < limitRect.left) {

            if (originIV.sHeight*originIV.scale < frameV.width) {

                if (center.x < limitRect.right) {

                    limitRect.right

                } else {

                    center.x

                }

            } else {

                limitRect.left

            }

        } else if (center.x > limitRect.right) {

            if (originIV.sHeight*originIV.scale < frameV.width) {

                limitRect.left

            } else {

                limitRect.right

            }

        } else {

            center.x

        }

        y = if (center.y < limitRect.top) {

            if (originIV.sWidth*originIV.scale < frameV.height) {

                if (center.y < limitRect.bottom) {

                    limitRect.bottom

                } else {

                    center.y

                }

            } else {

                limitRect.top

            }

        } else if (center.y > limitRect.bottom) {

            if (originIV.sWidth*originIV.scale < frameV.height) {

                limitRect.top

            } else {

                limitRect.bottom

            }

        } else {

            center.y

        }

    }

    if (x != center.x || y != center.y) {

        GlobalScope.launch(Dispatchers.Main) {

            originIV.animateCenter(PointF(x, y))

                    ?.withDuration(100)

                    ?.withEasing(SubsamplingScaleImageView.EASE_OUT_QUAD)

                    ?.withInterruptible(false)

                    ?.start()

        }

    }

}

算是这个裁剪组件里最长的一个方法了,比较不美观的套了很多条件检查值得去优化,不过做的事情描述起来很简单,就是如果原图没有完全包含在裁剪框中则按最近路径移动进来。比如安卓微信里的头像裁剪,图片就可以任意移出裁剪框,笔者觉得这样不妥。

旋转能力和执行裁剪

图片的旋转只需设置SubsamplingScaleImageVieworientation即可,麻烦的是不同旋转角度下原图的宽高要区分处理,也就是说,0度和180度下原图的宽就是宽,高就是高,但90度和270度下原图的宽是高而高是宽。这在上文边界保护和下文执行裁剪时都要加以区分处理。

执行裁剪作为一个方法提供给外界调用,实现如下:


fun shutter(): Bitmap? {

    if (resultBitmap?.isRecycled == false) {

        resultBitmap?.recycle()

    }

    val center = originIV.center ?: return null

    val cut = Rect(

            (center.x - frameV.width/2/originIV.scale).toInt(),

            (center.y - frameV.height/2/originIV.scale).toInt(),

            (center.x + frameV.width/2/originIV.scale).toInt(),

            (center.y + frameV.height/2/originIV.scale).toInt()

    )

    val bitmap = BitmapFactory.decodeFile(src)

    val matrix = Matrix()

    matrix.postRotate(originIV.orientation.toFloat())

    val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)

    resultBitmap = Bitmap.createBitmap(rotatedBitmap, Math.max(cut.left, 0), Math.max(cut.top, 0), Math.min(cut.right-cut.left, rotatedBitmap.width), Math.min(cut.bottom-cut.top, rotatedBitmap.height), null, true)

    if (bitmap != resultBitmap && !bitmap.isRecycled) {

        bitmap.recycle()

    }

    if (rotatedBitmap != resultBitmap && !rotatedBitmap.isRecycled) {

        rotatedBitmap.recycle()

    }

    return resultBitmap

}

此方法做的事情就是,先将原图根据当前的旋转角度旋转得到源bitmap,再根据当前原图的缩放和位置确定裁剪图片的起点坐标(x,y)以及宽高,调用createBitmap挖出裁剪图,最后返回裁剪后的目标bitmap。

总结

至此这个图片裁剪组件已经完成,说白了是以自定义View的形式对SubsamplingScaleImageView进行扩展和二次封装,使用起来需要把这个View放到自己的Activity中,目前支持传入ratio属性设置裁剪图的宽高比,并提供了这么几个方法供调用:

  1. reset() 重设图片的旋转和缩放

  2. rotate(degree: Int) 执行旋转,角度必须为 0, 90, 180, 270的其中一个

  3. shutter(): Bitmap? 按快门方法,执行裁剪并返回bitmap

最后再次膜拜SubsamplingScaleImageView并附上本文项目Github地址。

你可能感兴趣的:(Android图片裁剪工具封装)