最近在复习Android,重新捡起自定义控件。
总结一句话,不要用小聪明规避问题,不然你下次遇到了,你还是什么都不懂。
先看效果:
工程以及说明: QQ小红点
首先,看到这个,想到的就是使用贝塞尔曲线了。如果你对怎么绘制不了解,可以先通过启舰大佬,了解QQ小红点怎么实现的。
自定义控件三部曲之绘图篇(十五)——QQ红点拖动删除效果实现(基本原理篇)
不过上面的文章,只能在view级别上使用,就算使用了 clipchild = false,也无法夸层级绘制的,所以这篇文章是在此基础上,让它可以在所有view上都能使用。
思路如下:
- 用一个BezierPointView继承TextView,拿到自定义配置参数,比如大小,颜色
- 在 BezierPointView 的Down事件时,把QQ小红点 (BezierPointWindow) 通过 Windowmanager 添加进来,再把 BezierPointView 的visiable 设置 gone
- 当 BezierPointWindow 的Up 事件时,把座标传递回来,然后移除BezierPointWindow,添加一个ImageView,再在此做自定义的属性动画,或者默认的图片爆炸效果
- 移除windowmanager,让 BezierPointView 显示出来即可。
这里我没有跟启舰大佬那样,用viewgroup来实现,其实用自定义 View 即可实现。实现的原理,启舰大佬已经讲解得很清楚了
我们只要找准一个方向,计算切线即可。如果两个 a 相等呢?相信你的判断两条线是否平行的原理还记得。
既然知道了两个小球的座标和半径,那么切点的座标也很好理解了,然后再计算不断变化的半径和最大断开的长度即可。
/**
* 计算贝塞尔曲线
* 由于Math 的扫脚函数,都自带正负的,所以,只需要计算一种方向即可
* 这里的计算方向为右下
*/
private void calculateBeizer() {
float x0 = mStartPoint.x;
float y0 = mStartPoint.y;
float x = mMovePoint.x;
float y = mMovePoint.y;
//算出夹角
float dx = x - x0;
float dy = y - y0;
double a = Math.atan(dy / dx);
//拿到圆切点的长度偏移量
float offsetx0 = (float) (mDrawRadius * Math.sin(a));
float offsety0 = (float) (mDrawRadius * Math.cos(a));
//拿到第二个小球的偏移量
float offsetx = (float) (mDefaultRadius * Math.sin(a));
float offsety = (float) (mDefaultRadius * Math.cos(a));
//算出第一个圆的切点座标
float p0x = x0 + offsetx0;
float p0y = y0 - offsety0;
float p1x = x0 - offsetx0;
float p1y = y0 + offsety0;
//算出第二个圆的切点座标
float p2x = x + offsetx;
float p2y = y - offsety;
float p3x = x - offsetx;
float p3y = y + offsety;
//计算贝塞尔辅助点
float anchorx = (x0 + x) / 2;
float anchory = (y0 + y) / 2;
//清掉上次,避免残留
mPath.reset();
//形成贝塞尔曲线
mPath.moveTo(p0x, p0y);
mPath.quadTo(anchorx, anchory, p2x, p2y);
mPath.lineTo(p3x, p3y);
mPath.quadTo(anchorx, anchory, p1x, p1y);
mPath.close();
//超过一定距离时,且圆形的半径也要跟着变小
double distance = getDistance(mMovePoint, mStartPoint);
mDrawRadius = (int) (mDefaultRadius - distance / 12);
if (mDrawRadius <= 7) {
mDrawRadius = 7;
}
if (distance >= mMaxMoveLength) {
// 超过一定距离 贝塞尔和固定圆都不要画了
mIsBreakUp = true;
mDrawRadius = 0;
mPath.reset();
return;
}
}
在所有 View 上使用
我们可以通过一个 BezierPointView 拿到自定义属性之后,在 onSizeChange 的时候,配置windowmanger:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mPointHelper = new PointHelper(this);
setOnTouchListener(mPointHelper);
}
而PointHelper 其实是一个 windowmanager 的工具类,继承View.OnTouchListener,这样就可以在 onTouch 方法中,添加windowmanager :
@Override
public boolean onTouch(View v, MotionEvent event) {
ViewParent parent = v.getParent();
if (parent == null) {
return false;
}
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
if (!mWindowView.isCanMove()) {
return false;
}
//接管父控件的touch事件
parent.requestDisallowInterceptTouchEvent(true);
/**
* 当按下时,再把View添加到windowmanager,此时的windowmanager是全屏的;
* 所以,pointview的操作不会因为控件大小的限制,导致绘制不全的问题
*/
addPointToWindow();
}
return mWindowView.onTouchEvent(event);
可以看到 touch之后是交给 BezierPointWindow 的touch 事件,而addPointToWindow 为具体的添加效果。
/**
* 把TextView 的缓存bitmap给BezierPointWindow绘制
*/
private void addPointToWindow() {
if (mContainer == null) {
mContainer = new FrameLayout(mContext);
mContainer.setClipChildren(false);
mContainer.setClipToPadding(false);
mWindowView.setLayoutParams(mLayoutParams);
}
mContainer.removeAllViews();
mContainer.addView(mWindowView, mParams);
mWindowManager.addView(mContainer, mParams);
//初始化座标
mPointView.getLocationInWindow(mPos);
int width = mPointView.getWidth();
int height = mPointView.getHeight();
//设置大小和起始位置
mWindowView.initPoint(mPos[0] + width / 2, mPos[1] + height / 2);
mWindowView.setPointListener(this);
mWindowView.setVisibility(View.VISIBLE);
//拿到bitmap
mPointView.setDrawingCacheEnabled(true);
//拿到TextView的缓存bitmap,给BezierPointWindow 绘制,也为后面up的 imageview 当做背景图
Bitmap bitmap = Bitmap.createBitmap(mPointView.getDrawingCache());
mPointView.setDrawingCacheEnabled(false);
mWindowView.setBitmap(bitmap);
}
为了避免闪烁问题,我们需要当 BezierPointWindow 绘制好 bitmap 之后,再让BezierPointView 消失:
@Override
public void onDrawReady() {
//这个时候,可以消失了,避免闪烁
mPointView.setVisibility(View.GONE);
}
而在 up 之后,之后,只需要添加一个 ImageView,实现要消失的动画即可:
@Override
public void destroy(PointF pointF) {
//移除所有
mContainer.removeAllViews();
//添加imageview,用于动画
mContainer.addView(mImageView, mLayoutParams);
//指定初始位置
mImageView.setX(pointF.x - mPointView.getWidth() * 1.0f / 2);
mImageView.setY(pointF.y - mPointView.getHeight() * 1.0f / 2);
if (mPointView.isUseSelfAnim()){
//使用自定义动画
mImageView.setImageBitmap(mWindowView.getBitmap());
if (mPointView.getListener() != null) {
mPointView.getListener().destory(mImageView);
}
}else{
//使用布局标签的属性动画
if (mPointView.getAnimatorRes() != -1){
mImageView.setImageBitmap(mWindowView.getBitmap());
mAnimator = AnimatorInflater.loadAnimator(mContext, mPointView.getAnimatorRes());
mAnimator.setTarget(mImageView);
mAnimator.start();
mAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
removeView();
}
});
}else{
//使用默认动画,即图片爆炸
mImageView.setBackgroundResource(R.drawable.anim_blow);
mAnimationDrawable = (AnimationDrawable) mImageView.getBackground();
mAnimationDrawable.start();
mImageView.postDelayed(new Runnable() {
@Override
public void run() {
removeView();
}
},getAnimationDrawableTime(mAnimationDrawable));
}
}
}
需要注意的是,最后需要使用 removeView 方法,不然这层 windowmanager 的view 不会消失:
/**
* 移除View,这个必须要实现,否则就有一层view在顶层,啥也操作不了
*/
public void removeView() {
if (mWindowManager != null) {
if (mContainer != null && mContainer.isAttachedToWindow()) {
mWindowManager.removeView(mContainer);
}
}
//还原set属性,防止view被设置动画,初始位置错乱
mWindowView.clearAnimation();
mWindowView.setAlpha(1);
mWindowView.setX(0);
mWindowView.setY(0);
mWindowView.setScaleX(1);
mWindowView.setScaleY(1);
mWindowView.setVisibility(View.GONE);
if (mContainer != null && mContainer.getChildCount() > 0) {
mContainer.removeView(mWindowView);
}
}