转载请注明出处:codog_main的博客
前言
本文将通过代码讲解下拉阻尼效果的实现原理。
实现灵感来源于这篇博客,但是这篇博客的代码并不能让我满意,或者说是糟糕的,不过还是非常感谢作者带给我的启发。
现在大部分资讯类安卓APP都有一个下拉刷新的功能,又如微信联系人列表顶部的小程序入口,也使用了这种下拉阻尼的效果。
我的代码主要是解释其实现原理,为方便读者理解,所以代码逻辑非常简单,但如果想要实现例如下拉刷新转动的进度圈,还需要修改代码中的MoveHeaderTask类中的onProgressUpdate方法;如果要实现滑动列表顶部加入这种下拉阻尼效果,则需要修改代码中的onTouch方法,通过判断是否到达列表顶部来决定是否触发下拉阻尼效果的逻辑代码。
最新的微信版本还实现了一个具有惯性的滑动列表(不清楚这样表述是否正确),滑动的速度大小和小程序入口的下拉阻尼效果会形成互动,但这已不是本文讨论的重点,这需要感兴趣的读者自行对我的代码进行迭代。
运行效果如下:
如图,拉动"可见主体"到达一定高度,"隐藏头部"就会弹出,反之,向上滑动到一定高度,"隐藏头部"则会收回,如果未到达指定高度,则恢复原状。
实际运行效果其实很流畅,也不会出现上图中,头部无法完全隐藏的情况,只是AS自带的录屏工具比较差劲。我不建议把这个自定义控件用在对话框类型的activity上,因为前一个activity处于可见状态,可能会占用大量算力,导致动画效果不流畅,亲测。
原理
这种效果是通过自定义控件的方式来实现的,我自定义了一个控件类型,这个自定义控件(PullDownDumperLayout)继承自线性布局(LinearLayout)。
用户可以下拉弹出的那个视图,例如微信的小程序列表,开发者只是将这个视图移出了父元素之外,所以不可见,我们暂且称之为隐藏头部,只有下拉到一定程度才会弹出,而主体,例如微信的联系人列表,则是可见的,布局见下图。
实现这个效果需要我们做三件工作:
- 隐藏作为头部的控件
- 监听用户对屏幕的操作事件
- 实现下拉回弹的动画效果
我们这个自定义控件会自动获取内部第一个子元素充当头部,其余的元素则是充当可见的主体(详见代码中的注释)。
基本的布局原理差不多就这样了,但是我们还需要让自定义控件监听用户的手势操作,例如上下滑动等。这里我和灵感来源的那篇博客一样,让自定义控件实现View.OnTouchListener接口,实现内部的onTouch方法可以监听来自屏幕的所有触摸操作。代码中我让头部和第二个子元素(可见的主体)注册了这个监听器,这是为了方便读者理解,读者可根据自己的需求进行修改。
注意,对于不能监听屏幕触摸事件的控件需要添加:
android:clickable="true"
至此,我们已经可以进行布局和监听用户手势了,但是还需要实现一个头部展开和隐藏的动画效果。当用户将隐藏头部下拉或上滑到一定高度时,这个效果就会被触发,这需要依赖上面所述的onTouch方法。动画效果的实现需要另开一个线程进行操作,线程的启动方式我们可以采用继承AsyncTask类来实现。
除此之外,我们可能会多次复用这个控件,所以在自定义控件类的最后还需要一些调整参数的set方法。
这里提个醒,在接下来的代码中,我们的自定义控件因为继承自LinearLayout,里面需要重写onLayout方法,而onLayout方法顾名思义就是布局,这个方法在Activity中的onCreate方法执行之后才会被调用,所以我们可以在Activity的onCreate方法中利用findViewById获取实例,调用上面提到的set方法进行参数的初始化。
LinearLayout中不止onLayout一个方法,详细解析请读者移步其他关于XML标签加载过程的文章,这里不做赘述。
代码
PullDownDumperLayout .java:
public class PullDownDumperLayout extends LinearLayout implements View.OnTouchListener {
/**
* 取布局中的第一个子元素为下拉隐藏头部
*/
private View mHeadLayout;
/**
* 隐藏头部布局的高的负值
*/
private int mHeadLayoutHeight;
/**
* 隐藏头部的布局参数
*/
private MarginLayoutParams mHeadLayoutParams;
/**
* 判断是否为第一次初始化,第一次初始化需要把headView移出界面外
*/
private boolean mOnLayoutIsInit=false;
/**
* 移动时,前一个坐标
*/
private float mMoveY;
/**
* 如果为false,会退出头部展开或隐藏动画
*/
private boolean mChangeHeadLayoutTopMargin;
/**
* 触发动画的分界线,由mRatio计算得到
*/
private int mBoundary;
/**
* 头部布局的隐藏和展开速度,以及单次执行时间
*/
private int mHeadLayoutHideSpeed;
private int mHeadLayoutUnfoldSpeed;
private long mSleepTime;
/**
* 触发动画的分界线,头部布局上半部分和整体高度的比例
*/
private double mRatio;
public PullDownDumperLayout(Context context, AttributeSet attrs) {
super(context, attrs);
//初始化参数,根据自己的需求调整
mHeadLayoutHideSpeed=-20;
mHeadLayoutUnfoldSpeed=20;
mSleepTime=10;
mRatio=0.5;
}
/**
* 布局开始设置每一个控件
* 在activity的onCreate执行之后才会执行
* 因此可以在onCreate中调用set方法设置参数
*/
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if(!mOnLayoutIsInit && changed) {
//将第一个子元素作为头部移出界面外
mHeadLayout = this.getChildAt(0);
mHeadLayoutHeight=-mHeadLayout.getHeight();
mBoundary=(int)(mRatio*mHeadLayoutHeight);//计算触发动画分界线
mHeadLayoutParams=(MarginLayoutParams) mHeadLayout.getLayoutParams();
mHeadLayoutParams.topMargin=mHeadLayoutHeight;
mHeadLayout.setLayoutParams(mHeadLayoutParams);
//TODO 设置手势监听器,不能触碰的控件需要添加android:clickable="true"
getChildAt(1).setOnTouchListener(this);
mHeadLayout.setOnTouchListener(this);
//标记已被初始化
mOnLayoutIsInit=true;
}
}
/**
* 屏幕触摸操作监听器
* @return 返回false表示在执行onTouch后会继续执行onTouchEvent
*/
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mMoveY=event.getRawY();//捕获按下时的坐标,初始化mMoveY
mChangeHeadLayoutTopMargin=false;
break;
case MotionEvent.ACTION_MOVE:
float currY=event.getRawY();
int vector=(int)(currY-mMoveY);//向量,用于判断手势的上滑和下滑
mMoveY=currY;
//判断是否为滑动
if(Math.abs(vector)==0)<