查看原文
其他

解决几个ViewPager 异常问题 | 深入剖析

wurensen 鸿洋 2019-04-05

本文作者


作者:wurensen

链接:

https://blog.csdn.net/wurensen/article/details/81544776

本文由作者授权发布。


1概述


本文所有分析及解决方案都依赖于ViewPager的源码实现,阅读前推荐先阅读:


  • ViewPager源码分析(发现刷新数据的正确使用姿势)

    https://blog.csdn.net/wurensen/article/details/81390641


背景


我们项目常常会遇到首页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 >= -1 && position <= 1) {
        // [-1,1],中间以及相邻的页面,一般相邻的才会用于计算动画
        float scale = SCALE + (1 - SCALE) * (1 - Math.abs(position));
        page.setScaleX(scale);
        page.setScaleY(scale);
    } else {
        // [-Infinity,-1)、(1,+Infinity],超出相邻的范围
        page.setScaleX(SCALE);
        page.setScaleY(SCALE);
    }
}


完整代码可查看github上的demo

https://github.com/wurensen/GraceViewPager


2问题


问题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 = 0; 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() > 0) {
                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{9799430 V.E...... .......D 1710,0-2490,1542}], position = [-4.0]
transformPage() called with: page = [android.widget.LinearLayout{4bb8c V.E...... .......D 2490,0-3270,1542}], position = [-3.0]
transformPage() called with: page = [android.widget.LinearLayout{a96a751 V.E...... .......D 3270,0-4050,1542}], position = [-2.0]
transformPage() called with: page = [android.widget.LinearLayout{8f4f024 V.E...... .......D 4050,0-4830,1542}], position = [-1.0]
transformPage() called with: page = [android.widget.LinearLayout{e206153 V.E...... ......ID 4830,0-5610,1542}], position = [0.0]


在文章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 = 0;
    if (curInfo != null) {
        final int width = getClientWidth();
        destX = (int) (width * Math.max(mFirstOffset,
                Math.min(curInfo.offset, mLastOffset)));
    }
    if (smoothScroll) {
        smoothScrollTo(destX, 0, velocity);
        if (dispatchSelected) {
            dispatchOnPageSelected(item);
        }
    } else {
        if (dispatchSelected) {
            dispatchOnPageSelected(item);
        }
        completeScroll(false);
        scrollTo(destX, 0);
        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 -1;
}

/**
 * 数据是否正在刷新中,即是否处于{@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 = 0;
        } 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 = [0]
onPageSelected() called with: position = [1]
transformPage() called with: page = [android.widget.LinearLayout{a09b978 V.E......  .......D 150,0-930,1542}], position = [0.0]
transformPage() called with: page = [android.widget.LinearLayout{95b58b6 V.E...... .......D 930,0-1710,1542}], position = [1.0]
transformPage() called with: page = [android.widget.LinearLayout{cdcf524 V.E...... .......D 1710,0-2490,1542}], position = [2.0]
transformPage() called with: page = [android.widget.LinearLayout{5b0acc0 V.E...... ......I. 0,0-0,0}], position = [-0.1923077]


可以发现新添加的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(50) : dip2px(75);
            mViewPager.setPadding(padding, 0, padding, 0);
            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 > 0 && !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) : 0;
        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 == 0) {
            return;
        }
        if (mLastChildWidth == 0) {
            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 == 0) {
                pageMargin = dip2px(10);
            } else {
                pageMargin = 0;
            }
            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 == 0) {
        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定位异常的问题; 


提供自定义GraceViewPager,可快速实现一屏显示多Page的功能。已开源到github并发布到jcenter,详情:

https://github.com/wurensen/GraceViewPager


推荐阅读

推荐两个实用开源项目

启动优化提升60%


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存