Android自定义View实现QQ气泡效果
QQ气泡效果分析
QQ气泡 = 红色圆 + 消息数字 + 拖拽粘性效果 + 回弹效果 + 跟随移动 + 爆炸效果。
不得不说在这么个小小的UI小球上实现了这么多的效果,QQ是真的很有心呐~
- 根据我们上边拆分出来的公式,我们分别看看每一个效果需要如何去实现:
- 红色圆:canvas.drawCircle
- 消息数字:canvas.drawText
- 拖拽粘性效果:canvas.drawPath、(两条二阶)贝塞尔曲线
- 回弹效果:属性动画
- 跟随移动:OnTouchEvent处理MotionEvent.ACTION_MOVE事件
- 爆炸效果:属性动画
首先我们来看一下最终的效果:
View自定义属性
- 为了提高自定义View的灵活性,我们需要提供几种自定义属性给外部来设置,有如下属性:
- 气泡半径:bubble_radius
- 气泡颜色:bubble_color
- 气泡消息数字:bubble_text
- 气泡消息数字字体大小:bubble_textSize
- 气泡消息数字颜色:bubble_textColor
属性定义
在res -> values下添加如下attrs.xml文件:
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="DragBubbleView"> <attr name="bubble_radius" format="dimension"/> <attr name="bubble_color" format="color"/> <attr name="bubble_text" format="string"/> <attr name="bubble_textSize" format="dimension"/> <attr name="bubble_textColor" format="color"/> </declare-styleable> </resources>
属性读取
我们在View构造函数中读取自定义属性
DragBubbleView.java
public class DragBubbleView extends View { /** * 气泡半径 */ private float mBubbleRadius; /** * 气泡颜色 */ private int mBubbleColor; /** * 气泡消息文字 */ private String mTextStr; /** * 气泡消息文字大小 */ private float mTextSize; /** * 气泡文字颜色 */ private int mTextColor; public DragBubbleView(Context context) { this(context, null); } public DragBubbleView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public DragBubbleView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 读取属性值 readAttributes(context, attrs, defStyleAttr); // 初始化 init(); } private void readAttributes(Context context, AttributeSet attrs, int defStyleAttr){ // 获取自定义属性数组 TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.DragBubbleView, defStyleAttr, 0); // 获取气泡半径 mBubbleRadius = array.getDimension(R.styleable.DragBubbleView_bubble_radius, mBubbleRadius); // 气泡颜色,默认红色 mBubbleColor = array.getColor(R.styleable.DragBubbleView_bubble_color, Color.RED); // 消息数字 mTextStr = array.getString(R.styleable.DragBubbleView_bubble_text); // 字体大小 mTextSize = array.getDimension(R.styleable.DragBubbleView_bubble_textSize, mTextSize); // 消息数字颜色 mTextColor = array.getColor(R.styleable.DragBubbleView_bubble_textColor, Color.WHITE); // 回收属性数组 array.recycle(); } private void init() { //... } }
初始化
接下来需要根据自定义属性值初始化数据,这里需要注意的是,我们是需要绘制两个气泡来完成我们想要的效果:一个是固定在初始位置,在粘性拖拽时大小随粘性距离的增大而减小;另一个是大小不变,位置始终跟随点击位置移动的圆。
初始化两个圆的大小及画笔
DragBubbleView.java
public class DragBubbleView extends View { //... /** * 不动气泡的半径 */ private float mBubFixedRadius; /** * 可动气泡的半径 */ private float mBubMovableRadius; /** * 气泡相连状态最大圆心距离 */ private float mMaxDist; /** * 手指触摸偏移量 */ private static float MOVE_OFFSET; /** * 气泡的画笔 */ private Paint mBubblePaint; /** * 贝塞尔曲线path */ private Path mBezierPath; /** * 绘制文本的画笔 */ private Paint mTextPaint; /** * 文本绘制区域 */ private Rect mTextRect; /** * 爆炸图片的画笔 */ private Paint mBurstPaint; /** * 爆炸绘制区域 */ private Rect mBurstRect; /** * 气泡爆炸的bitmap数组 */ private Bitmap[] mBurstBitmapsArray; /** * 气泡爆炸的图片id数组 */ private int[] mBurstDrawablesArray = { R.mipmap.burst_1, R.mipmap.burst_2, R.mipmap.burst_3, R.mipmap.burst_4, R.mipmap.burst_5 }; private void init() { // 两个圆初始半径一致 mBubFixedRadius = mBubbleRadius; mBubMovableRadius = mBubFixedRadius; // 设置粘性拖拽最大距离 mMaxDist = 8 * mBubbleRadius; // 该值是为了增大气泡对触摸事件的识别范围 MOVE_OFFSET = mMaxDist / 4; // 初始化气泡画笔 // 设置抗锯齿 mBubblePaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 设置画笔颜色 mBubblePaint.setColor(mBubbleColor); // 设置风格为填充 mBubblePaint.setStyle(Paint.Style.FILL); // 初始化贝塞尔曲线Path,用于绘制粘性拖拽的粘性区域 mBezierPath = new Path(); // 初始化文本画笔 // 设置抗锯齿 mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 设置画笔颜色 mTextPaint.setColor(mTextColor); // 设置文本字体大小 mTextPaint.setTextSize(mTextSize); // 绘制文本的矩形区域 mTextRect = new Rect(); // 初始化爆炸画笔 // 设置抗锯齿 mBurstPaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 开启位图滤波处理(可以让图片显示更柔和) mBurstPaint.setFilterBitmap(true); // 初始化爆炸绘制区域 mBurstRect = new Rect(); // 初始化爆炸效果的bitmap数组 mBurstBitmapsArray = new Bitmap[mBurstDrawablesArray.length]; for (int i = 0; i < mBurstDrawablesArray.length; i++) { // 将气泡爆炸的drawable转为bitmap Bitmap bitmap = BitmapFactory.decodeResource(getResources(), mBurstDrawablesArray[i]); mBurstBitmapsArray[i] = bitmap; } } }
初始化两个圆的圆心坐标
我们需要在View的size确定后初始化圆心坐标,所以需要在onSizeChanged中进行初始化。
DragBubbleView.java
public class DragBubbleView extends View { //... /** * 不动气泡的圆心 */ private PointF mBubFixedCenter; /** * 可动气泡的圆心 */ private PointF mBubMovableCenter; @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 设置两个圆心初始位置在View中心位置,也可以通过自定义属性将此属性由外部自由设置 //初始化不动气泡的圆心 if (mBubFixedCenter == null){ mBubFixedCenter = new PointF(w / 2f, h / 2f); } else { mBubFixedCenter.set(w / 2f, h / 2f); } //初始化可动气泡的圆心 if (mBubMovableCenter == null){ mBubMovableCenter = new PointF(w / 2f, h / 2f); } else { mBubMovableCenter.set(w / 2f, h / 2f); } } }
这里用到PointF对象来保存圆心坐标信息,其可以保存一个二维坐标的x和y的值,方便处理。
定义气泡状态
- 气泡总共可以分为四个状态:
- 静止
- 相连(粘性拖拽、回弹状态)
- 分离(跟随触摸运动状态)
- 消失(爆炸状态)
DragBubbleView.java
public class DragBubbleView extends View { // ... /** * 气泡静止 */ private static final int BUBBLE_STATE_DEFAULT = 0; /** * 气泡相连 */ private static final int BUBBLE_STATE_CONNECT = 1; /** * 气泡分离 */ private static final int BUBBLE_STATE_APART = 2; /** * 气泡消失 */ private final int BUBBLE_STATE_DISMISS = 3; /** * 气泡当前状态,默认静止 */ private int mBubbleState = BUBBLE_STATE_DEFAULT; }
- 对于这四个状态,分别需要绘制以下内容:
- 静止状态,一个气泡 + 消息数
- 连接状态,一个气泡 + 消息数 + 贝塞尔曲线 + 原本位置上的气泡(变化的)
- 分离状态,一个气泡 + 消息数
- 消失状态,爆炸效果
可以看到,除了消失状态,都需要绘制一个气泡 + 消息数。
绘制一个气泡 + 消息数
DragBubbleView.java
public class DragBubbleView extends View { // ... @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 连接状态 if (mBubbleState == BUBBLE_STATE_CONNECT) { // ... } // 非消失状态 if (mBubbleState != BUBBLE_STATE_DISMISS) { // 绘制一个大小不变的气泡(可动气泡) canvas.drawCircle(mBubMovableCenter.x, mBubMovableCenter.y, mBubbleRadius, mBubblePaint); // 测量消息数的文本,并将测量数据保存在mTextRect中 mTextPaint.getTextBounds(mTextStr, 0, mTextStr.length(), mTextRect); // 绘制文本在可动气泡的中心(参数位置是绘制区域的左下角的坐标) canvas.drawText(mTextStr, mBubMovableCenter.x - mTextRect.width() / 2f, mBubMovableCenter.y + mTextRect.height() / 2f, mTextPaint); } else if (...) { // ...爆炸效果 } } }
这里需要注意绘制的先后顺序,消息数需要在最后绘制上去,否则可能会被后面的绘制遮盖。所以需要放在最后处理。
接下来我们处理爆炸效果的绘制,方法是每隔一定时间绘制爆炸效果的bitmap数组中的下一个图片。
绘制爆炸效果
DragBubbleView.java
public class DragBubbleView extends View { // ... /** * 当前气泡爆炸图片index */ private int mCurDrawableIndex; @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 连接状态 if (mBubbleState == BUBBLE_STATE_CONNECT) { // ... } if (mBubbleState != BUBBLE_STATE_DISMISS) { // ... } else if (mCurDrawableIndex < mBurstBitmapsArray.length) { // 爆炸效果 // 设置图片绘制区域四点坐标,即可动圆当前位置的矩形区域(left,top,right,bottom) mBurstRect.set( (int)(mBubMovableCenter.x - mBubMovableRadius), (int)(mBubMovableCenter.y - mBubMovableRadius), (int)(mBubMovableCenter.x + mBubMovableRadius), (int)(mBubMovableCenter.y + mBubMovableRadius) ); // 绘制爆炸图片 canvas.drawBitmap(mBurstBitmapsArray[mCurDrawableIndex], null, mBurstRect, mBurstPaint); } } }
我们只需要通过属性动画,不断改变mCurDrawableIndex即可控制绘制内容,从而实现爆炸效果。
绘制连接状态的粘性拖拽
连接状态最重要的部分就是粘性区域的绘制,需要使用两条二阶贝塞尔曲线进行绘制,通过下图我们分析如何得到二阶贝塞尔曲线的起点、终点、控制点的坐标。
粘性拖拽贝塞尔曲线分析
这里的BA、DC就是我们要绘制的二阶贝塞尔曲线,四个端点分别就是起点及终点,而控制点就是两个圆心的连线中心点,也就是G点。而OP则同时是AD和BC的垂直平分线,由此可以得到角POD = 角PBF,而O、P坐标及两个圆的半径均已知,就可以求出五个关键点的坐标。
绘制
DragBubbleView.java
public class DragBubbleView extends View { // ... /** * 两气泡圆心距离 */ private float mDist; @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 连接状态 if (mBubbleState == BUBBLE_STATE_CONNECT) { // 绘制不动气泡 canvas.drawCircle(mBubFixedCenter.x, mBubFixedCenter.y, mBubFixedRadius, mBubblePaint); // 绘制贝塞尔曲线,两个二阶贝塞尔,同一个控制点(圆心连线的中点),两个起点、两个终点 // 控制点坐标,圆心连线中心点 int iAnchorX = (int) ((mBubFixedCenter.x + mBubMovableCenter.x) / 2); int iAnchorY = (int) ((mBubFixedCenter.y + mBubMovableCenter.y) / 2); // Theta是圆心连线在x方向上的夹角,分别求出正弦和余弦值,mDist是两个圆心当前距离 float sinTheta = (mBubMovableCenter.y - mBubFixedCenter.y) / mDist; float cosTheta = (mBubMovableCenter.x - mBubFixedCenter.x) / mDist; //B点,第一条贝尔塞起点 float iBubMovableStartX = mBubMovableCenter.x - sinTheta * mBubMovableRadius; float iBubMovableStartY = mBubMovableCenter.y + cosTheta * mBubMovableRadius; //A点,第一条贝塞尔终点 float iBubFixedEndX = mBubFixedCenter.x - mBubFixedRadius * sinTheta; float iBubFixedEndY = mBubFixedCenter.y + mBubFixedRadius * cosTheta; //D,第二条贝尔塞起点 float iBubFixedStartX = mBubFixedCenter.x + mBubFixedRadius * sinTheta; float iBubFixedStartY = mBubFixedCenter.y - mBubFixedRadius * cosTheta; //C,第二条贝塞尔终点 float iBubMovableEndX = mBubMovableCenter.x + mBubMovableRadius * sinTheta; float iBubMovableEndY = mBubMovableCenter.y - mBubMovableRadius * cosTheta; // 设置贝赛尔曲线路径 // 每一次绘制前重置path mBezierPath.reset(); // 第一条,BA mBezierPath.moveTo(iBubMovableStartX, iBubMovableStartY); mBezierPath.quadTo(iAnchorX, iAnchorY, iBubFixedEndX, iBubFixedEndY); // 第二条,DC mBezierPath.lineTo(iBubFixedStartX, iBubFixedStartY); mBezierPath.quadTo(iAnchorX, iAnchorY, iBubMovableEndX, iBubMovableEndY); // 闭合路径 mBezierPath.close(); // 绘制路径 canvas.drawPath(mBezierPath, mBubblePaint); } // ... } }
设置触摸事件
当手指按下时,开始拖拽,气泡状态变为连接状态;
当手指移动时,处理粘性拖拽(连接状态)和跟随(分离状态);
当手指松开是,处理爆炸效果(消失状态)和回弹(回到静止状态);
手指按下
DragBubbleView.java
public class DragBubbleView extends View { // ... @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // 非消失状态 if (mBubbleState != BUBBLE_STATE_DISMISS){ // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心 mDist = (float) Math.hypot(event.getX() - mBubFixedCenter.x, event.getY() - mBubFixedCenter.y); // 为了方便进行拖拽,增大拖拽识别范围 if (mDist < mBubbleRadius + MOVE_OFFSET){ // 更改为连接状态 mBubbleState = BUBBLE_STATE_CONNECT; } else { // 重置为默认状态 mBubbleState = BUBBLE_STATE_DEFAULT; } } break; case MotionEvent.ACTION_MOVE: // ... break; case MotionEvent.ACTION_UP: // ... break; default: break; } return true; } }
手指移动
DragBubbleView.java
public class DragBubbleView extends View { // ... @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // ... break; case MotionEvent.ACTION_MOVE: // 非静止状态 if (mBubbleState != BUBBLE_STATE_DEFAULT){ // 计算两个圆心的距离,当前触摸位置即为可动圆的圆心 mDist = (float) Math.hypot(event.getX() - mBubFixedCenter.x, event.getY() - mBubFixedCenter.y); //修改可动圆的圆心为触摸点 mBubMovableCenter.x = event.getX(); mBubMovableCenter.y = event.getY(); // 连接状态 if (mBubbleState == BUBBLE_STATE_CONNECT){ if (mDist < mMaxDist - MOVE_OFFSET){ //当拖拽距离在指定范围内,调整不动圆半径 mBubFixedRadius = mBubbleRadius - mDist / 8; } else { //超过指定范围,分离状态 mBubbleState = BUBBLE_STATE_APART; } } // 重绘 invalidate(); } break; case MotionEvent.ACTION_UP: // ... break; default: break; } return true; } }
手指松开
DragBubbleView.java
public class DragBubbleView extends View { // ... @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // ... break; case MotionEvent.ACTION_MOVE: // ... break; case MotionEvent.ACTION_UP: // 连接状态下松开 if (mBubbleState == BUBBLE_STATE_CONNECT) { // 回弹效果 startBubbleRestAnim(); } else if (mBubbleState == BUBBLE_STATE_APART){ // 分离状态下松开 if (mDist < MOVE_OFFSET){ // 距离较近时,回弹,不爆炸 startBubbleRestAnim(); } else { // 爆炸效果 startBubbleBurstAnim(); } } break; default: break; } return true; } /** * 回弹动画 */ private void startBubbleRestAnim() { // PointF估值器(Android封装好的) 起始点(当前可动气泡位置) 终止点(不动气泡的位置) ValueAnimator anim = ValueAnimator.ofObject(new PointFEvaluator(), new PointF(mBubMovableCenter.x, mBubMovableCenter.y), new PointF(mBubFixedCenter.x, mBubFixedCenter.y)); // 设置动画时长 anim.setDuration(200); // 设置插值器,该插值器可以实现回弹效果,5f表示回弹时最大偏移量 anim.setInterpolator(new OvershootInterpolator(5f)); // 设置监听 anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { // 修改当前可动气泡的圆心 mBubMovableCenter = (PointF) animation.getAnimatedValue(); // 重绘 invalidate(); } }); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); // 还原状态为静止 mBubbleState = BUBBLE_STATE_DEFAULT; } }); // 开启动画 anim.start(); } /** * 爆炸动画 */ private void startBubbleBurstAnim() { // 设置当前状态为消失状态 mBubbleState = BUBBLE_STATE_DISMISS; // 初始化动画,0到mBurstBitmapsArray.length的int属性线性插值动画 ValueAnimator anim = ValueAnimator.ofInt(0, mBurstBitmapsArray.length); // 设置动画时长 anim.setDuration(500); // 设置线性插值器 anim.setInterpolator(new LinearInterpolator()); // 设置监听 anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { // 修改当前爆炸效果图片的index mCurDrawableIndex = (int) animation.getAnimatedValue(); // 重绘 invalidate(); } }); // 开启动画 anim.start(); } }
目前为止,我们已经完成了绝大部分工作,并且完成了基本的功能效果,接下来将我们的View添加到布局中去。
使用
MainActivity.java
public class MainActivity extends AppCompatActivity { private DragBubbleView mBubbleView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mBubbleView = findViewById(R.id.bubbleView); } public void reset(View view) { mBubbleView.reset(); } }
activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#999" tools:context=".MainActivity"> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentBottom="true" android:layout_centerHorizontal="true" android:onClick="reset" android:text="重置" android:layout_marginBottom="100dp"/> <com.mkl.pathdetailed.views.DragBubbleView android:id="@+id/bubbleView" android:layout_above="@id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello World!" app:bubble_color="#ff0000" app:bubble_radius="12dp" app:bubble_text="12" app:bubble_textColor="@android:color/white" app:bubble_textSize="12dp"/> </RelativeLayout>
在MainActivity中有一个"重置"按钮,可以让View状态重置,在DragBubbleView.java中添加如下reset方法:
DragBubbleView.java
public class DragBubbleView extends View { // ... public void reset(){ // 重置状态 mBubbleState = BUBBLE_STATE_DEFAULT; // 重置可动气泡圆心位置 mBubMovableCenter = new PointF(mBubFixedCenter.x, mBubFixedCenter.y); // 重绘 invalidate(); } }
顺便提供一下爆炸效果的图片资源:
总结
QQ气泡效果的难点主要是:属性动画、贝塞尔曲线。
曾经分析气泡如何实现时,没想过粘性拖拽的曲线是如何画出来的,因为不懂贝塞尔曲线,更不懂如何使用;没想过需要两个气泡;也没想过回弹效果怎么做。差不多就是啥都没看出来,完全不会做…233333
- Android 自定义View 实现QQ红点拖动删除效果
- android自定义View实现图片上传进度显示(仿手机QQ上传效果)
- Android 自定义View系列之贝塞尔曲线+QQ未读消息拖拽效果实现+水波浪充电效果
- 实现自定义view(2):仿Android QQ多屏幕显示ListView的效果
- Android使用ViewDragHelper实现QQ聊天气泡拖动效果
- Android 自定义View修炼-仿QQ5.0 的侧滑菜单效果的实现
- Android自定义view系列之99.99%实现QQ侧滑删除效果实例代码详解
- Android自定义View实现仿QQ实现运动步数效果
- android自定义View实现图片上传进度显示(仿手机QQ上传效果)
- android自定义View实现图片上传进度显示(仿手机QQ上传效果)
- android 自定义ImageView实现图片手势滑动,多点触摸放大缩小效果
- Android开发之ViewPager实现轮播图(轮播广告)效果的自定义View
- Android 自定义RecyclerView 实现真正的Gallery效果
- Android 自定义View修炼-自定义HorizontalScrollView视图实现仿ViewPager效果
- Android 自定义RecyclerView 实现真正的Gallery效果
- Android实战简易教程<三十三>(自定义View实现控件晃动提示效果)
- android自定义的TextView,实现跑马灯效果
- android 自定义ImageView实现图片手势滑动,多点触摸放大缩小效果
- Android 自定义RecyclerView 实现真正的Gallery效果
- android 自定义ImageView实现图片手势滑动,多点触摸放大缩小效果