本文已发表在掘金,转载请注明出处。
本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
本篇文章我们来实现一个带有弹性滑动效果的自定义View。当然,文章的侧重点是自定义View但也会涉及到View的事件分发以及一些其他方面的知识,例如使用Scroller实现带有阻尼效果的弹性滑动。因此,我相信看完这篇文章你不仅能学到自定义View的相关知识,还会了解到View的事件分发!还是老规矩,看下最终实现效果。
[外链图片转存失败(img-UgGKctGf-1564892675011)(https://github.com/zhpanvip/OvalLockView/raw/master/image/new.gif)]
分析图中效果会发现其核心功能类似于一个简单的下拉刷新、上拉加载的框架,但又有区别。开始前还是先来罗列一下几个核心步骤,如下:
一. 明确需求,确定对外开放的接口
二. 分析滑动效果,初步实现控件布局
三. 关于滑动,不得不说的事件分发
四. 实现自定义CircleWaveView
首先应该明确控件的需求,确定有哪些功能,然后做针对性开发。这里先贴出该控件的使用方法,也是为了更好地认识控件的需求。
1.布局文件添加
**2.设置操作的监听事件。**代码如下:
mLockView.setOnLockOperateListener(new OnLockOperateListener() {
@Override
public void onLockPrepared() {// 上锁就绪
}
@Override
public void onUnLockPrepared() {// 开锁就绪
}
@Override
public void onLockStart() {// 开始上锁
}
@Override
public void onUnlockStart() {// 开始开锁
}
@Override
public void onNotPrepared() {// 上下滑动距离未达到就绪状态
}
});
3.对外开放接口
// 设置蓝牙是否连接
mLockView.setBluetoothConnect(false);
// 设置上锁状态
mLockView.setLockState(isLock);
// 设置View是否可以滑动
mLockView.setCanSlide(true)
// 设置滑动阻尼大小
mLockView.setDamping(1.7)
// 设置View中心文字
mLockView.setText("已上锁");
// 设置中心大圆的颜色
mLockView.setCircleColor
// 开启心跳动画
mLockView.startWave();
// 停止心跳动画
mLockView.stopWave();
// 是否正在搜索/连接蓝牙
mLockView.connecting(true);
// 点击事件监听(只有在未连接蓝牙时有效)
mLockView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});
我们来总结下控件中需要实现的功能:
以上几点就是我们要完成的核心功能,有了需求之后就直接进入主题来实现我们想要的效果吧。
分析上图的效果发现,中间的View是可滑动的,且覆盖在上下小圆点的上面。对于这种效果直接继承View实现起来会不太方便。因此我们可以想到利用自定义ViewGroup来布局页面。这么一来使开发简单了许多。那么接下来先新建一个layout_oval_lock.xml的布局为文件,并采用FrameLayout来布局控件,这样就实现了层次叠加效果,FrameLayout内部是两个自定义View,我们可以暂且搁置不管,后面会讲到如何实现。布局文件如下:
接下来新建一个LockView类并继承FrameLayout。LockView与 上边layout_oval_lock的布局文件关联,并重写相应的方法。代码如下:
public LockView(Context context) {
this(context, null);
}
public LockView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LockView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
View view = View.inflate(context, R.layout.layout_oval_lock, this);
mCircleWaveView = (CircleWaveView) view.findViewById(R.id.circle_wave_view);
mCircleView = (CircleView) view.findViewById(R.id.green_cv);
distance = ((LayoutParams) mCircleView.getLayoutParams()).topMargin;
mProgressBar = (ProgressBar) view.findViewById(R.id.progress);
mScroller = mCircleWaveView.getScroller();
mContext = context;
mCircleWaveView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
}
});
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
View view = getChildAt(0);
view.layout(0, 0, getMeasuredWidth(), getMeasuredHeight());
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
}
接下来就要来处理中心View的滑动了!说到滑动,避免不了的就应该想到Android中View的事件分发,那么对于滑动事件的处理我们需要重写三个方法。我想很多小伙伴肯定已经想到了!没错,就是事件分发的三个核心方法:dispatchTouchEvent、onInterceptTouchEvent、以及onTouchEvent。我觉得还是先简单来了解一下这三个方法吧,因为它确实挺重要的。
首先来看LockView中重写的dispatchTouchEvent方法中的代码:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!canSlide)
switch (ev.getAction()) {
case ACTION_DOWN:
timestamp = System.currentTimeMillis();
break;
case ACTION_UP:
if (System.currentTimeMillis() - timestamp < 500) {
performClick();
return true;
}
break;
}
return super.dispatchTouchEvent(ev);
}
上面提到,只要有事件传递到当前的ViewGroup那么dispatchTouchEvent就会首先被调用!因此在这个方法里先来判断当前是否是可以滑动状态(蓝牙未连接时不可滑动)。如果不可以滑动,那么就去处理点击事件,我们认为ACTION_DOWN和ACTION_UP之间间隔小于500毫秒就是一次点击事件,那么就在此处调用performClick方法并消费掉当前事件,如果间隔大于500毫秒,不认为是点击事件,那么紧接着就去调用父类的dispatchTouchEvent方法。如果当前可以滑动,那么同样调用父类的dispatchTouchEvent方法来处理。
接下来我们看重写的onInterceptTouchEvent方法
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = true;
int y = (int) ev.getY();
switch (ev.getAction()) {
case ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(y - mLastY) > mTouchSlop) {
intercepted = true;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastY = y;
return intercepted;
}
在这个方法中我们重点来看ACTION_MOVE的时候,在这里先判断了滑动的距离是否大于mTouchSlop,这个值是认为滑动的最小距离,当大于这个值的时候就认为是滑动了。那么看此时intercepted返回了true,表示要拦截这个事件!此处拦截了这个滑动事件会怎么样呢?答案是当前View中的onTouchEvent方法被调用了!现在请将我们的目光聚焦到onTouchEvent方法中,注意前方高能!
核心中最核心的onTouchEvent方法
@Override
public boolean onTouchEvent(MotionEvent event) {
int y = (int) event.getY();
int scrollY = mCircleWaveView.getScrollY();
switch (event.getAction()) {
case ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
if (!canSlide) {
return super.onTouchEvent(event);
}
int deltaY = (int) ((mLastY - y) / damping);
if (mCircleWaveView.getScrollY() > mTouchSlop) {
mOption = Option.LOCK;
} else if (mCircleWaveView.getScrollY() < -mTouchSlop) {
mOption = Option.UNLOCK;
}
if (Math.abs(scrollY) > (distance - mCircleWaveView.getRadius() + mCircleView.getRadius())) {
if (mOption != null) {
switch (mOption) {
case LOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onLockPrepared();
mCircleWaveView.setLockPrepared(true);
break;
case UNLOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onUnLockPrepared();
mCircleWaveView.setUnLockPrePared(true);
break;
}
}
} else {
mCircleWaveView.setUnLockPrePared(false);
mCircleWaveView.setLockPrepared(false);
mOnLockOperateListener.onNotPrepared();
/* if (isLock()) {
mCircleWaveView.setText(mContext.getResources().getString(R.string.device_control_unlock));
} else {
mCircleWaveView.setText(mContext.getResources().getString(R.string.device_control_lock));
}*/
// isOperating = false;
}
/**
* 控制滑动边界
*/
int border = (distance - mCircleWaveView.getRadius() + mCircleView.getRadius()) +
DensityUtils.dp2px(mContext, 25);// 可上下滑动的最大距离
// 当前上下滑动的距离
int slideHeight = deltaY + mCircleWaveView.getScrollY();
if (slideHeight > border) {
mCircleWaveView.scrollTo(0, border);
return true;
} else if (slideHeight + border < 0) {
mCircleWaveView.scrollTo(0, -border);
return true;
}
mCircleWaveView.scrollBy(0, deltaY);
break;
case MotionEvent.ACTION_UP:
mCircleWaveView.setUnLockPrePared(false);
mCircleWaveView.setLockPrepared(false);
scrollY = mCircleWaveView.getScrollY();
if (Math.abs(scrollY) > (distance - mCircleWaveView.getRadius() + mCircleView.getRadius()) && mOption != null) {
switch (mOption) {
case LOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onLockStart();
break;
case UNLOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onUnlockStart();
break;
}
}
mCircleWaveView.smoothScroll(0, 0);
break;
}
mLastY = y;
return super.onTouchEvent(event);
}
看到这个方法中这么多代码不知道各位是否已经懵逼?(好吧,我承认,这地方代码写的确实比较乱)不过没关系,其实细细分析来还是不难理解的!同样,我们选择比较重要的点来看。首先来看ACTION_MOVE的时候,在这里先判断了是否可以滑动(其实不可以滑动的情况下应该不会走到这个方法,但是为了严谨还是加了判断),如果不能滑动则下边的逻辑全都不会再走了。接下来
通过判断滑动的方向来确定是要开锁还是上锁,并根据滑动距离来给出回调处理。即当中心圆CircleWaveView向上或向下滑动并完全覆盖到上/下的小圆点时则会回掉上锁就绪或者开锁就绪(onLockPrepared、onUnLockPrepared)的方法。此时释放CircleWaveView,则会回调开锁或者上锁(onLockStart、onUnlockStart)的方法。如果CircleWaveView在完全覆盖到上/下的小圆点的状态下,再向反方向滑动至未完全覆盖小圆点,此时则会回掉未就绪(onNotPrepared)的方法。下边贴一下回调接口,一共五个方法,如下:
public interface OnLockOperateListener {
// 上锁就绪
void onLockPrepared();
// 开锁就绪
void onUnLockPrepared();
// 开始上锁
void onLockStart();
// 开始开锁
void onUnlockStart();
// 未就绪
void onNotPrepared();
}
接下来是通过一系列计算来控制CircleWaveView的滑动边界。思路大致如此:首先根据CircleWaveView和上下小圆的位置来计算出可上下滑动的最大距离border。然后计算当CircleWaveView滑动的距离超过border时就强制将其滚动到border位置,已达到固定的效果。代码如下:
/**
* 控制滑动边界
*/
int border = (distance - mCircleWaveView.getRadius() + mCircleView.getRadius()) +
DensityUtils.dp2px(mContext, 25);// 可上下滑动的最大距离
int deltaY = (int) ((mLastY - y) / damping);
// 当前上下滑动的距离
int slideHeight = deltaY + mCircleWaveView.getScrollY();
if (slideHeight > border) {
mCircleWaveView.scrollTo(0, border);
return true;
} else if (slideHeight + border < 0) {
mCircleWaveView.scrollTo(0, -border);
return true;
}
mCircleWaveView.scrollBy(0, deltaY);
然后是实现CircleWaveView的弹性滑动,这里我们给CircleWaveView加了一个弹性滑动和阻尼效果。弹性滑动是在CircleWaveView中通过Scroller来实现的,CircleWaveView暴漏出来smoothScroll的弹性滑动接口供在LockView中调用。这点我们在后面讲解CircleWaveView时再说。而阻尼滑动则是将原滑动距离除以阻尼系数以减小滑动距离从而产生阻尼效果。
最后来看ACTION_UP,同样是根据Y轴滑动距离与滑动方向回掉对应的方法,并将CircleWaveView恢复到原位。代码如下:
scrollY = mCircleWaveView.getScrollY();
if (Math.abs(scrollY) > (distance - mCircleWaveView.getRadius() + mCircleView.getRadius()) && mOption != null) {
switch (mOption) {
case LOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onLockStart();
break;
case UNLOCK:
if (mOnLockOperateListener != null)
mOnLockOperateListener.onUnlockStart();
break;
}
mCircleWaveView.smoothScroll(0, 0);
关于自定义CircleWaveView就不具体来讲了,因为关于自定义View都是一样的步骤。这里我们只选取几个重要的地方来说。1.CircleWaveView中内容的绘制。2.关于弹性滑动的实现。3.心跳动画的实现以及状态改变的扩散动画。
1.CircleWaveView中内容的绘制。
**绘制主体圆。**主要分为几种情况:
a.蓝牙未连接,且未能获取到网络数据,背景色为灰色。
b.蓝牙未连接,且能获取到网络数据,背景色为淡绿色或淡红色。
c.蓝牙已连接,开锁状态为绿色,未开锁状态为红色。
d.上拉上锁就绪状态为深红色,下拉开锁就绪状态为深绿色。
结合以上需求有如下代码:
private void drawCircle(Canvas canvas) {
mPaint.setColor(circleColor);
int verticalCenter = getHeight() / 2;
int horizontalCenter = getWidth() / 2;
int mRadius = Math.min(verticalCenter, horizontalCenter) - Math.min(verticalCenter, horizontalCenter) / 5;
radius = Math.min(verticalCenter, horizontalCenter) - Math.min(verticalCenter, horizontalCenter) / 5;
if (transforming) {
mPaint.setColor(getResources().getColor(R.color.green));
canvas.drawCircle(mPieCenterX, mPieCenterY, mRadius, mPaint);
mRadius = isLock ? transformDelta : mRadius - transformDelta;
mPaint.setColor(getResources().getColor(R.color.red));
canvas.drawCircle(mPieCenterX, mPieCenterY, mRadius, mPaint);
} else {
mRadius = mRadius - waveDelta;
if (!isBluetoothConnect) {
if (isNoNetData) {
mPaint.setColor(getColor(R.color.gray));
} else
mPaint.setColor(isLock ? getColor(R.color.redLight) : getColor(R.color.greenLight));
} else {
if (isLockPrepared) {
mPaint.setColor(getColor(R.color.redDark));
} else if (isUnLockPrePared) {
mPaint.setColor(getColor(R.color.greenDark));
} else {
mPaint.setColor(isLock ? getColor(R.color.red) : getColor(R.color.green));
}
}
canvas.drawCircle(mPieCenterX, mPieCenterY, mRadius, mPaint);
}
}
绘制CircleWaveView中上下箭头。 关于箭头绘制,注释部分是通过Path来绘制的,但是发现效果不太好,绘制三角形圆角比较麻烦,所以后台改为了直接在canvas上绘制Bitmap来实现。代码如下:
// 绘制圆中两个三角
private void drawTriangle(Canvas canvas) {
int left = (mWidth - arrowUp.getWidth()) / 2;
canvas.drawBitmap(arrowUp, left, mHeight / 2 - radius + dp13, mPaint);
canvas.drawBitmap(arrowDown, left, mHeight / 2 + radius - dp13 - arrowDown.getHeight(), mPaint);
/*int radius = Math.min(mHeight, mWidth) / 2 - Math.min(mHeight, mWidth) / 8;
mPaintTrangel.setStyle(Paint.Style.FILL);
mPaintTrangel.setShadowLayer(4, 0, 3, Color.GRAY);
// 三角形顶点到圆边的距离
int h0 = DensityUtils.dp2px(mContext, 10);
// 三角形高
int h1 = DensityUtils.dp2px(mContext, 12);
// 三角形底边长
int w = DensityUtils.dp2px(mContext, 14);
mPaintTrangel.setColor(getResources().getColor(R.color.transparent_33));
mPath.moveTo(mWidth / 2, mHeight / 2 - (radius - h0));
mPath.lineTo(mWidth / 2 - w, mHeight / 2 - (radius - h1 - h0));
mPath.lineTo(mWidth / 2 + w, mHeight / 2 - (radius - h1 - h0));
canvas.drawPath(mPath, mPaintTrangel);
mPaintTrangel.setShadowLayer(4, 0, -3, Color.GRAY);
mPath.moveTo(mWidth / 2, mHeight / 2 + (radius - h0));
mPath.lineTo(mWidth / 2 - w, mHeight / 2 + (radius - h1 - h0));
mPath.lineTo(mWidth / 2 + w, mHeight / 2 + (radius - h1 - h0));
canvas.drawPath(mPath, mPaintTrangel);*/
}
绘制CircleWaveView中心的文字 中心文字分为两种情况:当蓝牙未连接时,显示为两行。当蓝牙已连接时显示为一行。绘制思路是先计算出中心基线,然后再来分情况实现。代码如下:
// 绘制圆中的文字
private void drawText(Canvas canvas) {
if (isConnecting) return;
if (TextUtils.isEmpty(mText)) { // 绘制单行文字
String text = mContext.getResources().getString(R.string.ble_not_connect);
canvas.drawText(text, mPieCenterX, getBaseline(text), mPaintText);
return;
}
if (isBluetoothConnect) { // 绘制单行文字
canvas.drawText(mText, mPieCenterX, getBaseline(mText), mPaintText);
} else { // 绘制两行文字
String text = mContext.getResources().getString(R.string.ble_not_connect);
int baseline = getBaseline(text);
canvas.drawText(text, mPieCenterX, baseline - 30, mPaintText);
mPaintText.setTextSize(DensityUtils.dp2px(mContext, 12));
canvas.drawText(mText, mPieCenterX, baseline + 30, mPaintText);
}
}
private int getBaseline(String text) {
mPaintText.setTextSize(mTextSize);
mPaintText.getTextBounds(text, 0, text.length(), bounds);
Paint.FontMetricsInt fontMetricsInt = mPaintText.getFontMetricsInt();
return (getMeasuredHeight() - fontMetricsInt.bottom + fontMetricsInt.top) / 2
- fontMetricsInt.top;
}
2.关于弹性滑动的实现。 关于滑动,通常我们会想到用ScrollTo或ScrollBy来实现。但由于这两个方法实现滑动都是瞬间完成的,因此滑动看起来会比较生硬,体验也很不好。因此想到可以使用Scroller来做一个滑动延迟实现带有弹性效果的滑动。由于Scroller本身时无法实现滑动的,因此还必须配合computeScroll方法共同完成。这里我们封装一个smoothScroll方法,提供给LockView调用。具体代码如下:
public void smoothScroll(int destX, int destY) {
int scrollY = getScrollY();
int delta = destY - scrollY;
mScroller.startScroll(destX, scrollY, 0, delta, 400);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
3.心跳动画的实现以及状态改变的扩散动画。
首先来看心跳动画的实现,这里使用属性动画,首先为ValueAnimator设置从0到1再到0的一个数值变化,并且周期时间设置为600毫秒、重复次数设置为ValueAnimator.INFINITE,即为无限次循环。通过ValueAnimator中不断变化的value来计算圆半径的大小,并通过invalidate()方法不断重绘View,从而达到一个心跳动画的效果。代码如下:
// 开始心跳动画
public void startWave() {
if (animator != null && animator.isRunning())
animator.end();
animator = ValueAnimator.ofFloat(0f, 1f, 0f);
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.setRepeatMode(ValueAnimator.RESTART);
animator.setDuration(600);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int verticalCenter = getHeight() / 2;
int horizontalCenter = getWidth() / 2;
waveDelta = (int) (Math.min(verticalCenter, horizontalCenter) * (float) animation.getAnimatedValue() / 16);
invalidate();
}
});
animator.start();
}
// 停止心跳动画
public void stopWave() {
if (animator != null && animator.isRunning())
animator.end();
}
接下来看状态改变时扩散动画的实现,其实方法和心跳动画一样,都是采用ValueAnimator动态计算绘制圆的半径,不在赘述。参考如下代码:
public void changeLockState(final boolean lock) {
stopWave();
if (this.isLock != lock) {
transforming = true;
ValueAnimator valueAnimator = ValueAnimator.ofFloat(0f, 0.99f);
valueAnimator.setDuration(500);
valueAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
transforming = false;
isLock = lock;
invalidate();
}
@Override
public void onAnimationCancel(Animator animation) {
transforming = false;
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int verticalCenter = getHeight() / 2;
int horizontalCenter = getWidth() / 2;
transformDelta = (int) ((Math.min(verticalCenter, horizontalCenter) - Math.min(verticalCenter, horizontalCenter) / 6)
* (float) animation.getAnimatedValue());
invalidate();
}
});
valueAnimator.start();
}
}
至此,关于LockView的绘制到这里就完全结束了。回顾一下本篇文章,重讲解了自定义LockView以及弹性滑动实现,然后探讨了关于事件分发的一些知识以及使用属性动画来实现心跳和扩散效果。相信看完本篇文章的小伙伴也会有不小的收获,最后关于源码,已放在文章末尾,欢迎start、forck!
源码链接