手把手讲解ViewPager翻页特效:向源码学习!

前言

2020年后第一篇,来点轻松的话题吧。在家办公,UI美眉心血来潮要搞一个滑动特效。ViewPager+TabLayout,老生常谈的东西了。 ViewPager 是基础的滑动切换控件,TabLayout 是和 ViewPager配合使用的标题栏部分(但是 TabLayout也可以脱离 ViewPager 独立使用)。根据查到的资料显示,谷歌工程师在 ViewPager创立之时,就给风骚的动画特效预留了接口,我们可以很方便地去使用这个接口进行动画编程,但是 TabLayout就比较悲情,不但动画没预留接口,甚至一些常规操作的接口都没有提供,所以网上也出现了一些人按照原 TabLayout的代码,自己去创造新的 xxTabLayout控件。

本文将提供 ViewPager+TabLayout 的实例效果开发思路,以及**Demo **github工程。有兴趣的童鞋们希望可以留言多多交流。

Demo地址:https://github.com/18598925736/StudyTabLayout/tree/hank_v1

正文大纲

  • 参考效果

  • 前置技能

  • 实现思路

  • 关键代码

  • 思维拓展

正文


参考效果

上图 UI 美眉给的手机录屏,是蚂蚁财富 app某一个版本上的滑动切换效果。

我们需要开发的是下面这一半这个滑动切换的控件


前置技能

经过对 ViewPager可能特效的研究,发现它自身就带有这种动画特效的可能性,不用我们去自定义控件。

但是上方的 TabLayout字体大小变化,指示器indicator的长度和位置变化,谷歌给的TabLayout貌似没法弄,所以只能自己 DIY了.

要完成这个特效,两个技能必须就位:

  • android 视图动画     android体系中比较原始的一种动画类型。原理,是将view的绘制过程指定区域,按照指定规则再进行一遍,但是原本view所携带的事件交互,则不受影响。由于无法真正地继承事件交互,所以被属性动画所取代。但是它仍然有自己的价值。在不涉及到交互,只考虑视觉效果的情况下,它的效率反而比属性动画更高。

  • 数学建模思想    不要误会,这里说的数学建模是一种思维方式,把我们肉眼看到的现象,用数学公式的形式表达出来而已,并不是什么高深的操作。学过自定义控件并且深入实践过的童鞋应该能够体会到,要想真正从0开始完成一个 DIY控件,会有大量的数学计算,而拥有好的数学思维能力,能够在自定义的时候如鱼得水。

实现思路

一、源码研究

要对ViewPager进行特效改造,那么首先我们要知道ViewPager是一个容器 ViewGroup,它内部的子 View是如何摆放的,虽然从视觉上我们能够感觉到 子view是横向摆放的,但是作为技术人,就要敢于追根究底,用源码说话。

进入源码,找到 onLayout方法( 以下是我提炼的关键代码):


1.  `protected  void onLayout(boolean changed,  int l,  int t,  int r,  int b)  {`

2.  `final  int count = getChildCount();`

3.  `...`

4.  `for  (int i =  0; i < count; i++)  {`

5.  `if  (child.getVisibility()  != GONE)  {`

6.  `final  LayoutParams lp =  (LayoutParams) child.getLayoutParams();`

7.  `int childLeft =  0;`

8.  `int childTop =  0;`

9.  `if  (lp.isDecor)  {`

10.  `...`

11.  `}`

12.  `}`

13.  `}`

14.  `...`

15.  `for  (int i =  0; i < count; i++)  {`

16.  `final  View child = getChildAt(i);`

17.  `if  (child.getVisibility()  != GONE)  {`

18.  `final  LayoutParams lp =  (LayoutParams) child.getLayoutParams();`

19.  `ItemInfo ii;`

20.  `if  (!lp.isDecor &&  (ii = infoForChild(child))  !=  null)  {`

21.  `int loff =  (int)  (childWidth * ii.offset);`

22.  `int childLeft = paddingLeft + loff;`

23.  `int childTop = paddingTop;`

24.  `if  (lp.needsMeasure)  {`

25.  `// This was added during layout and needs measurement.`

26.  `// Do it now that we know what we're working with.`

27.  `lp.needsMeasure =  false;`

28.  `final  int widthSpec =  MeasureSpec.makeMeasureSpec(`

29.  `(int)  (childWidth * lp.widthFactor),`

30.  `MeasureSpec.EXACTLY);`

31.  `final  int heightSpec =  MeasureSpec.makeMeasureSpec(`

32.  `(int)  (height - paddingTop - paddingBottom),`

33.  `MeasureSpec.EXACTLY);`

34.  `child.measure(widthSpec, heightSpec);`

35.  `}`

36.  `if  (DEBUG)  {`

37.  `Log.v(TAG,  "Positioning #"  + i +  " "  + child +  " f="  + ii.object`

38.  `+  ":"  + childLeft +  ","  + childTop +  " "  + child.getMeasuredWidth()`

39.  `+  "x"  + child.getMeasuredHeight());`

40.  `}`

41.  `child.layout(childLeft, childTop,`

42.  `childLeft + child.getMeasuredWidth(),`

43.  `childTop + child.getMeasuredHeight());`

44.  `}`

45.  `}`

46.  `}`

47.  `}`

上面代码中,对count进行了两轮循环,其中第一轮是针对lp.isDecortrue的,意为:如果当前view是一个decoration装饰,并不是adapter提供的view则返回true。

显然,我们要探讨的是adapter提供的View是如何摆放的,所以忽略这一块。

而在下面的循环中,可以看到:

1.  `child.layout(childLeft, childTop,`

2.  `childLeft + child.getMeasuredWidth(),`

3.  `childTop + child.getMeasuredHeight());`

这个便是child的排布的核心代码,追溯这4个参数,可以得知:第 1,3 参数表示left,right,他们都和一个 intloff=(int)(childWidth*ii.offset); 挂钩,而 第2,4参数表示 top,bottom,则并没有与任何动态参数相挂钩。

因此可以断定,ViewPager的子View排布,只会存在X轴方向上的位置偏差,在Y方向上会保持上下平齐。

其实还可以继续追溯 intloff=(int)(childWidth*ii.offset); 看看x轴方向上的位置偏差是如何造成的,但是目的已经达到,到有必要的时候再去追查。

确定是横向排布,那么左右滑动逻辑又是怎么样的呢?

找到 onTouchEvent() 方法,并且在其中找到 ACTION_MOVE 逻辑分支:

1.  `case  MotionEvent.ACTION_MOVE:`

2.  `if  (!mIsBeingDragged)  {`

3.  `final  int pointerIndex = ev.findPointerIndex(mActivePointerId);`

4.  `if  (pointerIndex ==  -1)  {`

5.  `// A child has consumed some touch events and put us into an inconsistent`

6.  `// state.`

7.  `needsInvalidate = resetTouch();`

8.  `break;`

9.  `}`

10.  `final  float x = ev.getX(pointerIndex);`

11.  `final  float xDiff =  Math.abs(x - mLastMotionX);`

12.  `final  float y = ev.getY(pointerIndex);`

13.  `final  float yDiff =  Math.abs(y - mLastMotionY);`

14.  `if  (DEBUG)  {`

15.  `Log.v(TAG,  "Moved x to "  + x +  ","  + y +  " diff="  + xDiff +  ","  + yDiff);`

16.  `}`

17.  `if  (xDiff > mTouchSlop && xDiff > yDiff)  {`

18.  `if  (DEBUG)  Log.v(TAG,  "Starting drag!");`

19.  `mIsBeingDragged =  true;`

20.  `requestParentDisallowInterceptTouchEvent(true);`

21.  `mLastMotionX = x - mInitialMotionX >  0  ? mInitialMotionX + mTouchSlop :`

22.  `mInitialMotionX - mTouchSlop;`

23.  `mLastMotionY = y;`

24.  `setScrollState(SCROLL_STATE_DRAGGING);`

25.  `setScrollingCacheEnabled(true);`

27.  `// Disallow Parent Intercept, just in case`

28.  `ViewParent parent = getParent();`

29.  `if  (parent !=  null)  {`

30.  `parent.requestDisallowInterceptTouchEvent(true);`

31.  `}`

32.  `}`

33.  `}`

34.  `// Not else! Note that mIsBeingDragged can be set above.`

35.  `if  (mIsBeingDragged)  {`

36.  `// Scroll to follow the motion event`

37.  `final  int activePointerIndex = ev.findPointerIndex(mActivePointerId);`

38.  `final  float x = ev.getX(activePointerIndex);`

39.  `needsInvalidate |= performDrag(x);`

40.  `}`

41.  `break;`

我们需要关注的只是X方向上的拖拽有什么规律。所以,顺着 finalfloatx=ev.getX(pointerIndex); 这个变量去找关键方法,最终锁定:performDrag(x); 它是处理X方向上位移的关键入口。


1.  `private  boolean performDrag(float x)  {`

2.  `boolean needsInvalidate =  false;`

4.  `final  float deltaX = mLastMotionX - x;`

5.  `mLastMotionX = x;`

7.  `...`

8.  `// Don't lose the rounded component`

9.  `mLastMotionX += scrollX -  (int) scrollX;`

10.  `scrollTo((int) scrollX, getScrollY());  // 关键代码1, 控件在画布上的横像滚动`

11.  `pageScrolled((int) scrollX);// 关键代码2,将 scrollX进一步往下传递`

13.  `return needsInvalidate;`

14.  `}`

发现两句关键代码,一个是处理滑动的scrolllTo,一个是把 scrollX往下传递的 pageScrolled(scrollX)。前面一句都明白,但是这个第二句就有点不懂了,继续深入。


1.  `private  boolean pageScrolled(int xpos)  {`

2.  `...`

3.  `final  float pageOffset =  (((float) xpos / width)  - ii.offset)`

4.  `/  (ii.widthFactor + marginOffset);`

5.  `final  int offsetPixels =  (int)  (pageOffset * widthWithMargin);`

7.  `mCalledSuper =  false;`

8.  `onPageScrolled(currentPage, pageOffset, offsetPixels);`

9.  `if  (!mCalledSuper)  {`

10.  `throw  new  IllegalStateException(`

11.  `"onPageScrolled did not call superclass implementation");`

12.  `}`

13.  `return  true;`

14.  `}`

追踪参数 xpos得知,x方向上的偏移量信息,最后进入了onPageScrolled(...)方法。


1.  `protected  void onPageScrolled(int position,  float offset,  int offsetPixels)  {`

2.  `// Offset any decor views if needed - keep them on-screen at all times.`

3.  `if  (mDecorChildCount >  0)  {`

4.  `...  // 这里还是在处理 装饰,所以不用看,而且参数也没进入到这里`

5.  `}`

7.  `dispatchOnPageScrolled(position, offset, offsetPixels);`

9.  `if  (mPageTransformer !=  null)  {`

10.  `final  int scrollX = getScrollX();`

11.  `final  int childCount = getChildCount();`

12.  `for  (int i =  0; i < childCount; i++)  {`

13.  `final  View child = getChildAt(i);`

14.  `final  LayoutParams lp =  (LayoutParams) child.getLayoutParams();`

16.  `if  (lp.isDecor)  continue;`

17.  `final  float transformPos =  (float)  (child.getLeft()  - scrollX)  / getClientWidth();`

18.  `mPageTransformer.transformPage(child, transformPos);`

19.  `}`

20.  `}`

22.  `mCalledSuper =  true;`

23.  `}`

又是两句关键代码:

dispatchOnPageScrolled(position,offset,offsetPixels);

点进去看了之后,发现只是调用了OnPageChangeListener监听回调。

如果我们设置了滑动监听,就可以在滑动的时候,收到回调。相信大家都用过这个。

mPageTransformer.transformPage(child,transformPos);

这里就比较奇怪了。这句代码把子view,以及子view当前的位置信息返回到了外界。

那么外界拿到这两个参数值之后可以做什么事呢?理论上,可以做任何事。

二、探索源码结论

  1. ViewPager的初始子view摆放,都是横向的。在纵向上是上下平齐。

  2. ViewPager将子view以及子view的当前位置参数,通过PageTransformer.transformPage(view,position)反馈到外界,能做很多。比如说,让横着排放的子view变成竖着放,又或者 让即将滑出屏幕的子view以倾斜的角度以某个加速度飞出去,为所欲为。这个就是我们可以完成这个动画的基础。

三、PageTransformer参数规律探索

ViewPager 提供了一个DIY滑动特效的可能性。不过在动手做动画之前,还需要了解这两个参数的变化规律。

新建一个Android工程,写好 ViewPager+TabLayout 的代码和布局。运行起来大概是这个效果:

同时,我们给viewpager加上setPageTransformer(…)方法,并且打印日志。


1.  `viewPager.adapter =  MyFragmentPagerAdapter(supportFragmentManager);`

2.  `viewPager.offscreenPageLimit =  3  // 最少缓存3个,让左右两边都显示出来` 

3.  `viewPager.setPageTransformer(true,  ViewPager.PageTransformer  { view, position ->`

4.  `Log.d("setPageTransformer",  "view:${view.hashCode()} | position:${position}")`

5.  `})`

然后启动app,看看日志:


1.  `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:136851691| position:0.0`

2.  `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:147234376| position:1.0`

3.  `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:75203809| position:2.0`

4.  `03-1214:14:46.2221583-1583/? D/setPageTransformer: view:35279366| position:3.0`

可以看到,在一开始,有4个子view被初始化,位置信息分别是 0.0 / 1.0 / 2.0 / 3.0。这是由于我设置了offscreenPageLimit 为3,所以除了当前view之外,还会初始化3个屏幕之外的view。这就意味着:当前view的position是0,而往右边,position会递增,每递增1个view,就会加1.0,反过来,我们也可以推导,往左边,每过一个view,position会递减。为了验证我们的推导,我们滑动一下,观察position的变化:

向左滑动一格。

日志节略如下:

hashCode为 136851691 的子view,它的position从原本的0.0,最终变成了 -1.0。


1.  `03-1214:22:11.8361583-1583/? D/setPageTransformer: view:136851691| position:-1.0`

而,原本hashCode为147234376,position为1的子view,position则变成了 0.0。


1.  `03-1214:22:11.8361583-1583/? D/setPageTransformer: view:147234376| position:0.0`

再试试向又滑动一格,hashCode为 136851691 的子view,从 -0.99326146 变成了0.0 ,这里的小数大概是由于计算精度丢失造成的。可以认为是 从 -1.0 变为了 0.0 。

画图描述刚才的结论(粉色是当前视野):

OK,了解到这里,position的变化规律基本也掌握了,那么接下来可以进行动画拆分编程实现。下篇放送关键代码。

完结

Redis基于内存,常用作于缓存的一种技术,并且Redis存储的方式是以key-value的形式。Redis是如今互联网技术架构中,使用最广泛的缓存,在工作中常常会使用到。Redis也是中高级后端工程师技术面试中,面试官最喜欢问的问题之一,因此作为Java开发者,Redis是我们必须要掌握的。

Redis 是 NoSQL 数据库领域的佼佼者,如果你需要了解 Redis 是如何实现高并发、海量数据存储的,那么这份腾讯专家手敲《Redis源码日志笔记》将会是你的最佳选择。

从 -1.0 变为了 0.0 。

画图描述刚才的结论(粉色是当前视野):

[外链图片转存中…(img-vM05s8rj-1623621023381)]

OK,了解到这里,position的变化规律基本也掌握了,那么接下来可以进行动画拆分编程实现。下篇放送关键代码。

完结

Redis基于内存,常用作于缓存的一种技术,并且Redis存储的方式是以key-value的形式。Redis是如今互联网技术架构中,使用最广泛的缓存,在工作中常常会使用到。Redis也是中高级后端工程师技术面试中,面试官最喜欢问的问题之一,因此作为Java开发者,Redis是我们必须要掌握的。

Redis 是 NoSQL 数据库领域的佼佼者,如果你需要了解 Redis 是如何实现高并发、海量数据存储的,那么这份腾讯专家手敲《Redis源码日志笔记》将会是你的最佳选择。

[外链图片转存中…(img-g39uYpT2-1623621023382)]

感兴趣的朋友可以通过点赞+戳这里的方式免费获取腾讯专家手写Redis源码日志笔记pdf版本!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值