Android自定义View之头像选择控件

前言

头像选择控件大概是现在90%以上的App必备的控件了吧。实用性还是蛮高的,记得以前在做毕业设计的时候也需要用到头像选择,然而当时时间比较赶,就没有去研究怎么实现了,直接选择完图片后设置回去了,不能缩放,移动,可以说毫无体验性可言。所以现在想来填填以前的坑,也希望这篇文章能给你带来一些思路。先上一张动态效果图。

效果预览
效果预览

功能点

界面是仿国内某读书App的。实现的功能点有:

  1. 从相册中选择图片。
  2. 加载图片,突出显示裁剪区域,不裁剪的区域加一层蒙层。
  3. 手势操作功能:移动,双指缩放,双击缩放。
  4. 手势操作后调整图片的位置,使得圆形裁剪框中始终有图片内容。
  5. 点击预览只显示图片的圆形裁剪区域,进入预览状态后不能编辑图片。
  6. 点击确定裁剪图片并返回结果。

实现思路

以前会觉得这个控件要做的事情很多,实现起来很麻烦,但实际上将它拆分成上面那一个个独立的任务后,再一个个去实现,现在回过头来看,好像也不难。

加载图片

万事开头难,要实现这一系列的需求,首先得将图片显示出来,后面的操作就好办了。从相册中去挑选图片这一步骤就不说了。
从图库挑选图片后,我们会得到一个Uri,然后我们根据这个Uri去获取Bitmap资源。我这里用的实现方式是AsyncTask。加载图片的操作是在ImageCropActivity中去操作的。加载图片的思路是先去获取图片的大小,然后根据需要对图片进行采样缩放,最后将得到的Bitmap设置给我们今天的主角ImageCropView。

private class LoadImageAsyncTask extends AsyncTask<Uri, Void, Bitmap> {
    @Override
    protected Bitmap doInBackground(Uri... uris) {
        Uri imageUri = uris[0];

        // 先去加载图片的大小
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        loadImage(imageUri, options);

        // 根据需要对图片进行缩放,这里的取值是960
        int maxSize = ImageCropView.MAX_CROP_SIZE;

        int sampleSize = 1;
        int width = options.outWidth;
        int height = options.outHeight;

        while (width > maxSize && height > maxSize) {
            sampleSize *= 2;
            width /= 2;
            height /= 2;
        }
        Log.d(TAG, "sample size is " + sampleSize);

        options.inJustDecodeBounds = false;
        options.inSampleSize = sampleSize;
        return loadImage(imageUri, options);
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        super.onPostExecute(bitmap);
        if (bitmap == null) {
            Toast.makeText(ImageCropActivity.this, "加载图片失败", Toast.LENGTH_LONG).show();
            finish();
        }
        // 将Bitmap设置给我们的自定义控件ImageCropView
        mImageCropView.setBitMap(bitmap);
    }
}


private Bitmap loadImage(Uri uri, BitmapFactory.Options options) {
    InputStream is = null;
    try {
        is = getContentResolver().openInputStream(uri);
        if (is != null) {
            return BitmapFactory.decodeStream(is, null, options);
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } finally {
        if (is != null) {
            try {
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    return null;
}

显示图片

拿到Bitmap资源之后,我们就可以将它显示出来了,但还不急。但显示图片之前,我们来分析下这个界面的组成先。

效果图
效果图

首先,图片处于最下层,然后上面是一层带有透明度的黑色蒙层,但最神奇的是这个蒙层在中间被挖出了个圆形,使得这部分区域不会被遮住。细心的你可能还会发现,图片的高度是跟裁剪区域的高度一致的。所以,这部分最关键的地方就是如何实现在蒙层中间去挖一个洞出来,处理好这部分之后,画个Bitmap还不是一个方法调用而已。

在蒙层中挖一个洞出来这个需求,只要知道了原理之后,其实也并不难,而这就需要我们利用混合模式来实现了。我们可以先画上蒙层,然后利用SRC_OUT模式来挖洞。

// 为了方便看效果,将颜色设置为白色
mMaskColor = getResources().getColor(R.color.white);
mMaskXfermode = new PorterDuffXfermode(PorterDuff.Mode.SRC_OUT);

mMaskPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mMaskPaint.setColor(Color.TRANSPARENT);
// 将Style设置为Fill模式,不能设置为Stroke
mMaskPaint.setStyle(Paint.Style.FILL);

@Override
protected void onDraw(Canvas canvas) {

    int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null, Canvas.ALL_SAVE_FLAG);

    // 画上蒙层
    canvas.drawColor(mMaskColor);

    mMaskPaint.setXfermode(mMaskXfermode);

    // 利用SRC_OUT模式在中间挖一个圆出来
    canvas.drawOval(mCropRectF, mMaskPaint);

    mMaskPaint.setXfermode(null);
    canvas.restoreToCount(sc);

    // 画上白色圆环
    canvas.drawOval(mCropRectF, mRingPaint);
}

几行代码过后,就得到了这样的效果。首先Activity的背景是黑色的,然后我们在画布画上了白色,然后利用SRC_OUT模式让挖走中间这一块,就能够得到我们想要的效果了。

解决了蒙层的难点之后,我们再来显示图片,我们想要达到的效果是图片较小的边可以和裁剪区域一样大,这样就可以保证整张图片可以覆盖到整个裁剪区域了,所以,我们需要对图片进行缩放和位移的处理先。我们需要借助Matrix这个类来帮助我们操作图片。

/**
 * 首次加载调整图片的显示
 */
private void fixImageSize() {
    mMatrix.reset();
    // 拿到裁剪区域的大小
    float cropSize = mCropRectF.width();
    int width = mSrcBitmap.getWidth();
    int height = mSrcBitmap.getHeight();

    int minSide = Math.min(width, height);
    // 对较小的边进行缩放计算,
    // 使得较小的一边宽度与裁剪框相等
    if (minSide < cropSize) {
        mInitScale = cropSize / minSide;
    } else {
        mInitScale = minSide / cropSize;
    }
    Log.d(TAG, "bitmap initialize scale is " + mInitScale);

    float offsetX = 0;
    float offsetY = 0;
    // 计算图片的偏移值,使得图片相对于裁剪区域居中
    if (minSide == width) {
        offsetY = (height * mInitScale - cropSize) / 2;
    } else {
        offsetX = (width * mInitScale - cropSize) / 2;
    }

    // 对图片进行缩放
    mMatrix.postScale(mInitScale, mInitScale);
    // 居中显示
    mMatrix.postTranslate(mCropRectF.left - offsetX, mCropRectF.top - offsetY);

    invalidate();
}

fixImageSize这个方法里,我们先是拿到了裁剪区域的边长,然后拿图片较小的边来和它相比得到缩放倍数,接着再计算出另一边需要偏移多少,最后将这些操作设置给Matrix,然后触发重绘。

拿到Matrix后,就可以绘制图片了。

@Override
protected void onDraw(Canvas canvas) {

    if (mSrcBitmap != null) {
        canvas.drawBitmap(mSrcBitmap, mMatrix, null);
    }

    // 画完图片后绘制蒙层,代码不再重复给出了。
}

至此,最重要的一步完成了,看看实现的效果怎么样。

手势操作

图片加载出来之后,后面的就好办了,因为所有的操作都是基于这张图片来的。

拖动图片

拖动图片的操作,比较容易,思路是在ACTION_MOVE中拿到当前的手指操作位置,然后通过Matrix去更新图片的位移。但这里需要注意的一点是,当你有多个手指在操作时,容易导致图片一下子移到别的地方去了,体验很不好,所以当检测到多指触碰的时候,屏蔽事件,并且如果不屏蔽的话还会和后面的双指缩放造成冲突。

@Override
public boolean onTouchEvent(MotionEvent event) {

    float x = event.getX();
    float y = event.getY();

    // 多指触碰情况下屏蔽移动事件
    if (event.getPointerCount() > 1) {
        mLastPoint.x = -1;
        mLastPoint.y = -1;
        return true;
    }

    int action = event.getAction();
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            mLastPoint.x = event.getX();
            mLastPoint.y = event.getY();
        case MotionEvent.ACTION_MOVE: {
            if (mLastPoint.x != -1 && mLastPoint.y != -1) {
                translateBitmap(x, y);
            }
            mLastPoint.x = x;
            mLastPoint.y = y;
            break;
        }
        case MotionEvent.ACTION_UP: {
            if (!isInAnimation) {
                Log.d(TAG, "ACTION_UP: not in animation.");
                adjustImagePosition();
            }
            mLastPoint.x = 0;
            mLastPoint.y = 0;
        }
    }

    return true;
}

private void translateBitmap(float x, float y) {
    mMatrix.postTranslate(x - mLastPoint.x, y - mLastPoint.y);
    invalidate();
}

双击缩放

双击缩放需要GestureDetector来辅助识别手势操作。只需要监听onDoubleTap事件即可。

@Override
public boolean onDoubleTap(MotionEvent motionEvent) {
    if (isInAnimation) {
        return true;
    }

    // 图片处于放大状态,双击回到初始状态先
    if (getScale() != mInitScale) {
        mScaleAnimator.setFloatValues(getScale(), mInitScale);
        mScaleAnimator.start();
    } else {
        mPivotX = motionEvent.getX();
        mPivotY = motionEvent.getY();
        mScaleAnimator.setFloatValues(mInitScale, mInitScale * 2);
        mScaleAnimator.start();
    }
    return true;
}

这里的动画更新缩放倍数有必要说一下。我们在上面设置的放大参数是从当前倍数再放大两倍,在onAnimationUpdate中拿到的数据是我们最后想应用在图片上的放大倍数,但由于Matrix本身就已经带有放大倍数了,所以我们还需要计算出实际上的放大倍数。

@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
    float updateScale = (float) valueAnimator.getAnimatedValue();
    // 拿到当前Matrix的放大倍数
    float currentScale = getScale();
    float actualScale = updateScale / currentScale;
    mMatrix.postScale(actualScale, actualScale, mPivotX, mPivotY);
    invalidate();
}

双指缩放

双指缩放需要ScaleGestureDetector来辅助识别,需要监听onScale事件。由于onScale是一个不断回调的事件,所以不需要Animator的帮助了,直接操作后进行重绘即可。

@Override
public boolean onScale(ScaleGestureDetector scaleGestureDetector) {

    float scaleFactor = scaleGestureDetector.getScaleFactor();
    // 这里也需要计算出真实的缩放倍数
    float scaleTo = getScale() * scaleFactor;

    // 控制放大倍数为初始放大倍数的1~2倍之间
    if (scaleTo < 2 * mInitScale && scaleTo > mInitScale) {
        mMatrix.postScale(scaleFactor, scaleFactor, scaleGestureDetector.getFocusX(), scaleGestureDetector.getFocusY());
        invalidate();
    }
    return true;
}

调整图片的显示位置

在进行手势操作后,图片可能会偏离裁剪区域,所以我们需要在用户操作完成后来调整图片的位置。具体触发时机是在ACTION_UP和双击图片缩放动画完成后。

private void adjustImagePosition() {

    float bmpLeft = getTranslationX();
    float bmpRight = bmpLeft + mSrcBitmap.getWidth() * getScale();
    float bmpTop = getTranslationY();
    float bmpBottom = bmpTop + mSrcBitmap.getHeight() * getScale();

    float circleLeft = mCropRectF.left;
    float circleTop = mCropRectF.top;
    float circleRight = mCropRectF.right;
    float circleBottom = mCropRectF.bottom;

    float translateX = 0;
    float translateY = 0;

    // 判断水平方向
    if (bmpLeft > circleLeft) {
        translateX = circleLeft - bmpLeft;
    } else if (bmpRight < circleRight) {
        translateX = circleRight - bmpRight;
    }

    // 判断垂直方向
    if (bmpTop > circleTop) {
        translateY = circleTop - bmpTop;
    } else if (bmpBottom < circleBottom) {
        translateY = circleBottom - bmpBottom;
    }

    PropertyValuesHolder xHolder = PropertyValuesHolder.ofFloat(TRANSLATION_X, 0, translateX);
    PropertyValuesHolder yHolder = PropertyValuesHolder.ofFloat(TRANSLATION_Y, 0, translateY);

    mTranslateAnimator.setValues(xHolder, yHolder);
    mTranslateAnimator.start();
}

同样的,位移动画也和缩放动画一样,需要计算出真实的偏移值。

@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
    float translateX = (float) valueAnimator.getAnimatedValue(TRANSLATION_X);
    float translateY = (float) valueAnimator.getAnimatedValue(TRANSLATION_Y);

    mMatrix.postTranslate(translateX - mLastTranslationX, translateY - mLastTranslationY);
    mLastTranslationX = translateX;
    mLastTranslationY = translateY;
    invalidate();
}

预览裁剪图片

这一步是最简单的了,只需要将mMaskColor设置为纯黑色的,然后重绘就完事了。加上这个操作只是锦上添花而已。

保存Bitmap

到这里为止,我们已经可以在这个界面自娱自乐了,然而如果不把图片裁剪后返回出去就是在耍流氓。所以是时候进入到最后一步去裁剪图片了。
在此之前,先来回忆一下我们是怎么对图片做操作的,无论是缩放还是平移,都是通过Matrix矩阵操作后在画图片的时候将矩阵应用到图片上的。也就是说,自始至终,原始图片mSrcBitmap都是没有变的,而我们所操作的都是经过处理的图片。所以我们需要将我们所看到的给映射到原始图片mSrcBitmap上。

public Bitmap saveImage(int size) {
    // 计算出经过矩阵操作后图片的大小
    RectF bitmapBound = new RectF(0, 0, mSrcBitmap.getWidth(), mSrcBitmap.getHeight());
    mMatrix.mapRect(bitmapBound);

    // 由于我们看到的是经过矩阵操作的图片,裁剪操作是要对原始图片进行的,
    // 所以需要将矩阵处理过的图片的裁剪区域换算成原始图片的裁剪区域
    float s = getScale();
    int left = (int) ((mCropRectF.left - bitmapBound.left) / s);
    int top = (int) ((mCropRectF.top - bitmapBound.top) / s);
    int w = (int) (mCropRectF.width() / s);
    int h = (int) (mCropRectF.height() / s);

    Matrix matrix = new Matrix();
    float scale = (float) size / w;
    matrix.setScale(scale, scale);

    // 需要注意的是,这个方法是先对原始进行裁剪操作后才去进行矩阵操作的
    // 而不是对原始图片进行矩阵操作后再裁剪的
    return Bitmap.createBitmap(mSrcBitmap, left, top, w, h, matrix, false);
}

经过上面的操作之后,总算把Bitmap给返回出去了。可是又好像还有点不对。慢着!说好的圆形头像呢,这样返回出去的是一张正方形的Bitmap啊!嘿嘿,这就要用到之前写的另一篇小文章自定义圆形头像了,其实这里再对Bitmap处理成圆形之后再给出去也是可以的。但假设拿到这个Bitmap的控件还从其他地方去获取图片,并且拿到的并不是圆形的,最终会造成效果不统一。所以这里就选择了返回一张没有处理过的Bitmap,至于圆形效果就让使用者自己去处理吧。

可改进的空间

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

推荐阅读更多精彩内容