Android 中 View移动总结:ViewDragHelper学习及用法详解

原文:  Android 中 View移动总结:ViewDragHelper学习及用法详解



转载注明:http://blog.csdn.net/itermeng/article/details/52159637 阿里噶多~

如上图简单呈现出两个方块后,提出一个需求: 
1.拖动方块时,方块(即子View)可以跟随手指移动。 
2.一个方块移动时,另一个方块可以跟随移动。 
3.将方块移动到左边区域(右边区域)后放开(即手指离开屏幕),它会自动移动到左边界(右边界)。 
4.移动的时候给方块加点动画(duang~duang~duang~) 。




View移动的相关方法总结:

1. layout

在自定义控件中,View绘制的一个重写方法layout(),用来设置显示的位置。所以,可以通过修改View的坐标值来改变view在父View的位置,以此可以达到移动的效果!但是缺点是只能移动指定的View:

    //通过layout方法来改变位置
    view.layout(l,t,r,b);
 
 
  • 1
  • 2
  • 1
  • 2

2.offsetLeftAndRight() 和 offsetTopAndBottom()

非常方便的封装方法,只需提供水平、垂直方向上的偏移量,展示效果与layout()方法相同。

    view.offsetLeftAndRight(offset);//同时改变left和right
    view.offsetTopAndBottom(offset);//同时改变top和bottom
 
 
  • 1
  • 2
  • 1
  • 2

3. LayoutParams

此类保存了一个View的布局参数,可通过LayoutParams动态改变一个布局的位置参数,以此动态地修改布局,达到View位置移动的效果!但是在获取getLayoutParams()时,要根据该子View对应的父View布局来决定自身的LayoutParams 。所以一切的前提是:必须要有一个父View,否则无法获取LayoutParams !

//必须获取父View的LayoutParams 
        LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams)getLayoutParams();
        layoutParams.leftMargin = getLeft() + dx;
        layoutParams.topMargin = getTop() + dy;
        setLayoutParams(layoutParams);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

4. scrollTo 和 scrollBy

通过改变scrollXscrollY来移动,但是可以移动所有的子ViewscrollTo(x,y)表示移动到一个具体的坐标点(x,y),而scrollBy(x,y)表示移动的增量为dx,dy。

    scrollTo(x,y);
    scrollBy(xOffset,yOffset);
 
 
  • 1
  • 2
  • 1
  • 2

注意:这里使用scrollBy(xOffset,yOffset);,你会发现并没有效果,因为以上两个方法移动的是View的content。若在ViewGroup中使用,移动的是所有子View;若在View中使用,移动的是View的内容(比如TextView)。

所以,不可在view中使用以上方法!应该在View所在的ViewGroup中使用:

((View)getParent()).scrollBy(offsetX, offsetY);
 
 
  • 1
  • 1

【视图坐标系】: 
这里写图片描述

可是即使这样,你会发现view移动的效果与设想方向相反!这是Android试图移动原因,若参数为正值,content将向坐标轴负方向移动;参数为负值,content将向坐标轴正方向移动。所以要实现随手指移动而滑动的效果,应将偏移量设置为负值即可:

((View)getParent()).scrollBy(-offsetX, -offsetY);
 
 
  • 1
  • 1

5. canvas

通过改变Canvas绘制的位置来移动View的内容,用的少:

 canvas.drawBitmap(bitmap, left, top, paint)
 
 
  • 1
  • 1

总结

但是要完成最开始的提的需求,不管使用哪一种方法,都需要通过onTouchEvent方法来捕捉手势,自己手动计算移动距离,再改变子View的布局,不免有些麻烦,所以在这里引出正文,介绍一个强大的类来处理移动:ViewDragHelper






ViewDragHelper介绍:

1. 产生: ViewDragHelper在高版本的v4包(android4.4以上的v4)中,于Google在2013年开发者大会提出的

2. 作用:它主要用于处理ViewGroup中对子View的拖拽处理。

3. 使用:它主要封装了对View的触摸位置触摸速度移动距离等的检测和Scroller,通过接口回调的方式通知我们。所以我们需要做的只是用接收来的数据指定这些子View是否需要移动,移动多少等。

4. 本质:是一个对触摸事件的解析类






ViewDragHelper实现

1. ViewDragHelper实例创建

    /**
     * Factory method to create a new ViewDragHelper.
     *
     * @param forParent Parent view to monitor
     * @param sensitivity Multiplier for how sensitive the helper should be about detecting
     *the start of a drag. Larger values are more sensitive. 1.0f is normal.
     * @param cb Callback to provide information and receive events
     * @return a new ViewDragHelper instance
     */
viewDragHelper = ViewDragHelper.create(forParent, sensitivity, cb);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

create()就是创建ViewDragHelper实例的方法,代码中的注释是create()中参数的解释,来查看: 
(1)forParent :“用来监视的父View”。传入参数父View,即可监视该父View中的所有子View。 
(2)sensitivity:“检测时的敏感度;值越大越敏感,1是正常范围”。比如说手指在滑动屏幕时速度特别快,敏感度越大时,此时速度快也可以检测到,反之亦然。 
(3)Callback :“提供信息和接受的事件”。最重要的参数!可以从这个回调提供的信息获取到View滑动的距离、速度等。




2. 自定义View继承FrameLayout

这里来个小提示:之前实现的布局中自定义DragLayout是继承于ViewGroup,并且实现重写了onMeasure()方法,如下:

DragLayout.java

public class DragLayout extends ViewGroup{

    ...
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        //方法一:对子View的测量需求
        /*获取子View的宽度100dp  的两种方法:
        int size = (int) getResources().getDimension(R.dimen.width);
        int size = readView.getLayoutParams().width;*/
        int measureSpec = MeasureSpec.makeMeasureSpec(redView.getLayoutParams().width, MeasureSpec.EXACTLY);//具体指定宽高,为精确模式
        redView.measure(measureSpec,measureSpec);//当父控件测量完子控件,才可以填(0,0)
        blueView.measure(measureSpec,measureSpec);

       /* //方法二:如果说没有特殊的对子View的测量需求,可用如下方法
        measureChild(redView,widthMeasureSpec,heightMeasureSpec);
        measureChild(blueView,widthMeasureSpec,heightMeasureSpec);*/
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

但是现在使DragLayout 类继承于FrameLayout即可!

public class DragLayout extends FrameLayout{
        ...
}
 
 
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

因为在自定义ViewGroup的时候,如果对子View的测量没有特殊的需求,那么可以继承系统已有的布局(比如FrameLayout、RelativeLayout),目的是为了让已有的布局帮我们实现onMeasure()

所以在继承之后,我们无需实现onMeasure()方法,以上代码全部不需要(这里选择继承FrameLayout帧布局,原因是在android源码中其实现最简单),所以重写继承的onLayout()方法其实是重写帧布局中的onLayout(),如果也注释的话,你会发现蓝色小方块覆盖红色,一起摆放在左上角(其实就是帧布局的摆放规则)




3. callback回调创建

    private ViewDragHelper.Callback callback = new Callback() {
        //必须要实现的方法
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return false;
        }
    };
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7



4. 触摸、拦截事件

以上部分ViewDragHelper的创建部分已完成,可是还没结束。比如大家熟悉的一个类:GestureDetector手势识别器,想要它生效,必须传一个触摸事件,这样GestureDetector类才可以解析当前手势。道理相同,之前在介绍ViewDragHelper已提到,它只是一个对触摸事件的解析类,需要传一个触摸事件,才会生效。

   //处理是否拦截
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //由viewDragHelper 来判断是否应该拦截此事件
        boolean result = viewDragHelper.shouldInterceptTouchEvent(ev);
        return result;
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //将触摸事件传给viewDragHelper来解析处理
        viewDragHelper.processTouchEvent(event);
        //消费掉此事件,自己来处理
        return true;
    }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

以上则viewDragHelper可以监视并解析我们的手势了,而且会把信息通过回调传递给callback。




5. 处理computeScroll()

该方法是Scroller类的核心,系统在绘制View的时候在draw()中调用此方法,实际与scrollTo()相同。

    @Override
    public void computeScroll() {
        super.computeScroll();
        if(scroller.computeScrollOffset()){
            scrollTo(scroller.getCurrX(),scroller.getCurrY());
            invalidate();
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

如上,Scroller类提供computeScrollOffset()方法来判断是否完成了整个滑动,同时getCurrX()getCurrY()来获得当前滑动坐标。

重点是invalidate()方法,因为只能在computeScroll()方法中获取模拟过程中的scrollX 和 scrollYcomputeScroll()方法是不会自动调用的,只能通过invalidate() —> draw() —>computeScroll()来间接调用computeScroll()方法!模拟过程结束,if判断中computeScrollOffset()方法返回false,中断循环,完成整个平滑移动过程!

但是!!!我们并不采取以上方法,之前介绍过ViewDragHelper已经封装好了Scroller,用另外一种:

    @Override
    public void computeScroll() {
        super.computeScroll();
        if(viewDragHelper.continueSettling(true)){
            ViewCompat.postInvalidateOnAnimation(DragLayout.this);
        }
    }
}
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

continueSettling()方法判断是否结束,同Scroller的方法相似,主要是postInvalidateOnAnimation(),此方法不像Scroller的scrollTo,还需要传值,其实此方法体内已经封装好移动的方法,它会自动去测量当前位置进行移动,所以我们只需调用即可!(在手指抬起时回调的方法中也会用到它,后面介绍)




6. 实现callback回调中的方法

之前在创建callback时,默认只实现了tryCaptureView()方法 ,完成需求仅仅不够,还需要其它方法,依次介绍:

(1) tryCaptureView()

此方法用于判断是否捕获当前child的触摸事件,可以指定ViewDragHelper移动哪一个子View。此例中,需要移动两个方块,则判断当前View是否是自己想移动的,返回boolean值。

        /**用于判断是否捕获当前child的触摸事件
         * @param child         当前触摸的子View
         * @return              true:捕获并解析      false:不处理
         */
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return child == blueView || child == redView;
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

(2) onViewCaptured()

此方法在View被开始捕获和解析时回调,即当tryCaptureView()中的返回值为true的时候,此方法才会被调用。

例如tryCaptureView()方法中只捕获红色方块,当移动红方快时,该方法会回调,移动蓝色方块时则不会!

        /** 当View被开始捕获和解析的回调(用处不大)
         * @param capturedChild     当前被捕获的子View
         */
        @Override
        public void onViewCaptured(View capturedChild, int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
            Log.e("tag","onViewCaptures");
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

(3) clampViewPositionHorizontal() 和 clampViewPositionVertical()

这两个为具体滑动方法,分别对应水平和垂直方向上的移动。要想子View移动,此方法必须重写实现!

而方法的返回值则是指定View在水平(left)垂直(top)方向上变成的值,参数中的dxdy则是代表相较于上一次位置的增量

        /**     控制child在水平方向的移动
         * @param child
         * @param left  ViewDragHelper会将当前child的left值改变成返回的值
         * @param dx    相较于上一次child在水平方向上移动的
         * @return
         */
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }
        /**控制child在垂直方向的移动
         * @param child         
         * @param top           ViewDragHelper会将当前child的top值改变成返回的值
         * @param dy            相较于上一次child在水平方向上移动的
         * @return
         */
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return top;
        }
    };
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

显示效果: 
这里写图片描述

这里写图片描述

通过以上GIF动图和日志打印可以看出,仅将返回值设置成方法中的参数,方块就可以任意移动了。也证实了方法中提供的参数而dx或dy是每一次移动的距离,left或top 是指定View移动到的位置,这是计算好了的,相当于left = child.getLeft() + dx。 
若想要它不移动,则: 
return left - dx; 
将它计算好后的距离减去相较于上次移动的距离即可,此时的View就不会移动。所以根据你的需求,可以任意改变此方法的返回值来移动View。


(4) getViewHorizontalDragRange() 和 getViewVerticalDragRange()

看到以上GIF动图,你会发现我在移动方块时,它可以超过边界,没有任何限制!有些不合理,想限制它的移动范围,这两个方法就可以获取View的拖拽范围,将它的返回值设为:父控件的宽/高 - 子控件的宽/高,即控件可以移动的范围。

        //获取View水平方向的拖拽范围
        @Override
        public int getViewHorizontalDragRange(View child) {
            return getMeasuredWidth() - child.getMeasuredWidth();
        }
        //     获取View垂直方向的拖拽范围
        @Override
        public int getViewVerticalDragRange(View child) {
            return getMeasuredHeight() - child.getMeasuredHeight();
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

可是以上实现后,你会发现拖拽方块还是可以超出边界,此方法并没有起作用!是否代表此方法完全无用?这返回的值有何用?

不是,它目前确实并不可以限制边界,但此方法返回的值会用在:比如说手指抬起时,View缓慢移动的动画时间的计算会用到此值,最好不要返回0(返回也不会错)!

但是我们还想要达到限制View拖拽边界的效果,这时在第三点介绍的clampViewPositionHorizontal() 和 clampViewPositionVertical()发挥效果了,该方法是通过返回值来改变View移动的位置,这时可以在方法中加判断是否有越界:

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            if(left <0){
                //限制左边界
                left = 0;
            }else if (left > (getMeasuredWidth() - child.getMeasuredWidth())){
                //限制右边界
                left = getMeasuredWidth() - child.getMeasuredWidth();
            }
            return left;
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

显示效果: 
这里写图片描述


(5)onViewPositionChanged()

目前为止,需求已经完成可以任意拖拽View了,接下来完成拖拽View时,另一块跟随移动。这时介绍一个新的方法:onViewPositionChanged()该方法在child(需要捕捉的View)位置改变时执行,参数left(top)跟之前介绍方法中含义相同,为child最新的left(top)位置,而dx(dy)是child相较于上一次移动时水平(垂直)方向上改变的距离。

了解之后,就知道这个方法很强大了,在方法体中判断具体View,再根据方法提供的参数设置另一View的位置,如下:

    /**当child位置改变时执行
         * @param changedView   位置改变的子View
         * @param left           child最新的left位置
         * @param top            child最新的top位置
         * @param dx            相较于上一次水平移动的距离
         * @param dy            相较于上一次垂直移动的距离
         */
    @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
            if(changedView == blueView){
                //拖动蓝色方块时,红色也跟随移动
                redView.layout(redView.getLeft()+dx , redView.getTop()+dy ,
                        redView.getRight()+dx , redView.getBottom()+dy);
            }else if(changedView == redView){
                //拖动红色方块时,蓝色也跟随移动
                blueView.layout(blueView.getLeft()+dx , blueView.getTop()+dy ,
                        blueView.getRight()+dx , blueView.getBottom()+dy);
            }
        }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

显示效果: 
这里写图片描述


(6)onViewReleased()

完成目前需求第三个:手指在左边(右边)区域离开屏幕后,方块自动移动到左边界(右边界)。接下来介绍最后一个方法onViewReleased()手指抬起的时候执行该方法。

这里有两个新参数: 
xvel: x方向移动的速度,若是正值,则代表向右移动,若是负值则向左移动; 
yvel: y方向移动的速度,若是正值则向下移动,若是负值则向上移动。

这里写图片描述

       /**手指抬起的时候执行该方法
         * @param releasedChild   当前抬起的View
         * @param xvel             x方向移动的速度:正值:向右移动  负值:向左移动
         * @param yvel             y方向移动的速度:正值:向下移动  负值:向上移动
         */
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            int centerLeft = getMeasuredWidth()/2 - releasedChild.getMeasuredWidth()/2;
            if(releasedChild.getLeft() < centerLeft){
                //在左半边,应该向左缓慢移动,不用scroller,ViewDragHelper已封装好
                viewDragHelper.smoothSlideViewTo(releasedChild,0,releasedChild.getTop());
                //仍需要刷新!
                ViewCompat.postInvalidateOnAnimation(DragLayout.this);
//                scroller.startScroll();
//                invalidate();

            }else {
                //在右半边,向右缓慢移动
                viewDragHelper.smoothSlideViewTo(releasedChild,getMeasuredWidth() - releasedChild.getMeasuredWidth(),releasedChild.getTop());
                ViewCompat.postInvalidateOnAnimation(DragLayout.this);
            }

        }
    };
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

显示效果: 
这里写图片描述




7. 执行伴随动画

还剩下最后一个需求,在方块移动时加些动画,说到动画引入一个概念:百分比(即子View左侧占子View可移动宽度的比例)。在移动子View的时候,比如从左到右,那么百分比则是0~1。做个实验,在回调onViewPositionChanged()加入两行:

            //1.计算view移动的百分比
            float fraction = changedView.getLeft() * 1f / (getMeasuredWidth() - changedView.getMeasuredWidth());
            Log.e("tag","fraction:"+fraction);
            //2.执行一系列的伴随动画
            executeAnim(fraction);
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

结果: 
这里写图片描述

证实了我们以上的推论,所以现在可以通过传参数百分比来完成我们想要的动画效果:

    /**
     * 执行伴随动画
     * @param fraction
     */
    private void executeAnim(float fraction){
        //fraction: 0 - 1
        //缩放
//      ViewHelper.setScaleX(redView, 1+0.5f*fraction);
//      ViewHelper.setScaleY(redView, 1+0.5f*fraction);
        //旋转
//      ViewHelper.setRotation(redView,360*fraction);//围绕z轴转
        ViewHelper.setRotationX(redView,360*fraction);//围绕x轴转
//      ViewHelper.setRotationY(redView,360*fraction);//围绕y轴转
        ViewHelper.setRotationX(blueView,360*fraction);//围绕z轴转
        //平移
//      ViewHelper.setTranslationX(redView,80*fraction);
        //透明
//      ViewHelper.setAlpha(redView, 1-fraction);

    }
 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

最终成品:

这里写图片描述






以上了解后,ViewDragHelper的学习到此为止,接下来利用它做一个侧滑什么的更是不在话下,包括现在网上的彷QQ侧滑面板都是利用ViewDragHelper完成的,所以工欲善其事,必先利其器呀~

关于View移动总结的,参照了徐宜生老师的《Android群英传》,讲解了许多View相关知识,重新加深理解了,还是很有帮助的。

如需要资源的,留言一下可发



希望对你们有帮助 :)


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值