- 解决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