为何说Android ViewDragHelper是神器 (二)

前言: 通过上一篇的为何说Android ViewDragHelper是神器 (一)中我们简单了解了ViewDragHelper的用法,然后实现了一个“view随手指滑动而滑动”的效果,代码很简单,但是VDH中处理的逻辑却很多很多,不得不说VDH真的是神器,要我们自己写的话得写一段时间了,接下来我们继续往下研究研究VDH,加油吧!骚年(^__^) !!!
以下demo内容大致参考鸿阳博客中的Android ViewDragHelper解析 一文,阳神一直是我崇拜的一个偶像(^__^) 。

ViewDragHelper还能做以下的一些操作:

  • 边界检测、加速度检测(eg:DrawerLayout边界触发拉出)

  • 移动到某个指定的位置(eg:点击Button,展开/关闭Drawerlayout)

  • 回调Drag Release(eg:DrawerLayout部分,手指抬起,自动展开/收缩)

    那么我们接下来对我们最基本的例子进行改造,包含上述的几个操作。
    我们再创建两个view,id叫autobackview(拖动后手指一抬起返回初始位置),edgeview(滑动边缘开始滑动的view):
    ids.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--可以被拖动的View-->
    <item name="draggedview" type="id"/>
    <!--拖动后手指一抬起返回初始位置-->
    <item name="autobackview" type="id"/>
    <!--滑动边缘开始滑动的view-->
    <item name="edgeview" type="id"/>
</resources>

text_layout.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.cisetech.demo.MainActivity">

    <com.cisetech.demo.DragView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true">
        <TextView
            android:id="@id/draggedview"
            android:layout_margin="10dp"
            android:gravity="center"
            android:layout_gravity="center"
            android:background="#44ff00ff"
            android:text="我可以被拖动"
            android:layout_width="100dp"
            android:layout_height="100dp"/>

        <TextView
            android:id="@id/autobackview"
            android:layout_margin="10dp"
            android:layout_gravity="center"
            android:gravity="center"
            android:background="#44ff"
            android:text="我可以自动回到初始位置"
            android:layout_width="100dp"
            android:layout_height="100dp"/>
        <TextView
            android:id="@id/edgeview"
            android:layout_margin="10dp"
            android:layout_gravity="center"
            android:gravity="center"
            android:background="#44ff00"
            android:text="滑动边缘移动我"
            android:layout_width="100dp"
            android:layout_height="100dp"/>
    </com.cisetech.demo.DragView>
</RelativeLayout>

我们改改DragView:

package com.cisetech.demo;

import android.content.Context;
import android.graphics.Point;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;

/**
 * author:yinqingy
 * date:2016-11-06 13:49
 * blog:http://blog.csdn.net/vv_bug
 * desc:
 */

public class DragView extends LinearLayout{
    private ViewDragHelper mDragger;
    private View mEdgeView,mAutoBackView;
    private Point mAutoBackOriginPos=new Point();

    public DragView(Context context) {
        super(context);
        init();
    }

    public DragView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public DragView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        mDragger=ViewDragHelper.create(this, 1.0f, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                //当id为edgeview的时候,不允许其滑动
                return child.getId()==R.id.draggedview||child.getId()==R.id.autobackview;
            }

            @Override
            public int clampViewPositionHorizontal(View child, int left, int dx) {
                return left;
            }

            @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                return top;
            }

            /**
             * 当手指在边缘拖动的时候回调此方法
             * edgeFlags分为left、top、right、bottom
             */
            @Override
            public void onEdgeDragStarted(int edgeFlags, int pointerId) {
                //当在边缘滑动的时候
                mDragger.captureChildView(mEdgeView, pointerId);
            }
            //手指释放的时候回调
            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
                //mAutoBackView手指释放时可以自动回去
                if (releasedChild.getId()==R.id.autobackview) {
                    mDragger.settleCapturedViewAt(mAutoBackOriginPos.x, mAutoBackOriginPos.y);
                    invalidate();
                }
            }
        });
        //一定要加上这句代码,不然就checkNewEdgeDrag就不会进入判断了
        mDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return mDragger.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mDragger.processTouchEvent(event);
        return true;
    }
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        //获取autoBackView的初始位置
        mAutoBackOriginPos.x = mAutoBackView.getLeft();
        mAutoBackOriginPos.y = mAutoBackView.getTop();
    }
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mEdgeView=findViewById(R.id.edgeview);
        mAutoBackView=findViewById(R.id.autobackview);
    }

    @Override
    public void computeScroll() {
        if(mDragger.continueSettling(true))
            invalidate();
        }
    }

运行代码:
这里写图片描述

我们来分析下代码:
代码中都有注释,我就不一一解释了,先解释下边缘滑动的代码:
首先:

@Override
            public boolean tryCaptureView(View child, int pointerId) {
                //当id为edgeview的时候,不允许其滑动
                return child.getId()==R.id.draggedview||child.getId()==R.id.autobackview;
            }

不允许edgeview直接滑动,所以返回的是false,
然后:

 /**
             * 当手指在边缘拖动的时候回调此方法
             * edgeFlags分为left、top、right、bottom
             */
            @Override
            public void onEdgeDragStarted(int edgeFlags, int pointerId) {
                //当在边缘滑动的时候
                mDragger.captureChildView(mEdgeView, pointerId);
            }

在当手指触碰到ViewGroup的边缘的时候,调用了mDragger.captureChildView方法,
最后:

//一定要加上这句代码,不然就checkNewEdgeDrag就不会进入判断了
        mDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);

很少的代码,我们的view就可以实现边缘滑动了(不了解边缘滑动的可以想象下侧滑菜单(^__^) 嘻嘻……),那么我们进入到VDH源码中看看为什么可以边缘滑动?
首先看看onEdgeDragStarted在哪调用的?

  private void reportNewEdgeDrags(float dx, float dy, int pointerId) {
        int dragsStarted = 0;
        if (checkNewEdgeDrag(dx, dy, pointerId, EDGE_LEFT)) {
            dragsStarted |= EDGE_LEFT;
        }
        if (checkNewEdgeDrag(dy, dx, pointerId, EDGE_TOP)) {
            dragsStarted |= EDGE_TOP;
        }
        if (checkNewEdgeDrag(dx, dy, pointerId, EDGE_RIGHT)) {
            dragsStarted |= EDGE_RIGHT;
        }
        if (checkNewEdgeDrag(dy, dx, pointerId, EDGE_BOTTOM)) {
            dragsStarted |= EDGE_BOTTOM;
        }

        if (dragsStarted != 0) {
            mEdgeDragsInProgress[pointerId] |= dragsStarted;
            mCallback.onEdgeDragStarted(dragsStarted, pointerId);
        }
    }

我们可以看到是在一个叫reportNewEdgeDrags的方法中调用的,那么reportNewEdgeDrags又是在哪调用的呢?
在VDH中的processTouchEvent方法中我们看到:

    case MotionEvent.ACTION_MOVE: {
                if (mDragState == STATE_DRAGGING) {
                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(mActivePointerId)) break;

                    final int index = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(index);
                    final float y = ev.getY(index);
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);

                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                } else {
                    // Check to see if any pointer is now over a draggable view.
                    final int pointerCount = ev.getPointerCount();
                    for (int i = 0; i < pointerCount; i++) {
                        final int pointerId = ev.getPointerId(i);

                        // If pointer is invalid then skip the ACTION_MOVE.
                        if (!isValidPointerForActionMove(pointerId)) continue;

                        final float x = ev.getX(i);
                        final float y = ev.getY(i);
                        final float dx = x - mInitialMotionX[pointerId];
                        final float dy = y - mInitialMotionY[pointerId];

                        reportNewEdgeDrags(dx, dy, pointerId);
                        if (mDragState == STATE_DRAGGING) {
                            // Callback might have started an edge drag.
                            break;
                        }

                        final View toCapture = findTopChildUnder((int) x, (int) y);
                        if (checkTouchSlop(toCapture, dx, dy)
                                && tryCaptureViewForDrag(toCapture, pointerId)) {
                            break;
                        }
                    }
                    saveLastMotion(ev);
                }
                break;
            }

当mDragState != STATE_DRAGGING的时候会调用reportNewEdgeDrags方法,在VDH中只有当mDragState ==STATE_DRAGGING的时候才能对view进行拖动,那么我们看看mDragState 在什么地方被置成STATE_DRAGGING标记的?

 /**
     * Capture a specific child view for dragging within the parent. The callback will be notified
     * but {@link Callback#tryCaptureView(android.view.View, int)} will not be asked permission to
     * capture this view.
     *
     * @param childView Child view to capture
     * @param activePointerId ID of the pointer that is dragging the captured child view
     */
    public void captureChildView(View childView, int activePointerId) {
        if (childView.getParent() != mParentView) {
            throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
                    + "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
        }

        mCapturedView = childView;
        mActivePointerId = activePointerId;
        mCallback.onViewCaptured(childView, activePointerId);
        setDragState(STATE_DRAGGING);
    }

在captureChildView中,我们很清晰的看到setDragState(STATE_DRAGGING);这么一段代码,当mDragg为STATE_DRAGGING状态的时候,当进入到processTouchEvent方法的ACTION_MOVE时,就会走:

 if (mDragState == STATE_DRAGGING) {
                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(mActivePointerId)) break;

                    final int index = ev.findPointerIndex(mActivePointerId);
                    final float x = ev.getX(index);
                    final float y = ev.getY(index);
                    final int idx = (int) (x - mLastMotionX[mActivePointerId]);
                    final int idy = (int) (y - mLastMotionY[mActivePointerId]);

                    dragTo(mCapturedView.getLeft() + idx, mCapturedView.getTop() + idy, idx, idy);

                    saveLastMotion(ev);
                }

调了dragTo方法就可以拖动了,dragTo在前一篇博客中有提及,我就不再说明了,到此,边界拖动的代码已经解析完毕了。

接下来看看松手自动返回的代码:

   }
            //手指释放的时候回调
            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
                //mAutoBackView手指释放时可以自动回去
                if (releasedChild.getId()==R.id.autobackview) {
                    mDragger.settleCapturedViewAt(mAutoBackOriginPos.x, mAutoBackOriginPos.y);
                    invalidate();
                }
            }
        });

在手指松开的时候会调用onViewReleased方法,然后我们调用了VDH的settleCapturedViewAt方法,我们看看settleCapturedViewAt内部:

private boolean forceSettleCapturedViewAt(int finalLeft, int finalTop, int xvel, int yvel) {
        final int startLeft = mCapturedView.getLeft();
        final int startTop = mCapturedView.getTop();
        final int dx = finalLeft - startLeft;
        final int dy = finalTop - startTop;

        if (dx == 0 && dy == 0) {
            // Nothing to do. Send callbacks, be done.
            mScroller.abortAnimation();
            setDragState(STATE_IDLE);
            return false;
        }

        final int duration = computeSettleDuration(mCapturedView, dx, dy, xvel, yvel);
        mScroller.startScroll(startLeft, startTop, dx, dy, duration);

        setDragState(STATE_SETTLING);
        return true;
    }

其内部主要调用了forceSettleCapturedViewAt方法,说到底还是调用了mScroller.startScroll(startLeft, startTop, dx, dy, duration);方法,Scroller的用法不懂的自己去脑补啊,还是很重要的一个组件的(^__^) 嘻嘻……既然有Scroller,我们就要重写View的computeScroll方法,所以我们在DragView中有重写:

@Override
    public void computeScroll() {
        if(mDragger.continueSettling(true))
            invalidate();
        }
    }

其实其continueSettling的内部想必知道Scroller的童鞋应该猜得出干了什么:

boolean keepGoing = mScroller.computeScrollOffset();

到此手指松开回到原来位置的代码也分析完毕了。

细心的童鞋可以发现,我们做测试用的View都是TextView,因为TextView本身就不具备可点击性,如果换成本身具有可点击性的Button,那么还会有一样的效果吗?
我们试试:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.cisetech.demo.MainActivity">

    <com.cisetech.demo.DragView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true">
        <Button
            android:id="@id/draggedview"
            android:layout_margin="10dp"
            android:gravity="center"
            android:layout_gravity="center"
            android:background="#44ff00ff"
            android:text="我可以被拖动"
            android:layout_width="100dp"
            android:layout_height="100dp"/>

        <Button
            android:id="@id/autobackview"
            android:layout_margin="10dp"
            android:layout_gravity="center"
            android:gravity="center"
            android:background="#44ff"
            android:text="我可以自动回到初始位置"
            android:layout_width="100dp"
            android:layout_height="100dp"/>
        <Button
            android:id="@id/edgeview"
            android:layout_margin="10dp"
            android:layout_gravity="center"
            android:gravity="center"
            android:background="#44ff00"
            android:text="滑动边缘移动我"
            android:layout_width="100dp"
            android:layout_height="100dp"/>
    </com.cisetech.demo.DragView>
</RelativeLayout>

当我们换成Button后,我们运行发现,只有边界移动的view可以移动,其它两个view不管怎么滑动都没效果哦,为什么呢?

主要是因为,如果子View不消耗事件,那么整个手势(DOWN-MOVE*-UP)都是直接进入onTouchEvent,在onTouchEvent的DOWN的时候就确定了captureView。如果消耗事件,那么就会先走onInterceptTouchEvent方法,判断是否可以捕获,而在判断的过程中会去判断另外两个回调的方法:getViewHorizontalDragRange和getViewVerticalDragRange,只有这两个方法返回大于0的值才能正常的捕获。

所以,如果你用Button测试,或者给TextView添加了clickable = true ,都记得重写下面这两个方法:

@Override
public int getViewHorizontalDragRange(View child){
     return getMeasuredWidth()-child.getMeasuredWidth();
}

@Override
public int getViewVerticalDragRange(View child){
     return getMeasuredHeight()-child.getMeasuredHeight();
}

方法的返回值应当是该childView横向或者纵向的移动的范围,当前如果只需要一个方向移动,可以只复写一个。
这个时候你肯定又会问“为什么重写这两个方法就可以了呢?”
(涉及到事件分发的知识,不懂的童鞋还是得脑补一下哈(^__^) 嘻嘻……)我们来看看原因:
在VDH中的shouldInterceptTouchEvent方法中我们看到这么一段代码:

  case MotionEvent.ACTION_MOVE: {
                if (mInitialMotionX == null || mInitialMotionY == null) break;

                // First to cross a touch slop over a draggable view wins. Also report edge drags.
                final int pointerCount = ev.getPointerCount();
                for (int i = 0; i < pointerCount; i++) {
                    final int pointerId = ev.getPointerId(i);

                    // If pointer is invalid then skip the ACTION_MOVE.
                    if (!isValidPointerForActionMove(pointerId)) continue;

                    final float x = ev.getX(i);
                    final float y = ev.getY(i);
                    final float dx = x - mInitialMotionX[pointerId];
                    final float dy = y - mInitialMotionY[pointerId];

                    final View toCapture = findTopChildUnder((int) x, (int) y);
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                    if (pastSlop) {
                        // check the callback's
                        // getView[Horizontal|Vertical]DragRange methods to know
                        // if you can move at all along an axis, then see if it
                        // would clamp to the same value. If you can't move at
                        // all in every dimension with a nonzero range, bail.
                        final int oldLeft = toCapture.getLeft();
                        final int targetLeft = oldLeft + (int) dx;
                        final int newLeft = mCallback.clampViewPositionHorizontal(toCapture,
                                targetLeft, (int) dx);
                        final int oldTop = toCapture.getTop();
                        final int targetTop = oldTop + (int) dy;
                        final int newTop = mCallback.clampViewPositionVertical(toCapture, targetTop,
                                (int) dy);
                        final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
                                toCapture);
                        final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
                        if ((horizontalDragRange == 0 || horizontalDragRange > 0
                                && newLeft == oldLeft) && (verticalDragRange == 0
                                || verticalDragRange > 0 && newTop == oldTop)) {
                            break;
                        }
                    }
                    reportNewEdgeDrags(dx, dy, pointerId);
                    if (mDragState == STATE_DRAGGING) {
                        // Callback might have started an edge drag
                        break;
                    }

                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }
                }
                saveLastMotion(ev);
                break;
            }

代码有点长,我们看重点,我们看到这么一段代码:

final View toCapture = findTopChildUnder((int) x, (int) y);
                    final boolean pastSlop = toCapture != null && checkTouchSlop(toCapture, dx, dy);
                    if (pastSlop) {
                        // check the callback's
                        // getView[Horizontal|Vertical]DragRange methods to know
                        // if you can move at all along an axis, then see if it
                        // would clamp to the same value. If you can't move at
                        // all in every dimension with a nonzero range, bail.
                        final int oldLeft = toCapture.getLeft();
                        final int targetLeft = oldLeft + (int) dx;
                        final int newLeft = mCallback.clampViewPositionHorizontal(toCapture,
                                targetLeft, (int) dx);
                        final int oldTop = toCapture.getTop();
                        final int targetTop = oldTop + (int) dy;
                        final int newTop = mCallback.clampViewPositionVertical(toCapture, targetTop,
                                (int) dy);
                        final int horizontalDragRange = mCallback.getViewHorizontalDragRange(
                                toCapture);
                        final int verticalDragRange = mCallback.getViewVerticalDragRange(toCapture);
                        if ((horizontalDragRange == 0 || horizontalDragRange > 0
                                && newLeft == oldLeft) && (verticalDragRange == 0
                                || verticalDragRange > 0 && newTop == oldTop)) {
                            break;
                        }
                    }
                    reportNewEdgeDrags(dx, dy, pointerId);
                    if (mDragState == STATE_DRAGGING) {
                        // Callback might have started an edge drag
                        break;
                    }

                    if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }

当pastSlop为true的时候,才会去跑:

 if (pastSlop && tryCaptureViewForDrag(toCapture, pointerId)) {
                        break;
                    }

当跑了tryCaptureViewForDrag的时候就会去走captureChildView方法:

    */
    public void captureChildView(View childView, int activePointerId) {
        if (childView.getParent() != mParentView) {
            throw new IllegalArgumentException("captureChildView: parameter must be a descendant "
                    + "of the ViewDragHelper's tracked parent view (" + mParentView + ")");
        }

        mCapturedView = childView;
        mActivePointerId = activePointerId;
        mCallback.onViewCaptured(childView, activePointerId);
        setDragState(STATE_DRAGGING);
    }

而这个时候setDragState(STATE_DRAGGING);会给mDragState设置成STATE_DRAGGING,当设置成了STATE_DRAGGING,在shouldInterceptTouchEvent的最后会返回true:

return mDragState == STATE_DRAGGING;

当shouldInterceptTouchEvent返回true以后,我们自定义的ViewGroup中的onInterceptTouchEvent也就返回true了,因此直接拦截了子View的事件,所以接下来才会进ViewGoup的onTouchEvent方法,所以才可以滑动。
有点复杂的感觉额,但是如果很清晰的掌握了事件分发流程,还是很好理解的。

细心的童鞋会发现,我们的View拖动的边界没有限制,以至于都拖到ViewGroup外面去了,好吧,我就直接贴代码了。

左右的边界:
左边为getPaddingLeft(),右边为getWidth() - mDragView.getWidth() - getPaddingRight():

 public int clampViewPositionHorizontal(View child, int left, int dx)
            {
                final int leftBound = getPaddingLeft();
                final int rightBound = getWidth() - mDragView.getWidth() -  getPaddingRight();

                final int newLeft = Math.min(Math.max(left, leftBound), rightBound);

                return newLeft;
            }

上下的的边界: 上边为getPaddingTop(),下边为 getHeight() - child.getHeight() - getPaddingBottom():

 @Override
            public int clampViewPositionVertical(View child, int top, int dy) {
                final int topBound = getPaddingTop();
                final int bottomBound = getHeight() - child.getHeight() -  getPaddingBottom();

                final int newTop = Math.min(Math.max(top, topBound), bottomBound);

                return newTop;
            }

好吧!到此,VDH的基本用法就介绍到这里了,接下来会进入到VDH的实战部分,有兴趣的童鞋可以跟我一起进入VDH实战部分哦!!!
未完待续………….

本文部分内容来自:
http://blog.csdn.net/lmj623565791/article/details/46858663

最后附上demo的git链接:
https://github.com/913453448/SwipeBackLayout

介绍:用ViewDragHelper实现的activity切换动画。运行效果:使用明: 你可以将这个库当成view来用:将DraggerView添加到root layout,并且在里面加入两个layout。<com.github.library.DraggerView     android:layout_width="match_parent"     android:layout_height="match_parent"     dragger_layout:drag_view_id="@ id/drag_view"     dragger_layout:shadow_view_id="@ id/shadow_view"     dragger_layout:drag_position="top">       <FrameLayout           android:id="@ id/shadow_view"           android:layout_width="match_parent"           android:layout_height="match_parent"           android:background="@color/transparent"           android:visibility="invisible"/>         <LinearLayout           android:id="@ id/drag_view"           android:layout_width="match_parent"           android:layout_height="match_parent"/>   </com.github.library.DraggerView>style文件中这样设置<style name="YourTheme" parent="Theme.AppCompat.Light.DarkActionBar">     <item name="android:windowIsTranslucent">true</item>       <item name="android:windowBackground">@android:color/transparent</item>       <item name="android:windowNoTitle">true</item>       <item name="windowActionBar">false</item>       <item name="android:windowAnimationStyle">@null</item> </style>manifest中<activity     android:name="com.github.dragger.BaseActivity"     android:theme="@style/YourTheme"/>
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值