仿海报工厂效果的自定义View

3,450 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情

之前做了一个自定义View,效果有些类似海报工厂,当做自定义View的入门学习吧~先看下效果图:

这里写图片描述

就是一个背景图,中间挖了若干个形状不同的“洞”,每个“洞”里放着一张图片,用手可以拖拽、缩放、旋转该图片,并且当前图片备操作时会有红色的高亮边框。点击选中某个图片的时候,底部会弹出菜单栏,菜单栏有三个按钮,分别是对该图片进行旋转90度、对称翻转图片、和保存整个海报到手机内置sd卡根目录。

这就类似海报工厂效果,选择若干张图片还有底部模板(就是背景图片和挖空部分的位置和形状),然后通过触摸改变选择的图片的大小位置角度,来制作一张自己喜爱的海报。

这里主要是一个自定义View,项目中叫做JigsawView完成的。它的基本结构是最底层绘制可操作的图片,第二层绘制背景图片,第三层绘制镂空的部分,镂空部分通过PorterDuffXfermode来实现,镂空部分的形状由对应手机目录的svg文件确定。

在用Android中的Canvas进行绘图时,可以通过使用PorterDuffXfermode将所绘制的图形的像素与Canvas中对应位置的像素按照一定规则进行混合,形成新的像素值,从而更新Canvas中最终的像素颜色值,这样会创建很多有趣的效果。关于PorterDuffXfermode详细可以参考 Android中Canvas绘图之PorterDuffXfermode使用及工作原理详解

首先这里要关掉硬件加速,因为硬件加速可能会使效果丢失。在View的初始化语句中调用

setLayerType(View.LAYER_TYPE_SOFTWARE, null);

即可。

由于JigsawView的代码不少,所以这里只展示比较重要的部分,完整代码请见文章末尾的GitHub链接。

首先需要两支画笔:

 //绘制图片的画笔
    Paint mMaimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    //绘制高亮边框的画笔
    Paint mSelectPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

这里图片的模型是PictureModel 。PictureModel 主要都是包含了位置和缩放信息以及镂空部分的HollowModel,而图片的具体位置和大小由HollowModel确定,图片时填充镂空部分的,以类似ImageView的CenterCrop方式填充,这个在JigsawView的makePicFillHollow方法中处理。

HollowModel持有解析svg文件得到的path对象集合,该集合可以表示一个svg文件表示的路径。具体的解析工作由自定义的SvgParseUtil类处理,SvgParseUtil从手机的内置sd卡中(当然路径可以灵活配置)读取对应的svg文件,然后解析为可以绘制的Path集合对象。SvgParseUtil本质是解析xml文件(以为svg就是一个xml文件),对于svg路径直接拷贝系统的PathParser处理,其他的圆形矩形多边形就自己处理。这里具体代码这里就不展示了,详细请看GitHub上的源码。

以下是完整的onDraw方法:

 @Override
    protected void onDraw(Canvas canvas) {
        if (mPictureModels != null && mPictureModels.size() > 0 && mBitmapBackGround != null) {
            //循环遍历画要处理的图片
            for (PictureModel pictureModel : mPictureModels) {
                Bitmap bitmapPicture = pictureModel.getBitmapPicture();
                int pictureX = pictureModel.getPictureX();
                int pictureY = pictureModel.getPictureY();
                float scaleX = pictureModel.getScaleX();
                float scaleY = pictureModel.getScaleY();
                float rotateDelta = pictureModel.getRotate();

                HollowModel hollowModel = pictureModel.getHollowModel();
                ArrayList<Path> paths = hollowModel.getPathList();
                if (paths != null && paths.size() > 0) {
                    for (Path tempPath : paths) {
                        mPath.addPath(tempPath);
                    }
                    drawPicture(canvas, bitmapPicture, pictureX, pictureY, scaleX, scaleY, rotateDelta, hollowModel, mPath);
                } else {
                    drawPicture(canvas, bitmapPicture, pictureX, pictureY, scaleX, scaleY, rotateDelta, hollowModel, null);
                }
            }
            //新建一个layer,新建的layer放置在canvas默认layer的上部,当我们执行了canvas.saveLayer()之后,我们所有的绘制操作都绘制到了我们新建的layer上,而不是canvas默认的layer。
            int layerId = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);

            drawBackGround(canvas);

            //循环遍历画镂空部分
            for (PictureModel pictureModel : mPictureModels) {
                int hollowX = pictureModel.getHollowModel().getHollowX();
                int hollowY = pictureModel.getHollowModel().getHollowY();
                int hollowWidth = pictureModel.getHollowModel().getWidth();
                int hollowHeight = pictureModel.getHollowModel().getHeight();
                ArrayList<Path> paths = pictureModel.getHollowModel().getPathList();
                if (paths != null && paths.size() > 0) {
                    for (Path tempPath : paths) {
                        mPath.addPath(tempPath);
                    }
                    drawHollow(canvas, hollowX, hollowY, hollowWidth, hollowHeight, mPath);
                    mPath.reset();
                } else {
                    drawHollow(canvas, hollowX, hollowY, hollowWidth, hollowHeight, null);
                }
            }

            //把这个layer绘制到canvas默认的layer上去
            canvas.restoreToCount(layerId);

            //绘制选择图片高亮边框
            for (PictureModel pictureModel : mPictureModels) {
                if (pictureModel.isSelect() && mIsNeedHighlight) {
                    canvas.drawRect(getSelectRect(pictureModel), mSelectPaint);
                }
            }
        }
    }

思路还是比较清晰的。第3行到第22行为绘制可操作图片。第19行的drawPicture就是绘制所有的可操作图片,而当该图片对应的镂空部分没有相应的svg时,就绘制HollowModel的位置尺寸对应的矩形作为镂空部分,即20行的drawPicture。

看下drawPicture方法:

private void drawPicture(Canvas canvas, Bitmap bitmapPicture, int coordinateX, int coordinateY, float scaleX, float scaleY, float rotateDelta
            , HollowModel hollowModel, Path path) {
        int picCenterWidth = bitmapPicture.getWidth() / 2;
        int picCenterHeight = bitmapPicture.getHeight() / 2;
        mMatrix.postTranslate(coordinateX, coordinateY);
        mMatrix.postScale(scaleX, scaleY, coordinateX + picCenterWidth, coordinateY + picCenterHeight);
        mMatrix.postRotate(rotateDelta, coordinateX + picCenterWidth, coordinateY + picCenterHeight);
        canvas.save();

        //以下是对应镂空部分相交的处理,需要完善
        if (path != null) {
            Matrix matrix1 = new Matrix();
            RectF rect = new RectF();
            path.computeBounds(rect, true);

            int width = (int) rect.width();
            int height = (int) rect.height();

            float hollowScaleX = hollowModel.getWidth() / (float) width;
            float hollowScaleY = hollowModel.getHeight() / (float) height;

            matrix1.postScale(hollowScaleX, hollowScaleY);
            path.transform(matrix1);
            //平移path
            path.offset(hollowModel.getHollowX(), hollowModel.getHollowY());
            //让图片只能绘制在镂空内部,防止滑动到另一个拼图的区域中
            canvas.clipPath(path);
            path.reset();
        } else {
            int hollowX = hollowModel.getHollowX();
            int hollowY = hollowModel.getHollowY();
            int hollowWidth = hollowModel.getWidth();
            int hollowHeight = hollowModel.getHeight();
            //让图片只能绘制在镂空内部,防止滑动到另一个拼图的区域中
            canvas.clipRect(hollowX, hollowY, hollowX + hollowWidth, hollowY + hollowHeight);
        }
        canvas.drawBitmap(bitmapPicture, mMatrix, null);
        canvas.restore();
        mMatrix.reset();
    }

这里主要是运用了Matrix处理图片的各种变化。在onTouchEvent方法中,会根据触摸的事件不同对正在操作的PictureModel对象的位置、缩放、角度进行对应的赋值,所以在drawPicture中将每次触摸后的赋值参数取出来,交给Matrix对象处理,最后通过

canvas.drawBitmap(bitmapPicture, mMatrix, null);

就能将触摸后的变化图片显示出来。 另外第26行的canvas.clipPath(path);是将图片的可绘制区域限定在镂空部分中,防止图片滑动到其他的镂空区域。

注意onDraw的第25行的

int layerId = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), null, Canvas.ALL_SAVE_FLAG);

为了正确显示PorterDuffXfermode所带来的的效果,需要新建一个图层,具体可以参见上面链接引用的博文。

onDraw第26行的drawBackGround方法就是绘制背景,这个很简单就不必说了。

第28行到第44行为绘制镂空部分,主要是先把HollowModel中存储的Path集合取出,再通过addPath方法将路径数据交给mPath对象,再由drawHollow方法去真正绘制镂空部分。

private void drawHollow(Canvas canvas, int hollowX, int hollowY, int hollowWidth, int hollowHeight, Path path) {
        mMaimPaint.setXfermode(mPorterDuffXfermodeClear);
        //画镂空
        if (path != null) {
            canvas.save();
            canvas.translate(hollowX, hollowY);
             //缩放镂空部分大小使得镂空部分填充HollowModel对应的矩形区域
            scalePathRegion(canvas, hollowWidth, hollowHeight, path);
            canvas.drawPath(path, mMaimPaint);
            canvas.restore();
            mMaimPaint.setXfermode(null);
        } else {
            Rect rect = new Rect(hollowX, hollowY, hollowX + hollowWidth, hollowY + hollowHeight);
            canvas.save();
            canvas.drawRect(rect, mMaimPaint);
            canvas.restore();
            mMaimPaint.setXfermode(null);
        }
    }

这里首先对设置画笔的PorterDuffXfermode:

mMaimPaint.setXfermode(mPorterDuffXfermodeClear);

这里为了镂空效果,PorterDuffXfermode使用PorterDuff.Mode.CLEAR。

然后对画布进行平移,然后通过scalePathRegion方法让表示镂空路径的Path对象进行缩放,使得镂空的路径填充HollowModel对应的矩形区域。接着使用

canvas.drawRect(rect, mMaimPaint);

将镂空的路径绘制上去。

最后别忘了

canvas.restore();
mMaimPaint.setXfermode(null);

恢复画布和画笔的状态。

然后onDraw的第47行把这个layer绘制到canvas默认的layer上去:

 canvas.restoreToCount(layerId);

onDraw最后的

 //绘制选择图片高亮边框
            for (PictureModel pictureModel : mPictureModels) {
                if (pictureModel.isSelect() && mIsNeedHighlight) {
                    canvas.drawRect(getSelectRect(pictureModel), mSelectPaint);
                }
            }

在onTouchEvent中,将通过触摸事件判断哪个图片当前被选择,然后在onDraw中让当前被选择的图片绘制对应的HollowModel的边框。

onDraw到此结束。

再看下onTouchEvent方法:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mPictureModels == null || mPictureModels.size() == 0) {
            return true;
        }
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_POINTER_DOWN:

                //双指模式
                if (event.getPointerCount() == 2) {
                    //mPicModelTouch为当前触摸到的操作图片模型
                    mPicModelTouch = getHandlePicModel(event);
                    if (mPicModelTouch != null) {
                        // mPicModelTouch.setSelect(true);
                        //重置图片的选中状态
                        resetNoTouchPicsState();
                        mPicModelTouch.setSelect(true);
                        //两手指的距离
                        mLastFingerDistance = distanceBetweenFingers(event);
                        //两手指间的角度
                        mLastDegree = rotation(event);
                        mIsDoubleFinger = true;
                        invalidate();
                    }
                }
                break;

            //单指模式
            case MotionEvent.ACTION_DOWN:
                //记录上一次事件的位置
                mLastX = event.getX();
                mLastY = event.getY();
                //记录Down事件的位置
                mDownX = event.getX();
                mDownY = event.getY();
                //获取被点击的图片模型
                mPicModelTouch = getHandlePicModel(event);
                if (mPicModelTouch != null) {
                    //每次down重置其他picture选中状态
                    resetNoTouchPicsState();
                    mPicModelTouch.setSelect(true);
                    invalidate();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                switch (event.getPointerCount()) {
                    //单指模式
                    case 1:
                        if (!mIsDoubleFinger) {
                            if (mPicModelTouch != null) {
                                //记录每次事件在x,y方向上移动
                                int dx = (int) (event.getX() - mLastX);
                                int dy = (int) (event.getY() - mLastY);
                                int tempX = mPicModelTouch.getPictureX() + dx;
                                int tempY = mPicModelTouch.getPictureY() + dy;

                                if (checkPictureLocation(mPicModelTouch, tempX, tempY)) {
                                    //检查到没有越出镂空部分才真正赋值给mPicModelTouch
                                    mPicModelTouch.setPictureX(tempX);
                                    mPicModelTouch.setPictureY(tempY);
                                    //保存上一次的位置,以便下次事件算出相对位移
                                    mLastX = event.getX();
                                    mLastY = event.getY();
                                    //修改了mPicModelTouch的位置后刷新View
                                    invalidate();
                                }
                            }
                        }
                        break;

                    //双指模式
                    case 2:
                        if (mPicModelTouch != null) {
                            //算出两根手指的距离
                            double fingerDistance = distanceBetweenFingers(event);
                            //当前的旋转角度
                            double currentDegree = rotation(event);
                            //当前手指距离和上一次的手指距离的比即为图片缩放比
                            float scaleRatioDelta = (float) (fingerDistance / mLastFingerDistance);
                            float rotateDelta = (float) (currentDegree - mLastDegree);

                            float tempScaleX = scaleRatioDelta * mPicModelTouch.getScaleX();
                            float tempScaleY = scaleRatioDelta * mPicModelTouch.getScaleY();
                            //对缩放比做限制
                            if (Math.abs(tempScaleX) < 3 && Math.abs(tempScaleX) > 0.3 &&
                                    Math.abs(tempScaleY) < 3 && Math.abs(tempScaleY) > 0.3) {
                                //没有超出缩放比才真正赋值给模型
                                mPicModelTouch.setScaleX(tempScaleX);
                                mPicModelTouch.setScaleY(tempScaleY);
                                mPicModelTouch.setRotate(mPicModelTouch.getRotate() + rotateDelta);
                                //修改模型之后,刷新View
                                invalidate();
                                //记录上一次的两手指距离以便下次计算出相对的位置以算出缩放系数
                                mLastFingerDistance = fingerDistance;
                            }
                            //记录上次的角度以便下一个事件计算出角度变化值
                            mLastDegree = currentDegree;
                        }
                        break;
                }
                break;
            //两手指都离开屏幕
            case MotionEvent.ACTION_UP:
//                for (PictureModel pictureModel : mPictureModels) {
//                    pictureModel.setSelect(false);
//                }
                mIsDoubleFinger = false;
                double distance = getDisBetweenPoints(event);

                if (mPicModelTouch != null) {
                    //是否属于滑动,非滑动则改变选中状态
                    if (distance < ViewConfiguration.getTouchSlop()) {
                        if (mPicModelTouch.isLastSelect()) {
                            mPicModelTouch.setSelect(false);
                            mPicModelTouch.setLastSelect(false);
                            if (mPictureCancelSelectListner != null) {
                                mPictureCancelSelectListner.onPictureCancelSelect();
                            }

                        } else {
                            mPicModelTouch.setSelect(true);
                            mPicModelTouch.setLastSelect(true);
                            //选中的回调
                            if (mPictureSelectListener != null) {
                                mPictureSelectListener.onPictureSelect(mPicModelTouch);
                            }
                        }
                        invalidate();
                    } else {
                        //滑动则取消所有选择的状态
                        mPicModelTouch.setSelect(false);
                        mPicModelTouch.setLastSelect(false);
                        //取消状态之后刷新View
                        invalidate();
                    }
                } else {
                    //如果没有图片被选中,则取消所有图片的选中状态
                    for (PictureModel pictureModel : mPictureModels) {
                        pictureModel.setLastSelect(false);
                    }
                    //没有拼图被选中的回调
                    if (mPictureNoSelectListener != null) {
                        mPictureNoSelectListener.onPictureNoSelect();
                    }
                    //取消所有图片选中状态后刷新View
                    invalidate();
                }
                break;
            //双指模式中其中一手指离开屏幕,取消当前被选中图片的选中状态
            case MotionEvent.ACTION_POINTER_UP:
                if (mPicModelTouch != null) {
                    mPicModelTouch.setSelect(false);
                    invalidate();
                }
        }
        return true;
    }

虽然比较长,但是并不难理解,基本是比较套路化的东西,看注释应该就能懂。

总的流程就是: 首先在Down事件: 不管单手还是双手模式,都将选择当前点击到的图片模型,这也是为了以后的事件中可以修改选中的图片模型以在onDraw中改变图片的显示。

Move事件中: 单手模式的话,针对每个MOVE事件带来的位移给PictureModel的位置赋值,然后就调用invalidate进行刷新界面。

如果是双手模式,则根据每个MOVE事件带来的角度变化和两个手指间的距离变化分别给PictureModel的角度和缩放比赋值,然后调用invalidate进行刷新界面。

Up事件: 单指模式下,先判断是否已经滑动过(滑动距离小于ViewConfiguration.getTouchSlop()就认为不是滑动而是点击),不是滑动的话就以改变当前的图片选中状态处理,切换选中状态。 是滑动过的话则取消所有图片的选中状态。

双指状态下均取消所有图片的选中状态。

这里为了使得缩放旋转体验更好,所以只要手指DOWN事件落在镂空部分中,在没有Up事件的情况下即使滑出镂空部分仍然可以继续对选中的图片进行操作,避免因为镂空部分小带来的操作不便,这也和海报工厂的效果一致。

源码地址:github.com/yanyinan/Ji…

原创不易,如果觉得本文对自己有帮助,别忘了随手点赞和关注,也是对笔者的肯定~