- 解決ViewPager動畫異常
- 背景
- 問題1:padding導緻動畫異常
- 異常現象
- 問題分析
- 解決方案
- 問題2:重新整理資料動畫異常
- 異常現象
- 問題分析
- 解決方案
- 問題3:改變ViewPager的width或paddingLeft、paddingRight導緻滾動位置異常
- 異常現象
- 問題分析
- 解決方案
- 問題4:setPageMargin()導緻滾動位置異常
- 異常現象
- 問題分析
- 解決方案
- 總結
解決ViewPager動畫異常
本文所有分析及解決方案都依賴于
ViewPager
的源碼實作,閱讀前推薦先閱讀:ViewPager源碼分析(發現重新整理資料的正确使用姿勢)。
背景
我們項目常常會遇到首頁banner、廣告banner的需求,要求一屏能同時看到旁邊兩頁,并且旁邊的頁面縮小。類似于下圖:

要實作這樣的效果很簡單,布局中給
ViewPager
設定合适的
paddingLeft、paddingRight
,配合
clipPadding=false
:
<android.support.v4.view.ViewPager
android:id="@+id/vp"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorAccent"
android:clipToPadding="false"
android:paddingLeft="50dp"
android:paddingRight="50dp" />
給
ViewPager
添加
PageTransformer
動畫實作(padding導緻position位置周遊):
@Override
public void transformPage(@NonNull View page, float position) {
if (position >= - && position <= ) {
// [-1,1],中間以及相鄰的頁面,一般相鄰的才會用于計算動畫
float scale = SCALE + ( - SCALE) * ( - Math.abs(position));
page.setScaleX(scale);
page.setScaleY(scale);
} else {
// [-Infinity,-1)、(1,+Infinity],超出相鄰的範圍
page.setScaleX(SCALE);
page.setScaleY(SCALE);
}
}
完整代碼可檢視github上的demo。
問題1:padding導緻動畫異常
異常現象
先來看看上述代碼在滑動頁面時會産生什麼問題:
可以明顯看到,顯示的頁面并非在中間的時候縮放到最大,而是要往左滑動一點距離才達到最大。
問題分析
直接看
transformPage
在
ViewPager
源碼中被調用的地方:
protected void onPageScrolled(int position, float offset, int offsetPixels) {
...
if (mPageTransformer != null) {
final int scrollX = getScrollX();
final int childCount = getChildCount();
for (int i = ; i < childCount; i++) {
final View child = getChildAt(i);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.isDecor) continue;
final float transformPos = (float) (child.getLeft() - scrollX) / getClientWidth();
mPageTransformer.transformPage(child, transformPos);
}
}
...
}
private int getClientWidth() {
return getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
}
可以看到,
transformPos
的計算并未減去
paddingLeft
,這就導緻了計算結果偏大。
解決方案
給position重新修正:
private float getPositionConsiderPadding(ViewPager viewPager, View page) {
// padding影響了position,自己生成position
int clientWidth = viewPager.getMeasuredWidth() - viewPager.getPaddingLeft() - viewPager.getPaddingRight();
return (float) (page.getLeft() - viewPager.getScrollX() - viewPager.getPaddingLeft()) / clientWidth;
}
檢視運作結果:
問題2:重新整理資料動畫異常
界面上添加了
資料反序
、
添加資料
、
删除資料
按鈕來模拟資料源發生變化的情況。
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.reverse_btn:
Collections.reverse(mData);
mAdapter.notifyDataSetChanged();
break;
case R.id.add_btn:
mData.add(mViewPager.getCurrentItem(), "add item:" + mData.size());
mAdapter.notifyDataSetChanged();
break;
case R.id.delete_btn:
if (mData.size() > ) {
mData.remove(mViewPager.getCurrentItem());
mAdapter.notifyDataSetChanged();
}
break;
}
}
異常現象
先滑動到
item:4
,點選
資料反序
:
問題分析
檢視日志:
getItemPosition: oldPos=2,newPos=7
getItemPosition: oldPos=3,newPos=6
getItemPosition: oldPos=4,newPos=5
getItemPosition: oldPos=5,newPos=4
getItemPosition: oldPos=6,newPos=3
transformPage() called with: page = [android.widget.LinearLayout{ V.E...... .......D ,-,}], position = [-]
transformPage() called with: page = [android.widget.LinearLayout{bb8c V.E...... .......D ,-,}], position = [-]
transformPage() called with: page = [android.widget.LinearLayout{a96a751 V.E...... .......D ,-,}], position = [-]
transformPage() called with: page = [android.widget.LinearLayout{f4f024 V.E...... .......D ,-,}], position = [-]
transformPage() called with: page = [android.widget.LinearLayout{e206153 V.E...... ......ID ,-,}], position = []
在文章ViewPager源碼分析(發現重新整理資料的正确使用姿勢)已經分析了調用重新整理後的流程,可知,在
dataSetChanged()
中會調用
setCurrentItemInternal()
,最終會調用到
onPageScrolled()
,即
transformPage()
會在重新整理過程中被調用。但是,該回調時刻
ViewPager
隻是确定了各個
ItemInfo
的屬性,包括
offset
,并未執行
onLayout()
,是以此時回調的
position
應該不變才對,為什麼和輸出的日志不一緻?那就往調用方法棧中找,在
setCurrentItemInternal()
中會調用
scrollToItem()
:
private void scrollToItem(int item, boolean smoothScroll, int velocity,
boolean dispatchSelected) {
final ItemInfo curInfo = infoForPosition(item);
int destX = ;
if (curInfo != null) {
final int width = getClientWidth();
destX = (int) (width * Math.max(mFirstOffset,
Math.min(curInfo.offset, mLastOffset)));
}
if (smoothScroll) {
smoothScrollTo(destX, , velocity);
if (dispatchSelected) {
dispatchOnPageSelected(item);
}
} else {
if (dispatchSelected) {
dispatchOnPageSelected(item);
}
completeScroll(false);
scrollTo(destX, );
pageScrolled(destX);
}
}
注意第20行代碼調用了
scrollTo(destX, 0);
,并且
destX
的值等于目标Page的
left
。經過上文修正position的計算時,變量
viewPager.getScrollX()==destX
,這也就解釋了為什麼日志中postion會依次傳回:-4.0,-3.0,-2.0,-1.0,0.0。顯然,在重新整理過程中
transformPage()
傳回
Page
對應的
position
值,與最終的正确結果相差甚遠。
解決方案
那如何能夠在資料重新整理過程中回調
transformPage()
時,得到
Page
對應的
position
呢?經過上文問題分析,隻要能夠知道
Page
對應在資料中的
index
,并計算出和目标Page的
index
間的偏移,該偏移值就是
position
。我們知道
child
的順序與
Page
順序并非一緻,并且
ViewPager
中與
ItemInfo
相關的方法都不可通路(可反射,但是不推薦,無法相容後續版本源碼改動),是以無法通過
ViewPager
直接擷取對應的資料索引。但是,開發者在繼承
PagerAdapter
時,傳回的視圖和資料索引對應關系是由開發者維護的。那我們可以讓實作的
PagerAdapter
提供
視圖-資料索引
的對應關系的接口:
@CallSuper
@Override
public void notifyDataSetChanged() {
mDataSetChanging = true;
super.notifyDataSetChanged();
mDataSetChanging = false;
}
/**
* 擷取頁面視圖對應的資料索引
*
* @param page 頁面視圖
* @return 未找到傳回-1
*/
public int getPageViewPosition(View page) {
for (ViewItemHolder viewItemHolder : mViewItemHolders) {
if (viewItemHolder.mItemView == page) {
return viewItemHolder.mPosition;
}
}
return -;
}
/**
* 資料是否正在重新整理中,即是否處于{@link #notifyDataSetChanged()}->{@link ViewPager#dataSetChanged()}執行過程
*
* @return 重新整理中傳回true
*/
public boolean isDataSetChanging() {
return mDataSetChanging;
}
并且在初始化
PageTransformer
的時候傳入該Adapter:
// 拓展的PagerAdapter
private GracePagerAdapter mPagerAdapter;
public GracePageTransformer(@NonNull GracePagerAdapter pagerAdapter) {
mPagerAdapter = pagerAdapter;
}
@Override
public void transformPage(@NonNull View page, float position) {
// 資料重新整理、填充新page的時候,要判斷page真正的位置才能得到正确的position
boolean dataSetChanging = mPagerAdapter.isDataSetChanging();
boolean requirePagePosition = dataSetChanging || viewPager.isLayoutRequested();
if (requirePagePosition) {
int currentItem = viewPager.getCurrentItem();
int pageViewIndex = mPagerAdapter.getPageViewPosition(page);
LogUtil.d("transformPage() requirePagePosition: currentItem = ["
+ currentItem + "], pageViewIndex = [" + pageViewIndex + "]");
if (currentItem == pageViewIndex) {
position = ;
} else {
position = pageViewIndex - currentItem;
}
} else {
position = getPositionConsiderPadding(viewPager, page);
}
LogUtil.d("transformPage() called with: page = [" + page + "], position = [" + position + "]");
transformPageWithCorrectPosition(page, position);
}
看下運作結果:
可以看到解決代碼中還多了
viewPager.isLayoutRequested()
判斷,因為重新整理可能包含資料添加,此時添加的View還未進行測量和布局,也會導緻動畫異常。
進入頁面顯示
item:0
,點選
添加資料
按鈕:
日志如下:
instantiateItem() called with: position = []
onPageSelected() called with: position = []
transformPage() called with: page = [android.widget.LinearLayout{a09b978 V.E...... .......D ,-,}], position = []
transformPage() called with: page = [android.widget.LinearLayout{b58b6 V.E...... .......D ,-,}], position = []
transformPage() called with: page = [android.widget.LinearLayout{cdcf524 V.E...... .......D ,-,}], position = []
transformPage() called with: page = [android.widget.LinearLayout{b0acc0 V.E...... ......I. ,-,}], position = [-]
可以發現新添加的
Page
的動畫是錯誤的,是以該情況下,也需要通過
Page
去擷取對應的索引來計算得到正确的
position
。
問題3:改變ViewPager的width或paddingLeft、paddingRight導緻滾動位置異常
在實際使用場景中,有很多手機是帶可動态展示和隐藏的底部操作欄,動态改變布局大小會影響到
ViewPager
的大小或是
Page
的大小(比如
Page
顯示的是圖檔,需要保持比例不變),通過
改變padding
按鈕來動态修改
paddingLeft
和
paddingRight
模拟實際場景:
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.change_padding_btn:
boolean visible = mPlaceholderView.getVisibility() == View.VISIBLE;
mPlaceholderView.setVisibility(visible ? View.GONE : View.VISIBLE);
int padding = visible ? dip2px() : dip2px();
mViewPager.setPadding(padding, , padding, );
break;
}
}
異常現象
先滑動到
item:1
,看下點選
改變padding
按鈕的現象:
可以看到頁面明顯出現了偏移。
問題分析
調用
setPadding()
會使得
ViewPager
重新走測量布局繪制流程。在
onMeasure()
中會去調用
populate()
,也會調用到
calculatePageOffsets()
計算各個
ItemInfo
的屬性,包括
offset
;在
onLayout()
中會根據得到的
offset
和新的
childWidth
進行child的布局,最後再根據目前的
scrollX
進行頁面繪制。那為什麼會發生偏移呢?
因為
getScrollX()
的值沒有變化。
ViewPager
是通過
scrollTo()
來實作滾動到指定的位置,如果各個child的位置更新了,但是
scrollX
沒有相應的更新,就會出現偏移。
其實
ViewPager
源碼中有考慮到寬度變化後需要重新滾動定位的情況:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// Make sure scroll position is set correctly.
if (w != oldw) {
recomputeScrollPosition(w, oldw, mPageMargin, mPageMargin);
}
}
private void recomputeScrollPosition(int width, int oldWidth, int margin, int oldMargin) {
if (oldWidth > && !mItems.isEmpty()) {
if (!mScroller.isFinished()) {
mScroller.setFinalX(getCurrentItem() * getClientWidth());
} else {
final int widthWithMargin = width - getPaddingLeft() - getPaddingRight() + margin;
final int oldWidthWithMargin = oldWidth - getPaddingLeft() - getPaddingRight()
+ oldMargin;
final int xpos = getScrollX();
// 該計算方式得到的pageOffset會有誤差,xpos越大,誤差越大
final float pageOffset = (float) xpos / oldWidthWithMargin;
final int newOffsetPixels = (int) (pageOffset * widthWithMargin);
scrollTo(newOffsetPixels, getScrollY());
}
} else {
final ItemInfo ii = infoForPosition(mCurItem);
final float scrollOffset = ii != null ? Math.min(ii.offset, mLastOffset) : ;
final int scrollPos =
(int) (scrollOffset * (width - getPaddingLeft() - getPaddingRight()));
if (scrollPos != getScrollX()) {
completeScroll(false);
scrollTo(scrollPos, getScrollY());
}
}
}
注意
recomputeScrollPosition()
方法中
scrollPos
的計算方式,會發現寬度的計算都是包含了
mPageMargin
,但是在計算各個
ItemInfo
的
offset
時,已經把
mPageMargin
計算進去了。也就是說,在
onLayout()
的時候,各個child布局的時候已經預留了
pageMargin
的位置,并且child位置取決于
offset
和
childWidth
。同時,滾動到具體某一個頁面的位置的
scrollX
也是根據
offset*childWidth
計算得出。
是以,如果在
mPageMargin=0
的時候,上述源碼不會有問題,但是如果設定了某個值,通過
final float pageOffset = (float) xpos / oldWidthWithMargin;
得到的頁面偏移就與實際的
offset
有誤差。
解決方案
既然
recomputeScrollPosition()
有問題,那就自己監聽布局變化,當child寬度發生變化後重新滾動修正:
/**
* 布局變化監聽
*/
private static final class ViewPagerLayoutChangeListener implements View.OnLayoutChangeListener {
private ViewPager mViewPager;
private int mLastChildWidth;
ViewPagerLayoutChangeListener(ViewPager viewPager) {
mViewPager = viewPager;
}
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft,
int oldTop, int oldRight, int oldBottom) {
int childWidth = right - left - v.getPaddingLeft() - v.getPaddingRight();
if (childWidth == ) {
return;
}
if (mLastChildWidth == ) {
mLastChildWidth = childWidth;
return;
}
if (mLastChildWidth == childWidth) {
return;
}
/*
* 問題:page寬度變化後,layout會正确放置child位置,但是scrollX值仍然是舊值,導緻繪制位置偏差;
* 同時,經過資料重新整理後scrollX=0不代表定位到第一個頁面,取決于最左邊child的位置,是以該值有可能是負值;
* 解決方案:根據舊值擷取頁面偏移,根據頁面偏移計算新的scrollX位置
*/
recomputeScrollPosition(mViewPager, mViewPager.getScrollX(), childWidth, mLastChildWidth);
mLastChildWidth = childWidth;
}
/**
* 重新計算滾動位置
*
* @param viewPager ViewPager
* @param scrollX 目前滾動位置
* @param childWidth 新的item寬度
* @param oldChildWidth 舊的item寬度
*/
private static void recomputeScrollPosition(ViewPager viewPager, int scrollX,
int childWidth, int oldChildWidth) {
float pageOffset = (float) scrollX / oldChildWidth;
int newOffsetPixels = (int) (pageOffset * childWidth);
viewPager.scrollTo(newOffsetPixels, viewPager.getScrollY());
}
}
在有無設定
pageMargin
的情況下都能得到修正:
問題4:setPageMargin()導緻滾動位置異常
從上文得知,
pageMargin
是會影響child的布局以及滾動位置。
改變pageMargin
按鈕來實作
pageMargin
變化。
@Override
public void onClick(View v) {
switch (v.getId()) {
case R.id.change_margin_btn:
int pageMargin = mViewPager.getPageMargin();
if (pageMargin == ) {
pageMargin = dip2px();
} else {
pageMargin = ;
}
mViewPager.setPageMargin(pageMargin);
break;
}
}
異常現象
先滑動到
item:1
,看下點選
改變pageMargin
按鈕的現象:
發現位置明顯偏移了。
問題分析
直接看
setPageMargin()
源碼:
public void setPageMargin(int marginPixels) {
final int oldMargin = mPageMargin;
mPageMargin = marginPixels;
final int width = getWidth();
recomputeScrollPosition(width, width, marginPixels, oldMargin);
requestLayout();
}
也是調用了
recomputeScrollPosition()
進行重新滾動定位。上文已經分析了源碼該方法有問題,也分析了産生的原因和解決方案。
解決方案
/**
* ViewPager.recomputeScrollPosition()方法源碼有Bug,計算的scrollX值有誤,導緻動态去調用setPageMargin()後,
* 滾動位置有問題。<br/>
* 直接調用該方法替代{@link ViewPager#setPageMargin(int)},可以修正滾動位置錯誤問題。
*
* @param viewPager ViewPager
* @param pageMargin pageMargin
*/
public static void setPageMargin(@NonNull ViewPager viewPager, int pageMargin) {
int oldPageMargin = viewPager.getPageMargin();
if (pageMargin == oldPageMargin) {
return;
}
int childWidth = viewPager.getMeasuredWidth() - viewPager.getPaddingLeft() - viewPager.getPaddingRight();
if (childWidth == ) {
viewPager.setPageMargin(pageMargin);
} else {
// setPageMargin()調用後目前item的offset值和childWidth不變,是以直接取出調用前的scrollX值進行定位即可
int oldScrollX = viewPager.getScrollX();
viewPager.setPageMargin(pageMargin);
viewPager.scrollTo(oldScrollX, viewPager.getScrollY());
}
}
為了看到child間的pageMargin,打開開發者模式的顯示布局邊界,運作結果:
在目前選中為靠後的頁面也沒有發生偏移。
總結
基于以上結論,為了友善使用,進行了封裝,滿足以下功能:
1.支援
ViewPager
按需添加、删除視圖,以及局部重新整理;
2.修複多場景下
ViewPager.PageTransformer
傳回的
position
錯誤,讓開發者專注于動畫實作;
3.修複
ViewPager
的
width、paddingLeft、paddingRight、pageMargin
動态改變導緻目前page定位異常的問題;
4.提供自定義
GraceViewPager
,可快速實作一屏顯示多Page的功能。
已開源到github并釋出到jcenter,詳情:GraceViewPager