您好,登錄后才能下訂單哦!
前言
本文將通過代碼講解下拉阻尼效果的實現原理。
實現靈感來源于這篇博客,但是這篇博客的代碼并不能讓我滿意,或者說是糟糕的,不過還是非常感謝作者帶給我的啟發。
現在大部分資訊類安卓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; } }```
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持億速云。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。