美文网首页Android UI特效实现Android项目Android知识
[Android] 自定义View之仿QQ讨论组头像

[Android] 自定义View之仿QQ讨论组头像

作者: 木木00 | 来源:发表于2017-04-24 17:08 被阅读215次

    转载文章作者:一息尚存   

    原文链接:http://www.jianshu.com/p/349aa6153fcc

    效果图:

    在以前的一个项目中,需要实现类似QQ讨论组头像的控件,只是头像数量和布局有一小点不一样:一是最头像数是4个,二是头像数是2个时的布局是横着排的。其实当时GitHub上就有类似的开源控件,只是那个控件在每一次绘制View的时候都会新创建一些Bitmap对象,这肯定是不可取的,而且那个控件头像输入的是Bitmap对象,不满足需求。所以只能自己实现一个了。实现的时候也没有过多的考虑,传入头像Drawable对象,根据数量排列显示就算完成了,而且传入的图像还必需是圆形的,限制很大,根本不具备通用性。因此要实现和QQ讨论组头像一样的又具备一定通用性的控件,还得重新设计、实现。下面就让我们开始实现吧。

    布局

    首先需要解决的是头像的布局,在头像数量分别为1至5的情况下,定义头像的布局排列方式,并计算出图像的大小和位置。先把布局图画出来再说:

    布局

    其中黑色正方形就是View的显示区,蓝色圆形就是头像了。已知的条件是View大小,姑且设为 D 吧,还有头像的数量 n ,求蓝色圆的半径 r 及圆心位置。这不就是一道几何题吗?翻开初中的数学课本——勾三股四弦五……好像不够用啊……

    辅助线画了又画,头皮挠了又挠,α,θ,OMG......sin,cos,sh*t......终于算出了r与D和n的关系:

    公式1

    其实 n=3 的时候半径和 n=4 的时候是一样的,但是考虑到 n=3,5 时在Y轴上还有一个偏移量 dy ,而且 r 和 dy 在 n=3,5 时是有通式的,所以就合在一起了。求偏移量 dy 的公式:

    公式2

    式中 R 就是布局图中红色大圆的半径。

    有了公式,那么代码就好写了,计算每个头像的大小和位置的代码如下:

    // 头像信息类,记录大小、位置等信息privatestaticclassDrawableInfo{intmId = View.NO_ID;    Drawable mDrawable;// 中心点位置floatmCenterX;floatmCenterY;// 头像上缺口弧所在圆上的圆心位置,其实就是下一个相邻头像的中心点floatmGapCenterX;floatmGapCenterY;booleanmHasGap;// 头像边界finalRectF mBounds =newRectF();// 圆形蒙板路径,把头像弄成圆形finalPath mMaskPath =newPath();}

    privatevoidlayoutDrawables(){    mSteinerCircleRadius =0;    mOffsetY =0;intwidth = getWidth() - getPaddingLeft() - getPaddingRight();intheight = getHeight() - getPaddingTop() - getPaddingBottom();    mContentSize = Math.min(width, height);finalList drawables = mDrawables;finalintN = drawables.size();floatcenter = mContentSize * .5f;if(mContentSize >0&& N >0) {// 图像圆的半径。finalfloatr;if(N ==1) {            r = mContentSize * .5f;        }elseif(N ==2) {            r = (float) (mContentSize / (2+2* Math.sin(Math.PI /4)));        }elseif(N ==4) {            r = mContentSize /4.f;        }else{            r = (float) (mContentSize / (2* (2* Math.sin(((N -2) * Math.PI) / (2* N)) +1)));finaldoublesinN = Math.sin(Math.PI / N);// 以所有图像圆为内切圆的圆的半径finalfloatR = (float) (r * ((sinN +1) / sinN));            mOffsetY = (float) ((mContentSize - R - r * (1+1/ Math.tan(Math.PI / N))) /2f);        }// 初始化第一个头像的中心位置finalfloatstartX, startY;if(N %2==0) {            startX = startY = r;        }else{            startX = center;            startY = r;        }// 变换矩阵finalMatrix matrix = mLayoutMatrix;// 坐标点临时数组finalfloat[] pointsTemp =this.mPointsTemp;        matrix.reset();for(inti =0; i < drawables.size(); i++) {            DrawableInfo drawable = drawables.get(i);            drawable.reset();            drawable.mHasGap = i >0;// 缺口弧的中心if(drawable.mHasGap) {                drawable.mGapCenterX = pointsTemp[0];                drawable.mGapCenterY = pointsTemp[1];            }            pointsTemp[0] = startX;            pointsTemp[1] = startY;if(i >0) {// 以上一个圆的圆心旋转计算得出当前圆的圆位置matrix.postRotate(360.f / N, center, center + mOffsetY);                matrix.mapPoints(pointsTemp);            }// 取出中心点位置drawable.mCenterX = pointsTemp[0];            drawable.mCenterY = pointsTemp[1];// 设置边界drawable.mBounds.inset(-r, -r);            drawable.mBounds.offset(drawable.mCenterX, drawable.mCenterY);// 设置“蒙板”路径drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);            drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);        }// 设置第一个头像的缺口,头像数量少于3个的时候没有if(N >2) {            DrawableInfo first = drawables.get(0);            DrawableInfo last = drawables.get(N -1);            first.mHasGap =true;            first.mGapCenterX = last.mCenterX;            first.mGapCenterY = last.mCenterY;        }        mSteinerCircleRadius = r;    }    invalidate();}

    绘制

    计算好每个头像的大小和位置后,就可以把它们绘制出来了。但在此之前,还得先解决一个问题——如何使头像图像变圆?因为输入Drawable对象并没有任何限制。在上面的layoutDrawables方法中有这样两行代码:

    drawable.mMaskPath.addCircle(drawable.mCenterX, drawable.mCenterY, r, Path.Direction.CW);

    drawable.mMaskPath.setFillType(Path.FillType.INVERSE_WINDING);

    其中第一行是添加一个圆形路径,这个路径就是布局图中蓝色圆的路径,而第二行是设置路径的填充模式,默认的填充模式是填充路径内部,而INVERSE_WINDING模式是填充路径外部,再配合Paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR))就可以绘制出圆形的图像了。头像上的缺口同理。(ps:关于Path.FillType和PorterDuff.Mode网上介绍挺多的,这里就不详细介绍了)

    下面来看一下onDraw方法:

    @OverrideprotectedvoidonDraw(Canvas canvas){super.onDraw(canvas);    ...    canvas.translate(0, mOffsetY);finalPaint paint = mPaint;finalfloatgapRadius = mSteinerCircleRadius * (mGap +1f);for(inti =0; i < drawables.size(); i++) {        DrawableInfo drawable = drawables.get(i);        RectF bounds = drawable.mBounds;finalintsavedLayer = canvas.saveLayer(0,0, mContentSize, mContentSize,null, Canvas.ALL_SAVE_FLAG);// 设置Drawable的边界drawable.mDrawable.setBounds((int) bounds.left, (int) bounds.top,                Math.round(bounds.right), Math.round(bounds.bottom));// 绘制Drawabledrawable.mDrawable.draw(canvas);// 绘制“蒙板”路径,将Drawable绘制的图像“剪”成圆形canvas.drawPath(drawable.mMaskPath, paint);// “剪”出弧形的缺口if(drawable.mHasGap && mGap >0f) {            canvas.drawCircle(drawable.mGapCenterX, drawable.mGapCenterY, gapRadius, paint);        }        canvas.restoreToCount(savedLayer);    }}

    Drawable支持

    既然输入的是Drawable对象,那就不能像Bitmap那样绘制出来就完事了的,除非你不打算支持Drawable的一些功能,如自更新、动画、状态等。

    Drawable自更新和动画Drawable

    Drawable的自更新和动画Drawable(如AnimationDrawableAnimatedVectorDrawable等)都是依赖于Drawable.Callback接口。其定义如下:

    publicinterfaceCallback{/**    * 当drawable需要重新绘制时调用。此时的view应该使其自身失效(至少drawable展示部分失效)    *@paramwho 要求重新绘制的drawable    */voidinvalidateDrawable(@NonNull Drawable who);/**    * drawable可以通过调用该方法来安排动画的下一帧。    *@paramwho 要预定的drawable    *@paramwhat 要执行的动作    *@paramwhen 执行的时间(以毫秒为单位),基于android.os.SystemClock.uptimeMillis()    */voidscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what,longwhen);/**    * drawable可以通过调用该方法来取消先前通过scheduleDrawable(Drawable, Runnable, long)调度的动作。    *@paramwho 要取消预定的drawable    *@paramwhat 要取消执行的动作    */voidunscheduleDrawable(@NonNull Drawable who, @NonNull Runnable what);}

    所以要支持Drawable自更新和动画Drawable,得通过Drawable.setCallback(Drawable.Callback)方法设置Drawable.Callback接口的实现对象才行。好在android.view.View已经实现了这个接口,在设置Drawable的时候调用一下Drawable.setCallback(MyView.this)即可。但需要注意的是,android.view.View实现Drawable.Callback接口的时候都调用了View.verifyDrawable(Drawable)以验证需要显示更新的Drawable是不是自己的Drawable,且其实现只是验证了View自己的背景和前景:

    protectedbooleanverifyDrawable(@NonNull Drawable who){// ...returnwho == mBackground || (mForegroundInfo !=null&& mForegroundInfo.mDrawable == who);}

    所以只是设置了Callback的话,当Drawable内容改变需要重新绘制时View还是不会更新重绘的,动画需要计划下一帧或者取消一个计划时也不会成功。因此我们也得验证自己的Drawable:

    privatebooleanhasSameDrawable(Drawable drawable){for(DrawableInfo d : mDrawables) {if(d.mDrawable == drawable) {returntrue;        }    }returnfalse;}@OverrideprotectedbooleanverifyDrawable(@NonNull Drawable drawable){returnhasSameDrawable(drawable) ||super.verifyDrawable(drawable);}

    此时,Drawable自更新的支持和动画Drawable的支持基本上是完成了。当然,View不可见和onDetachedFromWindow()时应该是要暂停或者停止动画的,这些在这里就不多说了,可以去看源码(在文章结尾处有链接),主要是调用Drawable.setVisible(boolean, boolean)方法。下面展示一下效果:

    AnimationDrawable

    状态

    一些Drawable是有状态的,它能根据View的状态(按下,选中,激活等)改变其显示内容,如StateListDrawable。要支持View状态的话,其实只要扩展View.drawableStateChanged()View.jumpDrawablesToCurrentState()方法,当View的状态改变的时候更新Drawable的状态就行了:

    // 状态改变时被调用@OverrideprotectedvoiddrawableStateChanged(){super.drawableStateChanged();booleaninvalidate =false;for(DrawableInfo drawable : mDrawables) {        Drawable d = drawable.mDrawable;// 判断Drawable是否支持状态并更新状态if(d.isStateful() && d.setState(getDrawableState())) {            invalidate =true;        }    }if(invalidate) {        invalidate();    }}// 这个方法主要针对状态改变时有过渡动画的Drawable@OverridepublicvoidjumpDrawablesToCurrentState(){super.jumpDrawablesToCurrentState();for(DrawableInfo drawable : mDrawables) {        drawable.mDrawable.jumpToCurrentState();    }}

    效果:

    状态

    好了,到这里控件算是完成了。

    其他效果展示:

    效果1

    效果2

    源代码:https://github.com/YiiGuxing/CompositionAvatar

    我的GitHub:https://github.com/YiiGuxing

    欢迎Star,谢谢!

    相关文章

      网友评论

      本文标题:[Android] 自定义View之仿QQ讨论组头像

      本文链接:https://www.haomeiwen.com/subject/hoppzttx.html