前言
由於手機螢幕尺寸有限,但是又經常需要在螢幕中顯示大量的內容,這就使得必須有部分內容顯示,部分內容隱藏。這就需要用一個Android中很重要的概念——滑動。滑動,顧名思義就是view從一個地方移動到另外一個地方,我們平時看到的各種很炫的移動效果,都是在基本的滑動基礎上加入一些動畫技術實現的。在Android中實現滑動的方式有多種,比如通過scrollTo/scrollBy,動畫位移,修改位置引數等。本文主要介紹通過scrollTo/scrollBy方式來實現View的滑動,並通過該方法來實現一個自定義PagerView。
本文的主要內容如下:
一、 scrollTo/scrollBy實際滑動的是控制元件的內容
這裡我們必須要先理解一個基本概念:使用scrollTo/scrollBy來實現滑動時,滑動的不是控制元件本身的位置,而是控制元件的內容。理解這一點,可以結合ScrollView控制元件,我們平時使用的使用會在xml佈局檔案中固定ScrollView的大小和位置,這也是我們肉眼看到的資訊。但是如果我們左右/上下滑動滾動條,會發現裡面原來還“藏”了許多“風景”。控制元件就像一個窗戶,我們看到的只有窗戶大小的內容,實際上窗戶中“另有乾坤”。就像下面這張圖顯示的一樣:
當我們手指在控制元件上滑動時,移動的其實是橙色部分表示的內容,而不是灰色部分表示的控制元件位置。
二、scrollBy實際上通過呼叫scrollTo來實現
scrollTo(int x, int y)方法的作用是:滑動到(x,y)這個座標點,是一個絕對位置。
scrollBy(int x, int y)方法的作用是:在原來的位置上,水平方向向左滑動x距離,豎直方向向上滑動的y距離(滑動方向問題我們後面會詳細講),是一個相對位置。
這裡我們先看看這兩個函式的原始碼:
1 //===========View.java========= 2 /** 3 * Set the scrolled position of your view. This will cause a call to 4 * {@link #onScrollChanged(int, int, int, int)} and the view will be 5 * invalidated. 6 * @param x the x position to scroll to 7 * @param y the y position to scroll to 8 */ 9 public void scrollTo(int x, int y) { 10 if (mScrollX != x || mScrollY != y) { 11 int oldX = mScrollX; 12 int oldY = mScrollY; 13 mScrollX = x; 14 mScrollY = y; 15 invalidateParentCaches(); 16 onScrollChanged(mScrollX, mScrollY, oldX, oldY); 17 if (!awakenScrollBars()) { 18 postInvalidateOnAnimation(); 19 } 20 } 21 } 22 23 /** 24 * Move the scrolled position of your view. This will cause a call to 25 * {@link #onScrollChanged(int, int, int, int)} and the view will be 26 * invalidated. 27 * @param x the amount of pixels to scroll by horizontally 28 * @param y the amount of pixels to scroll by vertically 29 */ 30 public void scrollBy(int x, int y) { 31 scrollTo(mScrollX + x, mScrollY + y); 32 }
註釋中也說明了這兩個方法的功能,也可以看到scrollBy,就是呼叫的scrollTo來實現的,所以實際上這兩個方法功能一樣,實際開發中看那個方便就用那個。這部分原始碼邏輯比較簡單,這裡就不囉嗦了,需要注意的是mScrollX/mScrollY這兩個變數,後面會用到,它們表示當前內容已經滑動的距離(向左/上滑動為正,向右/下滑動為負,方向問題下面詳細講)。
三、滑動座標系和View座標系正好相反
上面一節中介紹過,內容向左/上滑動時mScrollX/mScrollY為正,向右/下滑動時為負,這似乎和我們所理解的正好相反。我們平時理解的是基於View的座標系,水平向右為X軸正方向,豎直向下為Y軸正方向。但是滑動座標系和View座標系正好相反,對於滑動而言,水平向左為X軸正方向,豎直向上為Y軸正方向,原點都還是View控制元件的左上角頂點。如下圖所示:
僅從數值上看,mScrollX表示控制元件內容左邊緣到控制元件左邊緣的偏移距離,mScrollY表示控制元件內容上邊緣的距離與控制元件上邊緣的偏移距離。在實際開發中,經常通過getScrollX()/getScrollY()來獲取mScrollX/mScrollY的值。
1 //===========View.java========= 2 public final int getScrollX() { 3 return mScrollX; 4 } 5 ...... 6 public final int getScrollY() { 7 return mScrollY; 8 }
對於其值的正負問題,讀者可以自己通過列印log的方式來演示一下,比較簡單,此處不贅述了。這裡再提供幾個圖來體會一下滑動方向的問題。
水平方向的滑動
豎直方向的滑動
四、通過Scroller實現彈性滑動
通過scrollTo/scrollBy實現滑動時,是一瞬間來實現的。這樣看起來會比較生硬和突兀,使用者體驗顯然是不友好的,很多場景下,我們希望這個滑動是一個漸近式的,在給定的一段時間內緩慢移動到目標座標。Android提供了一個Scroller類,來輔助實現彈性滑動,至於它的使用方法,下一點的程式碼中有詳細演示,紅色加粗的文字部分顯示了使用步驟,這裡結合該示例進行講解。
通過Scroller實現彈性滑動的基本思想是,將一整段的滑動分為很多段微小的滑動,並在一定時間段內一一完成。
我們來看看CustomPagerView中第111行startScroll方法的原始碼:
1 //===================Scroller.java================== 2 /** 3 * Start scrolling by providing a starting point, the distance to travel, 4 * and the duration of the scroll. 5 * 6 * @param startX Starting horizontal scroll offset in pixels. Positive 7 * numbers will scroll the content to the left. 8 * @param startY Starting vertical scroll offset in pixels. Positive numbers 9 * will scroll the content up. 10 * @param dx Horizontal distance to travel. Positive numbers will scroll the 11 * content to the left. 12 * @param dy Vertical distance to travel. Positive numbers will scroll the 13 * content up. 14 * @param duration Duration of the scroll in milliseconds. 15 */ 16 public void startScroll(int startX, int startY, int dx, int dy, int duration) { 17 mMode = SCROLL_MODE; 18 mFinished = false; 19 mDuration = duration; 20 mStartTime = AnimationUtils.currentAnimationTimeMillis(); 21 mStartX = startX; 22 mStartY = startY; 23 mFinalX = startX + dx; 24 mFinalY = startY + dy; 25 mDeltaX = dx; 26 mDeltaY = dy; 27 mDurationReciprocal = 1.0f / (float) mDuration; 28 }
startScroll方法實際上沒有做移動的操作,只是提供了本次完整滑動的開始位置,需要滑動的距離,以及完成這次滑動所需要的時間。
第113行的invalidate()方法會讓CustomPagerView重繪,這會呼叫View中的draw(...)方法,
1 boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) { 2 ...... 3 computeScroll(); 4 ...... 5 } 6 ...... 7 /** 8 * Called by a parent to request that a child update its values for mScrollX 9 * and mScrollY if necessary. This will typically be done if the child is 10 * animating a scroll using a {@link android.widget.Scroller Scroller} 11 * object. 12 */ 13 public void computeScroll() { 14 }
draw()方法呼叫了computeScroll(),這是一個空方法,在CustomPagerView的126行重寫了該方法,重繪時會進入到這個方法體中。第127行中有一個判斷條件,看看它的原始碼:
1 /** 2 * Call this when you want to know the new location. If it returns true, 3 * the animation is not yet finished. 4 */ 5 public boolean computeScrollOffset() { 6 if (mFinished) { 7 return false; 8 } 9 10 int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime); 11 12 if (timePassed < mDuration) { 13 switch (mMode) { 14 case SCROLL_MODE: 15 final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal); 16 mCurrX = mStartX + Math.round(x * mDeltaX); 17 mCurrY = mStartY + Math.round(x * mDeltaY); 18 break; 19 case FLING_MODE: 20 ...... 21 break; 22 } 23 } 24 else { 25 mCurrX = mFinalX; 26 mCurrY = mFinalY; 27 mFinished = true; 28 } 29 return true; 30 }
這個判斷語句是在判斷本次滑動是否在在繼續,如果還沒結束,會返回false,重寫的computeScroll()中第130~135行會繼續執行,直到滑動完成為止。同時這個方法還會根據已經滑動的時間來更新當前需要移動到位置mCurrX/mCurrY。所以我們可以看到,在滑動還沒結束時,第134行就執行scrollTo方法來滑動一段距離。第134行又是一個重新整理,讓CustomPagerView重繪,又會呼叫draw(...)方法,computeScroll方法又被呼叫了,這樣反覆呼叫,直到整個滑動過程結束。(至於多長時間會執行一直重新整理,筆者目前還沒找到更深入的程式碼,有興趣的讀者可以自己再深入研究研究)
最後這裡做個總結,Scroller輔助實現彈性滑動的原理為: Scroller本身不能實現滑動,而是通過startScroll方法傳入起始位置、要滑動的距離和執行完滑動所需的時間,再通過invalidate重新整理介面來呼叫重寫的computeScroll方法,在沒有結束滑動的情況下,computeScroll中執行scrollTo方法來滑動一小段距離,並再次重新整理介面呼叫重寫的computeScroll方法,如此反覆,直到滑動過程結束。
五、實現一個自定義PagerView
本示例結合了該系列前面文章中提到的自定義View,View的繪製流程,觸控事件處理,速度等方面的知識,不明白的可以先去看看這些文章,打一下基礎。本示例的專案結構非常簡單,這裡就不提供下載地址了。
這裡先看看效果,一睹為快吧。
自定義一個view,繼承自ViewGroup
1 public class CustomPagerView extends ViewGroup { 2 3 private static final String TAG = "songzheweiwang"; 4 private Scroller mScroller; 5 private VelocityTracker mVelocityTracker; 6 private int mMaxVelocity; 7 private int mCurrentPage = 0; 8 private int mLastX = 0; 9 private List<Integer> mImagesList; 10 11 public CustomPagerView(Context context, @Nullable AttributeSet attrs) { 12 super(context, attrs); 13 init(context); 14 } 15 16 private void init(Context context) { 17 //第一步:例項化一個Scroller例項 18 mScroller = new Scroller(context); 19 mVelocityTracker = VelocityTracker.obtain(); 20 mMaxVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity(); 21 Log.i(TAG, "mMaxVelocity=" + mMaxVelocity); 22 } 23 24 //新增需要顯示的圖片,並顯示 25 public void addImages(Context context, List<Integer> imagesList) { 26 if (imagesList == null) { 27 mImagesList = new ArrayList<>(); 28 } 29 mImagesList = imagesList; 30 showViews(context); 31 } 32 33 private void showViews(Context context) { 34 if (mImagesList == null) { 35 return; 36 } 37 for (int i = 0; i < mImagesList.size(); i++) { 38 ImageView imageView = new ImageView(context); 39 LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); 40 imageView.setLayoutParams(params); 41 imageView.setBackgroundResource(mImagesList.get(i)); 42 addView(imageView); 43 } 44 } 45 46 @Override 47 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 48 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 49 int count = getChildCount(); 50 for (int i = 0; i < count; i++) { 51 View childView = getChildAt(i); 52 childView.measure(widthMeasureSpec, heightMeasureSpec); 53 } 54 } 55 56 @Override 57 protected void onLayout(boolean changed, int l, int t, int r, int b) { 58 int count = getChildCount(); 59 for (int i = 0; i < count; i++) { 60 View childView = getChildAt(i); 61 childView.layout(i * getWidth(), t, (i + 1) * getWidth(), b); 62 } 63 } 64 65 @Override 66 public boolean onTouchEvent(MotionEvent event) { 67 mVelocityTracker.addMovement(event); 68 int x = (int) event.getX(); 69 switch (event.getActionMasked()) { 70 case MotionEvent.ACTION_DOWN: 71 //如果動畫沒有結束,先停止動畫 72 if (!mScroller.isFinished()) { 73 mScroller.abortAnimation(); 74 } 75 mLastX = x; 76 break; 77 case MotionEvent.ACTION_MOVE: 78 int dx = x - mLastX; 79 //滑動座標系正好和View座標系是反的,dx為負數表示向右滑,為正表示向左滑 80 scrollBy(-dx, 0); 81 mLastX = x; 82 break; 83 case MotionEvent.ACTION_UP: 84 mVelocityTracker.computeCurrentVelocity(1000); 85 int xVelocity = (int) mVelocityTracker.getXVelocity(); 86 Log.i(TAG, "xVelocity=" + xVelocity); 87 if (xVelocity > mMaxVelocity && mCurrentPage > 0) { 88 //手指快速右滑後抬起,且當前頁面不是第一頁 89 scrollToPage(mCurrentPage - 1); 90 } else if (xVelocity < -mMaxVelocity && mCurrentPage < getChildCount() - 1) { 91 //手指快速左滑後抬起,且當前頁面不是最後一頁 92 scrollToPage(mCurrentPage + 1); 93 } else { 94 slowScrollToPage(); 95 } 96 break; 97 } 98 return true; 99 } 100 101 private void scrollToPage(int pageIndex) { 102 mCurrentPage = pageIndex; 103 if (mCurrentPage > getChildCount() - 1) { 104 mCurrentPage = getChildCount() - 1; 105 } 106 int scrollX = getScrollX(); 107 int dx = mCurrentPage * getWidth() - scrollX; 108 int duration = Math.abs(dx) * 2; 109 Log.i(TAG, "[scrollToPage]scrollX=" + scrollX + ";dx=" + dx + ";duration=" + duration); 110 //第二步:呼叫startScroll方法,指定起始座標,目的座標和滑動時長 111 mScroller.startScroll(scrollX, 0, dx, 0, duration); 112 //第三步:讓介面重繪 113 invalidate(); 114 } 115 116 private void slowScrollToPage() { 117 int scrollX = getScrollX(); 118 //緩慢滑動式,滑動一半以上後自動換到下一張,滑動不到一半則還原 119 int whichPage = (scrollX + getWidth() / 2) / getWidth(); 120 Log.i(TAG, "[slowScrollToPage]scrollX=" + scrollX + ";whichPage=" + whichPage); 121 scrollToPage(whichPage); 122 } 123 124 //第四步:重寫computeScroll方法,在該方法中通過scrollTo方法來完成滑動,並重繪 125 @Override 126 public void computeScroll() { 127 boolean isAnimateRun = mScroller.computeScrollOffset(); 128 Log.i(TAG, "[computeScroll]isAnimateRun=" + isAnimateRun); 129 if (isAnimateRun) { 130 //當前頁面的右上角,相對於第一頁右上角的座標 131 int curX = mScroller.getCurrX(); 132 int curY = mScroller.getCurrY(); 133 Log.i(TAG, "[computeScroll]curX=" + curX + ";curY=" + curY); 134 scrollTo(curX, curY); 135 postInvalidate(); 136 } 137 } 138 139 @Override 140 protected void onDetachedFromWindow() { 141 super.onDetachedFromWindow(); 142 if (mVelocityTracker != null) { 143 mVelocityTracker.recycle(); 144 mVelocityTracker = null; 145 } 146 } 147 }
程式碼看起來有點長,其實邏輯很簡單。基本思路是,使用者新增要顯示的圖片資源id列表,在CustomPagerView中為每一個要顯示的圖片例項一個ImageView進行顯示。在滑動的過程中,如果速度比較快(大於某個閾值),手指抬起後,就會滑動下一頁。如果速度很慢,手指抬起時,如果手指滑動的距離超過了螢幕的一半,則自動滑到下一頁,如果沒滑到一半,本次就不翻頁,仍然停留在本頁。
在佈局檔案中引入該控制元件
1 //=========activity_scroller_demo.xml========= 2 <?xml version="1.0" encoding="utf-8"?> 3 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" 4 android:layout_width="match_parent" 5 android:layout_height="match_parent" 6 android:orientation="vertical"> 7 8 <com.example.demos.customviewdemo.CustomPagerView 9 android:id="@+id/viewpager" 10 android:layout_width="match_parent" 11 android:layout_height="300dp" /> 12 </LinearLayout>
在Activity中使用該控制元件
1 public class ScrollerDemoActivity extends AppCompatActivity { 2 3 private static final String TAG = "ScrollerDemoActivity"; 4 5 @Override 6 protected void onCreate(Bundle savedInstanceState) { 7 super.onCreate(savedInstanceState); 8 setContentView(R.layout.activity_scroller_demo); 9 initViews(); 10 } 11 12 private void initViews() { 13 List<Integer> mImageList = new ArrayList<>(); 14 mImageList.add(R.drawable.dog); 15 mImageList.add(R.drawable.test2); 16 mImageList.add(R.drawable.test3); 17 mImageList.add(R.drawable.test4); 18 CustomPagerView customPagerView = findViewById(R.id.viewpager); 19 customPagerView.addImages(this, mImageList); 20 } 21 }
這裡再囉嗦一句,本示例很好地演示了一個自定義View的開發,包含了不少自定義View需要掌握的基礎知識點。通過該程式碼,希望能夠強化理解前面文章中介紹的相關知識。
六、其他實現滑動及彈性滑動的方法
前面只介紹了通過scrollTo/scrollBy,並結合Scroller來實現滑動和彈性滑動的方式,實際上還有很多方式來實現這些效果。比如,要實現滑動,還有使用動畫以及修改控制元件位置引數等方式。要實現彈性滑動,已經知道了基本思路是把一整段滑動分為很多小段滑動來一一實現,那麼還可以使用定時器,Handler,Thread/sleep等方式來實現。這些方法就不一一介紹了,在使用時可以根據實際的場景和需求選擇實現方式。
結語
本文主要介紹通過scrollTo/scrollBy來實現控制元件內容的滑動,以及結合Scroller實現彈性滑動的方式。由於筆者水平和經驗有限,有描述不準確或不正確的地方,歡迎來拍磚,謝謝!
參考資料
《Android開發藝術探索》