之前就看了一点关于阻尼效果的,也尝试了一下在ListView上加个HeaderView呈现阻尼效果
ListView添加阻尼效果的HeaderView
当时的思路是:下拉的时候改变图片控件的LayoutParams,然后设置一个最大阈值;
那个是继承ListView,然后在上面加的一个头;这次要换一种方式,要模仿IOS继承ScrollView使之上顶下底有弹性滑动,当然,我都是从各大神那偷来的,这里我就照搬了。
其实这种实现网上有很多,大多都带有一种套路性,只要知道了套路,那就是江湖任我飘。
public class BounceScrollView extends ScrollView {
@Override
protected void onFinishInflate() {
if (getChildCount() > 0) {
mView = getChildAt(0);
}
super.onFinishInflate();
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (mView != null) {
commonOnTouch(ev);
}
return super.onTouchEvent(ev);
}
private void commonOnTouch(MotionEvent ev) {
int action = ev.getAction();
int cy = (int) ev.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int dy = cy - y;
if (isFirst) {
dy = 0;
isFirst = false;
}
y = cy;
if (isNeedMove()) {
if (mRect.isEmpty()) {
mRect.set(mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom());
}
mView.layout(mView.getLeft(), mView.getTop() + dy / 5,
mView.getRight(), mView.getBottom() + dy / 5);
}
break;
case MotionEvent.ACTION_UP:
if (!mRect.isEmpty()) {
resetPosition();
}
break;
default:
break;
}
}
private boolean shouldCallBack(int dy) {
...
return false;
}
private void resetPosition() {
...
}
public boolean isNeedMove() {
int offset = mView.getMeasuredHeight() - getHeight();
int scrollY = getScrollY();
if (scrollY == 0 || scrollY == offset) {
return true;
}
return false;
}
}
上面的套用代码大概就是这个样子,咱们慢慢走:
1、因为是继承ScrollView,所以熟知的人应该知道它的子View只有一个;
onFinishInflate()
这个方法就用上了,我们要在xml界面解析完成之后来获取这个唯一的子View
private View mView;
@Override
protected void onFinishInflate() {
if (getChildCount() > 0) {
mView = getChildAt(0);
}
super.onFinishInflate();
}
这是第一个套路,都是惯用;
2、获取到唯一的这个子View之后,我们就开始操作了:
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (mView != null) {
commonOnTouch(ev);
}
return super.onTouchEvent(ev);
}
直接跑到了
private void commonOnTouch(MotionEvent ev)
这是第二个套路;
3、点击滑动之前要获取一个当前的y坐标,更要获取点击事件:
int action = ev.getAction();
//当前位置
int cy = (int) ev.getY();
ACTION_DOWN中什么都不处理:
case MotionEvent.ACTION_DOWN:
break;
ACTION_MOVE:主要就在这了;
case MotionEvent.ACTION_MOVE:
//移动的距离
int dy = cy - y;
//第一次移动的时候,因为上次残留的位置信息和当前的并不一定是连贯的,
//可能会有一个很大的跳跃过程。所以从第二次开始记录位移比较准确一点
if (isFirst) {
dy = 0;
isFirst = false;
}
y = cy;
//判断是否到了顶端或者底部需要做阻尼运动的时候
if (isNeedMove()) {
//因为运动,所以需要不停的记录信息并绘制
if (mRect.isEmpty()) {
mRect.set(mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom());
}
//保持阻尼运动的比例差,看起来效果要好一点,有橡皮经效果
mView.layout(mView.getLeft(), mView.getTop() + dy / 5,
mView.getRight(), mView.getBottom() + dy / 5);
if (shouldCallBack(dy)) {
//如果设置了响应条件则响应事件,里面的逻辑可以自己处理
if (callBack != null) {
if (!isCalled) {
isCalled = true;
// 这个处理感觉有点问题,滑到这个位置还没松手就自己弹回去了,体验差
// resetPosition();
callBack.callback();
}
}
}
}
break;
这里cy是事件到来时的最新y坐标,y是cy的上一个坐标,所以dy就是两次坐标的距离差了;
那isFirst是什么呢?试想一下,当你第一次从y轴的50滑动到100的时候起开了,这时候y保留着这个最后值100,这时候你又伸开手指也不知道是从哪,可能是从200开始滑动,这样距离差就是200-100=100,这是一个大跳跃,整个界面直接一晃,把用户吓坏了怎么办?正常的滑动都是连贯的,距离差是非常非常小的才达到人眼所能认知的滑动,一下跳100,估计吓得用户连挂号都排不上队;所以这个isFirst就是起到了一个过滤的作用,把第一次点击产生的距离差设置为0,时间太短,距离太短,人眼识别不出来,从第二次开始,此时y已经是cy的上一个值了,微小的距离差就解决了。
//移动的距离
int dy = cy - y;
//第一次移动的时候,因为上次残留的位置信息和当前的并不一定是连贯的,
//可能会有一个很大的跳跃过程。所以从第二次开始记录位移比较准确一点
if (isFirst) {
dy = 0;
isFirst = false;
}
y = cy;
这段代码至关重要;
这个也算是一个小套路吧;
isNeedMove()
到了这个,那看看吧!
public boolean isNeedMove() {
//滑动距离
int offset = mView.getMeasuredHeight() - getHeight();
int scrollY = getScrollY();
//==0就是在顶部从上往下滑,scrollY == offset就是从底部往上滑
if (scrollY == 0 || scrollY == offset) {
return true;
}
return false;
}
这个不知道我能不能说的懂,mView.getMeasuredHeight()是ScrollView的高度,getHeight()是可视高度,也就是一屏的高度,scrollY是从滑动开始到结束,滑动的长度,往下滑为整数,往上滑是负数;但我们初始化进入这个界面的时候,scrollY肯定是等于0(前提是你不设置个什么),这时候也就是顶端,然后滑动到底端的时候正好scrollY==offset;
画了一张简陋的图,是从顶端滑动到底端的时候,验证这个公式的。
最终的判断条件就是滑动到顶端或者底端的时候是允许阻尼效果呈现的。
这个也算是一个套路
//判断是否到了顶端或者底部需要做阻尼运动的时候
if (isNeedMove()) {
//因为运动,所以需要不停的记录信息并绘制
if (mRect.isEmpty()) {
mRect.set(mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom());
}
//保持阻尼运动的比例差,看起来效果要好一点,有橡皮经效果
mView.layout(mView.getLeft(), mView.getTop() + dy / 5,
mView.getRight(), mView.getBottom() + dy / 5);
if (shouldCallBack(dy)) {
//如果设置了响应条件则响应事件,里面的逻辑可以自己处理
if (callBack != null) {
if (!isCalled) {
isCalled = true;
// 这个处理感觉有点问题,滑到这个位置还没松手就自己弹回去了,体验差
// resetPosition();
callBack.callback();
}
}
}
}
如果阻尼时机到了,有一个判断
mRect.isEmpty()
这是个什么东西呢?mRect是一个View,但是本身的作用并不是提现太他是一个View,他就是一个记录账本,记录这个ScrollView本来的位置信息,在要还原的时候用这个位置信息做参考还原。
if (mRect.isEmpty()) {
mRect.set(mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom());
}
这个也是一个套路
mView.layout(mView.getLeft(), mView.getTop() + dy / 5,
mView.getRight(), mView.getBottom() + dy / 5);
这个是什么,这个就是开始滑动了,但是要达到一种橡皮经效果,那个dy距离差就不能原封不动的使用了,不然看起来App有点骨质疏松的样子,所以我只取了1/5;
这时候就滑动起来了,然后手指松开之后就应该自动返回了。
这也是一个套路;
ACTION_UP:
if (!mRect.isEmpty()) {
resetPosition();
}
调用了
private void resetPosition() {
//这里用的是mView.getTop()和mRect.top计算的是顶部的距离缓冲差,因为是一个整体,所以顶部位移是多少则底部位移就是多少
Animation animation = new TranslateAnimation(0, 0, mView.getTop(), mRect.top);
animation.setDuration(100);
animation.setFillAfter(true);
mView.startAnimation(animation);
//不停的绘制位置
mView.layout(mRect.left, mRect.top, mRect.right, mRect.bottom);
//不停的重置位置信息
mRect.setEmpty();
isFirst = true;
isCalled = false;
}
可能会有人有疑惑,既然是上下阻尼,为什么这个计算中只有上面的:
Animation animation = new TranslateAnimation(0, 0, mView.getTop(), mRect.top);
其实注释已经说了,看看之前的layout,他们是同时作用的,他们的距离差是 一样的;
在动画的作用下,最终回到初始值。然后是一些值得初始还原。
这个也是套路。
这么看来我们就是在套路中走过来,根本就没做什么了不起的壮举,一路飘过来的。这里贴一下源码
package com.xiey94.damp.view;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.TranslateAnimation;
import android.widget.ScrollView;
import com.xiey94.damp.util.showLog;
/**
* @author : xiey
* @project name : As30.
* @package name : com.xiey94.damp.view.
* @date : 2018/1/19.
* @signature : do my best.
* @from : https://www.jianshu.com/p/59c366f27185
* @explain : 整体上下有阻尼效果
*/
public class BounceScrollView extends ScrollView {
private boolean isCalled;
private Callback callBack;
//取ScrollView的第一个子View,也就是整个
private View mView;
//只是用于记录位置
private Rect mRect = new Rect();
//记录y坐标
private int y;
private boolean isFirst = true;
public BounceScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 获取到要实现阻尼效果的View
*/
@Override
protected void onFinishInflate() {
if (getChildCount() > 0) {
mView = getChildAt(0);
}
super.onFinishInflate();
}
/**
* 事件拦截处理
*
* @param ev
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (mView != null) {
commonOnTouch(ev);
}
return super.onTouchEvent(ev);
}
/**
* 具体处理
*/
private void commonOnTouch(MotionEvent ev) {
int action = ev.getAction();
//当前位置
int cy = (int) ev.getY();
switch (action) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
//移动的距离
int dy = cy - y;
//第一次移动的时候,因为上次残留的位置信息和当前的并不一定是连贯的,
//可能会有一个很大的跳跃过程。所以从第二次开始记录位移比较准确一点
if (isFirst) {
dy = 0;
isFirst = false;
}
y = cy;
//判断是否到了顶端或者底部需要做阻尼运动的时候
if (isNeedMove()) {
//因为运动,所以需要不停的记录信息并绘制
if (mRect.isEmpty()) {
mRect.set(mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom());
}
//保持阻尼运动的比例差,看起来效果要好一点,有橡皮经效果
mView.layout(mView.getLeft(), mView.getTop() + dy / 5,
mView.getRight(), mView.getBottom() + dy / 5);
if (shouldCallBack(dy)) {
//如果设置了响应条件则响应事件,里面的逻辑可以自己处理
if (callBack != null) {
if (!isCalled) {
isCalled = true;
// 这个处理感觉有点问题,滑到这个位置还没松手就自己弹回去了,体验差
// resetPosition();
callBack.callback();
}
}
}
}
break;
case MotionEvent.ACTION_UP:
if (!mRect.isEmpty()) {
resetPosition();
}
break;
default:
break;
}
}
/**
* 往下滑到一定距离则有了响应事件的条件,其实到了这里就没必要了,可以直接拿下了
*
* @param dy
* @return
*/
private boolean shouldCallBack(int dy) {
if (dy > 0 && mView.getTop() > getHeight() / 10) {
return true;
}
return false;
}
private void resetPosition() {
//这里用的是mView.getTop()和mRect.top计算的是顶部的距离缓冲差,因为是一个整体,所以顶部位移是多少则底部位移就是多少
Animation animation = new TranslateAnimation(0, 0, mView.getTop(), mRect.top);
animation.setDuration(100);
animation.setFillAfter(true);
mView.startAnimation(animation);
//不停的绘制位置
mView.layout(mRect.left, mRect.top, mRect.right, mRect.bottom);
//不停的重置位置信息
mRect.setEmpty();
isFirst = true;
isCalled = false;
}
public boolean isNeedMove() {
//滑动距离
int offset = mView.getMeasuredHeight() - getHeight();
int scrollY = getScrollY();
//==0就是在顶部从上往下滑,scrollY = offset就是从底部往上滑
if (scrollY == 0 || scrollY == offset) {
return true;
}
return false;
}
public void setCallBack(Callback callBack) {
this.callBack = callBack;
}
public interface Callback {
void callback();
}
private int x;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//很重要
super.onInterceptTouchEvent(ev);
boolean intercept = false;
int cx = (int) ev.getX();
int cy = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int dy = cy - y;
int dx = cx - x;
if (Math.abs(dy) > Math.abs(dx)) {
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
default:
break;
}
y = cy;
x = cx;
showLog.show(intercept);
return intercept;
}
}
大家还发现有一个接口回调,这个是当阻尼滑动到一定距离之后响应一个事件,这个看具体需求,如果你用不到可以不要他。
现在来看第二个样式,是横着的阻尼效果:
这个效果跟之前那个差不多,只不过是横着的,所以代码也差不了多少,基本上一样:
package com.xiey94.damp.view;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.TranslateAnimation;
import android.widget.HorizontalScrollView;
/**
* @author : xiey
* @project name : As30.
* @package name : com.xiey94.damp.view.
* @date : 2018/1/19.
* @signature : do my best.
* @from : http://blog.csdn.net/darling_shadow/article/details/41514233
* @explain :
*/
public class dampHorizontalScrollView extends HorizontalScrollView {
//整个子View
private View inner;
//x的位置
private float x;
//记录位置信息的
private Rect normal = new Rect();
//初次点击的时候,和上次遗留的位置信息产生的距离不是我们想要的,会产生位置差,所以要过滤掉第一次移动事件
private boolean isCount = false;
//判断是否需要移动
private boolean isMoveing = false;
public dampHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* 获取第一个子元素
*/
@Override
protected void onFinishInflate() {
if (getChildCount() > 0) {
inner = getChildAt(0);
}
super.onFinishInflate();
}
/**
* 点击事件处理
* 存在子View才处理
*/
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (inner != null) {
commonTouchEvent(ev);
}
return super.onTouchEvent(ev);
}
/**
* 滑动事件
* 让滑动的速度变为原来的1/2
*/
@Override
public void fling(int velocityY) {
super.fling(velocityY / 2);
}
/**
* 事件处理
*/
public void commonTouchEvent(MotionEvent ev) {
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
final float preX = x;
float nowX = ev.getX();
//滑动位移
int deltaX = (int) (nowX - preX);
//第一次滑动同样要置0,不然会有晃动效果
if (!isCount) {
deltaX = 0;
}
isNeedMove();
if (isMoveing) {
//记录位置信息
if (normal.isEmpty()) {
normal.set(inner.getLeft(), inner.getTop(), inner.getRight(), inner.getBottom());
}
//绘制位置信息,在整个儿的高度上进行移动
inner.layout(inner.getLeft() + deltaX / 3, inner.getTop(),
inner.getRight() + deltaX / 3, inner.getBottom());
}
isCount = true;
x = nowX;
break;
case MotionEvent.ACTION_UP:
isMoveing = false;
//手指松开
if (isNeedAnimation()) {
animation();
}
break;
default:
break;
}
}
/***
* 回缩动画
*/
public void animation() {
TranslateAnimation ta = new TranslateAnimation(inner.getLeft(), normal.left, 0, 0);
ta.setDuration(100);
inner.startAnimation(ta);
// 设置回到正常的布局位置
inner.layout(normal.left, normal.top, normal.right, normal.bottom);
normal.setEmpty();
isCount = false;
// 手指松开要归0.
x = 0;
}
/**
* 是否开启动画
*/
public boolean isNeedAnimation() {
return !normal.isEmpty();
}
/**
* 是否需要移动
*/
public void isNeedMove() {
int offset = inner.getMeasuredWidth() - getWidth();
int scrollX = getScrollX();
if (scrollX == 0 || scrollX == offset) {
isMoveing = true;
} else {
isMoveing = false;
}
}
}
可以看到从上到下基本上一样,只是在方向上改变了,当然我这个还有一点问题,在和上面那个嵌套使用的时候,滑动冲突解决的还不是很好,还要周全一下,但这个不是这里的重点,到这里我们就有两种阻尼效果了。
全部代码:GitHub
或者CSDN资源:damp
参考1:Android拉伸阻尼效果实现
参考2:带阻尼效果的可拉伸的HorizontalScrollView