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

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

问题1:padding导致动画异常

异常现象

先来看看上述代码在滑动页面时会产生什么问题:
padding导致position偏移
可以明显看到,显示的页面并非在中间的时候缩放到最大,而是要往左滑动一点距离才达到最大。

问题分析

直接看transformPageViewPager源码中被调用的地方:

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

查看运行结果:
padding导致position偏移修正

问题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按钮来动态修改paddingLeftpaddingRight模拟实际场景:

@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按钮的现象:
动态修改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,但是在计算各个ItemInfooffset时,已经把mPageMargin计算进去了。也就是说,在onLayout()的时候,各个child布局的时候已经预留了pageMargin的位置,并且child位置取决于offsetchildWidth。同时,滚动到具体某一个页面的位置的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按钮的现象:
动态修改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,打开开发者模式的显示布局边界,运行结果:
动态修改pageMargin修正

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

总结

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

1.支持ViewPager按需添加、删除视图,以及局部刷新;
2.修复多场景下ViewPager.PageTransformer返回的position错误,让开发者专注于动画实现;
3.修复ViewPagerwidth、paddingLeft、paddingRight、pageMargin动态改变导致当前page定位异常的问题;
4.提供自定义GraceViewPager,可快速实现一屏显示多Page的功能。

github:GraceViewPager(开发中,目前需要使用请自行拷贝对应类,控件完成后会发布到jcenter中)

猜你喜欢

转载自blog.csdn.net/wurensen/article/details/81544776
今日推荐