手把手讲解 ViewPager翻页特效

前言

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

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

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

正文大纲

  • 参考效果
  • 前置技能
  • 实现思路
  • 关键代码
  • 思维拓展

正文


参考效果

特效.gif

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

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


前置技能

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

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

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

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

  • 数学建模思想

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

实现思路

一 ,源码研究

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

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

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    final int count = getChildCount();
    ...
     for (int i = 0; i < count; i++) {
        if (child.getVisibility() != GONE) {
              final LayoutParams lp = (LayoutParams) child.getLayoutParams();
              int childLeft = 0;
              int childTop = 0;
              if (lp.isDecor) {
                    ...
              }
          }
      }
    ...
    for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
                final LayoutParams lp = (LayoutParams) child.getLayoutParams();
                ItemInfo ii;
                if (!lp.isDecor && (ii = infoForChild(child)) != null) {
                    int loff = (int) (childWidth * ii.offset);
                    int childLeft = paddingLeft + loff;
                    int childTop = paddingTop;
                    if (lp.needsMeasure) {
                        // This was added during layout and needs measurement.
                        // Do it now that we know what we're working with.
                        lp.needsMeasure = false;
                        final int widthSpec = MeasureSpec.makeMeasureSpec(
                                (int) (childWidth * lp.widthFactor),
                                MeasureSpec.EXACTLY);
                        final int heightSpec = MeasureSpec.makeMeasureSpec(
                                (int) (height - paddingTop - paddingBottom),
                                MeasureSpec.EXACTLY);
                        child.measure(widthSpec, heightSpec);
                    }
                    if (DEBUG) {
                        Log.v(TAG, "Positioning #" + i + " " + child + " f=" + ii.object
                                + ":" + childLeft + "," + childTop + " " + child.getMeasuredWidth()
                                + "x" + child.getMeasuredHeight());
                    }
                    child.layout(childLeft, childTop,
                            childLeft + child.getMeasuredWidth(),
                            childTop + child.getMeasuredHeight());
                }
            }
        }
}

上面代码中,对 count 进行了两轮循环,其中第一轮是针对 lp.isDecortrue的,

意为:如果当前view是一个 decoration 装饰,并不是adapter提供的view 则返回 true

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

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

child.layout(childLeft, childTop,
                            childLeft + child.getMeasuredWidth(),
                            childTop + child.getMeasuredHeight());

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

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

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

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

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

case MotionEvent.ACTION_MOVE:
                if (!mIsBeingDragged) {
                    final int pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex == -1) {
                        // A child has consumed some touch events and put us into an inconsistent
                        // state.
                        needsInvalidate = resetTouch();
                        break;
                    }
                    final float x = ev.getX(pointerIndex);
                    final float xDiff = Math.abs(x - mLastMotionX);
                    final float y = ev.getY(pointerIndex);
                    final float yDiff = Math.abs(y - mLastMotionY);
                    if (DEBUG) {
                        Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
                    }
                    if (xDiff > mTouchSlop && xDiff > yDiff) {
                        if (DEBUG) Log.v(TAG, "Starting drag!");
                        mIsBeingDragged = true;
                        requestParentDisallowInterceptTouchEvent(true);
                        mLastMotionX = x - mInitialMotionX > 0 ? mInitialMotionX + mTouchSlop :
                                mInitialMotionX - mTouchSlop;
                        mLastMotionY = y;
                        setScrollState(SCROLL_STATE_DRAGGING);
                        setScrollingCacheEnabled(true);

                        // Disallow Parent Intercept, just in case
                        ViewParent parent = getParent();
                        if (parent != null) {
                            parent.requestDisallowInterceptTouchEvent(true);
                        }
                    }
                }
                // Not else! Note that mIsBeingDragged can be set above.
                if (mIsBeingDragged) {
                    // Scroll to follow the motion event
                    final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(activePointerIndex);
                    needsInvalidate |= performDrag(x);
                }
                break;

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

private boolean performDrag(float x) {
        boolean needsInvalidate = false;

        final float deltaX = mLastMotionX - x;
        mLastMotionX = x;

        ...
        // Don't lose the rounded component
        mLastMotionX += scrollX - (int) scrollX;
        scrollTo((int) scrollX, getScrollY()); // 关键代码1, 控件在画布上的横像滚动
        pageScrolled((int) scrollX);// 关键代码2,将 scrollX进一步往下传递

        return needsInvalidate;
    }

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

private boolean pageScrolled(int xpos) {
        ...
        final float pageOffset = (((float) xpos / width) - ii.offset)
                / (ii.widthFactor + marginOffset);
        final int offsetPixels = (int) (pageOffset * widthWithMargin);

        mCalledSuper = false;
        onPageScrolled(currentPage, pageOffset, offsetPixels);
        if (!mCalledSuper) {
            throw new IllegalStateException(
                    "onPageScrolled did not call superclass implementation");
        }
        return true;
    }

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

    protected void onPageScrolled(int position, float offset, int offsetPixels) {
        // Offset any decor views if needed - keep them on-screen at all times.
        if (mDecorChildCount > 0) {
            ... // 这里还是在处理 装饰,所以不用看,而且参数也没进入到这里
        }

        dispatchOnPageScrolled(position, offset, 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);
            }
        }

        mCalledSuper = true;
    }

又是两句关键代码:

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 的代码和布局。运行起来大概是这个效果:

一般滑动效果.gif

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

viewPager.adapter = MyFragmentPagerAdapter(supportFragmentManager);
viewPager.offscreenPageLimit = 3 // 最少缓存3个,让左右两边都显示出来        
viewPager.setPageTransformer(true, ViewPager.PageTransformer { view, position ->
    Log.d("setPageTransformer", "view:${view.hashCode()} | position:${position}")
})

然后启动app,看看日志:

03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:136851691 | position:0.0
03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:147234376 | position:1.0
03-12 14:14:46.222 1583-1583/? D/setPageTransformer: view:75203809 | position:2.0
03-12 14:14:46.222 1583-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

03-12 14:22:11.836 1583-1583/? D/setPageTransformer: view:136851691 | position:-1.0

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

03-12 14:22:11.836 1583-1583/? D/setPageTransformer: view:147234376 | position:0.0

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

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

滑动position变化.png

OK,了解到这里,position的变化规律基本也掌握了,那么接下来可以进行动画拆分 编程实现.

关键代码

有了思路,那么IT民工现在开始搬砖。

一,动画拆分各个击破

  1. 子view重叠排布

原本的子view都是横向,从左到又排布,默认的排布方式并没有相互覆盖. 所以我们可以考虑使用视图动画

? 为什么是视图动画,而不是属性动画?因为没必要,当前的需求我只需要视觉效果上的位置变化,不需要子view的交互事件,用属性动画理论上应该也可以,但是直觉会存在交互问题,有时间再试试).

使用视图动画,将所有子view层叠在一起。原本都是横向排布,所以只需要将所有的view进行x轴位移,即可。

上代码:

代码1.png

公式的推导很简单,就是让右边的子view向左平移 -position个自身宽度.

效果为:

改造1.gif

滑动之后,不再出现其他子view。

  1. 让多个子view之间呈现x轴上的位置差

虽然重叠在了一起,但是我还需要让右边的子view呈现位置偏差. 并且,越往右,偏差越大。

上代码:

代码2.png

效果:

改造2.png
  1. 让多个子view之间呈现缩放差

x轴上的位置差虽然有了,但是,原图上,越往右,越小,所以还需要做出x,y方向上的缩放

上代码:

代码3.png

效果:

改造3.png
  1. 监听滑动position,做出透明度逐渐变化

视觉效果都有了,那么可以开始做动画效果.

经过对position的观察,我们知道position会以小数的形式渐变。原图中,向左滑出的view,会以一个透明度慢慢减小的方式消失,那么先来完成这一步。


代码4.png

效果:

改造4.gif
  1. 监听滑动position,做出左滑时 当前view的平移动画

最后一步,滑出消失的view虽然透明度的动画完成了,但是原图中,还有一个渐渐向左移动的动画。

上代码:

代码5.png

效果:

改造5.gif

最终效果和原图差不多。

二,声明几个坑

如果有人按照我的思路去实现上面的效果,很有可能失败,因为其中几个坑。

  1. ViewGroup.clipChildren 属性

    任何一个ViewGroup的子类都具备的属性,它的作用是,决定是否消减掉 子view超出自身绘制范围的部分

    意思就是说,子view的绘制范围其实是无限大的,但是它能显示的范围由父viewGroup决定,这个属性为true,父view不允许子view超出自身的部分显示出来,反之,则是允许超出。这个属性默认是true。所以,如果发现 上述效果中某些部分显示不出来,就要看看ViewPager(它是一个ViewGroup)的clipChildren属性是否为true,如果是true,设置成false试试。如果还是不行,看看ViewPager的父容器 的 clipChildren属性是否为false。以此类推。

  2. ViewPager.setPageTranformer(boolean reverseDrawingOrder,PageTransformer transformer)方法有两个参数,第一个是 bool值,它能决定子view的绘制顺序。如果按照上述思路实现效果发现,是右边的子view覆盖了左边的子view,那么就要看看是不是这个值是不是true。如果不是ture,改成true再尝试。

  3. 第2点中,如果不想把 reverseDrawingOrder 设置为true,也有办法解决。android View体系中存在一个z轴概念,z值越大,就越在上层,其实,也可以使用改变子viewz属性的办法来解决覆盖效果错误的问题。(但是Z轴的设置与版本有关,要区分设备版本,不然低版本上可能程序崩溃)

思维拓展

还记得前文讲过的么,拿到了View之后,再根据滑动时的参数变化,我们几乎可以对它为所欲为,那么我们能做的,就不仅仅是 本次的目标效果,像是类似这种滑动特效,还有很多风骚的操作可以玩。像是:

额外效果1.gif
额外效果2.gif

没有做不到,只有想不到,想到之后,最终能做成什么效果,就要看自己的数学造诣够不够高了。

这次的研究,最大的收获,并不是 知道了pageTransformer这个接口,而是 一种解耦的编程思维,比如我们希望给一个View控件加特效,可以直接在,原本View控件里面去修改代码,重写onTouchEvent来响应滑动事件,或者重写draw/onDraw 进行另外的绘制,用这种方法,无论怎么做,都已经在对原View进行侵入式的变动,这种方法不到万不得已,不想用,因为一不小心改出连锁反应的bug,导致原来的某些特性都受到影响,得不偿失。

但是谷歌ViewPager提供了一个另外的思路,将内部View对象,以及view的相关参数通过接口的形式开放给外界,让编程者可以不再需要关心原本View的内部实现,而直接专心做自己的特效,符合编程的开闭法则,即保证了原代码的安全,又让新的特效代码与原View代码没有直接关联。这是一种优雅,安全又高效的编程方式!


结语

至此,ViewPager部分结束。做出这个效果的基本思路和详细过程都已经呈上。

再次给出Demo地址:https://github.com/18598925736/StudyTabLayout/tree/hank_v1

此Demo不仅仅是针对 ViewPager的滑动特效,还包含了TabLayout 呈现效果的完全自定义。至于TabLayout如何随心所欲的操纵,下一篇文章将会详解。先预告一个最终效果图, 文章会尽快出炉。

TabLayout+ViewPager特效.gif
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容