仿QQ个人信息展示页气泡标签效果

目标效果查看:打开QQ->点击个人头像->点击个性名片->选择一个名片->选择效果二。
(仅限 vip及以上用户)

分析效果

  • 进入界面正常展示界面(中间头像,背景图片)
  • 下滑界面依次进行以下动画:
    • 整体高度增加及背景模糊化
    • 头像周围显示光晕
    • 标签从头像中心向周围扩散
  • 标签在一定范围内随机移动
  • 上滑恢复正常界面显示 同时进行以下动画:
    • 整体高度缩小背景去模糊
    • 头像光晕隐藏
    • 标签向头像中心聚集并消失
  • 标签可以拖动,当标签拖动时 头像光晕显示特定动画
  • 标签拖动到头像显示区域时,头像光晕显示另一种特定动画

现在实现的效果


具体实现

分析实现需要的步骤

先分析布局类别分为三个类别:1.最外层的容器 2.气泡标签 3.中间的圆形头像。
下面开始对这三个部分分别进行实现

气泡标签(TagView)

首先对最简单的控件开始,这个控件为一个圆形背景及中间的文字显示区域展示,初看下来可以使用TextView来实现。不过TextView 不好进行测量,而且用到的功能也很简单,所以在这里使用自定义View来实现对于相关文字的绘制使用StaticLayout来辅助实现。

这里对于显示的文字需要做特殊处理,文字分为两个部分:标签名字(最多5个)显示及点赞数量。标签的整体的大小会根据tag中文字的数量来计算。在设置需要显示的信息时初始化StaticLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void setSource(PresentationLayout.Tag tag){
this.tag = tag;
String summary = tag.getTag();
int count = tag.getCount();
String countWithBracket;
if(count<=99){//对于99以上都显示为99+
countWithBracket = "("+count+")";
}else {
countWithBracket = "(99+)";
}
String source;
int width;
if(summary.length()<=3){
source = summary+"\n"+countWithBracket;
width = (int) Math.max(mTextPaint.measureText(countWithBracket),mTextPaint.measureText(summary));

}else {// 长度超过3 则按照 第一行2个来显示
source = summary.substring(0,2)+"\n"+summary.substring(2)+"\n"+countWithBracket;
width = (int) Math.max(mTextPaint.measureText(countWithBracket),mTextPaint.measureText(summary.substring(0,3)));
}
//注意这里mTextPaint 不要设置Align 会造成显示异常
mStaticLayout = new StaticLayout(source,mTextPaint,width, Layout.Alignment.ALIGN_CENTER,1f,0,false);
}

测量时计算出文字区域所需大小及外层绘制圆形所需要的半径大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if(mStaticLayout == null){
super.onMeasure(widthMeasureSpec,heightMeasureSpec);
}else {//不关心parent的属性 来测量高宽
int width = mStaticLayout.getWidth() + originPaddingLeft + originPaddingRight;
int height = mStaticLayout.getHeight() + originPaddingTop + originPaddingBottom;
int diameter = (int) Math.hypot(width, height);
mRadius = 0.5f*diameter;
int paddingLeft = (diameter - width) / 2 + originPaddingLeft;
int paddingRight = (diameter - width) / 2 + originPaddingRight;
int paddingTop = (diameter - height) / 2 + originPaddingTop;
int paddingBottom = (diameter - height) / 2 + originPaddingBottom;
//将控制点设为中心 方便之后动画效果的展示
setPivotX(mRadius);
setPivotY(mRadius);
//设置新的padding大小保证圆形背景的绘制
setPadding(paddingLeft,paddingTop,paddingRight,paddingBottom);
setMeasuredDimension(diameter, diameter);
}
}

绘制效果分为三个部分:圆形背景绘制、圆形边框绘制、文字绘制

1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void onDraw(Canvas canvas) {
canvas.save();
//绘制圆形背景
canvas.drawCircle(mRadius,mRadius,mRadius-mBorderWidth,mBgPaint);
//绘制圆形边框
canvas.drawCircle(mRadius,mRadius,mRadius-mBorderWidth,mBorderPaint);
canvas.translate(getPaddingLeft(),getPaddingTop());
//绘制文字
mStaticLayout.draw(canvas);
canvas.restore();
}

圆形头像(PortraitWrapper)

显示圆形头像的控件很多了,这里使用一个ViewGroup 包裹对应控件 并负责绘制光晕

由于需要知道内部控件显示的半径的大小 所以定义一个接口用来获取这个半径

1
2
3
public interface Circle {
float getRadius();
}

这里圆形头像展示使用CircleImageView使用这个控件 实现Circle接口(Circle 声明了getRadius()用于获取半径)

1
2
3
4
@Override
public float getRadius() {
return mDrawableRadius;
}

这里需要在CircleImageview绘制一层圆形边框及两层光晕,所以对应有3个半径,这里对这3个半径建立对应Property方便动画修改半径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Property<PortraitWrapper, Integer> mFirstHaloProperty = new Property<PortraitWrapper, Integer>(Integer.class, "firstHalo") {
@Override
public Integer get(PortraitWrapper object) {
return object.mFirstHaloRadius;
}

@Override
public void set(PortraitWrapper object, Integer value) {
object.mFirstHaloRadius = value;
ViewCompat.postInvalidateOnAnimation(object);
}
};

Property<PortraitWrapper, Integer> mSecondHaloProperty = new Property<PortraitWrapper, Integer>(Integer.class, "secondHalo") {
@Override
public Integer get(PortraitWrapper object) {
return object.mSecondHaloRadius;
}

@Override
public void set(PortraitWrapper object, Integer value) {
object.mSecondHaloRadius = value;
ViewCompat.postInvalidateOnAnimation(object);
}
};

Property<PortraitWrapper, Integer> mBorderProperty = new Property<PortraitWrapper, Integer>(Integer.class, "borderRadius") {
@Override
public Integer get(PortraitWrapper object) {
return object.mBorderRadius;
}

@Override
public void set(PortraitWrapper object, Integer value) {
if(isWant|| isDesire) {
object.mBorderRadius = value;
ViewCompat.postInvalidateOnAnimation(object);
}
}
};
```

显示动画

```java
ObjectAnimator step1 = ObjectAnimator.ofInt(this, mFirstHaloProperty, mCircleChildRadius + mBorderWidth / 2, (mHaloTotalWidth - mBorderWidth) / 3 + mCircleChildRadius + mBorderWidth / 2);
ObjectAnimator step2 = ObjectAnimator.ofInt(this, mSecondHaloProperty, mCircleChildRadius + mBorderWidth / 2, (mHaloTotalWidth - mBorderWidth) * 2 / 3 + mCircleChildRadius + mBorderWidth / 2);
AnimatorSet set = new AnimatorSet();
set.playTogether(step1, step2);
mShowAnimator = set;

隐藏动画

1
2
3
4
5
ObjectAnimator step1 = ObjectAnimator.ofInt(this, mFirstHaloProperty, mCircleChildRadius + mBorderWidth / 2);
ObjectAnimator step2 = ObjectAnimator.ofInt(this, mSecondHaloProperty, mCircleChildRadius + mBorderWidth / 2);
AnimatorSet set = new AnimatorSet();
set.playTogether(step1, step2);
mHideAnimator = set;

当标签拖动时的动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int increment = mHaloTotalWidth / 3;
ObjectAnimator step1 = ObjectAnimator.ofInt(this, mBorderProperty, mCircleChildRadius, mCircleChildRadius + mBorderWidth / 2 + increment/2, mCircleChildRadius);
ObjectAnimator step2 = ObjectAnimator.ofInt(this, mFirstHaloProperty, mCircleChildRadius, mCircleChildRadius + (mHaloTotalWidth - mBorderWidth) / 3 + increment, mCircleChildRadius);
ObjectAnimator step3 = ObjectAnimator.ofInt(this, mSecondHaloProperty, mCircleChildRadius, mCircleChildRadius + (mHaloTotalWidth - mBorderWidth) * 2 / 3 + increment, mCircleChildRadius);
step1.setRepeatCount(ValueAnimator.INFINITE);
step2.setRepeatCount(ValueAnimator.INFINITE);
step3.setRepeatCount(ValueAnimator.INFINITE);
step1.setDuration(1500);
step2.setDuration(1500);
step2.setStartDelay(400);
step3.setDuration(1500);
step3.setStartDelay(600);
step1.setInterpolator(new AccelerateDecelerateInterpolator());
step2.setInterpolator(new AccelerateDecelerateInterpolator());
step3.setInterpolator(new AccelerateDecelerateInterpolator());
AnimatorSet set = new AnimatorSet();
set.playTogether(step1, step2, step3);
mWantAnimator = set;

当标签拖动到头像中时的动画(这里会有一个颜色alpha值渐渐降低的动画)所以添加修改画笔颜色的Property

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Property<PortraitWrapper,Float> mPaintProperty = new Property<PortraitWrapper,Float>(Float.class,"paintAlpha"){

@Override
public Float get(PortraitWrapper object) {
return 1f;
}

@Override
public void set(PortraitWrapper object, Float value) {
object.mBorderPaint.setColor(covertAlpha(value,mBorderColor));
object.mFirstHaloPaint.setColor(covertAlpha(value,mFirstHaloColor));
object.mSecondHaloPaint.setColor(covertAlpha(value,mSecondHaloColor));
ViewCompat.postInvalidateOnAnimation(object);
}
};


private int covertAlpha(float ratio,int color){
int alpha = (int) (Color.alpha(color)*ratio);
return alpha<<24|(color&0xFFFFFF);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int increment = mHaloTotalWidth/4;
ObjectAnimator step1 = ObjectAnimator.ofInt(this,mBorderProperty,mCircleChildRadius+mBorderWidth/2,mCircleChildRadius+mBorderWidth/2 + increment);
ObjectAnimator step2 = ObjectAnimator.ofInt(this,mFirstHaloProperty,mCircleChildRadius+mBorderWidth/2 + increment,mCircleChildRadius+mBorderWidth/2 + increment+increment);
ObjectAnimator step3 = ObjectAnimator.ofInt(this,mSecondHaloProperty,mCircleChildRadius+mBorderWidth/2 + increment+increment,mCircleChildRadius+mBorderWidth/2 + increment+increment+increment);
ObjectAnimator step4 = ObjectAnimator.ofFloat(this,mPaintProperty,1f,0f);
step1.setDuration(800);
step2.setDuration(800);
step3.setDuration(800);
step4.setDuration(800);
step1.setRepeatCount(ValueAnimator.INFINITE);
step2.setRepeatCount(ValueAnimator.INFINITE);
step3.setRepeatCount(ValueAnimator.INFINITE);
step4.setRepeatCount(ValueAnimator.INFINITE);
step1.setInterpolator(new DecelerateInterpolator(2));
step2.setInterpolator(new DecelerateInterpolator(2));
step3.setInterpolator(new DecelerateInterpolator(2));
AnimatorSet set = new AnimatorSet();
set.playTogether(step1,step2,step3,step4);
mDesireAnimator = set;

外层布局(PresentationLayout)

继承至RelativeLayout

模糊背景及高度变化

为了支持高度变化的动画 添加方法

1
2
3
4
5
6
7
8
9
public void setHeight(int height) {
getLayoutParams().height = height;
if (Build.VERSION.SDK_INT >= 18 ) {
if(!isInLayout()) requestLayout();
} else {
requestLayout();
}
invalidate();
}

之后就可以获取高度的属性动画:

1
ObjectAnimator step1 = ObjectAnimator.ofInt(this, "height", mCollapsedHeight, mExpandHeight);

对于背景模糊 是通过在背景之上添加已经模糊之后的图片 并通过修改模糊图片的Alpha值来实现动画效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 模糊背景操作任务
*/
private Runnable mDoBlurRunnable = new Runnable() {
@Override
public void run() {
Bitmap bitmap = Util.getBitmapFromDrawable(getBackground());
if (mOriginBackground == null) mOriginBackground = getBackground();
if (bitmap != null) {
Bitmap blurBitmap = Util.doBlur(getContext(), bitmap, 10);
Drawable newDrawable = new BitmapDrawable(getResources(), blurBitmap);
mBluredBackground = newDrawable;
Util.setBackground(mBackgroundOverlay, newDrawable);
}
}
};

在初始化之后就View.post(mDoBlurRunnable) 模糊图片。
模糊图片代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 /**
* 模糊图片
* @param context
* @param bitmap 需要模糊的原图片
* @param radius
* @return 模糊后的图片
*/
public static Bitmap doBlur(Context context,Bitmap bitmap,int radius){
Bitmap result = Bitmap.createBitmap(bitmap.getWidth(),bitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(result);
canvas.drawBitmap(bitmap,0,0,null);
RenderScript renderScript = RenderScript.create(context);
Allocation allocation = Allocation.createFromBitmap(renderScript,result);
ScriptIntrinsicBlur blur = ScriptIntrinsicBlur.create(renderScript,allocation.getElement());
blur.setInput(allocation);
blur.setRadius(radius);
blur.forEach(allocation);
allocation.copyTo(result);
return result;
}

标签气泡展示及隐藏

标签的展示及隐藏使用路径动画来实现 路径为二阶Bezier曲线。显示动画 出发点为头像中心点 终点为标签最终要移动到的位置。所有的标签移动到一个圆形的范围中,最多支持7个标签,这7个标签所对应的弧度:

1
private final static double[] RADIUS = new double[]{0.7d, 2.25d, 3.14d, 5.5d, 0.1d, 1.4d, 4.7d};

隐藏则反之。
控制点,显示状况下。为了保证曲线的一致性 在标签显示的圆 内部圆 并加上弧度的偏移量计算对应的控制点。隐藏状况下,由于中心点的移动不能使用内部圆的方式保证曲线的一致性,所以单独进行计算(隐藏效果和QQ不太一致)
显示情况下计算控制点:

1
2
controlX = Math.round(centerX + mInnerRadius * Math.cos(RADIUS[i] + CONTROL_RADIANS_OFFSET));
controlY = Math.round(centerY - mInnerRadius * Math.sin(RADIUS[i] + CONTROL_RADIANS_OFFSET));

隐藏情况下计算控制点:

1
2
3
4
5
6
7
8
//将centerX 和 centerY 看做坐标原点
float x = targetX - centerX;
float y = targetY - centerY;
float scaledX = 0.3f * x;
float scaledY = 0.3f * y;
//计算控制点 保证bezier曲线的导数相同
controlX = centerX + Math.round(scaledX * Math.cos(CONTROL_RADIANS_OFFSET) + scaledY * Math.sin(CONTROL_RADIANS_OFFSET));
controlY = centerY + Math.round(scaledY * Math.cos(CONTROL_RADIANS_OFFSET) - scaledX * Math.sin(CONTROL_RADIANS_OFFSET));

对于AndroidL 以上可以直接使用 path动画:

1
pathAnimator = ObjectAnimator.ofObject(tagView, mTagViewProperty, null, path);

对于AnddroidL 以下使用 PathMeasure 帮助进行path动画

1
pathAnimator = ObjectAnimator.ofObject(tagView, mTagViewProperty, new PathEvaluator(path), new PointF());

TagViewProperty 用于调用TagView 的 setPoint 修改TagView的位置

1
2
3
4
5
6
void setPoint(PointF pointF){
float left = pointF.x - getWidth()/2;
float top = pointF.y - getHeight()/2;
setX(left);
setY(top);
}

对于path 转换为pointF 使用 PathEvaluator 来实现(内部通过PathMeasure计算Path)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class PathEvaluator implements TypeEvaluator<PointF> {
private PathMeasure mPathMeasure;
private float mPathLength;
private float points[] = new float[2];
private PointF mPointF = new PointF();
public PathEvaluator(Path path){
mPathMeasure = new PathMeasure(path,false);
mPathLength = mPathMeasure.getLength();
}


@Override
public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
if(mPathMeasure.getPosTan(mPathLength*fraction,points,null)) {
mPointF.set(points[0], points[1]);
}
return mPointF;
}
}

标签气泡的移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 /**
* 做tag 周围小幅度移动动画
*/
private Runnable mTagWanderRunnable = new Runnable() {
@Override
public void run() {
if (mState == STATE_EXPANDED) {
for (int i = 0; i < mTagViews.size(); i++) {
TagView tagView = mTagViews.get(i);
if (tagView.shouldWander) {
float targetX = mTargets[2*i] - tagView.getWidth()/2;
float targetY = mTargets[2*i+1] - tagView.getHeight()/2;
float x = Math.round(targetX + Math.random()*mThickness - mThickness/2);
float y = Math.round(targetY + Math.random()*mThickness - mThickness/2);
tagView.animate().translationX(x).translationY(y).setDuration(2000);
}
}
ViewCompat.postOnAnimationDelayed(PresentationLayout.this, mTagWanderRunnable, 2000);
}
}
};

标签气泡的拖拽(使用ViewDragHelper)

由于移动的TagView是同过改变TranslationX 及 TranslationY来移动,而ViewDragHelper 是通过 left top right bottom 判断是否点击到相应的View,所以这里不去捕获View

1
2
3
4
5
@Override
public boolean tryCaptureView(View child, int pointerId)
{
return false;
}

之后自行处理触摸事件并主动捕获View:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
mDragHelper.processTouchEvent(event);

switch (MotionEventCompat.getActionMasked(event)) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
if(mState == STATE_EXPANDED){
TagView tagView = findTopTagViewUnder(downX,downY);
if(tagView != null){
mDragHelper.captureChildView(tagView,event.getPointerId(0));
}
}
break;
...
1
2
3
4
5
6
7
8
9
10
11
public TagView findTopTagViewUnder(float x,float y){
for(int i=0;i<getChildCount();i++){
View child = getChildAt(i);
if(child instanceof TagView){
if(x>=child.getX()&&x<=child.getX()+child.getWidth()&&y>=child.getY()&&y<=child.getY()+child.getHeight()){
return (TagView) child;
}
}
}
return null;
}

项目地址

QQPresentation