自定义控件:侧滑面板

本篇博客讲解的是自定义View之侧滑面板,应用场景:QQ,知乎,效果图如下


侧滑面板

一、内容摘要

  • 了解ViewDragHelper 的产生及解决的问题
  • 掌握ViewDragHelper 的使用步骤
  • 掌握属性动画的使用
  • 掌握状态更新及事件回调的用法

二、实现最简单的拖拽

1、实现最简单的拖拽

在创建DragLayout 时,继承FrameLayout,这里需要注意两个问题

为什么不继承ViewGroup,因为继承ViewGroup 需要重写onMeasure()和实现onLayout()方法,自己实现子view 的测量和摆放,在这里我们不需要自己去做测量和摆放,而FrameLayout 已经对这两个方法进行了具体实现,所以继承FrameLayout 更加简单省事

为什么不继承RelativeLayout,因为这里我们只需要层级关系,不需要相对关系,继承RelativeLayout界面效果是一样的,但RelativeLayout 对FrameLayout 多了相对关系的计算,效率会低一些,所以选择继承FrameLayout

public class DragLayout extends FrameLayout {
        public DragLayout(Context context) {
            super(context);
        }
        public DragLayout(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
        public DragLayout(Context context, AttributeSet attrs, int defStyle) {
            super(context, attrs, defStyle);
        }
    }

2、串联构造方法

DragLayout 实例化时需要做一些初始化操作,如果我们定义一个init()方法,则我们需要在三个构造方法中都调用init()方法,这样非常麻烦,我们可以通过串连三个构造方法的方式实现只调用一次init()方法这样无论是代码创建还是布局在xml 中都能调用到我们的初始化代码

public class DragLayout extends FrameLayout {
        public DragLayout(Context context) {
            //代码创建时调用
            this(context, null);
        }

        public DragLayout(Context context, AttributeSet attrs) {
            //布局在xml 中,实例化时调用
            this(context, attrs, 0);
        }

        public DragLayout(Context context, AttributeSet attrs, int defStyle) {
            super(context, attrs, defStyle);
            //在这里初始化
        }
    }

3、ViewDragHelper 简介

我们要实现拖拽的效果,则需要自己去解析Touch 事件的ACTION_DOWN,ACTION_MOVE,ACTION_UP,相当的麻烦。所以Google 在2013 年的IO 大会上发布了ViewDragHelper 这个类,用来解决滑动拖拽问题,用这个类可以非常简单的实现view 的拖拽

4、创建ViewDragHelper

由于eclipse 创建项目时,为我们添加的android-support-v4.jar 没有包含ViewDragHelper,我们需要将最新的android-support-v4.jar 拷贝到libs 下面,然后clean 一下工程。

在这里我们需要关联android-support-v4.jar 的源码,通过配置文件的方法来关联源码

在libs 下面创建一个android-support-v4.jar.properties 的文件


这里写图片描述

android-support-v4.jar.properties 中的内容为src = V4 包源码路径


这里写图片描述

我们只需要在第三个构造方法中实现ViewDragHelper 的实例即可

public DragLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        // 在这里初始化
        // forParent 父类容器
        // sensitivity 敏感度,越大越敏感,1.0f 是默认值
        // Callback 回调事件
        //1.通静态方法创建拖拽辅助类
        mViewDragHelper = ViewDragHelper.create(this, 1.0f, mCallback);
    }

ViewDragHelper 三个参数的创建的方法源码中的mTouchSlop 表示触摸的最小敏感范围,越小越敏感即在界面拖动的瞬间变化量大于mTouchSlop 时才可以成功触发拖拽事件

 public static ViewDragHelper create(ViewGroup forParent, float sensitivity, Callback cb){
    final ViewDragHelper helper = create(forParent, cb);
    helper.mTouchSlop = (int) (helper.mTouchSlop * (1 / sensitivity));
    return helper;
 }

5、触摸事件转交

ViewDragHelper 创建成功了,但它和DragLayout 并没有任何关系,我们需要让它们建立关系

 //2.转交触摸事件
 @Override
 public boolean onInterceptTouchEvent(MotionEvent event) {
    //由ViewDragHelper 判断是否拦截
    return mViewDragHelper.shouldInterceptTouchEvent(event);
 };

重写onInterceptTouchEvent 方法,将触摸事件交给ViewDragHelper 判断是否拦截,这样它们就建立了关系,事件拦截后,还需要对拦截到的事件进行处理,注意返回值必须是true

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        try {
            //由ViewDragHelper 处理拦截的事件
            mViewDragHelper.processTouchEvent(event);
        } catch (Exception e) {}
        //事件已被处理,所以需要返回true
        return true;
    };

6、处理回调事件

ViewDragHelper 在处理触摸事件时会通过传入的callback 给我们反馈,通过对回调方法的处理即可实现简单的拖拽

//3.处理回调事件
    ViewDragHelper.Callback mCallback = new ViewDragHelper.Callback() {
        @Override
        //返回值决定了child 是否可以被拖拽
        public boolean tryCaptureView(View child, int pointerId) {
            //child 被用户拖拽的孩子
            //pointerId 多点触摸的手指id
            return true;
        }
        @Override
        //修正子view 水平方向上的位置,此时还没有真正的移动,返回值决定view 将移动到的位置
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            //left 建议移动到的位置
            return left;
        }
    };

7、DragLayout 布局到xml 中

给左面板和主面板设置不同的背景颜色便于拖拽时观察效果,运行工程,即可实现简单的拖拽

<com.example.draglayout.widget.DragLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/bg">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#66ff0000">
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#00ff00">
    </LinearLayout>

</com.example.draglayout.widget.DragLayout>

三、限定拖拽范围

现在左面板和主面板可以任意拖动,本节要实现左面板不动,拖动时,主面板在一定范围内拖动

3.1 OnFinishInflate()介绍

onFinishInflate()在控件inflate 完成时会被调用,可以在这个方法中查找子控件

  • 可以通过findViewById()的方式查找子控件
  • 可以通过子view 索引的方式查找子控件

这里采用第二种方式

@Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        //增强代码的健壮性
        if(getChildCount() < 2){
            //必须有两个子view
            throw new IllegalStateException("Your viewgroup must have two children.");
        }
        if(!(getChildAt(0)instanceofViewGroup)||!(getChildAt(1)instanceof ViewGroup)){
            //子view 必须是viewgroup 的子类
            throw new IllegalStateException("The child must an instance of viewgroup.");
        }
        mLeftContent = getChildAt(0);
        mMainContent = getChildAt(1);
    };

3.2 获取控件宽高

在onMeasure()方法中可以获取到控件的宽高,也可以在onSizeChanged()方法中去获取宽高,onMeasure()方法调用后会检测宽高值有没有变化,有变化才调用onSizeChanged()方法,无变化则不调用,所以onSizeChanged()调用的次数比onMeasure()少,在这里我们在onSizeChanged()方法中去获取宽高,同时计算出拖拽范围为宽度的60%

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = getMeasuredWidth();
        mHeight = getMeasuredHeight();
        //拖拽的范围
        mRange = (int) (mWidth * 0.6f);
        System.out.println("mWidth:"+mWidth+" mHeight:"+mHeight +" mRange:"+mRange);
    }

3.3 限定主面板的拖动范围

对callback 中的其它几个方法进行重写

 //3.处理回调事件
    ViewDragHelper.Callback mCallback = new ViewDragHelper.Callback() {
        @Override
        //返回值决定了child 是否可以被拖拽
        public boolean tryCaptureView(View child, int pointerId) {
            //child 被用户拖拽的孩子
            //pointerId 多点触摸的手指id
            return true;
        }
        @Override
        public int getViewHorizontalDragRange(View child) {
            return super.getViewHorizontalDragRange(child);
        }
        @Override
        //修正子view 水平方向上的位置,此时还没有真正的移动,返回值决定view 将移动到的位置
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            //left 建议移动到位置
            return left;
        }
        @Override
        public void onViewPositionChanged(View changedView, int left, int top,
                                          int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
        }
    };

回调方法中的getViewHorizontalDragRange(View child)方法返回拖拽的范围,但不会真正限定这个范围,只要返回一个大于零的值即可。

在ViewDragHelper 源码中,computeSettleDuration()会调用这个返回值来计算动画执行的时长,checkTouchSlop()方法会调用这个返回值检查左面板,主面板是否可以被滑动,所以需要返回一个大于0的值才能实现拖动。

如果返回值为0,左面板,主面板中不能有子view 或子view 没有对touch 事件做处理,最后触摸还是会交给ViewDragHelper 处理,所以也能实现拖动

 @Override
    //返回拖拽的范围,返回一个大于零的值,计算动画执行的时长,水平方向是否可以被滑开
    public int getViewVerticalDragRange(View child) {
        //computeSettleDuration 计算动画执行的时长
        //checkTouchSlop 检查是否可以被滑动(没有孩子处理触摸事件,最后返回给DragLayout 处理)
        return mRange;
    }

限定主面板的拖拽范围,当建议的值left 小于0 时,让left 等于0,大于mRange 时等于mRange,然后再将left 返回


    @Override
    // 修正子view 水平方向上的位置,此时还没有真正的移动,返回值决定view 将移动到的位置
    public int clampViewPositionHorizontal(View child, int left, int dx) {
        // child 被用户拖拽的孩子
        // left 建议移动到位置
        // dx 新的位置与旧的位置的差值
        int oldLeft = mMainContent.getLeft();
        System.out.println("clamp: left:" + left + " oldLeft:" + oldLeft
                + " dx:" + dx);
        if (child == mMainContent) {
            left = fixLeft(left);
        }
        return left;
    }
    /**
     * 修正左边的位置,限定拖拽范围在0 到mRange 间变化
     *
     * @param left
     * @return
     */
    private int fixLeft(int left) {

        if (left < 0) {
            left = 0;
        } else if (left > mRange) {
            left = mRange;
        }
        return left;
    }

当控件位置变化时会调用onViewPositionChanged()方法,可以在此方法中做伴随动画,状态更新,事件回调,left 表示最新的水平位置,dx 表示刚刚发生的水平变化量。

此时左面板还可以任意拖动,为了实现拖动左面板时界面表现为拖动主面板,可以对changedView 进行判断,如果changedView 是左面板,则通过layout()把左面板放回到原来的位置,然后把变化量dx 累加给主面板,再通过layout()方法来移动主面板

@Override
    // 当控件位置变化时调用,可以做伴随动画,状态更新,事件回调
    public void onViewPositionChanged(View changedView, int left, int top,
                                      int dx, int dy) {
        super.onViewPositionChanged(changedView, left, top, dx, dy);
        // left 最新的水平位置
        // dx 刚刚发生的水平变化量
        System.out.println("onViewPositionChanged: left:" + left + " dx:"
                + dx);
        if (changedView == mLeftContent) {
            // 如果滑动的是左面板
            // 1.放回到原来的位置
            mLeftContent.layout(0, 0, mWidth, mHeight);
            // 2.把变化量传递给主面板,主面板旧的值+变化量
            int newLeft = mMainContent.getLeft() + dx;
            // 需要修正左边值
            newLeft = fixLeft(newLeft);
            mMainContent.layout(newLeft, 0, newLeft + mWidth, mHeight);
        }
        // offsetLeftAndRight 在低版本中没有重绘界面,手动调用重绘
        invalidate();
    }

注意:由于onViewPositionChanged()方法调用前调用了offsetLeftAndRight()方法,此方法在低版本中没有重绘界面,并且在高版本中也有一个bug,最后一帧没有被绘制,所以需要手动调用一次invalidate(),否则在低版本中无法实现拖拽效果

四、结束动画

拖拽过程中当手指抬起时,需要实现一个打开,关闭面板的动画,结束动画可以在 onViewReleased()方法实现

跳转的结束动画

onViewReleased()方法在松手之后会被调用,此时可以做结束动画,结束动画只需要考虑需要打开的
情况,其它则为需要关闭情况

  1. 当水平方向的速度等于 0,并且主面板此时左边的位置在拖拽范围中轴线的右边则需要执行打开动
    画,即 mMainContent.getLeft() > mRange*0.5f
  2. 当水平方向的速度大于 0 时,则需要执行打开动画
  3. 其它情况则需要执行关闭动画
//5.决定松手后要做的事件,结束动画
    public void onViewReleased(View releasedChild, float xvel, float yvel) {
        super.onViewReleased(releasedChild, xvel, yvel);
        //releasedChild 被释放的孩子
        //xvel 水平方向的速度,向左为-,向右为+
        System.out.println("onViewReleased: xvel:"+xvel);
        //考虑开启的情况,其它情况则关闭的情况
        if(xvel == 0 && mMainContent.getLeft()> mRange*0.5f){
            //在允许滑动的范围的中轴线右边,则打开
            open();
        }else if(xvel > 0){
            //速度向右时,则打开
            open();
        }else{
            //关闭
            close();
        }
    }

open(),close()创建为 DragLayout 的方法,这样方便外界调用

//直接打开
    protected void open() {
        mMainContent.layout(mRange, 0, mRange + mWidth, mHeight);
    }
    //直接关闭
    protected void close() {
        mMainContent.layout(0, 0, 0 + mWidth, mHeight);
    }

平滑的结束动画

首先实现平滑的打开动画,在这里需要用到 ViewDragHelper 提供的一个方法smoothSlideViewTo(child,finalLeft,finalTop),三个参数的意思分别是:

  • child 需要平滑移动的 view
  • finalLeft 需要移动到的终点左边位置
  • finalTop 需要移动到的终点的上边位置

smoothSlideViewTo()方法的返回值为 true,表示位置不是最终位置,需要重绘界面

重载一个 open(boolean isSmooth)方法,用参数 isSmooth 标识是调用平滑动画还是跳转动画,open()方法则直接调用 open(true),默认为平滑动画

protected void open() {
        open(true);
    }
    protected void open(boolean isSmooth) {
        int finalLeft = mRange;
        if(isSmooth){
            //触发一个平滑动画
            if(mViewDragHelper.smoothSlideViewTo(mMainContent, finalLeft, 0)){
                //invalidate();可能会漏帧
                ViewCompat.postInvalidateOnAnimation(this);
            };
        }else{
            //直接跳转
            mMainContent.layout(finalLeft, 0, finalLeft + mWidth, mHeight);
        }
    }

注意:smoothSlideViewTo()方法返回 true,需要重绘界面,此时不建议使用 invalidate(),因为在动画的过程中可能会丢帧,推荐使用 ViewCompat.postInvalidateOnAnimation(this),参数一定要传子 view 所在的容器,因为只有容器才知道子 view 的具体位置

重绘命令调用后,还需要重写 computScroll()方法,重绘时,系统会在 draw()方法后调用 computScroll(),在该方法中调用 ViewDragHelper 的维持动画的方法
continueSettling(deferCallbacks)参数 deferCallbacks 表示是否延迟画下一帧,此处传入 true,返回值表示是否已经移动到最终位置,如果为 true,还没有移动到最终位置,需要重绘界面,这样 computeScroll()方法就会不断的调用,界面也就会不断的重绘,直到移动到最终位置

    @Override
    //维持动画的执行,高频率调用
    public void computeScroll() {
        super.computeScroll();
        //调用完后会调用 draw()
        if(mViewDragHelper.continueSettling(true)){
            //参数传入 true,表示延迟画下一帧
            //mViewDragHelper.continueSettling(true)
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

同样的道理,关闭的平滑动画只需要修改 finalLeft = 0 即可

protected void close() {
        close(true);
    }
    protected void close(boolean isSmooth) {
        int finalLeft = 0;
        if(isSmooth){
            //触发一个平滑动画
            if(mViewDragHelper.smoothSlideViewTo(mMainContent, finalLeft, 0)){
                //invalidate();可能会漏帧
                ViewCompat.postInvalidateOnAnimation(this);
            };

        }else{
            mMainContent.layout(finalLeft, 0, finalLeft + mWidth, mHeight);
        }
    }

五、伴随动画

分解伴随动画

伴随动画是拖拽的过程中,左面板,主面板会跟随拖拽百分比所做的动画,该动画需要在onViewPositionChanged()回调方法中实现

  • 左面板:缩放动画,平移动画,透明度动画
  • 主面板:缩放动画
  • 背景: 亮度变化

实现伴随动画

创建一个方法 dispatchDragEvent(),在 onViewPositionChanged()方法中调用

public void onViewPositionChanged(View changedView, int left, int top,
                                      int dx, int dy) {
        super.onViewPositionChanged(changedView, left, top, dx, dy);
        //...此处代码省略
        dispatchDragEvent();
        invalidate();
    }

实现左面板的缩放动画

protected void dispatchDragEvent() {

        //0.0f->1.0f 获取动画的百分比,主面板左边的位置引起的一系列变化
        float percent = mMainContent.getLeft()*1.0f/mRange;
        System.out.println("dispatchDragEvent: percent:"+percent);

        //左面板:缩放动画,平移动画,透明度动画
        //0.0f ->1.0f percent*0.5f => 0.0f -> 0.5f
        //寻找规律->拷贝 FloatEvaluator.java 中的估值方法
        //percent*0.5f + 0.5f => 0.5f -> 1.0f
        //percent*(1.0f -0.6f)+0.6f => 0.6f -> 1.0f => start + percent(end - start)

        //兼容低版本引入 nineoldandroid.jar
        //用 ViewHelper 做属性动画
        //1.缩放动画
        ViewHelper.setScaleX(mLeftContent, evaluate(percent, 0.5f, 1.0f));
        ViewHelper.setScaleY(mLeftContent, evaluate(percent, 0.5f, 1.0f));
    }

    //源码 FloatEvaluator.java 中拷贝的估值方法
    public Float evaluate(float fraction, Number startValue, Number endValue) {
        float startFloat = startValue.floatValue();
        return startFloat + fraction * (endValue.floatValue() - startFloat);
    }
  • 第 3 行通过主面板左边位置与拖拽范围的相除可以得到一个 0.0f ->1.0f 的比例值,因为在整个拖拽过
    程中,主面板左边位置的变化是引起一系列变化的原因
  • 第 7-10 行可以推出一个公式 start + percent(end - start),即通过 percent 的变化可以计算出 start 到 end 间
    的任意值。源码 FloatEvaluator.java 中已经提供了这么一个方法,将其拷贝到代码中,即第 20-23 行
  • 第 12-16 行为了兼容低版本引入 nineoldandroid.jar 中的 ViewHelper 做属性动画

同理可以实现其它伴随动画

protected void dispatchDragEvent() {
        //0.0f->1.0f 获取动画的百分比,主面板左边的位置引起的一系列变化
        float percent = mMainContent.getLeft()*1.0f/mRange;
        System.out.println("dispatchDragEvent: percent:"+percent);
        //左面板:缩放动画,平移动画,透明度动画
        //0.0f ->1.0f percent*0.5f => 0.0f -> 0.5f
        //寻找规律->拷贝 FloatEvaluator.java 中的估值方法
        //percent*0.5f + 0.5f => 0.5f -> 1.0f
        //percent*(1.0f -0.6f)+0.6f => 0.6f -> 1.0f => start + percent(end - start)

        //兼容低版本引入 nineoldandroid.jar
        //用 ViewHelper 做属性动画
        //1.缩放动画,从 50%->100%
        ViewHelper.setScaleX(mLeftContent, evaluate(percent, 0.5f, 1.0f));
        ViewHelper.setScaleY(mLeftContent, evaluate(percent, 0.5f, 1.0f));


        //2.平移动画,从宽度一半在屏幕外->全部移到屏幕内
        ViewHelper.setTranslationX(mLeftContent, evaluate(percent, -mWidth*0.5f, 0f));

        //3.透明度动画,从 20%->100%
        ViewHelper.setAlpha(mLeftContent, evaluate(percent, 0.2f, 1.0f));

        //主面板:缩放动画,从 100%->80%
        ViewHelper.setScaleY(mMainContent, evaluate(percent, 1.0f, 0.8f));

        //背景亮度变化,PorterDuff.Mode.SRC_OVER 叠加模式,直接叠加在上面
        getBackground().setColorFilter((Integer)
                evaluateColor(percent, Color.BLACK,Color.TRANSPARENT), PorterDuff.Mode.SRC_OVER);
    }
    //源码 ArgbEvaluator.java 中拷贝的估值方法
    public Object evaluateColor(float fraction, Object startValue, Object endValue) {
        //api18 以上的代码才有透明度的过滤
        int startInt = (Integer) startValue;
        int startA = (startInt >> 24) & 0xff;
        int startR = (startInt >> 16) & 0xff;
        int startG = (startInt >> 8) & 0xff;
        int startB = startInt & 0xff;

        int endInt = (Integer) endValue;
        int endA = (endInt >> 24) & 0xff;
        int endR = (endInt >> 16) & 0xff;
        int endG = (endInt >> 8) & 0xff;
        int endB = endInt & 0xff;

        return (int)((startA + (int)(fraction * (endA - startA))) << 24) |
                (int)((startR + (int)(fraction * (endR - startR))) << 16) |
                (int)((startG + (int)(fraction * (endG - startG))) << 8) |
                (int)((startB + (int)(fraction * (endB - startB))));
    }
  • 第 27 行叠加模式 PorterDuff.Mode.SRC_OVER 表示直接叠加在上面
  • 第 30-48 行 ArgbEvaluator.java 源码中拷贝的估值方法,api18 以上的代码才有透明度的过滤

六、状态更新及事件回调

状态分析

拖拽的状态可以分为:

  • 打开状态
  • 关闭状态
  • 拖拽状态

通过枚举定义这三种状态,且定义默认状态为关闭

    //默认状态为关闭
    private Status status = Status.Close;
    //提供 get()方法
    public Status getStatus() {
        return status;
    }
    //状态的枚举值,有三种状态,打开,关闭,拖拽中
    public enum Status{
        Open,Close,Draging;
    }

事件回调分析

定义一个事件回调接口,事件回调和状态密切相关

  • 打开状态时回调 onOpen()方法
  • 关闭状态时回调 onClose()方法

拖拽中回调 onDraging(float percent)方法,并将拖拽百分比传出去

//接收外界注册的接口类,以便回调接口方法
    private OnDragChangeListener onDragChangeListener;
    //提供 set()方法,让外界注册监听接口类
    public void setOnDragChangeListener(OnDragChangeListener onDragChangeListener) {
        this.onDragChangeListener = onDragChangeListener;
    }
    //模仿 View 的 OnClickListener 的写法,定义一个内部的公开的接口
    public interface OnDragChangeListener{
        /**
         * 打开时调用
         */
        public void onOpen();
        /**
         * 关闭时调用
         */
        public void onClose();
        /**
         * 拖拽中调用
         * @param percent 当前拖拽的百分比
         */
        public void onDraging(float percent);
    }

实现状态更新及事件回调

通过拖拽百分比可以判断当前的状态,在 dispatchDragEvent()方法中实现状态更新及事件回调

1.百分比为 0,则为关闭状态
2.百分比为 1,则为打开状态
3.其它百分比,则为拖拽状态

事件回调需要先做空判断,拖拽状态调用频率高,直接调用即可,打开和关闭可以判断上次状态和当
前状态是否一致,不一致则调用

protected void dispatchDragEvent() {
        //0.0f->1.0f 获取动画的百分比,主面板左边的位置引起的一系列变化
        float percent = mMainContent.getLeft()*1.0f/mRange;
        System.out.println("dispatchDragEvent: percent:"+percent);

        Status lastStatus = status;
        //更新状态,通过动画百分比判断
        if(percent ==0 ){
            status = Status.Close;
        }else if(percent == 1){
            status = Status.Open;
        }else{
            status = Status.Close;
        }
        if(onDragChangeListener != null){
            //调用频率高,直接调用
            onDragChangeListener.onDraging(percent);
        }

        if(lastStatus != status && onDragChangeListener != null){
            if(status == Status.Open){
                //最新状态是 open,说明刚才不是 open,则需要调用一下 onOpen 方法
                onDragChangeListener.onOpen();
            }else if(status == Status.Close){
                //最新状态是 close,说明刚才不是 close,则需要调用一下 onClose 方法
                onDragChangeListener.onClose();
            }
        }

        //左面板:缩放动画,平移动画,透明度动画
        //0.0f ->1.0f percent*0.5f => 0.0f -> 0.5f
        //寻找规律->拷贝 FloatEvaluator.java 中的估值方法
        //percent*0.5f + 0.5f => 0.5f -> 1.0f
        //percent*(1.0f -0.6f)+0.6f => 0.6f -> 1.0f => start + percent(end - start)
        //兼容低版本引入 nineoldandroid.jar
        //用 ViewHelper 做属性动画
        //1.缩放动画
        ViewHelper.setScaleX(mLeftContent, evaluate(percent, 0.5f, 1.0f));
        ViewHelper.setScaleY(mLeftContent, evaluate(percent, 0.5f, 1.0f));
        //2.平移动画
        ViewHelper.setTranslationX(mLeftContent, evaluate(percent, -mWidth*0.5f, 0f));
        //3.透明度动画
        ViewHelper.setAlpha(mLeftContent, evaluate(percent, 0.2f, 1.0f));
        //主面板:缩放动画
        ViewHelper.setScaleY(mMainContent, evaluate(percent, 1.0f, 0.8f));
        //背景亮度变化,如果没有设置背景会出问题,PorterDuff.Mode.SRC_OVER 叠加模式,直接叠加在上
        面
        getBackground().setColorFilter((Integer)
                evaluateColor(percent, Color.BLACK,Color.TRANSPARENT), PorterDuff.Mode.SRC_OVER);
    }

七、触摸优化

填充界面数据

1.修改主界面 xml,左面板,主面板分别加入 ListView 及头像

<com.example.draglayout.widget.DragLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/dl"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@drawable/bg"
    tools:context=".MainActivity" >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="50dp"
        android:paddingLeft="10dp"
        android:paddingRight="50dp"
        android:orientation="vertical"
        android:paddingTop="50dp" >

        <ImageView
            android:layout_width="50dp"
            android:layout_height="50dp"
            android:contentDescription="@null"
            android:src="@drawable/head" />

        <ListView
            android:id="@+id/lv_left"
            android:layout_width="match_parent"
            android:layout_height="match_parent" >
        </ListView>
    </LinearLayout>

    <com.example.draglayout.widget.MyLinearLayout
        android:id="@+id/ll_my"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#ffffff"
        android:orientation="vertical" >

        <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="50dip"
            android:background="#18b6ef"
            android:gravity="center_vertical" >

            <ImageView
                android:id="@+id/iv_header"
                android:layout_width="30dp"
                android:layout_height="30dp"
                android:layout_marginLeft="10dp"
                android:contentDescription="@null"
                android:src="@drawable/head" />
        </RelativeLayout>

        <ListView
            android:id="@+id/lv_main"
            android:layout_width="match_parent"
            android:layout_height="match_parent" >
        </ListView>
    </com.example.draglayout.widget.MyLinearLayout>

</com.example.draglayout.widget.DragLayout>

2.ListView 数据源

 public class Cheeses {
        public static final String[] sCheeseStrings = {
                "Abbaye de Belloc", "Abbaye du Mont des Cats", "Abertam", "Abondance", "Ackawi",
                "Acorn", "Adelost", "Affidelice au Chablis", "Afuega'l Pitu", "Airag", "Airedale",
                "Xanadu", "Xynotyro", "Yarg Cornish", "Yarra Valley Pyramid", "Yorkshire Blue",
                "Zamorano", "Zanetti Grana Padano", "Zanetti Parmigiano Reggiano"
        };

        public static final String[] NAMES = new String[]{"宋江", "卢俊义", "吴用",
                "公孙胜", "关胜", "林冲", "秦明", "呼延灼", "花荣", "柴进", "李应", "朱仝", "鲁智 深",
                "武松", "董平", "张清", "杨志", "徐宁", "索超", "戴宗", "刘唐", "李逵", "史进", " 穆弘",
                "雷横", "李俊", "阮小二", "张横", "阮小五", " 张顺", "阮小七", "杨雄", "石秀", " 解珍",
                " 解宝", "燕青", "朱武", "黄信", "孙立", "宣赞", "郝思文", "韩滔", "彭玘", "单廷珪 ",
                "魏定国", "萧让", "裴宣", "欧鹏", "邓飞", " 燕顺", "杨林", "凌振", "蒋敬", "吕方 ",
                "郭 盛", "安道全", "皇甫端", "王英", "扈三娘", "鲍旭", "樊瑞", "孔明", "孔亮", " 项充",
                "李衮", "金大坚", "马麟", "童威", "童猛", "孟康", "侯健", "陈达", "杨春", "郑天寿 ",
                "陶宗旺", "宋清", "乐和", "龚旺", "丁得孙", "穆春", "曹正", "宋万", "杜迁", "薛永 ", " 施恩",
                "周通", "李忠", "杜兴", "汤隆", "邹渊", "邹润", "朱富", "朱贵", "蔡福", "蔡庆", " 李立",
                "李云", "焦挺", "石勇", "孙新", "顾大嫂", "张青", "孙二娘", " 王定六", "郁保四", " 白胜",
                "时迁", "段景柱"};
    }
public class DragLayout extends FrameLayout {

    private static final String TAG = "TAG";
    private View mLeftContent;
    private View mMainContent;
    private View mRightContent;
    private int mWidth;
    private int mHeight;
    private int mRangeLeft;
    private ViewDragHelper mDragHelper;
    private Status mStatus = Status.Close;
    private Direction mDirction = Direction.Left;
    private OnDragListener mDragListener;
    private boolean mScaleEnable = true;
    private int mRightWidth;
    private int mRangeRight;
    
    public interface OnDragListener {
        void onClose();
        
        void onStartOpen(Direction direction);
        
        void onOpen();
        
        void onDrag(float percent);
    }
    
    public static enum Status {
        Open, Close, Draging
    }

    public static enum Direction {
        Left, Right, Default
    }
    
    public Direction getDirction() {
        return mDirction;
    }

    public void setDirction(Direction dirction) {
        mDirction = dirction;
    }

    public DragLayout(Context context) {
        this(context, null);
    }

    public DragLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        mDragHelper = ViewDragHelper.create(this, mCallBack);
        mGestureDetector = new GestureDetectorCompat(context, mYGestureListener);
    }

    SimpleOnGestureListener mYGestureListener = new SimpleOnGestureListener() {
        public boolean onScroll(MotionEvent e1, MotionEvent e2,
                float distanceX, float distanceY) {
            return Math.abs(distanceX) >= Math.abs(distanceY);
        };
    };

    @Override
    protected void onFinishInflate() {
        Log.i(TAG, "--onFinishInflate");
        mLeftContent = (View) getChildAt(0);
        mRightContent = getChildAt(1);
        mMainContent = (View) getChildAt(2);

    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        Log.i(TAG, "--onSizeChanged");
        
        mWidth = mMainContent.getMeasuredWidth();
        mHeight = mMainContent.getMeasuredHeight();
        
        mRightWidth = mRightContent.getMeasuredWidth();
        mRangeLeft = (int) (mWidth * 0.6f);
        mRangeRight = mRightWidth;
    }

    private int mMainLeft = 0;

    ViewDragHelper.Callback mCallBack = new ViewDragHelper.Callback() {

        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            // 1. 决定当前被拖拽的child是否拖的动。(抽象方法,必须重写)
            Log.d(TAG, "tryCaptureView: " + (child == mMainContent) + " : "
                    + (child == mLeftContent) + " : "
                    + (child == mRightContent));
            return true;
        }

        @Override
        public int getViewHorizontalDragRange(View child) {
            // 2. 决定拖拽的范围
            return mWidth;
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            // 3. 决定拖动时的位置,可在这里进行位置修正。(若想在此方向拖动,必须重写,因为默认返回0)

            Log.d(TAG, "clampViewPositionHorizontal left: " + left + " dx: "
                    + dx + " mRange: " + mRangeLeft);

            return clampResult(mMainLeft + dx, left);
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top,
                int dx, int dy) {
            // 4. 决定了当View被拖动时,希望同时引发的其他变化
            Log.d(TAG, "onViewPositionChanged left: " + left + " dx: " + dx);

            if (changedView == mMainContent) {
                mMainLeft = left;
            } else {
                mMainLeft += dx;
            }

            mMainLeft = clampResult(mMainLeft, mMainLeft);
            
            if(changedView == mLeftContent || changedView == mRightContent){
                layoutContent();
            }
            
            dispathDragEvent(mMainLeft);
            invalidate();
        };

        /**
         * @param releasedChild
         *            被释放的孩子
         * @param xvel
         *            释放时X方向的速度
         * @param yvel
         *            释放时Y方向的速度
         */
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            // 5. 决定当childView被释放时,希望做的事情——执行打开/关闭动画,更新状态

            boolean scrollRight = xvel > 1.0f;
            boolean scrollLeft = xvel < -1.0f;
            if (scrollRight || scrollLeft) {
                if (scrollRight && mDirction == Direction.Left) {
                    open(true, mDirction);
                } else if (scrollLeft && mDirction == Direction.Right) {
                    open(true, mDirction);
                } else {
                    close(true);
                }
                return;
            }

            if (releasedChild == mLeftContent && mMainLeft > mRangeLeft * 0.7f) {
                open(true, mDirction);
            } else if (releasedChild == mMainContent) {
                if (mMainLeft > mRangeLeft * 0.3f)
                    open(true, mDirction);
                else if (-mMainLeft > mRangeRight * 0.3f)
                    open(true, mDirction);
                else
                    close(true);
            } else if (releasedChild == mRightContent
                    && -mMainLeft > mRangeRight * 0.7f) {
                open(true, mDirction);
            } else {
                close(true);
            }
        }

        @Override
        public void onViewDragStateChanged(int state) {
            if (mStatus == Status.Close && state == ViewDragHelper.STATE_IDLE
                    && mDirction == Direction.Right) {
                mDirction = Direction.Left;
            }
        }

        @Override
        public void onViewCaptured(View capturedChild, int activePointerId) {
        };

    };

    private int clampResult(int tempValue, int defaultValue) {
        Integer minLeft = null;
        Integer maxLeft = null;

        if (mDirction == Direction.Left) {
            minLeft = 0;
            maxLeft = 0 + mRangeLeft;
        } else if (mDirction == Direction.Right) {
            minLeft = 0 - mRangeRight;
            maxLeft = 0;
        }

        if (minLeft != null && tempValue < minLeft)
            return minLeft;
        else if (maxLeft != null && tempValue > maxLeft)
            return maxLeft;
        else
            return defaultValue;
    }

    private GestureDetectorCompat mGestureDetector;

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

        Log.i(TAG, "--onMeasure");
    }
    @Override
    protected void onLayout(boolean changed, int left, int top, int right,
            int bottom) {
        Log.i(TAG, "--onLayout");
        layoutContent();
    }

    private void layoutContent() {
        mLeftContent.layout(0, 0, mWidth, mHeight);
        mRightContent.layout(mWidth - mRightWidth, 0, mWidth, mHeight);
        mMainContent.layout(mMainLeft, 0, mMainLeft + mWidth, mHeight);
    }

    @Override
    public void computeScroll() {

        if (mDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    public void setDragListener(OnDragListener mDragListener) {
        this.mDragListener = mDragListener;
    }

    /**
     * 处理其他同步动画
     * 
     * @param mainLeft
     */
    protected void dispathDragEvent(int mainLeft) {
        // 注意转换成float
        float percent = 0;
        if (mDirction == Direction.Left)
            percent = mainLeft / (float) mRangeLeft;
        else if (mDirction == Direction.Right)
            percent = Math.abs(mainLeft) / (float) mRangeRight;

        if (mDragListener != null) {
            mDragListener.onDrag(percent);
        }

        // 更新动画
        if (mScaleEnable) {
            animViews(percent);
        }
        // 更新状态
        Status lastStatus = mStatus;
        if (updateStatus() != lastStatus) {
            if(lastStatus == Status.Close && mStatus == Status.Draging){
                mLeftContent.setVisibility(mDirction == Direction.Left ? View.VISIBLE : View.GONE);
                mRightContent.setVisibility(mDirction == Direction.Right ? View.VISIBLE : View.GONE);
                
                if(mDragListener != null){
                    mDragListener.onStartOpen(mDirction);
                }
            }
            
            if (mStatus == Status.Close) {
                if (mDragListener != null)
                    mDragListener.onClose();
            } else if (mStatus == Status.Open) {
                if (mDragListener != null)
                    mDragListener.onOpen();
            }
        }

    }

    private Status updateStatus() {
        if (mDirction == Direction.Left) {
            if (mMainLeft == 0) {
                mStatus = Status.Close;
            } else if (mMainLeft == mRangeLeft) {
                mStatus = Status.Open;
            } else {
                mStatus = Status.Draging;
            }
        } else if (mDirction == Direction.Right) {
            if (mMainLeft == 0) {
                mStatus = Status.Close;
            } else if (mMainLeft == 0 - mRangeRight) {
                mStatus = Status.Open;
            } else {
                mStatus = Status.Draging;
            }
        }
        return mStatus;

    }

    private void animViews(float percent) {
        Log.d(TAG, "percent: " + percent);
        animMainView(percent);

        animBackView(percent);
    }

    private void animBackView(float percent) {
        if (mDirction == Direction.Right) {
            // 右边栏X, Y放大,向左移动, 逐渐显示
            ViewHelper.setScaleX(mRightContent, 0.5f + 0.5f * percent);
            ViewHelper.setScaleY(mRightContent, 0.5f + 0.5f * percent);
            ViewHelper.setTranslationX(mRightContent,
                    evaluate(percent, mRightWidth + mRightWidth / 2.0f, 0.0f));

            ViewHelper.setAlpha(mRightContent, percent);
        } else {
            // 左边栏X, Y放大,向右移动, 逐渐显示
            ViewHelper.setScaleX(mLeftContent, 0.5f + 0.5f * percent);
            ViewHelper.setScaleY(mLeftContent, 0.5f + 0.5f * percent);
            ViewHelper.setTranslationX(mLeftContent,
                    evaluate(percent, -mWidth / 2f, 0.0f));
            ViewHelper.setAlpha(mLeftContent, percent);
        }
        // 背景逐渐变亮
        getBackground().setColorFilter(
                caculateValue(percent, Color.BLACK, Color.TRANSPARENT),
                PorterDuff.Mode.SRC_OVER);
    }

    private void animMainView(float percent) {
        Float inverseP = null;
        if (mDirction == Direction.Left) {
            inverseP = 1 - percent * 0.25f;
        } else if (mDirction == Direction.Right) {
            inverseP = 1 - percent * 0.25f;
        }
        // 主界面X,Y缩小
        if (inverseP != null) {
            if (mDirction == Direction.Right) {
                ViewHelper.setPivotX(mMainContent, mWidth);
                ViewHelper.setPivotY(mMainContent, mHeight / 2.0f);
            } else {
                ViewHelper.setPivotX(mMainContent, mWidth / 2.0f);
                ViewHelper.setPivotY(mMainContent, mHeight / 2.0f);
            }
            ViewHelper.setScaleX(mMainContent, inverseP);
            ViewHelper.setScaleY(mMainContent, inverseP);
        }
    }

    public Float evaluate(float fraction, Number startValue, Number endValue) {
        float startFloat = startValue.floatValue();
        return startFloat + fraction * (endValue.floatValue() - startFloat);
    }

    private int caculateValue(float fraction, Object start, Object end) {
        
        int startInt = (Integer) start;
        int startIntA = startInt >> 24 & 0xff;
        int startIntR = startInt >> 16 & 0xff;
        int startIntG = startInt >> 8 & 0xff;
        int startIntB = startInt & 0xff;

        int endInt = (Integer) end;
        int endIntA = endInt >> 24 & 0xff;
        int endIntR = endInt >> 16 & 0xff;
        int endIntG = endInt >> 8 & 0xff;
        int endIntB = endInt & 0xff;

        return ((int) (startIntA + (endIntA - startIntA) * fraction)) << 24
                | ((int) (startIntR + (endIntR - startIntR) * fraction)) << 16
                | ((int) (startIntG + (endIntG - startIntG) * fraction)) << 8
                | ((int) (startIntB + (endIntB - startIntB) * fraction));
    }
    

    float mDownX;

    private SwipeListAdapter adapter;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {

        if(getStatus() == Status.Close){
            int actionMasked = MotionEventCompat.getActionMasked(ev);
            switch (actionMasked) {
                case MotionEvent.ACTION_DOWN:
                    mDownX = ev.getRawX();
                    break;
                case MotionEvent.ACTION_MOVE:
    
                    if(adapter.getUnClosedCount() > 0){
                        return false;
                    }
                    
                    float delta = ev.getRawX() - mDownX;
                    if(delta < 0){
                        return false;
                    }
                    break;
                default:
                    mDownX = 0;
                    break;
            }
        }

        return mDragHelper.shouldInterceptTouchEvent(ev)
                & mGestureDetector.onTouchEvent(ev);
    }
    public void close(){
        close(true);
    }
    public void close(boolean withAnim) {

        mMainLeft = 0;
        if (withAnim) {
            if (mDragHelper.smoothSlideViewTo(mMainContent, mMainLeft, 0)) {
                ViewCompat.postInvalidateOnAnimation(this);
            }
        } else {
            layoutContent();
            
            dispathDragEvent(mMainLeft);
        }
    }

    public void open(){
        open(true);
    }
    
    public void open(boolean withAnim) {
        open(withAnim, Direction.Left);
    }

    public void open(boolean withAnim, Direction d) {
        mDirction = d;

        if (mDirction == Direction.Left)
            mMainLeft = mRangeLeft;
        else if (mDirction == Direction.Right)
            mMainLeft = -mRangeRight;

        if (withAnim) {
            // 引发动画的开始
            if (mDragHelper.smoothSlideViewTo(mMainContent, mMainLeft, 0)) {
                // 需要在computeScroll中使用continueSettling方法才能将动画继续下去(因为ViewDragHelper使用了scroller)。
                ViewCompat.postInvalidateOnAnimation(this);
            }
        } else {
            layoutContent();
            
            dispathDragEvent(mMainLeft);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        try {
            mDragHelper.processTouchEvent(event);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }

    public Status getStatus() {
        return mStatus;
    }

    public void switchScaleEnable() {
        this.mScaleEnable = !mScaleEnable;
        if (!mScaleEnable) {
            animBackView(1.0f);
        }

    }
    
    public void setAdapterInterface(SwipeListAdapter adapter) {
        this.adapter = adapter;
        
    }

}
public class DragRelativeLayout extends RelativeLayout {

    private DragLayout dl;

    public DragRelativeLayout(Context context) {
        super(context);
    }

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

    public DragRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }


    public void setDragLayout(DragLayout dl) {
        this.dl = dl;
    }
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (dl.getStatus() != Status.Close) {
            return true;
        }
        return super.onInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (dl.getStatus() != Status.Close) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                dl.close(true);
            }
            return true;
        }
        return super.onTouchEvent(event);
    }
}

代码:https://github.com/JackChen1999/DragLayout

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

推荐阅读更多精彩内容