Android自定义ViewGroup第十三式之移花接木

前言

上个星期更新了网易云音乐之后,在发现->歌单页面中看到一个挺炫酷的效果,介系我没有见过的船新版本,看图:

在这里插入图片描述

对,一眼看上去就像是在ViewPager的基础上改造过,但仔细看,又不太像ViewPager的行为,因为它固定只有三个子View(我特意观察了几天),而且,在滑动的时候,除了尺寸和透明度的渐变,跟ViewPager有一个明显的区别就是,最前面的子View会向相反方向移动,这就像六一儿童节孩子们排队领糖果一样:最前面的领到了糖果,还想再领一次,于是就到最后面重新排队。哈哈
还有一个比较细节的效果就是,在手指滑动到屏幕宽度的一半左右,本来在中间的子View即将来到中间的子View他们会交换层级顺序,看:

在这里插入图片描述

emmmm,这样的话,基本不用考虑改造ViewPager了,直接自定义ViewGroup吧,而且ViewPager使用起来还要定义Adapter,很繁琐。


初步分析

先来观察一下它的行为:

  1. 静止的时候,中间大,两边小并且半透明,最左边的子View看上去是在总宽度的1/4上,也就是这三个子View把屏幕宽度分成了4份;
  2. 滑动时,在最前面的子View会向相反方向移动,但它的透明度和尺寸都不变;中间的子View,在移动过程中会越来越透明,尺寸也会越来越小;后面的子View刚好跟中间的相反,它会变得越来越大而且透明度也越来越大;
  3. 在手指移动了大概屏幕宽度的一半时,后面的两个子View会交换层级顺序;
  4. 手指松开后,会根据当前滑动的距离自行调整位置,即像ViewPager那样;
  5. 静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间,即一个选中的效果;

再根据它的行为来捋一下大致思路:

  1. 既然说把屏幕宽度分成了4份,那就是三条线,我们刚好可以根据这三条线来作为基准线,用来辅助子View定位;
  2. 可以用一个变量来记录当前手指滑动距离相对于屏幕宽度的百分比,有了这个百分比,要计算出来这些alpha,scale之类的就轻而易举了;
  3. 这个我们在下面讨论;
  4. 这个没难度,ACTION_UP之后播放一个ValueAnimator来更新位置就行了;
  5. 后面讨论;

交换子View层级顺序

那这个交换子View层级顺序的效果,这个应该怎么做呢?
很多同学第一时间可能会想到:先remove,再add回去。
嗯,这样做虽然也能实现交换顺序,但是还是重量级了点,在一些低端机上面还可能会出现闪一下的效果(因为移除了之后不能及时地add回去)。我们还有更高效和更轻量的方法:
了解过RecyclerView回收机制的同学,应该对LayoutManager中的detachViewattachView方法很有印象:在进行滚动的时候,LayoutManager会不断地detach无效的item,重新绑定数据之后,又会立即attach回去,那么,我们做交换层级顺序的,也可以用这种方式,来试试。
在ViewGroup中,这两个方法分别对应detachViewFromParentattachViewToParent,但都是用protected修饰的,我们要在外面调用它就要创建一个类继承现有的ViewGroup然后重写这两个方法并把protected改为public:

    @Override
    public void detachViewFromParent(View child) {
        super.detachViewFromParent(child);
    }

    @Override
    public void attachViewToParent(View child, int index, ViewGroup.LayoutParams params) {
        super.attachViewToParent(child, index, params);
    }

接着我们在Activity中监听子View点击事件:哪个子View被点击,就置顶哪个:

    public void moveToTop(View target) {
        //先确定现在在哪个位置
        int startIndex = mViewGroup.indexOfChild(target);
        //计算一共需要几次交换,就可到达最上面
        int count = mViewGroup.getChildCount() - 1 - startIndex;
        for (int i = 0; i < count; i++) {
            //更新索引
            int fromIndex = mViewGroup.indexOfChild(target);
            //目标是它的上层
            int toIndex = fromIndex + 1;
            //获取需要交换位置的两个子View
            View from = target;
            View to = mViewGroup.getChildAt(toIndex);

            //先把它们拿出来
            mViewGroup.detachViewFromParent(toIndex);
            mViewGroup.detachViewFromParent(fromIndex);
            
            //再放回去,但是放回去的位置(索引)互换了
            mViewGroup.attachViewToParent(to, fromIndex, to.getLayoutParams());
            mViewGroup.attachViewToParent(from, toIndex, from.getLayoutParams());
        }
        //刷新
        mViewGroup.invalidate();
    }

好,来看看效果:

在这里插入图片描述

哈哈,可以了。


拦截子View点击事件

上面我们观察到,当那个ViewGroup静止的时候,点击两边的子View并不会直接响应它的onClick事件,而是把它移动到中间。
网易云的做这个就舒服些,因为它可以在子View接收到onClick事件的时候,先跟这个ViewGroup互动一下,但因为我们做的是一个库,不可能叫大家自己去监听onClick后,还通知一下ViewGroup的,所以我们要在内部处理好这个逻辑,也就是要拦截子View的点击事件了。
到这里有同学可能会问:拦截子View点击事件?直接重写onInterceptTouchEvent方法,在里面判断并return true不就行了?

没错,大致流程是这样,但现在的问题是:如何找到这个被点击的View,来进行选择性的拦截?

这时候同学就会说:判断是否在子View[left, top, right, bottom]内不就行了。

emmmm,这个方法在一般情况下是可以,但是,如果子View应用过scale,translation,rotation之类的变换,就有问题了,再加上我们等下处理滑动的时候,还要将子View应用scale变换呢。

同学又会说:这种效果,在绝大多数情况下,子View都不会单独应用那些变换的,那我们也不用scale,要缩放就直接layout成子View的目标尺寸不就行了?这样的话,直接判断是否在边界内就行啦

不,直接layout成缩放后的大小是不行的,因为如果有子View在xml里面就已经写死了长宽,那么它在测量完之后,getMeasuredWidthgetMeasuredHeight方法通常会返回这个写死的值(这里为什么是用通常,而不用总是呢? 因为这个数值完全取决于那个子View,有的自定义View可能根本不会理会这个设置的值),这样一来,我们就控制不了它实际的尺寸了(除非是用wrap_content或match_parent),这种情况,在进行布局时,如果不按照它实际大小去layout,那么就会出现好像子View被裁剪了一样(如果layout的尺寸比实际的尺寸小的话),所以我认为网易云的效果,它子View是定制过的,也就是说那几个子View会根据最终layout出来的尺寸去调整View的内容。但是如果作为一个库的话,不可能让大家都像网易云这样做的,所以我们就选择用scale的方式来做了。

好了,来想想应该怎么拦截吧:

本来的思路是通过反射拿到onClickListener,然后在这个listener上面再套一层我们自己的listener的,结果看源码的时候发现:View.ListenerInfo(全部监听器都是由这个静态内部类来保管)里面的mOnClickListener是标记了@hide的!!!,又因为9.0系统禁止调用Hidden API的缘故(这里暂不讨论要怎么调用Hidden API),所以只能绕路走了,我们想一下其他方法。

还记不记得我们上次分析过的ViewGroup如何正确处理旋转、缩放、平移后的View的触摸事件
我们这次也刚好可以用的上,因为子View在移动过程中会进行scale操作,像刚刚那位同学说的直接判断是否在子View[left, top, right, bottom]内是不行的,正确的做法应该要像ViewGroup那样:先检查一下这个子View所对应的矩阵有没有应用过变换,如果有的话,还要先把触摸坐标映射到矩阵变换之前的对应位置,再来判断是否在View内。
那我们现在就来模拟一下,如何判断手指当前在哪个子View上:
先看ViewGroup的源码:

    public void transformPointToViewLocal(float[] point, View child) {
        ...
        if (!child.hasIdentityMatrix()) {
            child.getInverseMatrix().mapPoints(point);
        }
    }

它先是调用子View的hasIdentityMatrix方法来判断是否应用过变换,如果有的话,会接着调用getInverseMatrix方法。。。。
咦??等等,为什么我们平时写代码的时候,AS没有出现过这几个方法的提示呢?点进去看看先:

    final boolean hasIdentityMatrix() {
        return mRenderNode.hasIdentityMatrix();
    }

在这里插入图片描述
看。。看看getInverseMatrixpointInView方法:

    /**
     * Utility method to retrieve the inverse of the current mMatrix property.
     * We cache the matrix to avoid recalculating it when transform properties
     * have not changed.
     *
     * @return The inverse of the current matrix of this view.
     * @hide
     */
    public final Matrix getInverseMatrix() {
        ensureTransformationInfo();
        if (mTransformationInfo.mInverseMatrix == null) {
            mTransformationInfo.mInverseMatrix = new Matrix();
        }
        final Matrix matrix = mTransformationInfo.mInverseMatrix;
        mRenderNode.getInverseMatrix(matrix);
        return matrix;
    }
    
    /**
     * Utility method to determine whether the given point, in local coordinates,
     * is inside the view, where the area of the view is expanded by the slop factor.
     * This method is called while processing touch-move events to determine if the event
     * is still within the view.
     *
     * @hide
     */
    public boolean pointInView(float localX, float localY, float slop) {
        return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
                localY < ((mBottom - mTop) + slop);
    }

@hide @hide @hide!
在这里插入图片描述

没事!来仔细看一下它们各自方法内的实现,

  • hasIdentityMatrix(),里面是直接调用mRenderNode的hasIdentityMatrix;
  • getInverseMatrix(),核心就是mRenderNode.getInverseMatrix(matrix)这句,也就是说,他也是依赖于mRenderNode的;
  • pointInView(),就是几个简单的判断,里面[mLeft, mRight, mTop, mBottom]我们也完全可以在外面使用它们的get方法来获取,这就代表着我们可以自己在外面定义方法,来代替它这个pointInView;

那么,我们现在来看mRenderNode了,先检查下它的声明:

    /**
     * RenderNode holding View properties, potentially holding a DisplayList of View content.
     * <p>
     * When non-null and valid, this is expected to contain an up-to-date copy
     * of the View content. Its DisplayList content is cleared on temporary detach and reset on
     * cleanup.
     */
    final RenderNode mRenderNode;

太好了,没有被标记@hide,接下来看看它里面的hasIdentityMatrix和getInverseMatrix方法:

    public boolean hasIdentityMatrix() {
        return nHasIdentityMatrix(mNativeRenderNode);
    }

    public void getInverseMatrix(@NonNull Matrix outMatrix) {
        nGetInverseTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
    }

哇,这么好!居然是public的,也就是说,我们连反射都省了。
那再看看它这个类本身是不是也是public的:

    /**
     * ...
     * ...
     * @hide
     */
    public class RenderNode {

啊,绝望,RenderNode这个类被标记了@hide。。。
在这里插入图片描述
不得不说,Google爸爸在这方面确实做得很绝考虑得很周到。

怎么办,要妥协吗?

其实,还有一个不是很装逼优雅的方法也可以正确判断到当前手指在哪个View上:
我们刚刚的思路,不是有记录每个子View的scale的吗?

  1. 我们可以用子View的当前宽高来乘对应的scale,最终得出缩放后的[left, top, right, bottom],并保存在自定义的LayoutParams中;
  2. 定义pointInView方法,在里面判断[x, y]是否在刚刚保存的边界范围内就行;

emmmm,在没有其他更好的办法的情况下,用这种方法也是挺好的,起码能解决问题。

既然有后手,为何不尝试一下?

细心的同学会发现,View里面也有个getMatrix方法,这个方法可以在外部调用,即没有被标记@hide的:

    public Matrix getMatrix() {
        ensureTransformationInfo();
        final Matrix matrix = mTransformationInfo.mMatrix;
        mRenderNode.getMatrix(matrix);
        return matrix;
    }

可以看到,它最终也是通过RenderNodegetMatrix方法来实现的,来看看RenderNode的实现:

    public void getMatrix(@NonNull Matrix outMatrix) {
        nGetTransformMatrix(mNativeRenderNode, outMatrix.native_instance);
    }

有没有发现,这个getMatrix,跟我们刚刚在上面贴出来的hasIdentityMatrixgetInverseMatrix方法一样,都是直接调用对应的native方法的

而熟悉Matrix的同学会知道,在Matrix里面也有个isIdentity方法,那么,

  • MatrixisIdentityRenderNodehasIdentityMatrix之间又有着什么样的联系呢?
  • RenderNodegetMatrixgetInverseMatrix之间又有什么不同呢?它们里面究竟做了些什么呢?

emmmm,源码会给我们答案。
在系统源码 /frameworks/base/core/jni/ 目录下,会看到一个叫android_view_RenderNode.cpp的文件
先来看看nHasIdentityMatrixnGetTransformMatrixnGetInverseTransformMatrix分别对应哪三个方法:

    static const JNINativeMethod gMethods[] = {
        ...
        { "nHasIdentityMatrix",       "(J)Z",  (void*) android_view_RenderNode_hasIdentityMatrix },
        { "nGetTransformMatrix",       "(JJ)V", (void*) android_view_RenderNode_getTransformMatrix },
        { "nGetInverseTransformMatrix","(JJ)V", (void*) android_view_RenderNode_getInverseTransformMatrix },
        ...
    };

可以看到nHasIdentityMatrix是对应android_view_RenderNode_hasIdentityMatrix方法,来看看它的实现:

    static jboolean android_view_RenderNode_hasIdentityMatrix(jlong renderNodePtr) {
        RenderNode* renderNode = reinterpret_cast<RenderNode*>(renderNodePtr);
        return !renderNode->stagingProperties().hasTransformMatrix();
    }

它拿到RenderNode实例后(RenderNode可以在系统源码/frameworks/base/libs/hwui/目录下找到),先是通过stagingProperties方法拿到RenderProperties这个属性,接着调用它的hasTransformMatrix方法

打开RenderNode.h,可以分别看到以下声明:

    RenderProperties mStagingProperties;
    const RenderProperties& stagingProperties() {
        return mStagingProperties;
    }

那么我们接下来,就要看RenderProperties了,打开RenderProperties.h,找一下hasTransformMatrix方法:

    bool hasTransformMatrix() const {
        // getTransformMatrix方法返回的SkMatrix不为空,
        // 并且SkMatrix的isIdentity方法返回false,就表示这个矩阵应用过变换
        return getTransformMatrix() && !getTransformMatrix()->isIdentity();
    }
    
    const SkMatrix* getTransformMatrix() const {
        return mComputedFields.mTransformMatrix;
    }

看到了吗?hasTransformMatrix里面最终也会调用SkMatrix的isIdentity方法!
SkMatrixisIdentityMatrixisIdentity又有什么关系呢?
我们来看Matrix.cpp (/frameworks/base/core/jni/android/graphics/)

    static jboolean isIdentity(jlong objHandle) {
        SkMatrix* obj = reinterpret_cast<SkMatrix*>(objHandle);
        return obj->isIdentity() ? JNI_TRUE : JNI_FALSE;
    }

哈哈哈,可以看到,它里面也是调用SkMatrix的isIdentity方法,也就是说,RenderNode的hasIdentityMatrix和Matrix的isIdentity最终都是会调用SkMatrix的isIdentity方法,它们的效果是一样的!

我们刚刚分析过,ViewgetMatrix方法,里面是调用RenderNodegetMatrixViewgetInverseMatrix,里面是调用RenderNodegetInverseMatrix
那么我们现在就来看看getTransformMatrix(RenderNode的getMatrix)和getInverseTransformMatrix(RenderNode的getInverseMatrix)这两个方法:

    static void android_view_RenderNode_getTransformMatrix(jlong renderNodePtr, jlong outMatrixPtr) {
        // 通过强制类型转换得到renderNode
        RenderNode* renderNode = reinterpret_cast<RenderNode*>(renderNodePtr);
        
        // 通过强制类型转换得到outMatrix
        SkMatrix* outMatrix = reinterpret_cast<SkMatrix*>(outMatrixPtr);
        
        // 通过renderNode中的StagingProperties属性的getTransformMatrix方法来获取变换矩阵
        const SkMatrix* transformMatrix = renderNode->stagingProperties().getTransformMatrix();
        
        // 如果这个变换矩阵不为空的话,
        if (transformMatrix) {
            // 把指针transformMatrix所指的内容,覆盖到outMatrix所指的内容上
            *outMatrix = *transformMatrix;
        } else {
            // 如果变换矩阵为空,那就表示没有应用过变换,所以isIdentity要=true
            outMatrix->setIdentity();
        }
    }
    
    static void android_view_RenderNode_getInverseTransformMatrix(jlong renderNodePtr, jlong outMatrixPtr) {
        // 先获取变换矩阵,如果有的话
        android_view_RenderNode_getTransformMatrix(renderNodePtr, outMatrixPtr);
        
        // 通过强制类型转换得到outMatrix
        SkMatrix* outMatrix = reinterpret_cast<SkMatrix*>(outMatrixPtr);
    
        // 将outMatrix反转(即获取逆矩阵),并把反转之后的矩阵重新赋给outMatrix
        if (!outMatrix->invert(outMatrix)) {
            // 如果反转失败,证明此矩阵没有应用过变换
            outMatrix->setIdentity();
        }
    }

啊!看到没有?!RenderNode中getInverseMatrix(即getInverseTransformMatrix)的具体实现,就是先调用它的getMatrix(即getTransformMatrix)方法得到变换矩阵之后,再调用这个矩阵的invert方法! 这样就没了!
矩阵的invert方法,就是把当前矩阵反转,得到它的逆矩阵,逆矩阵是什么? 即:

  • 如果一个矩阵它水平平移了50(setTranslate(50, 0)),那么这个矩阵的逆矩阵就是水平平移了-50;
  • 如果一个矩阵它缩小了20%(setScale(0.8F, 0.8F)),那么这个矩阵的逆矩阵就是放大了20%;
  • 如果一个矩阵它顺时针旋转了90°(setRotate(90)),那么这个矩阵的逆矩阵就是逆时针旋转90°;

那么,我们现在就不难理解,为什么ViewGroup要获取逆矩阵来映射触摸点了。
还有就是,现在我们完全可以不使用反射,来做到像ViewGroup那样,判断触摸点是否在子View范围内了
现在来试一下:

    /**
     * @param view 目标view
     * @param points 坐标点(x, y)
     * @return 坐标点是否在view范围内
     */
    private boolean pointInView(View view, float[] points) {
        // 像ViewGroup那样,先对齐一下Left和Top
        points[0] -= view.getLeft();
        points[1] -= view.getTop();
        // 获取View所对应的矩阵
        Matrix matrix = view.getMatrix();
        // 如果矩阵有应用过变换
        if (!matrix.isIdentity()) {
            // 反转矩阵
            matrix.invert(matrix);
            // 映射坐标点
            matrix.mapPoints(points);
        }
        //判断坐标点是否在view范围内
        return points[0] >= 0 && points[1] >= 0 && points[0] < view.getWidth() && points[1] < view.getHeight();
    }

在这里插入图片描述

哈哈哈,成功了!完美避开使用反射。
那么等下写代码的时候,就可以将这个方法应用到我们的ViewGroup当中,用来拦截子View原有的点击事件。

在这里插入图片描述

编写代码

好啦,那么现在我们总体的思路也有了,是时候开始写代码了。

在开始之前,先给我们的ViewGroup起个名字吧,因为它的行为比较像ViewPager,不过它的Item又不能像ViewPager那样不固定,在可扩展性这方面是不及ViewPager。但是,ViewPager使用起来的流程比较繁琐,还要定义Adapter之类的,而我们这个就不用,所以在易用性方面,我们的ViewGroup更胜一筹。在日常开发中,我们所使用的数据库通常都是SQLite,寓意是轻量级的数据库,那么我们的ViewGroup也可以叫LitePager,寓意是轻量级的ViewPager,挺洋气的名字,哈哈哈哈哈,就叫LitePager吧。

测量

好,开始吧,首先是onMeasure,那宽高应该怎么确定呢? 如果宽高设置了wrap_content的话:

  • 宽度可以用它那三个子View的宽度之和;
  • 高度就使用它的子View中高度最大的吧(大多数场景下子View高度都是统一的);

来看看代码怎么写:

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 先测量子View
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        
        val width = measureWidth(widthMeasureSpec)
        val height = measureHeight(heightMeasureSpec)

        setMeasuredDimension(width, height)
    }

    private fun measureHeight(heightMeasureSpec: Int): Int {
        var height = 0

        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize
        } else {
            //如果高度设置了wrap_content,则取最大的子View高
            var maxChildHeight = 0
            for (i in 0 until childCount) {
                val child = this[i]
                val layoutParams = child.layoutParams as LayoutParams
                maxChildHeight = Math.max(maxChildHeight, child.measuredHeight
                        + layoutParams.topMargin + layoutParams.bottomMargin)
            }
            height = maxChildHeight
        }
        return height
    }

    private fun measureWidth(widthMeasureSpec: Int): Int {
        var width = 0

        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize
        } else {
            //如果宽度设置了wrap_content,则取全部子View的宽度和
            for (i in 0 until childCount) {
                val child = this[i]
                val layoutParams = child.layoutParams as LayoutParams
                width += child.measuredWidth + layoutParams.leftMargin + layoutParams.rightMargin
            }
        }
        return width
    }

虽然是Kotlin代码,但是逻辑挺清晰的,就算不熟悉Kotlin的同学也能很轻易的看懂(还没开始学Kotlin的同学赶快跟上大家的脚步啦~)。

细心的同学可能会发现这个:

    val child = this[i]

这个像访问数组元素一样的操作方式,在Kotlin中叫下标运算符,想要支持这个运算符的话,只需要在类中声明一个get方法,并标记operator:

    operator fun get(index: Int) = getChildAt(index)

我们现在的实现是调用getChildAt方法。在java中可没有这种特性的哦~

布局

好,接下来到布局了,上面我们分析过,可以用三条基准线来辅助定位,来看看代码怎么写:

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (index in 0 until childCount) {
            val child = this[index]
            //获取基准线
            val baseLine = getBaselineByChild(child)
            //布局子View
            layoutChild(child, baseLine)
        }
    }

    private fun getBaselineByChild(child: View) =
            //根据子View在ViewGroup中的索引计算基准线
            when (indexOfChild(child)) {
                0 -> width / 4 //左边的线 (最底的子View放在左边)
                1 -> width / 2 + width / 4 //右边的线 (接着放在右边)
                2 -> width / 2 //中间的线 (最顶的子View放在中间)
                else -> 0
            }

    private fun layoutChild(child: View, baseLine: Int) {
        //获取子View测量宽高
        val childWidth = child.measuredWidth
        val childHeight = child.measuredHeight
        //垂直的中心位置,即高度的一半
        val baseLineCenterY = height / 2
        //根据基准线来定位水平上的位置
        val left = baseLine - childWidth / 2
        val right = left + childWidth
        //垂直居中
        val top = baseLineCenterY - childHeight / 2
        val bottom = top + childHeight

        val lp = child.layoutParams as LayoutParams

        child.layout(left + lp.leftMargin + paddingLeft,
                top + lp.topMargin + paddingTop,
                right + lp.leftMargin - paddingRight,
                bottom + lp.topMargin - paddingBottom)
    }

可以看到,获取基准线被定义成了一个单独的方法getBaselineByChild,为什么呢,因为等下处理滑动手势的时候,这个基准线是需要动态计算的。
有同学可能会问:这个方法里面的width是从哪里来的呢?没看到有在哪里声明啊

这个也是Kotlin的特性之一,我们看到的width,其实是访问getter方法,在这里也就是getWidth()了,还有layoutChild方法里面的height也是同理,调用的是getHeight()

好,来看看初步的效果(为了更容易理解,加上了辅助线):

在这里插入图片描述

emmmm,挺好。

处理缩放和透明度

上面我们看到网易云的效果是两边的子View会变小和变透明,那么这些属性(缩放比例、透明度)肯定要用变量保存起来的,保存在哪里好呢?用一个集合来装吗?当然不是了,我们可以扩展LayoutParams,把这些属性都放在LayoutParams里面:
因为我们的ViewGroup需要支持Margin,所以继承自MarginLayoutParams:

    class LayoutParams : MarginLayoutParams {

        var scale = 0F
        var alpha = 0F

        constructor(c: Context, attrs: AttributeSet) : super(c, attrs)

        constructor(width: Int, height: Int) : super(width, height)

        constructor(source: ViewGroup.LayoutParams) : super(source)
    }

定义好之后,还要重写三个生成LayoutParams的方法,并在里面返回我们自己的LayoutParams:

    override fun generateLayoutParams(attrs: AttributeSet) = LayoutParams(context, attrs)

    override fun generateLayoutParams(p: ViewGroup.LayoutParams) = LayoutParams(p)

    override fun generateDefaultLayoutParams() = LayoutParams(WRAP_CONTENT, WRAP_CONTENT)

那么,LayoutParams中的scale和alpha在哪里初始化好呢?
当然是在addView方法里了,在这里,我们还可以顺便限制一下子View的个数(因为超过三个的话,就不知道应该怎么布局了。。。网易云上也只有固定的三个):

    override fun addView(child: View, index: Int, params: ViewGroup.LayoutParams) {
        if (childCount > 2) {
            //满座了就直接拋异常,提示不能超过三个子View
            throw IllegalStateException("LitePager can only contain 3 child!")
        }
        //如果传进来的LayoutParams不是我们自定义的LayoutParams的话,就要创建一个
        val lp = if (params is LayoutParams) params else LayoutParams(params)
        if (childCount < 2) {
            lp.alpha = mMinAlpha
            lp.scale = mMinScale
        } else {
            lp.alpha = 1F
            lp.scale = 1F
        }
        super.addView(child, index, params)
    }

可以看到,我们重写的addView方法,在限制子View个数之后,会进行判断:如果是最后一个,也就是第三个添加进来的子View,alphascale才是"正常"的(缩放比例和透明度都是1,即100%)
mMinAlphamMinScale用来保存最小的不透明度和缩放比例,当然了,这两个值是可以给外部修改的,默认值分别是0.40.8

接下来,还需要修改一下刚刚的layoutChild方法:在进行layout之前,先更新一下不透明度和缩放比例:

    private fun layoutChild(child: View, baseLine: Int) {
        val lp = child.layoutParams as LayoutParams

        //更新不透明度
        child.alpha = lp.alpha
        //更新缩放比例
        child.scaleX = lp.scale
        child.scaleY = lp.scale
        
        //其他地方不变
        ...
        ...
    }

这个child.xxx = xxx,其实是调用setter方法啦,child.alpha = lp.alpha 等于java中的 child.setAlpha(lp.alpha);

好了,来看看效果:
在这里插入图片描述

emmmm,可以看到,现在的效果已经跟网易云的差不多了,下面我们来添加手势滑动的效果。

支持滑动手势

网易云的处理方式是:子View从当前基准点移动到下一个基准点时,偏移量刚好等于ViewGroup的宽度,也就是说,当手指的水平滑动距离=ViewGroup宽度时,这个ViewGroup也刚好切换页面了(即进行了一次完整的滑动)。
这样的话,我们就需要计算手指水平滑动的百分比,然后转换成基准线之间的百分比,计算出偏移的距离后,还需要进行以下处理:

  • 如果手指是向左滑动,那么最左边的子View(index=0),要向右偏移。反之,如果是向右滑动的话,最右边的子View(index=1)要向左偏移;
  • 当滑动到ViewGroup宽度的一半时,新旧的中间View要交换层级顺序;
  • 当水平滑动的距离超出ViewGroup宽度时,应该当作是新的一次偏移了,这个在每次计算前判断一下就行了;

第一个没什么难度,先用indexOfChild方法获取到子View在ViewGroup中的索引然后根据这个索引来判断就行了。
第二个,我们在开头的时候就已经分析过要怎么交换顺序了,所以等下可以直接把那个方法应用到这里来;

第三,如果当前向右滑动的距离=ViewGroup的宽度,也就是说这时候已经进行了一次完整的滑动了:本来在右边的子View,现在已经到了左边、本来在中间的,现在在右边、本来在左边的,现在到了中间。那么,如果现在继续向右滑动的话,一开始在右边的子View(现在在左边),就要向右移动而不是上一次的向左了,其他两个子View同理。
这种情况的话,怎么去正确的判断哪个子View是要往左,哪个要往右呢?想一下
有没有发现,这种问题可以用我们生活中的场景来辅助解决:我们平时叫滴滴,你告诉师傅你在哪,要去哪里,只要你的出发地和目的地正确,师傅就能顺利的把你送到目的地。哈哈哈

那么,我们也可以在LayoutParams里面记录子View的fromto,在每一次完整的滑动之后,更新每一个子View的这两个值。
嗯,就这么决定了,开始写代码:
首先是重新onInterceptTouchEvent方法,我们要在这里去判断并拦截触摸事件:

    override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        val x = event.x
        val y = event.y
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                //更新上一次的触摸坐标
                mLastX = x
                mLastY = y
            }
            MotionEvent.ACTION_MOVE -> {
                val offsetX = x - mLastX
                val offsetY = y - mLastY
                //判断是否触发拖动事件
                if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) {
                    //更新上一次的触摸坐标
                    mLastX = x
                    mLastY = y
                    //标记已开始拖拽
                    isBeingDragged = true
                }
            }
            MotionEvent.ACTION_UP -> {
                //标记没有在拖拽
                isBeingDragged = false
            }
        }
        return isBeingDragged
    }

这里跟一般的ViewGroup没什么不同,大致逻辑就是:判断手指的移动距离是否>指定值,如果是,就拦截并标记正在拖拽。

接下来到onTouchEvent,我们在里面要处理的逻辑有:

  • 更新滑动百分比;
  • 更新子View的出发点和目的地;
  • 更新子View的层级顺序;
  • 更新子View的不透明度和缩放比例;

来看看代码:

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val x = event.x
        val y = event.y
        when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                val offsetX = x - mLastX
                mOffsetX += offsetX
                onItemMove()
            }
            MotionEvent.ACTION_UP -> {
                isBeingDragged = false
            }
        }
        mLastX = x
        mLastY = y
        return true
    }

可以看到我们把处理ACTION_MOVE单独定义了一个方法,把刚刚说的要做的事情都放在onItemMove里面:

    private fun onItemMove() {
        //更新滑动百分比
        mOffsetPercent = mOffsetX / width
        //更新子View的出发点和目的地
        updateChildrenFromAndTo()
        //更新子View的层级顺序
        updateChildrenOrder()
        //更新子View的不透明度和缩放比例
        updateChildrenAlphaAndScale()
        //请求重新布局
        requestLayout()
    }

先来看一下如何更新出发点和目的地(updateChildrenFromAndTo()),逻辑稍微有点复杂,要仔细看一下注释。:

    private fun updateChildrenFromAndTo() {
        //如果滑动的距离>=ViewGroup宽度
        if (Math.abs(mOffsetPercent) >= 1) {
            //遍历子View,标记已经到达目的地
            for (i in 0 until childCount) {
                val lp = this[i].layoutParams as LayoutParams
                lp.from = lp.to
            }
            //处理溢出: 比如总宽度是100,现在是120,那么处理之后会变成20
            mOffsetX %= width.toFloat()
            //同理,这个是百分比
            mOffsetPercent %= 1F
        } else {
            //遍历子View,并根据当前滑动的百分比来更新子View的目的地
            for (i in 0 until childCount) {
                val lp = this[i].layoutParams as LayoutParams
                lp.to = when (lp.from) {
                    //最左边的子View,如果是向右滑动的话,那么它的目的地是中间,也就是2了
                    //如果是向左滑动的话,目的地是最右边的位置,也是1了,下面同理
                    0 -> if (mOffsetPercent > 0) 2 else 1
                    //最右边的子View,如果是向右滑动,那么目的地就是最左边(0),反之,在中间(2)
                    1 -> if (mOffsetPercent > 0) 0 else 2
                    //中间的子View,如果向右滑动,目的地是右边(1),向左就是左边(0)
                    2 -> if (mOffsetPercent > 0) 1 else 0
                    else -> return
                }
            }
        }
    }

可以看到有一句是lp.to = when (lp.from),还没开始学习Kotlin的同学可能会不了解
这个when,有点像java的switch,但是跟switch最大的区别就是,when是一个表达式,也就是可以有返回值,所以上面那句lp.to = when (lp.from),这个lp.to,最终接收的时候when里面的if else的返回值。
好,接下来看看updateChildrenOrder方法,要注意的是,每次滑动距离超过50%的时候只会交换一次顺序,除了这个还要处理回退的问题,也就是滑动超过一半后(这时已经交换过顺序),又反方向滑动,这时候也要交换一次顺序:

    private fun updateChildrenOrder() {
        //如果滑动距离超过了ViewGroup宽度的一半,
        //就把索引为1,2的子View交换顺序,并标记已经交换过
        if (Math.abs(mOffsetPercent) > .5F) {
            if (!isReordered) {
                exchangeOrder(1, 2)
                isReordered = true
            }
        } else {
            //滑动距离没有超过宽度一半,即有可能是滑动超过一半然后滑动回来
            //如果isReordered=true,就表示本次滑动已经交换过顺序
            //所以要再次交换一下
            if (isReordered) {
                exchangeOrder(1, 2)
                isReordered = false
            }
        }
    }

现在还不行,还要在刚刚的updateChildrenFromAndTo方法内判断滑动完成那里,重置这个isReordered,因为如果不在那里重置的话,在下一次该交换顺序的时候就会出问题了,我们来修改一下updateChildrenFromAndTo方法:

    private fun updateChildrenFromAndTo() {
        if (Math.abs(mOffsetPercent) >= 1) {
            //在这里要重置一下标记
            isReordered = false
            //其他地方不变
            ...
        } else {
            //其他地方不变
            ...
        }
    }

好,回到updateChildrenOrder方法中,可以看到交换顺序的exchangeOrder方法,也就是我们一开始分析的那段:

    private fun exchangeOrder(fromIndex: Int, toIndex: Int) {
        //一样的就不用换了
        if (fromIndex == toIndex) {
            return
        }
        //先获取引用
        val from = this[fromIndex]
        val to = this[toIndex]

        //分离出来
        detachViewFromParent(from)
        detachViewFromParent(to)
       
        //重新放回去,但是index互换了
        attachViewToParent(from, if (toIndex > childCount) childCount else toIndex, from.layoutParams)
        attachViewToParent(to, if (fromIndex > childCount) childCount else fromIndex, to.layoutParams)
      
        //通知重绘,刷新视图
        invalidate()
    }

还有最后的updateChildrenAlphaAndScale,这个逻辑也有点复杂,要仔细看注释:

    private fun updateChildrenAlphaAndScale() {
        //遍历子View
        for (i in 0 until childCount) {
            updateAlphaAndScale(this[i])
        }
    }

    private fun updateAlphaAndScale(child: View) {
        val lp = child.layoutParams as LayoutParams
        //用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
        when (lp.from) {
            //最左边的子View
            0 -> when (lp.to) {
                //如果它目的地是最右边的话
                1 -> {
                    //要把它放在最底,为了避免在移动过程中遮挡其他子View
                    setAsBottom(child)
                    //透明度和缩放比例都不用变,因为现在就已经满足条件了
                }
                //如果它要移动到中间
                2 -> {
                    //根据滑动比例来计算出当前的透明度和缩放比例
                    lp.alpha = mMinAlpha + (1F - mMinAlpha) * mOffsetPercent
                    lp.scale = mMinScale + (1F - mMinScale) * mOffsetPercent
                }
            }
            //最右边的子View
            1 -> when (lp.to) {
                0 -> {
                    //把它放在最底,避免在移动过程中遮挡其他子View
                    setAsBottom(child)
                    //透明度和缩放比例都不用变
                }
                2 -> {
                    //这里跟上面唯一不同的地方就是mOffsetPercent要取负的
                    //因为它向中间移动的时候,mOffsetPercent是负数,这样做就刚好抵消
                    lp.alpha = mMinAlpha + (1F - mMinAlpha) * -mOffsetPercent
                    lp.scale = mMinScale + (1F - mMinScale) * -mOffsetPercent
                }
            }
            //中间的子View
            2 -> {
                //这里不用判断to了,因为无论向哪一边滑动,不透明度和缩放比例都是减少
                lp.alpha = 1F - (1F - mMinAlpha) * Math.abs(mOffsetPercent)
                lp.scale = 1F - (1F - mMinScale) * Math.abs(mOffsetPercent)
            }
        }
    }

setAsBottom方法也就是把目标子View的索引跟索引0交换顺序:

    private fun setAsBottom(child: View) {
        //获取child索引后跟0交换层级顺序
        exchangeOrder(indexOfChild(child), 0)
    }

还记不记得我们刚刚重写onLayout方法的时候,有个获取基准线的方法(getBaselineByChild),里面返回的数值是写死的?
我们现在还要改一下它,改成根据滑动百分比(mOffsetPercent)来动态计算基准线:

    private fun getBaselineByChild(child: View): Int {
        //左边View的初始基准线
        val baseLineLeft = width / 4
        //中间的
        val baseLineCenter = width / 2
        //右边的
        val baseLineRight = width - baseLineLeft

        var baseLine = 0

        val lp = child.layoutParams as LayoutParams
        //用出发点来作为条件,而不是当前索引,因为如果使用当前索引的话,在交换顺序之后,就不正确了
        when (lp.from) {
            //左边的子View
            0 -> baseLine = when (lp.to) {
                //目的地是1,证明手指正在向左滑动,所以下面的mOffsetPercent是用负的
                //当前基准线 = 初始基准线 + 与目标基准线(现在是右边)的距离 * 滑动百分比
                1 -> baseLineLeft + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
                
                //如果目的地是中间(2),那目标基准线就是ViewGroup宽度的一半了(baseLineCenter),计算方法同上
                2 -> baseLineLeft + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
                else -> baseLineLeft
            }
            //右边的子View
            1 -> baseLine = when (lp.to) {
                //原理同上
                0 -> baseLineRight + ((baseLineRight - baseLineLeft) * -mOffsetPercent).toInt()
                2 -> baseLineRight + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
                else -> baseLineRight
            }
            //中间的子View
            2 -> baseLine = when (lp.to) {
                //原理同上
                0 -> baseLineCenter + ((baseLineCenter - baseLineLeft) * mOffsetPercent).toInt()
                1 -> baseLineCenter + ((baseLineRight - baseLineCenter) * mOffsetPercent).toInt()
                else -> baseLineCenter
            }
        }
        return baseLine
    }

好啦,来看看现在的效果是怎么样的:

在这里插入图片描述

哈哈哈,差不多了,接下来我们处理一下手指松开的事件:当手指松开后,要播放选中动画。

加入选中动画

终于来到简单的部分了,我们先改一下onTouchEventonInterceptTouchEvent方法,在ACTION_UP里面加上一个handleActionUp方法:

    override fun onTouchEvent(event: MotionEvent): Boolean {
        ...
        when (event.action) {
            ...
            MotionEvent.ACTION_UP -> {
                ...
                handleActionUp(x, y)
            }
        }
        ...
    }
    
    override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        ...
        when (event.action) {
            ...
            MotionEvent.ACTION_UP -> {
                ...
                handleActionUp(x, y)
            }
        }
        ...
    }

    private fun handleActionUp(x: Float, y: Float) {
        playFixingAnimation()
    }

可以看到handleActionUp里面先是直接调用了playFixingAnimation方法:

    private fun playFixingAnimation() {
        //没有子View还播放什么动画,出去
        if (childCount == 0) {
            return
        }
        //起始点,就是当前的滑动距离
        val start = mOffsetX
        //结束点
        val end = when {
            //如果滑动的距离超过了宽度的一半,那么就顺势往那边走下去
            //如果滑动百分比是正数,表示是向右滑了>50%,所以目的地就是宽度
            mOffsetPercent > .5F -> width.toFloat()
            //相反,如果是负数,那就拿负的宽度
            mOffsetPercent < -.5F -> -width.toFloat()
            //如果滑动没超过50%,那就把距离变成0,也就是回退了
            else -> 0F
        }
        startValueAnimator(start, end)
    }

    private fun startValueAnimator(start: Float, end: Float) {
        if (start == end) {
            //起始点和结束点一样,那还播放什么动画,出去
            return
        }
        //先打断之前的动画,如果正在播放的话
        abortAnimation()
        //创建动画对象
        mAnimator = with(ValueAnimator.ofFloat(start, end)){
            //指定动画时长
            duration = mFlingDuration
            //监听动画更新
            addUpdateListener { animation ->
                val currentValue = animation.animatedValue as Float
                //更新滑动距离
                mOffsetX = currentValue
                //处理子View的移动行为
                onItemMove()
            }
            //开始动画
            start()
            this
        }
    }

    private fun abortAnimation() {
        mAnimator?.let { if (it.isRunning) it.cancel() }
    }

创建动画对象那里,对于还没开始学习Kotlin的同学可能会觉得很陌生,那个with看起来好像是跟java中的switch、if、else这些关键字一样
其实不是,它也是一个方法:接收一个对象,然后返回一个对象,不难看出,我们传进去的是一个ValueAnimator对象,后面紧跟的{ }先忽略,直接看里面的内容:它一开始的赋值,和接下来的调用addUpdateListenerstart方法,都是ValueAnimator的,也就是说,在Kotlin中,使用这个with方法之后,后面的lambda可以直接访问with参数里面的属性和方法,而不用指定对象(xxx.),最后的this,就是返回传进去的这个对象。
那个abortAnimation方法,其实等于java的:

    private void abortAnimation() {
        if (mAnimator != null && mAnimator.isRunning()) {
            mAnimator.cancel();
        }
    }

好,接着,当动画未播放完成时,接收到手指按下的事件,也应该要停止播放动画,所以我们应该在onTouchEventonInterceptTouchEvent方法也加上abortAnimation

    override fun onTouchEvent(event: MotionEvent): Boolean {
        ...
        when (event.action) {
            ...
            MotionEvent.ACTION_DOWN -> {
                //开始滑动前先打断动画
                abortAnimation()
                ...
            }
        }
        ...
    }
    
    override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
        ...
        when (event.action) {
            ...
            MotionEvent.ACTION_DOWN -> {
                //开始滑动前先打断动画
                abortAnimation()
                ...
            }
        }
        ...
    }

好啦,来看看效果:

在这里插入图片描述

可以可以,接下来到最后一个功能了。

处理点击事件

一开始我们分析的时候,发现网易云的点击两边的子View会切换位置,而不是触发它们的点击事件,为了不使用反射也是费了一番功夫,那么现在就把那个解决方案应用到这里来吧。
既然是点击事件,那么还要判断是不是点击事件,要怎么判断呢?

  • 在手指按下时,记录按下的坐标(mDownX, mDownY);
  • 当手指松开后,用当前手指的坐标点和按下时的坐标点作比较,如果没有超出最小滑动距离(mTouchSlop),就认为是一次点击事件;
  • 再判断当前手指坐标有没有在某个子View范围内,如果有的话,就选中这个子View,没有的话,就当作一次普通的ACTION_UP,即播放选中动画;

好,首先来改造一下handleActionUp方法:

    private fun handleActionUp(x: Float, y: Float): Boolean {
        val offsetX = x - mDownX
        val offsetY = y - mDownY
        //判断是否点击手势
        if (Math.abs(offsetX) < mTouchSlop && Math.abs(offsetY) < mTouchSlop) {
            //查找被点击的子View
            val hitView = findHitView(x, y)
            if (hitView != null) {
                return if (indexOfChild(hitView) == 2) {
                    //点击第一个子view不用播放动画,直接不拦截
                    false
                } else {
                    val lp = hitView.layoutParams as LayoutParams
                    setSelection(lp.from)
                    //拦截ACTION_UP事件,内部消费
                    true
                }
            }
        }
        //手指在空白地方松开
        playFixingAnimation()
        return false
    }

接下来看看findHitView如何找到当前手指所在的子View:

    private fun findHitView(x: Float, y: Float): View? {
        //从顶层的子View开始递减遍历
        for (index in childCount - 1 downTo 0) {
            val child = this[index]
            //判断触摸点是否在这个子View内
            if (pointInView(child, floatArrayOf(x, y))) {
                //如果在就直接返回它
                return child
            }
        }
        //没有找到,返回null
        return null
    }

最后是一个setSelection方法:

    fun setSelection(index: Int) {
        //目标index已被选中、无子View、正在播放动画,这几种情况下,都直接忽略,即不播放本次动画
        if (indexOfChild(this[childCount - 1]) == index || childCount == 0 || mAnimator?.isRunning) {
            return
        }
        //起始点就是当前滑动距离
        val start = mOffsetX
        //结束点
        val end = when (index) {
            //如果要选中0,即左边的子View,那么就要向右移动
            0 -> width.toFloat()
            //反之,如果是1(右边的子View),则向左移动,也就是负数了
            1 -> -width.toFloat()
            else -> return
        }
        //开始播放动画
        startValueAnimator(start, end)
    }

看看效果:

在这里插入图片描述

哈哈哈,可以啦~
发下最终的效果图:

在这里插入图片描述在这里插入图片描述

那个切换方向,还有指定最大缩放比例、最大不透明度就当留给同学们的作业啦,在Github上有源码。

好了,本篇文章到此结束,有错误的地方请指出,谢谢大家!

Github地址:https://github.com/wuyr/LitePager 欢迎Star

  • 42
    点赞
  • 56
    收藏
    觉得还不错? 一键收藏
  • 39
    评论
Android自定义ViewGroup是指在Android开发中,通过继承ViewGroup类来创建自定义的布局容器。自定义ViewGroup可以用于实现一些特殊的布局效果,比如侧滑菜单、滑动卡片等等。通过自定义ViewGroup,我们可以更灵活地控制子视图的布局和交互行为,以满足特定的需求。自定义ViewGroup的实现主要包括重写onMeasure()方法和onLayout()方法,来测量和布局子视图。同时,我们还可以通过重写onInterceptTouchEvent()方法和onTouchEvent()方法来处理触摸事件,实现自定义的交互效果。如果你对自定义ViewGroup还不是很了解,或者正想学习如何自定义,可以参考相关的教程和文档,如引用\[1\]和引用\[2\]所提到的博客和官方文档。 #### 引用[.reference_title] - *1* [Android 手把手教您自定义ViewGroup(一)](https://blog.csdn.net/iteye_563/article/details/82601716)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *2* [使用LayoutParams自定义安卓ViewGroup](https://blog.csdn.net/lfq88/article/details/127268493)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] - *3* [Android自定义ViewGroup](https://blog.csdn.net/farsight2009/article/details/62046643)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^insertT0,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 39
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值