ViewPager源码简析

2020-05-17  本文已影响0人  凤鸣游子
欣赏一下
听说庐山的冬季很美,一直没机会去.....我很喜欢冬季里银装素裹的大地,那么的淡雅和纯洁
功能点

API 21

  1. 测量,布局,绘制;
  2. 事件的处理机制, viewPager的主动消耗,拦截等;
  3. 页面滚动计算,手动滚动;
  4. viewPager设计带来的问题;
0. 核心变量和标记
- mItems: 已经缓存过的page, 按照page的position从小到大来排列。    
- mCurItem: 当前显示的page的position, 这是全局的。全局是针对mItems来说的.假如有5个page,
mItems存储的可能是最后的三个页面,那他缓存的第一个页面并不是系统中的第一个page,而是全局的第三个page.
- mAdapter: 动态加载子page。
- ItemInfo: page控件构建的对象,里面的position即为全局page的position。
- mOffscreenPageLimit: 离屏限制数量,默认是1,也就是除了当前page左右缓存各一个,总数是3;如果是2,那么就左右各缓存两个,总数是5。
- Scroller: 一个平滑滚动效果的计算工具类,类似的有Overscroller.他是根据起始坐标,终点坐标,以及时间这几个变量来计算不同时间的view的x, y坐标的哦,从而实现滚动计算。
1. 测量:
2. ViewPager的布局过程:
3. 绘制的地方
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // 存在着margin,并且设定了drawable.
    if (mPageMargin > 0 && mMarginDrawable != null && mItems.size() > 0 
        && mAdapter != null) {
        final int scrollX = getScrollX();
        final int width = getWidth();
        // 计算margin与viewager的宽度的比例
        final float marginOffset = (float) mPageMargin / width;
        int itemIndex = 0;
        // 取出缓存的第一个子View.
        ItemInfo ii = mItems.get(0);
        float offset = ii.offset;
        final int itemCount = mItems.size();
        final int firstPos = ii.position;
        final int lastPos = mItems.get(itemCount - 1).position;
        //遍历缓存中所有的view.
        for (int pos = firstPos; pos < lastPos; pos++) {
            // 这个写法其实有点不好看,意思就是不停地从mItems缓存中取出新的View.
            while (pos > ii.position && itemIndex < itemCount) {
                ii = mItems.get(++itemIndex);
            }

            float drawAt;
            //通过view的宽度因子和左边的便宜来计算marginDrawable绘制的开始位置;
            if (pos == ii.position) {
                drawAt = (ii.offset + ii.widthFactor) * width;
                offset = ii.offset + ii.widthFactor + marginOffset;
            } else {
                float widthFactor = mAdapter.getPageWidth(pos);
                drawAt = (offset + widthFactor) * width;
                offset += widthFactor + marginOffset;
            }

            if (drawAt + mPageMargin > scrollX) {
                mMarginDrawable.setBounds((int) drawAt, mTopPageBounds,
                                          (int) (drawAt + mPageMargin + 0.5f), mBottomPageBounds);
                mMarginDrawable.draw(canvas);
            }
            //其实前面已经绘制过了,这个忽略的绘制本意却没有达到
            if (drawAt > scrollX + width) {
                break; // No more visible, no sense in continuing
            }
        }
    }
}


说完了基本的测量、布局、绘制,就要来看看viewPager的内容滚动吧,毕竟这不只是一个静态的容器.

4. 事件的拦截与触摸消耗

上面情形一,向右滑动,计算出来的item是page0, 它是vp左边界中显示的页面。情形二,向左滑动,计算出来的item是page1.

//currentPage指的是vp最左边对应的页面哦,不是当前mCurItem哦;
private int determineTargetPage(int currentPage, float pageOffset, int velocity, int deltaX) {
    int targetPage;
    //这是快速滑动的判断,当速度达到了滑翔条件(view往右滑动速度为负,向左滑动速度才是正数。)
    if (Math.abs(deltaX) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity) {
        //向左快速滑的话,就停靠在当前vp左边界的page位置。向右快速滑,就停靠在下一个页面上。
        //参照上图,向右快速滑停靠的页面是page0,向左快速滑动停靠的页面是page2
        targetPage = velocity > 0 ? currentPage : currentPage + 1;
    } else {
        //从这里看到,如果往右边滑动,truncator = 0.4f,要想选中下一个page,必须要划过下一个page
        //0.6的宽度因子哦;如果往左边滑动currentPage会小于mCurItem,那么必须也要划出来0.6因子
        //那么余下的pageOffset会小于0.4,这样家起来小于1,会跳到前面的页面;
        final float truncator = currentPage >= mCurItem ? 0.4f : 0.6f;
        targetPage = (int) (currentPage + pageOffset + truncator);
    }

    if (mItems.size() > 0) {//这里是确保page都是我们缓存中的page.
        final ItemInfo firstItem = mItems.get(0);
        final ItemInfo lastItem = mItems.get(mItems.size() - 1);

        // Only let the user target pages we have items for
        targetPage = Math.max(firstItem.position, Math.min(targetPage, lastItem.position));
    }

    return targetPage;
}
5. page页面的滚动处理:
  1. 当手指慢慢滑动,页面需要跟随手指去滑动,它是由 performDrag 来负责的, 来看源代码吧:

    //参数x是将要滚动到的x坐标;
    private boolean performDrag(float x) {
        boolean needsInvalidate = false;
     //需要滚动的距离
        final float deltaX = mLastMotionX - x;
        mLastMotionX = x;
    
        float oldScrollX = getScrollX();
        //计算最终的scrollX, vp的滚动是通过scoll内容来实现的哦;
        float scrollX = oldScrollX + deltaX;
        final int width = getClientWidth();
     //这里的firstoffset并不是指第一个全局的page,而是内存中缓存的第一个page,mLastOffset同理如此;
        float leftBound = width * mFirstOffset;
        float rightBound = width * mLastOffset;
        boolean leftAbsolute = true;
        boolean rightAbsolute = true;
    
        final ItemInfo firstItem = mItems.get(0);
        final ItemInfo lastItem = mItems.get(mItems.size() - 1);
        if (firstItem.position != 0) {//如果不是第一个全局page.
            leftAbsolute = false;//就不会绘制边缘拖拽效果
            leftBound = firstItem.offset * width;
        }
        if (lastItem.position != mAdapter.getCount() - 1) {//如果不是最后一个全局page.
            rightAbsolute = false;//就不会绘制边缘拖拽效果
            rightBound = lastItem.offset * width;
        }
    
        if (scrollX < leftBound) {
            if (leftAbsolute) {//如果到了第一个的顶边了,就要绘制拖拽边缘效果
                float over = leftBound - scrollX;
                needsInvalidate = mLeftEdge.onPull(Math.abs(over) / width);
            }
            scrollX = leftBound;
        } else if (scrollX > rightBound) {
            if (rightAbsolute) {//如果到了最后一个的顶边了,就要绘制拖拽边缘效果
                float over = scrollX - rightBound;
                needsInvalidate = mRightEdge.onPull(Math.abs(over) / width);
            }
            scrollX = rightBound;
        }
        
        mLastMotionX += scrollX - (int) scrollX;
        //通过View.scollTo来滚动到指定的位置;触发之后,系统会不停地调用我们vp中重写的computeScroll
        //方法,在该方法中会调用completeScroll(true),他做了一件重要的事情,就是
        //重新计算内存中应该缓存的page,即populate方法触发。
        scrollTo((int) scrollX, getScrollY());
        //这里会不停地回调onPageScrolled,告诉使用者当前在滚动的位置是多少.....
        pageScrolled((int) scrollX);
     
        //返回数值表示是否需要重绘制,即调用vp自身的onDaw方法。从前面看到只有到达了边缘才需要重绘制,难道
        //我们滚动的时候不需要重新绘制ui吗,不符合view绘制策略呀。实际上vp的ondraw只负责marginDrawable
        //和边缘滚动效果,vp自身内容的绘制是交给View来做的,所以在边缘触发只是绘制边缘效果。其他的绘制会在
        //scrollTo中主动触发呢。
        return needsInvalidate;
    }
    
    
    • 总结一下performDrag方法吧: 当在滚动过程中,即onTouch的move中会不停地调用该方法来实现内容的滚动,它根据手势的位置计算滚动的距离,然后还会不断地去计算内存中应该重新存储哪些新的page页面。这就是他的主要目的啦......
  1. 手动设置滚动的页面或者手指抬起要停靠的页面,由 setCurrentItemInternal,setCurrentItem这类方法族来实现, 在onTouchEvent中的手指抬起的时候会有这么一段,

    //等待计算page内存页
    mPopulatePending = true;
    
    //计算抬起手指后要滚动到的页面
    int nextPage = determineTargetPage(currentPage, pageOffset, initialVelocity,
                                                       totalDelta);
    //设置滚动到对应的页面;
    setCurrentItemInternal(nextPage, true, true, initialVelocity);
    
    

    来看看setCurrentItemInternal的源码吧:

    //决定是否回调onPageSelected方法,可以看出只有不等的时候才会回调,因此
    //第一次显示page时候是不会调的哦;
    final boolean dispatchSelected = mCurItem != item;
    if (mFirstLayout) {
        mCurItem = item;
        if (dispatchSelected && mOnPageChangeListener != null) {
            mOnPageChangeListener.onPageSelected(item);
        }
        if (dispatchSelected && mInternalPageChangeListener != null) {
            mInternalPageChangeListener.onPageSelected(item);
        }
        requestLayout();
    } else {
        //重新计算page内存页面集合,但是由于前面mPopulatePending=true,up这里其实会跳过内部的计算的。
        populate(item);
        //滚动到特定的页面,这里会利用到Vp自带的Scroller去实现平滑滚动效果;
        scrollToItem(item, smoothScroll, velocity, dispatchSelected);
    }
    

    继续来看看scollToItem怎么来实现滚动页面的吧:

    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) {//up手势走的是这里;
            //根据距离和初速度来实现平滑地滚动;
            smoothScrollTo(destX, 0, velocity);
            if (dispatchSelected && mOnPageChangeListener != null) {
                //告诉使用者我们的变化到了哪个页面;
                mOnPageChangeListener.onPageSelected(item);
            }
            if (dispatchSelected && mInternalPageChangeListener != null) {
                mInternalPageChangeListener.onPageSelected(item);
            }
        } else {//非平滑滚动
            if (dispatchSelected && mOnPageChangeListener != null) {
                mOnPageChangeListener.onPageSelected(item);
            }
            if (dispatchSelected && mInternalPageChangeListener != null) {
                mInternalPageChangeListener.onPageSelected(item);
            }
         
            completeScroll(false);
             //调用View.scrollTo来实现滚动
            scrollTo(destX, 0);
            pageScrolled(destX);
        }
    }
    
    

    来吧,接着看smoothScrollTo方法, 看他怎么来实现平滑滚动:

    void smoothScrollTo(int x, int y, int velocity) {
         .......
            //如果已经滚动结束了,就设置SCROLL_STATE_IDLE状态, 然后使用populate计算内存页
            //如果还没到滚动结束点呢?
        if (dx == 0 && dy == 0) {
            completeScroll(false);
            populate();
            setScrollState(SCROLL_STATE_IDLE);
            return;
        }
     
        setScrollingCacheEnabled(true);
        //设置滚动状态SCROLL_STATE_SETTLING,表示还在自己滑动
        setScrollState(SCROLL_STATE_SETTLING);
    
        //下面就是计算慢性滑动的时间,最终的x,y坐标:
        final int width = getClientWidth();
        final int halfWidth = width / 2;
        final float distanceRatio = Math.min(1f, 1.0f * Math.abs(dx) / width);
        final float distance = halfWidth + halfWidth *
            distanceInfluenceForSnapDuration(distanceRatio);
    
        int duration = 0;
        //根据速度来计算时间
        velocity = Math.abs(velocity);
        if (velocity > 0) {
            duration = 4 * Math.round(1000 * Math.abs(distance / velocity));
        } else {
            final float pageWidth = width * mAdapter.getPageWidth(mCurItem);
            final float pageDelta = (float) Math.abs(dx) / (pageWidth + mPageMargin);
            duration = (int) ((pageDelta + 1) * 100);
        }
        duration = Math.min(duration, MAX_SETTLE_DURATION);
     //调用辅助来Scoller来计算不同时间的坐标
        mScroller.startScroll(sx, sy, dx, dy, duration);
        //发命令给系统做重绘制操作,系统接着会调用computeScroll方法,来根据滚动位置来滑动内容到指定位置;
        ViewCompat.postInvalidateOnAnimation(this);
    }
    
    

    来吧,来看看ViewPager重写的computeScroll方法;

        public void computeScroll() {
            //当是我们的滚动Scroller来负责计算,这里如果还没有滚动结束
            if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
                int oldX = getScrollX();
                int oldY = getScrollY();
                int x = mScroller.getCurrX();
                int y = mScroller.getCurrY();
             //滚动到指定的位置
                if (oldX != x || oldY != y) {
                    scrollTo(x, y);
                    if (!pageScrolled(x)) {
                        mScroller.abortAnimation();
                        scrollTo(0, y);
                    }
                }
    
                // 执行重新绘制操作,这里保证边缘效果能有机会绘制,vp的滚动位置绘制由scrollTo
                //自己去负责的;
                ViewCompat.postInvalidateOnAnimation(this);
                return;
            }
    
            // 如果滚动结束了,那么要干什么呢?
            completeScroll(true);
        }
    

    继续,快结束了, completeScroll:

    private void completeScroll(boolean postEvents) {
        
        //如果是还在滚动状态,就要计算page内存内容啦;
        boolean needPopulate = mScrollState == SCROLL_STATE_SETTLING;
    
        .......
        
        mPopulatePending = false;
        for (int i=0; i<mItems.size(); i++) {
            ItemInfo ii = mItems.get(i);
            if (ii.scrolling) {
                needPopulate = true;
                ii.scrolling = false;
            }
        }
        //这下面两个,一个是触发重绘,一个不是,但是都要执行mEndScrollRunnable,这个就是
        //调用我们的populate大法了,真不容易。
        if (needPopulate) {
            if (postEvents) {
                ViewCompat.postOnAnimation(this, mEndScrollRunnable);
            } else {
                mEndScrollRunnable.run();
            }
        }
    }
    
    

    看看mEndScrollRunnable实现,在前面手指抬起的时候,我们其实是没有计算内存中的page页的,有一个mPopulatePending状态跳过了实际计算,所以在最后页面滚动结束的时候来一次最终的计算,就是在这里了。

    private final Runnable mEndScrollRunnable = new Runnable() {
        public void run() {
            //设置SCROLL_STATE_IDLE状态
            setScrollState(SCROLL_STATE_IDLE);
            //计算内存中的page缓存内容;
            populate();
        }
    };
    
    
6. 存在的问题
  1. 当同时设置viewpager的padding和page item之间的margin, page的marginDrawable会绘制在错误的地方,他累计了对应的对应的padding,这是错误的计算;

2. 在ScrollView中直接使用viewager,宽高不生效。原因是ScrollView给子view的测量规格模式是UNSPECIFIED,而我们的Viewpager测量又是setMeasuredDimension(getDefaultSize(0, widthMeasureSpec), etDefaultSize(0, heightMeasureSpec))组合。解决也不是很难,只不过要针对不同的模式进行自定义测量策略,后面如果有时间,综合写一下系统控件各种测量存在的问题吧....

上一篇 下一篇

猜你喜欢

热点阅读