package com.netease.nim.uikit.common.ui.drop; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.text.TextPaint; import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; import android.view.animation.Animation; import android.view.animation.CycleInterpolator; import android.view.animation.TranslateAnimation; import com.netease.nim.uikit.common.util.sys.ScreenUtil; import java.util.ArrayList; import java.util.List; /** * 悬浮在屏幕上的红点拖拽动画绘制区域 * <p> * Created by huangjun on 2016/9/13. */ public class DropCover extends View { public interface IDropCompletedListener { void onCompleted(Object id, boolean explosive); } private final float MAX_RATIO = 0.8f; // 固定圆最大的缩放比例 private final float MIN_RATIO = 0.4f; // 固定圆最小的缩放比例 private final int DISTANCE_LIMIT = ScreenUtil.dip2px(70); // 固定圆和移动圆的圆心之间的断裂距离 private static final int SHAKE_ANIM_DURATION = 150; // 抖动动画执行的时间 private static final int EXPLOSION_ANIM_FRAME_INTERVAL = 50; // 爆裂动画帧之间的间隔 private static final int CLICK_DISTANCE_LIMIT = ScreenUtil.dip2px(15); // 不超过此距离视为点击 private static final int CLICK_DELTA_TIME_LIMIT = 10; // 超过此时长需要爆裂 private View dropFake; private Path path = new Path(); private int radius; // 移动圆形半径 private float curX; // 当前手指x坐标 private float curY; // 当前手指y坐标 private float circleX; // 固定圆的圆心x坐标 private float circleY; // 固定圆的圆心y坐标 private float ratio = 1; // 圆缩放的比例,随着手指的移动,固定的圆越来越小 private boolean needDraw = true; // 是否需要执行onDraw方法 private boolean hasBroken = false; // 是否已经断裂过,断裂过就不需要再画Path了 private boolean isDistanceOverLimit = false; // 当前移动圆和固定圆的距离是否超过限值 private boolean click = true; // 是否在点击的距离限制范围内,超过了clickDistance则不属于点击 private long clickTime; // 记录down的时间点 private String text; // 显示的数字 private Bitmap[] explosionAnim; // 爆裂动画位图 private boolean explosionAnimStart; // 爆裂动画是否开始 private int explosionAnimNumber; // 爆裂动画帧的个数 private int curExplosionAnimIndex; // 爆裂动画当前帧 private int explosionAnimWidth; // 爆裂动画帧的宽度 private int explosionAnimHeight; // 爆裂动画帧的高度 private List<IDropCompletedListener> dropCompletedListeners; // 拖拽动作完成,回调 /** * ************************* 绘制 ************************* */ public DropCover(Context context, AttributeSet attrs) { super(context, attrs); DropManager.getInstance().initPaint(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 绘制两个圆/Path/文本 if (needDraw) { drawCore(canvas); } // 爆裂动画 if (explosionAnimStart) { drawExplosionAnimation(canvas); } } private void drawCore(Canvas canvas) { if (!needDraw) { return; } final Paint circlePaint = DropManager.getInstance().getCirclePaint(); // 画固定圆(如果已经断裂过了,就不需要画固定圆了) if (!hasBroken && !isDistanceOverLimit) { canvas.drawCircle(circleX, circleY, radius * ratio, circlePaint); } // 画移动圆和连线(如果已经断裂过了,就不需要再画Path了) if (curX != 0 && curY != 0) { canvas.drawCircle(curX, curY, radius, circlePaint); if (!hasBroken && !isDistanceOverLimit) { drawPath(canvas); } } // 数字要最后画,否则会被连线遮掩 if (!TextUtils.isEmpty(text)) { final float textMove = DropManager.getInstance().getTextYOffset(); final TextPaint textPaint = DropManager.getInstance().getTextPaint(); if (curX != 0 && curY != 0) { // 移动圆里面的数字 canvas.drawText(text, curX, curY + textMove, textPaint); } else { // 只有初始时需要绘制固定圆里面的数字 canvas.drawText(text, circleX, circleY + textMove, textPaint); } } } /** * 画固定圆和移动圆之间的连线 */ private void drawPath(Canvas canvas) { path.reset(); float distance = (float) distance(circleX, circleY, curX, curY); // 移动圆和固定圆圆心之间的距离 float sina = (curY - circleY) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的sin值 float cosa = (circleX - curX) / distance; // 移动圆圆心和固定圆圆心之间的连线与X轴相交形成的角度的cos值 float AX = circleX - sina * radius * ratio; float AY = circleY - cosa * radius * ratio; float BX = circleX + sina * radius * ratio; float BY = circleY + cosa * radius * ratio; float OX = (circleX + curX) / 2; float OY = (circleY + curY) / 2; float CX = curX + sina * radius; float CY = curY + cosa * radius; float DX = curX - sina * radius; float DY = curY - cosa * radius; path.moveTo(AX, AY); // A点坐标 path.lineTo(BX, BY); // AB连线 path.quadTo(OX, OY, CX, CY); // 控制点为两个圆心的中间点,贝塞尔曲线,BC连线 path.lineTo(DX, DY); // CD连线 path.quadTo(OX, OY, AX, AY); // 控制点也是两个圆心的中间点,贝塞尔曲线,DA连线 canvas.drawPath(path, DropManager.getInstance().getCirclePaint()); } /** * ************************* TouchListener回调 ************************* */ public void down(View fakeView, String text) { this.needDraw = true; // 由于DropCover是公用的,每次进来时都要确保needDraw的值为true this.hasBroken = false; // 未断裂 this.isDistanceOverLimit = false; // 当前移动圆和固定圆的距离是否超过限值 this.click = true; // 点击开始 this.dropFake = fakeView; int[] position = new int[2]; dropFake.getLocationOnScreen(position); this.radius = DropManager.CIRCLE_RADIUS; // 固定圆圆心坐标,固定圆圆心坐标y,需要减去系统状态栏高度 this.circleX = position[0] + dropFake.getWidth() / 2; this.circleY = position[1] - DropManager.getInstance().getTop() + dropFake.getHeight() / 2; // 移动圆圆心坐标 this.curX = this.circleX; this.curY = this.circleY; this.text = text; this.clickTime = System.currentTimeMillis(); // hide fake view, show current dropFake.setVisibility(View.INVISIBLE); // 隐藏固定范围的DropFake this.setVisibility(View.VISIBLE); // 当前全屏范围的DropCover可见 invalidate(); } public void move(float curX, float curY) { curY -= DropManager.getInstance().getTop(); // 位置校准,去掉通知栏高度 this.curX = curX; this.curY = curY; calculateRatio((float) distance(curX, curY, circleX, circleY)); // 计算固定圆缩放的比例 invalidate(); } /** * 计算固定圆缩放的比例 */ private void calculateRatio(float distance) { if (isDistanceOverLimit = distance > DISTANCE_LIMIT) { hasBroken = true; // 已经断裂过了 } // 固定圆缩放比例0.4-0.8之间 ratio = MIN_RATIO + (MAX_RATIO - MIN_RATIO) * (1.0f * Math.max(DISTANCE_LIMIT - distance, 0)) / DISTANCE_LIMIT; } public void up() { boolean longClick = click && (System.currentTimeMillis() - this.clickTime > CLICK_DELTA_TIME_LIMIT); // 长按 // 没有超出最大移动距离&&不是长按点击事件,UP时需要让移动圆回到固定圆的位置 if (!isDistanceOverLimit && !longClick) { if (hasBroken) { // 如果已经断裂,那么直接回原点,显示FakeView onDropCompleted(false); } else { // 如果还未断裂,那么执行抖动动画 shakeAnimation(); } // reset curX = 0; curY = 0; ratio = 1; } else { // 超出最大移动距离,那么执行爆裂帧动画 initExplosionAnimation(); needDraw = false; explosionAnimStart = true; } invalidate(); } public double distance(float x1, float y1, float x2, float y2) { double distance = Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)); if (distance > CLICK_DISTANCE_LIMIT) { click = false; // 已经不是点击了 } return distance; } /** * ************************* 爆炸动画(帧动画) ************************* */ private void initExplosionAnimation() { if (explosionAnim == null) { int[] explosionResIds = DropManager.getInstance().getExplosionResIds(); explosionAnimNumber = explosionResIds.length; explosionAnim = new Bitmap[explosionAnimNumber]; for (int i = 0; i < explosionAnimNumber; i++) { explosionAnim[i] = BitmapFactory.decodeResource(getResources(), explosionResIds[i]); } explosionAnimHeight = explosionAnimWidth = explosionAnim[0].getWidth(); // 每帧长宽都一致 } } private void drawExplosionAnimation(Canvas canvas) { if (!explosionAnimStart) { return; } if (curExplosionAnimIndex < explosionAnimNumber) { canvas.drawBitmap(explosionAnim[curExplosionAnimIndex], curX - explosionAnimWidth / 2, curY - explosionAnimHeight / 2, null); curExplosionAnimIndex++; // 每隔固定时间执行 postInvalidateDelayed(EXPLOSION_ANIM_FRAME_INTERVAL); } else { // 动画结束 explosionAnimStart = false; curExplosionAnimIndex = 0; curX = 0; curY = 0; onDropCompleted(true); // explosive true } } private void recycleBitmap() { if (explosionAnim != null && explosionAnim.length != 0) { for (int i = 0; i < explosionAnim.length; i++) { if (explosionAnim[i] != null && !explosionAnim[i].isRecycled()) { explosionAnim[i].recycle(); explosionAnim[i] = null; } } explosionAnim = null; } } /** * ************************* 抖动动画(View平移动画) ************************* */ public void shakeAnimation() { // 避免动画抖动的频率过大,所以除以10,另外,抖动的方向跟手指滑动的方向要相反 Animation translateAnimation = new TranslateAnimation((circleX - curX) / 10, 0, (circleY - curY) / 10, 0); translateAnimation.setInterpolator(new CycleInterpolator(1)); translateAnimation.setDuration(SHAKE_ANIM_DURATION); startAnimation(translateAnimation); translateAnimation.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { // 抖动动画结束时,show Fake, hide current onDropCompleted(false); } @Override public void onAnimationRepeat(Animation animation) { } }); } /** * ************************* 拖拽动作结束事件 ************************* */ public void addDropCompletedListener(IDropCompletedListener listener) { if (listener == null) { return; } if (dropCompletedListeners == null) { dropCompletedListeners = new ArrayList<>(1); } dropCompletedListeners.add(listener); } public void removeDropCompletedListener(IDropCompletedListener listener) { if (listener == null || dropCompletedListeners == null) { return; } dropCompletedListeners.remove(listener); } public void removeAllDropCompletedListeners() { if (dropCompletedListeners == null) { return; } dropCompletedListeners.clear(); } private void onDropCompleted(boolean explosive) { dropFake.setVisibility(explosive ? View.INVISIBLE : View.VISIBLE); // show or hide fake view this.setVisibility(View.INVISIBLE); // hide current recycleBitmap(); // recycle // notify observer if (dropCompletedListeners != null) { for (IDropCompletedListener listener : dropCompletedListeners) { listener.onCompleted(DropManager.getInstance().getCurrentId(), explosive); } } // free DropManager.getInstance().setTouchable(true); } }