Android安卓下拉阻尼效果實現原理及簡單實例


原理
  這種效果是通過自定義控件的方式來實現的,我自定義了一個控件類型,這個自定義控件(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則注冊本監聽器的控件將不會對事件做出響應,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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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);

}
}
1
2
3
4
5
6
7
8
9
10
11
下面是筆者正在使用的自定義控件,比上述的控件多了一個效果:
  頭部處於隱藏或展開的不同狀態時,觸發動畫效果的分界線可以隨狀態不同而改變。
  還是拿最新版的微信小程序入口來講,用戶在下拉時,小程序界面會占用整個屏幕,如果觸發動畫的分界線太低,這樣導致的結果是用戶可能無法通過上滑重新返回聯系人列表,但由於微信沒有對滑動距離進行減半處理,所以不存在上述問題,可能是出於防止誤觸的原因,從小程序界面返回聯系人列表的方式改用點擊底部的一個按鈕。而我的控件可以通過改變觸發動畫效果的分界線來解決這一問題,感興趣的讀者可以研究一下。

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;
}
}```

--------------------- 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM