最近项目需求中要求给一个左右滑动的剧集列表添加阻尼回弹动画效果。大家都知道安卓原生的View一般滑动到View的边界之后就停止,不会有拖拽效果的,网上查找了一些方法,也跳了一些坑,今天就来总结下分享给大家。
方法一:View.overScrollBy
当 View 滚动超过了 View 的内容边界的时候, 该方法可以使用统一的标准的行为来滚动 view。overScrollBy各个参数解释如下:
/**
* 当滑动的超出上,下,左,右最大范围时回调
* @param deltaX x方向的瞬时偏移量,左边到头,向右拉为负,右边到头,向左拉为正
* @param deltaY y方向的瞬时偏移量,顶部到头,向下拉为负,底部到头,向上拉为正
* @param scrollX 水平方向的永久偏移量
* @param scrollY 竖直方向的永久偏移量
* @param scrollRangeX 水平方向滑动的范围
* @param scrollRangeY 竖直方向滑动的范围
* @param maxOverScrollX 水平方向最大滑动范围
* @param maxOverScrollY 竖直方向最大滑动范围
* @param isTouchEvent 是否是手指触摸滑动, true为手指, false为惯性
* @return
*/
@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY,isTouchEvent);
}
通过覆盖该方法,就可以达到阻尼回弹的效果。
示例1、竖向滚动
public class ReboundScrollView extends ScrollView{
private static final int MAX_SCROLL = 200;
private static final float SCROLL_RATIO = 0.5f;// 阻尼系数
public ReboundScrollView(Context context) {
super(context);
}
public ReboundScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ReboundScrollView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
int newDeltaY = deltaY;
int delta = (int) (deltaY * SCROLL_RATIO);
if((scrollY+deltaY)==0 || (scrollY-scrollRangeY+deltaY)==0){
newDeltaY = deltaY; //回弹最后一次滚动,复位
}else{
newDeltaY = delta; //增加阻尼效果
}
return super.overScrollBy(deltaX, newDeltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, MAX_SCROLL, isTouchEvent);
}
}
示例2、横向滚动
public class ReboundHScrollView extends HorizontalScrollView{
private static final int MAX_SCROLL = 200;
private static final float SCROLL_RATIO = 0.5f;// 阻尼系数
public ReboundHScrollView(Context context) {
super(context);
}
public ReboundHScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ReboundHScrollView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) {
int newDeltaX = deltaX;
int delta = (int) (deltaX * SCROLL_RATIO);
if((scrollX+deltaX)==0 || (scrollX-scrollRangeX+deltaX)==0){
newDeltaX = deltaX; //回弹最后一次滚动,复位
}else{
newDeltaX = delta; //增加阻尼效果
}
return super.overScrollBy(newDeltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, MAX_SCROLL, maxOverScrollY, isTouchEvent);
}
}
方法二:通过监听OnTouch事件,滚动到边界时重新设置View的位置
public class ReboundEffectsView extends FrameLayout {
private View mPrinceView;// 太子View
private int mInitTop, mInitBottom, mInitLeft, mInitRight;// 太子View初始时上下坐标位置(相对父View,
// 即当前ReboundEffectsView)
private boolean isEndwiseSlide;// 是否纵向滑动
private float mVariableY;// 手指上下滑动Y坐标变化前的Y坐标值
private float mVariableX;// 手指上下滑动X坐标变化前的X坐标值
private int orientation;//1:竖向滚动 2:横向滚动
public ReboundEffectsView(Context context) {
this(context, null);
}
public ReboundEffectsView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ReboundEffectsView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.setClickable(true);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.ReboundEffectsView);
orientation = ta.getInt(R.styleable.ReboundEffectsView_orientation, 1);
ta.recycle();
}
/**
* Touch事件
*/
@Override
public boolean onTouchEvent(MotionEvent e) {
if (null != mPrinceView) {
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
onActionDown(e);
break;
case MotionEvent.ACTION_MOVE:
return onActionMove(e);
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
onActionUp(e);// 当ACTION_UP一样处理
break;
}
}
return super.onTouchEvent(e);
}
/**
* 手指按下事件
*/
private void onActionDown(MotionEvent e) {
mVariableY = e.getY();
mVariableX = e.getX();
/**
* 保存mPrinceView的初始上下高度位置
*/
mInitTop = mPrinceView.getTop();
mInitBottom = mPrinceView.getBottom();
mInitLeft = mPrinceView.getLeft();
mInitRight = mPrinceView.getRight();
}
/**
* 手指滑动事件
*/
private boolean onActionMove(MotionEvent e) {
float nowY = e.getY();
float diffY = (nowY - mVariableY) / 2;
if (orientation == 1 && Math.abs(diffY) > 0) {// 上下滑动
// 移动太子View的上下位置
mPrinceView.layout(mPrinceView.getLeft(), mPrinceView.getTop() + (int) diffY,
mPrinceView.getRight(), mPrinceView.getBottom() + (int) diffY);
mVariableY = nowY;
isEndwiseSlide = true;
return true;// 消费touch事件
}
float nowX = e.getX();
float diffX = (nowX - mVariableX) / 5;//除数越大可以滑动的距离越短
if (orientation == 2 && Math.abs(diffX) > 0) {// 左右滑动
// 移动太子View的左右位置
mPrinceView.layout(mPrinceView.getLeft() + (int) diffX, mPrinceView.getTop(),
mPrinceView.getRight() + (int) diffX, mPrinceView.getBottom());
mVariableX = nowX;
isEndwiseSlide = true;
return true;// 消费touch事件
}
return super.onTouchEvent(e);
}
/**
* 手指释放事件
*/
private void onActionUp(MotionEvent e) {
if (isEndwiseSlide) {// 是否为纵向滑动事件
// 是纵向滑动事件,需要给太子View重置位置
if (orientation==1){
resetPrinceViewV();
}else if (orientation==2){
resetPrinceViewH();
}
isEndwiseSlide = false;
}
}
/**
* 回弹,重置太子View初始的位置
*/
private void resetPrinceViewV() {
TranslateAnimation ta = new TranslateAnimation(0, 0, mPrinceView.getTop() - mInitTop, 0);
ta.setDuration(600);
mPrinceView.startAnimation(ta);
mPrinceView.layout(mPrinceView.getLeft(), mInitTop, mPrinceView.getRight(), mInitBottom);
}
private void resetPrinceViewH() {
TranslateAnimation ta = new TranslateAnimation(mPrinceView.getLeft() - mInitLeft, 0, 0, 0);
ta.setDuration(600);
mPrinceView.startAnimation(ta);
mPrinceView.layout(mInitLeft, mPrinceView.getTop(), mInitRight, mPrinceView.getBottom());
}
/**
* XML布局完成加载
*/
@Override
protected void onFinishInflate() {
super.onFinishInflate();
if (getChildCount() > 0) {
mPrinceView = getChildAt(0);// 获得子View,太子View
}
}
}
attr.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="ReboundEffectsView">
<attr name="orientation">
<enum name="portrait" value="1"/>
<enum name="landscape" value="2"/>
</attr>
</declare-styleable>
</resources>
layout.xml
<com.handsome.boke2.AccessibilityService.ReboundEffectsView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:orientation="portrait">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
android:src="@drawable/huntuan"/>
</com.handsome.boke2.AccessibilityService.ReboundEffectsView>
方法三:通过NestedScrolling实现RecyclerView拖拽回弹效果
Android5.0后Google为Android的滑动机制提供了NestedScrolling特性,可以使我们对嵌套View更简单事件拦截处理。
具体实现思路就是:
给RecyclerView添加头和尾,并控制滑动距离然后通过NestedScrolling进行分发,最后在手指离开界面开启还原动画。
NestedScrolling介绍:
NestedScrolling与传统事件分发机制作对比:
- 比如某个外部的ViewGroup拦截掉内部View的事件,那本次事件会被ViewGroup消费并且不会向下传递,如果子view也想处理只能等待下一次手指再按下。
- NestedScrolling可以很好的解决传统事件拦截的缺点,内部View在滚动的时候通过NestedScrollingChild将dx,dy交给NestedScrollingParent,NestedScrollingParent可对其进行部分消耗,剩余的部分还给内部View。
NestedScrolling是support.v4提供的支持类,在老版本也可以很好的兼容。
通过NestedScrolling可以实现哪些效果?
- 菜单悬停效果
- 下拉刷新、上拉加载更多
- 拖拽回弹
实现嵌套分发主要通过以下两个接口:
NestedScrollingParent
NestedScrollingChild
要使用 NestedScrolling机制,父View 需要实现NestedScrollingParent接口,而子View需要实现NestedScrollingChild接口。RecyclerView已经默认实现了NestedScrollingChild,如果RecyclerView不满足你的业务需求,那需要去实现NestedScrollingChild,这里我只对NestedScrollingParent实现。
代码实例:
public class ReboundLayout extends LinearLayout implements NestedScrollingParent {
private View mHeaderView;
private View mFooterView;
private static final int MAX_WIDTH = 200;
private View mChildView;
// 解决多点触控问题
private boolean isRunAnim;
private int mDrag = 5;//除数越大可以滑动的距离越短
public ReboundLayout(Context context, AttributeSet attrs) {
super(context, attrs);
setOrientation(LinearLayout.HORIZONTAL);
mHeaderView = new View(context);
mHeaderView.setBackgroundColor(0xfff);
mFooterView = new View(context);
mFooterView.setBackgroundColor(0xfff);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mChildView = getChildAt(0);
LayoutParams layoutParams = new LayoutParams(MAX_WIDTH, LayoutParams.MATCH_PARENT);
addView(mHeaderView, 0, layoutParams);
addView(mFooterView, getChildCount(), layoutParams);
// 左移
scrollBy(MAX_WIDTH, 0);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
ViewGroup.LayoutParams params = mChildView.getLayoutParams();
params.width = getMeasuredWidth();
}
/**
* 必须要复写 onStartNestedScroll后调用
*/
@Override
public void onNestedScrollAccepted(View child, View target, int axes) {
}
/**
* 返回true代表处理本次事件
*/
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
if (target instanceof RecyclerView && !isRunAnim) {
return true;
}
return false;
}
/**
* 复位初始位置
*/
@Override
public void onStopNestedScroll(View target) {
startAnimation(new ProgressAnimation());
}
/**
* 回弹动画
*/
private class ProgressAnimation extends Animation {
// 预留
private float startProgress = 0;
private float endProgress = 1;
private ProgressAnimation() {
isRunAnim = true;
}
@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
float progress = ((endProgress - startProgress) * interpolatedTime) + startProgress;
scrollBy((int) ((MAX_WIDTH - getScrollX()) * progress), 0);
if (progress == 1) {
isRunAnim = false;
}
}
@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
setDuration(260);
setInterpolator(new AccelerateInterpolator());
}
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int
dyUnconsumed) {
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
// 如果在自定义ViewGroup之上还有父View交给我来处理
getParent().requestDisallowInterceptTouchEvent(true);
// dx>0 往左滑动 dx<0往右滑动
boolean hiddenLeft = dx > 0 && getScrollX() < MAX_WIDTH && !ViewCompat
.canScrollHorizontally(target, -1);
boolean showLeft = dx < 0 && !ViewCompat.canScrollHorizontally(target, -1);
boolean hiddenRight = dx < 0 && getScrollX() > MAX_WIDTH && !ViewCompat
.canScrollHorizontally(target, 1);
boolean showRight = dx > 0 && !ViewCompat.canScrollHorizontally(target, 1);
if (hiddenLeft || showLeft || hiddenRight || showRight) {
scrollBy(dx / mDrag, 0);
consumed[0] = dx;
}
// 限制错位问题
if (dx > 0 && getScrollX() > MAX_WIDTH && !ViewCompat.canScrollHorizontally(target, -1)) {
scrollTo(MAX_WIDTH, 0);
}
if (dx < 0 && getScrollX() < MAX_WIDTH && !ViewCompat.canScrollHorizontally(target, 1)) {
scrollTo(MAX_WIDTH, 0);
}
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
return false;
}
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
// 当RecyclerView在界面之内交给它自己惯性滑动
if (getScrollX() == MAX_WIDTH) {
return false;
}
return true;
}
@Override
public int getNestedScrollAxes() {
return 0;
}
/**
* 限制滑动 移动x轴不能超出最大范围
*/
@Override
public void scrollTo(int x, int y) {
if (x < 0) {
x = 0;
} else if (x > MAX_WIDTH * 2) {
x = MAX_WIDTH * 2;
}
super.scrollTo(x, y);
}
}
这里大家可以仿照方法二,添加一个自定义属性,这样就可以灵活的定制回弹动画的方向了。
总结
1、当Recyclerview嵌套方法一控件实现回弹动画时,会导致ScrollView和Recyclerview的嵌套显示不全和滑动问题,此时只需要按照解决这两个View的嵌套使用问题的常用方法解决即可:在Recyclerview的外面嵌套一层RelativeLayout即可解决;
但是,还有有一个问题:当你调用Recyclerview的smoothScrollTo(int position)方法时,这时滚动失效,目前没有找到解决方法,衰。。。望解决过次问题的大神告知,多谢。
2、当Recyclerview嵌套方法二控件实现回弹动画时,没有回弹效果,如果修改代码的话bug太多,所以暂时没有解决此问题;其他控件都可正常使用没有问题;
3、当Recyclerview嵌套方法三控件实现回弹动画时,就可以完美解决方法一种的所有问题啦,(/≧▽≦)/,所以如果你想给Recyclerview添加阻尼回弹动画建议用方法三哦。
参考文章:
1、 通过NestedScrolling实现RecyclerView拖拽回弹效果
2、ScrollView拖动回弹效果(包括横向和竖向)