Android下拉阻尼效果实现原理及简单实例
前言
本文将通过代码讲解下拉阻尼效果的实现原理。
实现灵感来源于这篇博客,但是这篇博客的代码并不能让我满意,或者说是糟糕的,不过还是非常感谢作者带给我的启发。
现在大部分资讯类安卓APP都有一个下拉刷新的功能,又如微信联系人列表顶部的小程序入口,也使用了这种下拉阻尼的效果。
我的代码主要是解释其实现原理,为方便读者理解,所以代码逻辑非常简单,但如果想要实现例如下拉刷新转动的进度圈,还需要修改代码中的MoveHeaderTask类中的onProgressUpdate方法;如果要实现滑动列表顶部加入这种下拉阻尼效果,则需要修改代码中的onTouch方法,通过判断是否到达列表顶部来决定是否触发下拉阻尼效果的逻辑代码。
最新的微信版本还实现了一个具有惯性的滑动列表(不清楚这样表述是否正确),滑动的速度大小和小程序入口的下拉阻尼效果会形成互动,但这已不是本文讨论的重点,这需要感兴趣的读者自行对我的代码进行迭代。
运行效果如下:
如图,拉动"可见主体"到达一定高度,"隐藏头部"就会弹出,反之,向上滑动到一定高度,"隐藏头部"则会收回,如果未到达指定高度,则恢复原状。
实际运行效果其实很流畅,也不会出现上图中,头部无法完全隐藏的情况,只是AS自带的录屏工具比较差劲。我不建议把这个自定义控件用在对话框类型的activity上,因为前一个activity处于可见状态,可能会占用大量算力,导致动画效果不流畅,亲测。
原理
这种效果是通过自定义控件的方式来实现的,我自定义了一个控件类型,这个自定义控件(PullDownDumperLayout)继承自线性布局(LinearLayout) 。
用户可以下拉弹出的那个视图,例如微信的小程序列表,开发者只是将这个视图移出了父元素之外,所以不可见,我们暂且称之为隐藏头部,只有下拉到一定程度才会弹出,而主体,例如微信的联系人列表,则是可见的,布局见下图。
实现这个效果需要我们做三件工作:
1.隐藏作为头部的控件
2.监听用户对屏幕的操作事件
3.实现下拉回弹的动画效果
我们这个自定义控件会自动获取内部第一个子元素充当头部,其余的元素则是充当可见的主体(详见代码中的注释)。
基本的布局原理差不多就这样了,但是我们还需要让自定义控件监听用户的手势操作,例如上下滑动等。这里我和灵感来源的那篇博客一样,让自定义控件实现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则注册本监听器的控件将不会对事件做出响应,true则相反 */ @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){ return false; } //头部完全隐藏时不再向上滑动 if (vector < 0 && mHeadLayoutParams.topMargin <= mHeadLayoutHeight) { return false; } //头部完全展开时不再向下滑动 if (vector > 0 && mHeadLayoutParams.topMargin >= 0) { return false; } //对增量进行修正,对滑动距离进行减半 int topMargin = mHeadLayoutParams.topMargin + (vector/2);//阻尼值 if(topMargin>0){ // 瞬间拉动的距离超过了头部高度,因为这一瞬间很短,这里采用直接赋值的方式 // 如需平滑过渡,要另开线程,并且监听到ACTION_DOWN时线程可被打断 topMargin = 0; } else if(topMargin<mHeadLayoutHeight){ // 瞬间拉动的距离超过了头部高度,因为这一瞬间很短,这里采用直接赋值的方式 // 如需平滑过渡,要另开线程,并且监听ACTION_DOWN时线程可被打断 topMargin = mHeadLayoutHeight; } //用户对屏幕的滑动将会改变控件的TopMargin mHeadLayoutParams.topMargin = topMargin ; mHeadLayout.setLayoutParams(mHeadLayoutParams); break; default: //TODO 出现其他触碰事件,如MotionEvent.ACTION_UP时,根据阈值判断此时头部应该弹出还是隐藏 mChangeHeadLayoutTopMargin=true; if(mHeadLayoutParams.topMargin<=mBoundary){ //隐藏 new MoveHeaderTask().execute(true); } else{ //展开 new MoveHeaderTask().execute(false); } break; } return false; } /** * 新线程,隐藏或者展开头部布局,线程可被ACTION_DOWN打断 */ class MoveHeaderTask extends AsyncTask<Boolean, Integer, Integer> { /** * * @param opt true为隐藏动画,false为展开动画 * @return */ @Override protected Integer doInBackground(Boolean... opt) { int topMargin=mHeadLayoutParams.topMargin; //true为隐藏,false为展开 int speed=(opt[0])?mHeadLayoutHideSpeed:mHeadLayoutUnfoldSpeed; while(mChangeHeadLayoutTopMargin){ topMargin += speed; if (topMargin <= mHeadLayoutHeight||topMargin>=0) { topMargin=(opt[0])?mHeadLayoutHeight:0; publishProgress(topMargin); break; } publishProgress(topMargin); sleep(mSleepTime); } return null; } //调用publishProgress后会执行 @Override protected void onProgressUpdate(Integer... topMargin) { mHeadLayoutParams.topMargin=topMargin[0]; mHeadLayout.setLayoutParams(mHeadLayoutParams); } } //调整参数 public void setHeadLayoutHideSpeed(int speed){ this.mHeadLayoutHideSpeed=speed; } public void setHeadLayoutUnfoldSpeed(int speed){ this.mHeadLayoutUnfoldSpeed=speed; } public void setSleepTime(long time){ this.mSleepTime=time; } public void setRatio(double ratio){ this.mRatio=ratio; } }
activity_main.xml:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.example.pulldowndumpertest.PullDownDumperLayout android:tag="记得将这个标签修改为自己的包名" android:id="@+id/PullDownDumper" android:layout_width="900px" android:layout_height="1920px" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" android:background="@null" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="500px" android:orientation="vertical" android:background="@color/colorPrimary" android:clickable="true"> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:text="隐藏头部" android:textSize="100px" android:gravity="center" android:textColor="#FFFFFF" android:background="@null"/> </LinearLayout> <LinearLayout android:layout_width="match_parent" android:layout_height="1700px" android:background="@color/colorPrimaryDark" android:clickable="true"> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:text="可见主体" android:textSize="100px" android:gravity="center" android:textColor="#FFFFFF" android:background="@null"/> </LinearLayout> </com.example.pulldowndumpertest.PullDownDumperLayout> </android.support.constraint.ConstraintLayout>
MainActivity.java:
public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //TODO 读者可在这里初始化参数 PullDownDumperLayout pddl=findViewById(R.id.PullDownDumper); } }
下面是笔者正在使用的自定义控件,比上述的控件多了一个效果:
头部处于隐藏或展开的不同状态时,触发动画效果的分界线可以随状态不同而改变。
还是拿最新版的微信小程序入口来讲,用户在下拉时,小程序界面会占用整个屏幕,如果触发动画的分界线太低,这样导致的结果是用户可能无法通过上滑重新返回联系人列表,但由于微信没有对滑动距离进行减半处理,所以不存在上述问题,可能是出于防止误触的原因,从小程序界面返回联系人列表的方式改用点击底部的一个按钮。而我的控件可以通过改变触发动画效果的分界线来解决这一问题,感兴趣的读者可以研究一下。
public class PullDownDumperLayout extends LinearLayout implements View.OnTouchListener { /** * 取布局中的第一个子元素为下拉隐藏头部 */ private View mHeadLayout; /** * 隐藏头部布局的高的负值 */ private int mHeadLayoutHeight; /** * 隐藏头部的布局参数 */ private MarginLayoutParams mHeadLayoutParams; /** * 判断是否为第一次初始化,第一次初始化需要把headView移出界面外 */ private boolean mOnLayoutIsInit=false; /** * 从配置获取的滚动判断阈值,为两点间的距离,超过此阈值判断为滚动 */ // private int mScaledTouchSlop; /** * 按下时的y轴坐标 */ // private float mDownY; /** * 移动时,前一个坐标 */ private float mMoveY; /** * 如果为false,会退出头部展开或隐藏动画 */ private boolean mChangeHeadLayoutTopMargin; /** * 头部布局的隐藏和展开速度,以及单次执行时间 */ private int mHeadLayoutHideSpeed; private int mHeadLayoutUnfoldSpeed; private long mSleepTime; /** * 初始化头部布局的偏移值,数值越大,头部可见部分越多,预设值为0,即初始时头部完全不可见 */ private int mTopMarginOffset; /** * 触发动画的分界线,头部布局上半部分和整体高度的比例 */ private double mUnfoldRatio; private double mHideRatio; /** * 触发动画的分界线,初始值由mRatio计算得到 * 头部处于隐藏时等于mUnfoldBoundary * 头部处于展开时等于mHideBoundary * mBoundary在onTouch的ACTION_DOWN中变化 */ private int mBoundary; private int mUnfoldBoundary; private int mHideBoundary; /** * 阻尼值,越大越难拖动,呈线性趋势 */ private int mDumper; public PullDownDumperLayout(Context context, AttributeSet attrs) { super(context, attrs); // mScaledTouchSlop= ViewConfiguration.get(context).getScaledTouchSlop(); mHeadLayoutHideSpeed=-30; mHeadLayoutUnfoldSpeed=30; mSleepTime=10; mUnfoldRatio=0.6; mHideRatio=mUnfoldRatio; mDumper=2; mTopMarginOffset=-200; } /** * 布局开始设置每一个控件 * 在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(); mUnfoldBoundary=(int)(mUnfoldRatio*mHeadLayoutHeight);//计算触发展开动画分界线 mHideBoundary=(int)(mHideRatio*mHeadLayoutHeight);//计算触发隐藏动画分界线 mBoundary=mUnfoldBoundary;//触发动画的分界线初始为mUnfoldBoundary mHeadLayoutHeight-=mTopMarginOffset;//头部隐藏布局可见的部分 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: 注册本监听器的控件将不会对事件做出响应,true则相反 */ @Override public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //根据此时处于完全展开或完全隐藏决定mBoundary的值,如果两种情况都不满足则不做改变 if(mHeadLayoutParams.topMargin==mHeadLayoutHeight) mBoundary=mUnfoldBoundary; else if(mHeadLayoutParams.topMargin==0) mBoundary=mHideBoundary; // mDownY=event.getRawY();//获取按下的屏幕y坐标 mMoveY=event.getRawY(); mChangeHeadLayoutTopMargin=false;//false会打断隐藏或展开头部布局的动画 break; case MotionEvent.ACTION_MOVE: float currY=event.getRawY(); int vector=(int)(currY-mMoveY);//向量,用于判断手势的上滑和下滑 mMoveY=currY; //判断是否为滑动 if(Math.abs(vector)==0){ return false; } //头部完全隐藏时不再向上滑动 if (vector < 0 && mHeadLayoutParams.topMargin <= mHeadLayoutHeight) { return false; } //头部完全展开时不再向下滑动 else if (vector > 0 && mHeadLayoutParams.topMargin >= 0) { return false; } //对增量进行修正 int topMargin = mHeadLayoutParams.topMargin + (vector/mDumper); if(topMargin>0){ // 瞬间拉动的距离超过了头部高度,因为这一瞬间很短,这里采用直接赋值的方式 // 如需实现平滑过渡,要另开线程,并且监听到ACTION_DOWN时线程可被打断 topMargin = 0; } else if(topMargin<mHeadLayoutHeight){ // 瞬间拉动的距离超过了头部高度,因为这一瞬间很短,这里采用直接赋值的方式 // 如需实现平滑过渡,要另开线程,并且监听ACTION_DOWN时线程可被打断 topMargin = mHeadLayoutHeight; } //使参数生效 mHeadLayoutParams.topMargin = topMargin ; mHeadLayout.setLayoutParams(mHeadLayoutParams); break; default: //出现其他触碰事件,如MotionEvent.ACTION_UP时,根据阈值mBoundary判断此时头部应该弹出还是隐藏 mChangeHeadLayoutTopMargin=true;//允许执行动画 if(mHeadLayoutParams.topMargin<=mBoundary){ //隐藏 new MoveHeaderTask().execute(true); } else{ //展开 new MoveHeaderTask().execute(false); } break; } return false; } /** * 新线程,隐藏或者展开头部布局,线程可被ACTION_DOWN打断 */ private class MoveHeaderTask extends AsyncTask<Boolean, Integer, Integer> { /** * * @param opt true为隐藏动画,false为展开动画 * @return */ @Override protected Integer doInBackground(Boolean... opt) { int topMargin=mHeadLayoutParams.topMargin; //true为隐藏,false为展开 int speed=(opt[0])?mHeadLayoutHideSpeed:mHeadLayoutUnfoldSpeed; while(mChangeHeadLayoutTopMargin){ topMargin += speed; if (topMargin <= mHeadLayoutHeight||topMargin>=0) { topMargin=(opt[0])?mHeadLayoutHeight:0; publishProgress(topMargin); break; } publishProgress(topMargin); sleep(mSleepTime); } return null; } //调用publishProgress后会执行 @Override protected void onProgressUpdate(Integer... topMargin) { mHeadLayoutParams.topMargin=topMargin[0]; mHeadLayout.setLayoutParams(mHeadLayoutParams); } } //调整参数 public void setHeadLayoutHideSpeed(int speed){ this.mHeadLayoutHideSpeed=speed; } public void setHeadLayoutUnfoldSpeed(int speed){ this.mHeadLayoutUnfoldSpeed=speed; } public void setSleepTime(long time){ this.mSleepTime=time; } public void setDumper(int dumper){ this.mDumper=dumper; } public void setTopMarginOffset(int offset){ this.mTopMarginOffset=-offset; } /** * 头部处于隐藏状态时,触发展开动画的分界线 * @param ratio 头部布局上部分与下部分的分界线 */ public void setUnfoldRatio(double ratio){ this.mUnfoldRatio=ratio; } /** * 头部处于展开状态时,触发隐藏动画的分界线 * @param ratio 头部布局上部分与下部分的分界线 */ public void setHideRatio(double ratio){ this.mHideRatio=ratio; } }```