天天看点

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

  • 解决ViewPager动画异常
    • 背景
    • 问题1:padding导致动画异常
      • 异常现象
      • 问题分析
      • 解决方案
    • 问题2:刷新数据动画异常
      • 异常现象
      • 问题分析
      • 解决方案
    • 问题3:改变ViewPager的width或paddingLeft、paddingRight导致滚动位置异常
      • 异常现象
      • 问题分析
      • 解决方案
    • 问题4:setPageMargin()导致滚动位置异常
      • 异常现象
      • 问题分析
      • 解决方案
    • 总结

解决ViewPager动画异常

本文所有分析及解决方案都依赖于

ViewPager

的源码实现,阅读前推荐先阅读:ViewPager源码分析(发现刷新数据的正确使用姿势)。

背景

我们项目常常会遇到首页banner、广告banner的需求,要求一屏能同时看到旁边两页,并且旁边的页面缩小。类似于下图:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

要实现这样的效果很简单,布局中给

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导致动画异常

异常现象

先来看看上述代码在滑动页面时会产生什么问题:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

可以明显看到,显示的页面并非在中间的时候缩放到最大,而是要往左滑动一点距离才达到最大。

问题分析

直接看

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

查看运行结果:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

问题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

,点击

数据反序

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

问题分析

查看日志:

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动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

可以看到解决代码中还多了

viewPager.isLayoutRequested()

判断,因为刷新可能包含数据添加,此时添加的View还未进行测量和布局,也会导致动画异常。

进入页面显示

item:0

,点击

添加数据

按钮:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

日志如下:

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

按钮的现象:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

可以看到页面明显出现了偏移。

问题分析

调用

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

的情况下都能得到修正:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

问题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

按钮的现象:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

发现位置明显偏移了。

问题分析

直接看

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,打开开发者模式的显示布局边界,运行结果:

解决ViewPager动画异常(数据刷新、padding、pageMargin)解决ViewPager动画异常

在当前选中为靠后的页面也没有发生偏移。

总结

基于以上结论,为了方便使用,进行了封装,满足以下功能:

1.支持

ViewPager

按需添加、删除视图,以及局部刷新;

2.修复多场景下

ViewPager.PageTransformer

返回的

position

错误,让开发者专注于动画实现;

3.修复

ViewPager

width、paddingLeft、paddingRight、pageMargin

动态改变导致当前page定位异常的问题;

4.提供自定义

GraceViewPager

,可快速实现一屏显示多Page的功能。

已开源到github并发布到jcenter,详情:GraceViewPager

继续阅读