Android开发

android自定义viewgroup

2019-12-29  本文已影响0人  神迹12

实现了一个有点意思的自定义布局,有点类似于android主页的app图标布局。主要效果可以归结为如下四点:
1、支持4个View2行2列排列,也可以支持一个View最大化显示。
2、在2x2排列时,可以进行拖动,交换2个View的位置。
3、可以支持拖动来删除(达到删除状态执行自定义的操作)。
4、在放大状态,能通过左右滑动进行前后View的切换。
要实现这样一种效果,会涉及到一些如自定义布局、view拖动、滑动等技术。在实现过程中也会碰到很多问题,在此对在开发中遇到的一些问题进行总结。先上效果图:

拖动2.gif 滑动1.gif

二、问题分解和细化

为了实现上述效果,主要有以下一些关键点:一是要自定义一种布局,可以显示1个View的布局,也可以显示4个View(2行2列)的布局;二是要实现View的拖动以及位置的交换;三是可以支持View左右滑动切换,类似于viewpager的功能。整体功能效果有点类似于android系统的应用图标管理,只不过这里拖动图标时是交换2个相距最近的图标的位置,而android系统拖动时是直接将拖动的图标定位到手放开时的位置。

三、自定义布局

布局的话,可以采用自定义布局,当显示4个view时,4个view按照2行2列进行排列,当切换到单个view布局时,将选定的view宽高调整为父view的宽高,其他3个view进行隐藏(后面考虑到要在处于单view时进行左右view切换,将其他3个view按顺序排列在被选中view的2侧或1侧)。

android自定义viewgroup需要先继承于ViewGroup。有2个方法需要进行重写onMeasure和onLayout。onMeasure方法中主要进行根据childView的测量模式对childView的大小进行测量,在综合viewGroup自身的测量模式确定自身的宽、高。

3.1 测量onMeasure

android中view有三种测量模式:

EXACTLY:表示设置了精确的值,一般当childView设置其宽、高为精确值、match_parent时,ViewGroup会将其设置为EXACTLY;
AT_MOST:表示子布局被限制在一个最大值内,一般当childView设置其宽、高为wrap_content时,ViewGroup会将其设置为AT_MOST;
UNSPECIFIED:表示子布局想要多大就多大,一般出现在AadapterView的item的heightMode中、ScrollView的childView的heightMode中;此种模式比较少见。

而在此场景中子view的宽、高都是根据其父view来所固定确定的。而父view则要根据所设定的测量模式来确定大小。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        /** 获得此ViewGroup上级容器为其推荐的宽和高,以及计算模式 */
        super.onMeasure(widthMeasureSpec,heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        //将子view的长宽都置为父view的一半
        childWidth = sizeWidth/ROW_NUM;
        childHeight = sizeHeight/ROW_NUM;
//        Log.e(TAG, "onMeasure: father width="+sizeWidth+" father height="+sizeHeight);
        int childWidthSppec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY);
        int childHeightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY);
        //判断是否有子view要进行放大,要放大的子view将长度置为父view的长宽
        if (zoomIndex == ZOOM_INDEX_NONE) {
            measureChildren(childWidthSppec, childHeightSpec);
        } else {
            int count = getChildCount();
            for (int i = 0; i < count; i++) {
                View childView = getChildAt(i);
                int index = (int) childView.getTag();
//                if (index == zoomIndex) {
                    int zoomChildWidthSpec = MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY);
                    int zoomChildHeightSpec = MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY);
                    measureChild(childView, zoomChildWidthSpec, zoomChildHeightSpec);
//                } else {
//                    measureChild(childView,childWidthSppec, childHeightSpec);
//                }
            }
        }
    }

关于viewgroup的测量,有些文章理论感觉写得很复杂。我感觉简单了讲大部分情形可以分为2个思考方向:1、这个viewgroup大小是不是主要由其childview确定,对应于该viewgroup在xml中宽高设的WRAP_CONTENT;2、这个viewgroup的大小由其父viewgroup确定,主要对应于xml中的属性设定为指定宽高或MATCH_PARENT。
了解下相关 onMeasure(int widthMeasureSpec, int heightMeasureSpec) 方法中宽、高都是int。MeasureSpec的getMode(int measureSpec)方法如下:

    private static final int MODE_SHIFT = 30;
    private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

/**
     * Extracts the mode from the supplied measure specification.
     *
     * @param measureSpec the measure specification to extract the mode from
     * @return {@link android.view.View.MeasureSpec#UNSPECIFIED},
     *         {@link android.view.View.MeasureSpec#AT_MOST} or
     *         {@link android.view.View.MeasureSpec#EXACTLY}
     */
    @MeasureSpecMode
    public static int getMode(int measureSpec) {
        //noinspection ResourceType
        return (measureSpec & MODE_MASK);
    }

getMode方法是为了获取测量模式,其通过位运算,获取到高2位的mode(测量模式)。MODE_MASK = 0x3 << MODE_SHIFT,0x3就是十六进制的3,换成二进制就是11,左移30位,就是1100···0000,总共32位,前2位是1,后30位都是0。measureSpec & MODE_MASK就是进行位与运算。

/**
     * Extracts the size from the supplied measure specification.
     *
     * @param measureSpec the measure specification to extract the size from
     * @return the size in pixels defined in the supplied measure specification
     */
    public static int getSize(int measureSpec) {
        return (measureSpec & ~MODE_MASK);
    }

getSize()方法则是通过位运算,拿到低30位的数值,高2位都是0。
makeMeasureSpec方法根据给定的大小和模式,创建测量格式。

        public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                          @MeasureSpecMode int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }

看下else分支代码,又是位运算,有点巧妙哈。由前面分析可知,测量模式mode由int型的高2位表示,而具体的大小size由低30位表示,所以整个表达式即2个()中间的或(“|”)运算符的效果相当与就是拼接低30位和高2位,得到或者叫组成新的测量值。

3.2 布局onLayout

onLayout方法主要用于控制子view在父view中的显示位置。位置的参数的设置,通过调用子view的layout方法来进行设定。

    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int count = getChildCount();
        int childWidth = sizeWidth / ROW_NUM;
        int childHeight = sizeHeight / ROW_NUM;
        int left = 0;
        int top = 0;
        for (int i = 0; i < count; i++) {
            View childView = getChildAt(i);
            int oriIndex = (int) childView.getTag();
            //1、正常分割显示情况
            if (zoomIndex == ZOOM_INDEX_NONE) {
                left = (oriIndex % ROW_NUM) * childWidth;
                top = (oriIndex / ROW_NUM) * childHeight;
                childView.layout(left, top, left + childWidth, top + childHeight);
                if (childView.getVisibility() == INVISIBLE || childView.getVisibility() == GONE) {
                    childView.setVisibility(VISIBLE);
                }
            } else {    //2、选中的子view要放大(充满父view的情况)
                if (zoomIndex == oriIndex) {
                    childView.layout(0, 0, sizeWidth, sizeHeight);
                } else {
//                    Log.e(TAG, "onLayout: 放大 正常分割大小");
                    //适应scroll page的适配,将子view排成一排
                    int indexDis = oriIndex - zoomIndex;
                    left = indexDis * sizeWidth;
                    top = 0;
                    childView.layout(left, top, left + sizeWidth, top + sizeHeight);
                }
            }
        }
    }

layout方法总共需要传入4个参数,即通过left(左)、top(上)、right(右)、bottom(底/下),其实4个参数也就是确定了2个坐标点,左上角、右下角2个坐标位置。则这个view所占用的区域也就确定了。用一张网上找的view的坐标系图,看着更明了。


1083990-20170413131902814-1577804594.jpg

剩下的就是计算来产生这些坐标点,这里行数、列数一样,用ROW_NUM =2 来表示。目前总共4个view,按照2x2来排列,计算出每个view左上角坐标(left、top),其右下角坐标就是(left+childWidth,top+childHeight)。如此来进行排列。

四 使用ViewDragerHelper实现View的拖拽

实现View的拖拽一般思路可能会想自己监听手势滑动事件,重写onTouchEvent方法等。其实对于View的拖拽,Google官方提供了一个工具类ViewDragHelper。所以尝试下不同的方式,使用ViewDragHelper来实现拖拽。

ViewDragHelper是Google在v4包中增加的一个工具类,方便开发者实现View拖拽(ps:官方的DrawerLayout就是用此类实现)。ViewDragHelper的使用也不是很复杂。

   private void initDragHelper(){
        mDragHelper = ViewDragHelper.create(this, new ViewDragHelper.Callback() {
            @Override
            public boolean tryCaptureView(View child, int pointerId) {
                Log.e(TAG, "tryCaptureView: ");
                if (zoomIndex == ZOOM_INDEX_NONE) {
                    //当拖拽的view正在回弹时,不允许再进行拖拽
                    if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_SETTLING) {
                        return false;
                    } else {
                        child.bringToFront();
                    }
//                child.requestLayout();
//                invalidate();
                    return true;
                } else {
                    return false;
                }
            }

            @Override
            public void onViewCaptured(View capturedChild, int activePointerId) {
                super.onViewCaptured(capturedChild, activePointerId);
                //将拖动的子view里面的surfaceview顺序置为最上层,解决surfaceview重叠的问题
                ((SingleRealPlayView)capturedChild).setSurfaceviewOrderOnTop();
                mDragOriLeft = capturedChild.getLeft();
                mDragOriTop = capturedChild.getTop();
                Log.e(TAG, "onViewCaptured: left="+mDragOriLeft+" top="+mDragOriTop+" x="+capturedChild.getX()+" y="+capturedChild.getY());
                if (callback != null) {
                    callback.prepareDeleteRealPlay();
                }
                isCanDelete = false;
            }

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

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

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

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

            @Override
            public void onViewReleased(View releasedChild, float xvel, float yvel) {
                //去除删除标志
                if (callback != null) {
                    callback.cancelOrFinishDelete();
                }
                if (isCanDelete) {      //判断是否处于删除状态
                    deleteSingleRealPlay(releasedChild);
                }else if (findExchangeItemView(releasedChild)) {
                    //判断什么情况下view弹回,什么情况下进行两个子view位置交换

                } else {
                    Log.e(TAG, "onViewReleased: go back");
                    mDragHelper.settleCapturedViewAt(mDragOriLeft, mDragOriTop);
                    invalidate();
                }
                isCanDelete = false;
            }

            @Override
            public void onViewDragStateChanged(int state) {
                super.onViewDragStateChanged(state);
                Log.e(TAG, "onViewDragStateChanged: state="+state);
            }

            @Override
            public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
                super.onViewPositionChanged(changedView, left, top, dx, dy);
//                Log.e(TAG, "onViewPositionChanged: left="+left+" top="+top+" dx="+dx+" dy="+dy);
//                Log.e(TAG, "onViewPositionChanged: location on screen x="+location[0]+" y="+location[1]);
                //当是拖动状态时,判断被拖动的子View与删除标志view的位置关系
                if (deleteFlagView != null && mDragHelper.getViewDragState()== ViewDragHelper.STATE_DRAGGING) {
                    int[] location = new int[2];
                    changedView.getLocationOnScreen(location);
                    int[] locDeleteView = new int[2];
                    deleteFlagView.getLocationOnScreen(locDeleteView);
                    int delHeight = deleteFlagView.getHeight();
                    int delCenterHeight = locDeleteView[1]+delHeight/2;
                    int changeViewCenterHeight = location[1]+childHeight/2;
                    if (changeViewCenterHeight < delCenterHeight) {
                        Log.e(TAG, "onViewPositionChanged: 达到删除条件了,让删除view置为选中");
                        Log.e(TAG, "onViewPositionChanged: viewdrager 状态"+mDragHelper.getViewDragState());
                        if (callback != null) {    //达到删除条件
                            callback.satisfyDeleteRealPlayCondition();
                            isCanDelete = true;
                        }
                    } else {
                        if (callback != null) {
                            callback.prepareDeleteRealPlay();
                            isCanDelete = false;
                        }
                    }
                }

            }
        });
    }

4.1 重写相关方法

ViewDragHelper里面有很多方法需要重写。
(一) 首先看下tryCaptureView方法,public abstract boolean tryCaptureView(View child, int pointerId);当用户想捕获这个childView时调用此方法,返回true表示用户想捕获这个childView,才会执行后面的拖拽。返回false代表不捕获这个childView。pointerId是代表多点触控的参数。
(二) 再看onViewCaptured方法,public void onViewCaptured(View capturedChild, int activePointerId)。当childView被捕获,准备进行拖拽前调用。
(三) onViewReleased方法,public void onViewReleased(View releasedChild, float xvel, float yvel)。手指拖拽放开时的回调。
(四)onViewPositionChanged方法,public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) 拖拽或放开时被拖拽的childView位置变动。

要想ViewDragHelper起作用,还需要拦截触摸事件,在view的触摸事件交给ViewDragHelper来处理。

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mDragHelper.processTouchEvent(event);
        return true;
    }

4.2 拖拽过程中的问题

先看tryCaptureView中的代码,拖拽过程中多个childview的重叠问题,重叠区域怎么显示。

child.bringToFront();

这个代码是什么意思呢,其效果是将当前拖拽的childView移到最上层,View的bringToFront方法最终调用的是ViewGroup的bringChildToFront。

    @Override
    public void bringChildToFront(View child) {
        final int index = indexOfChild(child);
        if (index >= 0) {
            removeFromArray(index);
            addInArray(child, mChildrenCount);
            child.mParent = this;
            requestLayout();
            invalidate();
        }
    }

每个view是按照一定的先后顺序(索引)添加到ViewGroup中的,ViewGroup在绘制时也是按照这个索引顺序来先后进行绘制,所以如果各个子view有重叠的部分,则因为index大的子view是后面画的,所以重叠的部分就会显示index大的子view的内容。ViewGroup维持了一个View数组,而这个函数的作用其实就是将这个view移到数组的最末端。

但这会引入另一个问题,在前面ViewGroup的onLayout中进行子View的排列布局的时候,如果也是根据其索引index来排序,假设我当前拖动第一个view,未拖动前其索引为0(View1)、1(View2)、2(View3)、3(View4),一拖动因为调用了bringToFront方法,索引变成了3(View1)、0(View2)、1(View3)、2(View4),则还是以2x2布局排列时,原来是左上角的则会被排到右下角,所有View的位置都变了。所以在onLayout中,进行布局排列时所用的子view的索引由自己维护,当2个子view由于拖拽满足位置交换条件时,索引也进行交换。

4.3 View拖拽释放后的情况判断

如何判断被拖拽的view与周围其他view的位置关系。可以从这几个方面来考虑。当view被捕获的时候,记录其原始的位置,方便后面放开回弹到此位置,或跟其他view进行交换位置。view被拖拽后,最终操作结果可分为3种情况。

(一) 达到删除条件
当进行拖拽时,显示删除标志view,当被拖拽的view与删除标志view的位置关系满足一定条件时,即认为达到删除条件,可以执行删除操作。
这里判断被拖动的view的位置是否达到了deleteFlagView的中心高度以上。达到则认为达到了删除条件。

(二) 达到与其他view位置交换条件
如何判断是否达到与其他view位置交换的条件。可以简化为2个条件:


距离越远.jpg
距离越近.jpg

1 与其他view的距离越来越近,对比值为未拖动前的距离。
2 在满足条件1的情况下,从中找出最近的那个view。
因为view拖动时,被拖动的VIEW可能与其他VIEW距离越来越远,也可能与其中一部分VIEW变近,一部分距离变远。看下距离比对逻辑代码:

                float childX = childView.getX();
                float childY = childView.getY();
                int childWidth = childView.getWidth();
                int childHeight = childView.getHeight();
                float desCenterX = childX + childWidth/2;
                float desCenterY = childY + childHeight/2;
                float oriCenterX = releasedChid.getX()+childWidth/2;
                float oriCenterY = releasedChid.getY()+childHeight/2;
                float distance = (float) Math.sqrt(Math.pow((desCenterX-oriCenterX),2)+ Math.pow((desCenterY-oriCenterY),2));
                float distanceRefer = (float) Math.sqrt(Math.pow((desCenterX-(mDragOriLeft+childWidth/2)),2)+ Math.pow((desCenterY-(mDragOriTop+childHeight/2)),2));
                //方法2 判断item位置时,水平、垂直方向差值都小于某个比值时,则认为找到了该item,否则认为没找到
                // 移动后与移动前距离比值
                float rate = distance / distanceRefer;
                if (rate <= 0.3) {
                    if (!isFound) {     //还没找到一个符合条件的item
                        isFound = true;
                        result = true;
                        selDis = distance;
                        selView = childView;
                    } else {    //找到了多于一个符合条件的item,对距离进行对比,选择距离更小的那一个
                        if (distance < selDis) {
                            selDis = distance;
                            selView = childView;
                        }
                    }
                }

而我们只取距离比未拖动前距离变小,而且只取最小的距离对应的VIEW进行位置交换。
distance计算出的就是当前拖拽时被拖拽view与其他一个view的距离,而distanceRefer则是未拖拽前2者之间的距离,当拖拽后距离小于拖拽前距离的30%以下时,认为达到了位置交换条件。30%只是个经验值,因为不能说拖拽时距离稍微一变小就交换位置,肯定得小到一定程度。

(三)其他情况,view进行位置回弹,什么都不做
其他情况就是除了删除,位置交换以外的其他情况。

五 Scroller实现类似viewpager滑动

Scroller本身并不能实现View的滑动,最终的滑动还是scrollTo/scrollBy实现的,而scrollTo/scrollBy的滑动使瞬间完成的。而Scroller的主要目的使为了使滑动更平滑,Scroller的作用是通过计算产生到目标距离的一些列中间值。然后View重写computeScroll方法,在此方法中调用scrollTo/scrollBy来达到真正使View平滑滑动的效果。

scrollTo/scrollBy滑动的使View中的内容。而我们要从View1通过滑动,滑动到View2,就需要调用其parent中的scrollTo/scrollBy方法。scrollTo是相对于初始位置来进行移动的,而scrollBy(int x ,int y)则是相对于上一次移动的距离来进行本次移动。

因为scrollTo/scrollBy方法移动的内容,所以跟直接移动View位置是不一样的。简单对比,将View通过移动位置,偏移量(10,10),则效果就是将View网右下角x轴 y轴方向都移动了10个像素。而通过scrollBy方法实现同样效果,则需要调用View的父View,scrollBy(-10,-10),将内容往左上角x轴 y轴滑动-10个像素。即将屏幕理解成一个相框,将相框往左上角移,来达到同样的视觉效果。


        svRealPlay.setOnTouchListener(new OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                int x = (int) event.getX();
                int y = (int) event.getY();
                switch (event.getAction()) {
                    case MotionEvent.ACTION_DOWN:
//                        Log.e(TAG, "onTouch: down");
                        lastX = (int) event.getX();
                        lastY = (int) event.getY();
                        if (isOnZoom == false) {
                            zoomIndex = getChildIndex();
                        }
                        break;
                    case MotionEvent.ACTION_MOVE:
                        int offsetX = x - lastX;
                        int offsetY = y - lastY;
                        Log.e(TAG, "onTouch: move");
                        View viewGroupOri = (View) getParent();
                        int scrollX = viewGroupOri.getScrollX();
                        int scrollY = viewGroupOri.getScrollY();
                        Log.e(TAG, "onTouchEvent: scrollX=" + scrollX + " scrollY=" + scrollY + " offsetX=" + offsetX + " childWidth=" + getMeasuredWidth());
                        if (isOnZoom) {
                            ((View) getParent()).scrollBy(-offsetX, 0);
                        }
                        break;
                    case MotionEvent.ACTION_UP:
                        Log.e(TAG, "onTouch: up");
                        // 手指离开时,执行滑动过程
                        View viewGroup = (View) getParent();
                        //进行判断,应该往左翻到前一个预览界面、往右翻到后一个预览界面、还是回弹
                        int childWidth = getMeasuredWidth();
                        int curScrollX = viewGroup.getScrollX();
                        int curScrollY = viewGroup.getScrollY();
                        int curChildIndex = getChildIndex();
                        int indexDes = curChildIndex - zoomIndex;
                        // 方法2 可以在4个view中任意进行左右滑动
                        //当前view显示出来,父view所应滚动的距离
                        if (isOnZoom) {
                            int curViewPostionX = (curChildIndex - zoomIndex) * childWidth;
                            int parentChildCount = ((ViewGroup) getParent()).getChildCount();
                            if (curScrollX != 0 && (curScrollX < curViewPostionX - childWidth / 2) && curChildIndex != 0) {    //往左滑,切换到上一个画面
                                int disLeft = (curChildIndex - 1 - zoomIndex) * childWidth - curScrollX;
                                Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disLeft=" + disLeft + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
                                mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disLeft, 0);
                                invalidate();
                            } else if (curScrollX != 0 && (curScrollX > curViewPostionX + childWidth / 2) && curChildIndex != parentChildCount - 1) { //往右,切换到下一个画面
                                int disRight = (curChildIndex + 1 - zoomIndex) * childWidth - curScrollX;
                                Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disRight=" + disRight + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
                                mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disRight, 0);
                                invalidate();
                            } else {
                                int disOther = curViewPostionX - curScrollX;
                                Log.e(TAG, "onTouch: curviewIndex=" + curChildIndex + " 滑动" + " disOther=" + disOther + " curScrollX=" + curScrollX + " curViewPostionX=" + curViewPostionX + " zoomIndex=" + zoomIndex);
                                mScroller.startScroll(viewGroup.getScrollX(), viewGroup.getScrollY(), disOther, 0);
                                invalidate();
                            }
                        }

                        break;
                }
                return false;
            }
        });

主要看ACTION_UP时的处理。处理的结果有3种:
1 手势向右滑,向左滑动切换到前一个view。
2 手势向左滑,向右滑动切换到后一个view。
3 手势滑动到距离不够,回弹到原来到位置。
zoomIndex是左上角刚好在其parent view的(0,0)位置处的view的索引,宽高也正好等于parent view的宽 高。所以Scroller滑动的距离是以zoomIndex所在View的位置为基准的。手势滑动的距离要大于View宽度的1/2,才会决定是否要进行view(页面)切换。所以简易版viewpager的原理也不过如此,完全可以自定义实现一个viewpager。

https://github.com/godtrace12/DragFourScreen

上一篇 下一篇

猜你喜欢

热点阅读