仿网易新闻标签栏(动画拖动变化标签位置)

网易新闻标签栏的实现效果我一直想实现试试,最近花了点时间终于实现,初步实现效果如下,后面有时间还会继续完善(最近突然发现其实可以通过RecyclerView实现,但我这种实现方式也算是一种学习和练习吧)


效果

详细Demo和源码这里给出GitHub地址。
TabMoveLayout

实现功能

1.长按抖动
2.标签可随意拖动,其他标签随之变换位置
3.拖动变换子View顺序

难点:

1.熟悉自定义ViewGroup过程,onMeasure、onLayout
2.ViewGroup事件处理
3.多种拖动情况考虑(位置移动计算)
4.ViewGroup中子View的变更替换添加

实现思路:

1.自定义ViewGroup,实现标签栏的排列,这里我以4列为例(onMeasure,onLayout)

2.实现触摸标签的拖动,通过onTouch事件,在DOWN:获取触摸的x,y坐标,找到被触摸的View,在MOVE:通过view.layout()方法动态改变View的位置

3.其他标签的位置变换,主要通过TranslateAnimation,在MOVE:找到拖动过程中经过的View,并执行相应的Animation
(这里重点要考虑清楚所有拖动可能的情况)

4.拖动结束后,随之变换ViewGroup中view的实际位置,通过removeViewAt和addView进行添加和删除,中间遇到一点问题(博客)已分析。

关键代码:

1.自定义ViewGroup

这里主要是onMeasure和onLayout方法。这里我要说一下我的布局方式

 /**
     * 标签个数 4
     * |Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|Magin|View|Magin|
     * 总宽度:4*(标签宽度+2*margin)  按照比例 (总份数):4*(ITEM_WIDTH+2*MARGIN_WIDTH)
     * 则一个比例占的宽度为:组件总宽度/总份数
     * 一个标签的宽度为:组件宽度/总份数 * ITEM_WIDTH(宽度占的比例)
     * 一个标签的MARGIN为:组件宽度/总份数 * MARGIN_WIDTH(MARGIN占的比例)
     * 行高=(ITEN_HEIGHT+2*MARGIN_HEIGHT)*mItemScale
     * 一个组件占的宽度=(ITEM_WIDTH + 2*MARGIN_WIDTH)*mItemScale
     */

可能看起来比较复杂,其实理解起来就是:
一个标签所占的宽度=标签的宽度+2marginwidth
一个标签所占的高度=标签的高度+2
marginheight
这里都是用的权值计算的
一个比例占的长度为=总宽度/总份数
假如屏幕宽度为1000px,标签的宽度占10份,marginwidth占2份,标签的高度占5份,marginheight占1份
一个比例所占的长度(以一行4个标签为例) = 1000/((10+22)4)
一个标签所占的宽度 = (10+22)一个比例所占的长度
一个标签所占的高度 = (5+21)一个比例所占的长度

onMeasure方法

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
        int width;
        int height;
        int childCount = getChildCount();
        if (modeWidth == MeasureSpec.EXACTLY) {
            width = sizeWidth;
        } else {
            width = Math.min(sizeWidth, getScreenWidth(mContext));
        }

        if (modeHeight == MeasureSpec.EXACTLY) {
            height = sizeHeight;
        } else {
            int rowNum = childCount / ITEM_NUM;
            if (childCount % ITEM_NUM != 0) {
                height = (int) Math.min(sizeHeight, (rowNum + 1) * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
            } else {
                height = (int) Math.min(sizeHeight, rowNum * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale);
            }
        }

        measureChildren(
                MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_WIDTH), MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec((int) (mItemScale * ITEM_HEIGHT), MeasureSpec.EXACTLY));
        setMeasuredDimension(width, height);
    }

这里也是自定义View常见的一个点,注意MeasureSpace的三种模式EXACITY,AT_MOST,UNSPECIFIED,三种模式的对应关系可以简单理解为:

EXACITY -> MATCH_PARENT或者具体值
AT_MOST -> WARP_CONTENT
UNSPECIFIED是未指定尺寸,这种情况不多,一般都是父控件是AdapterView,通过measure方法传入的模式。

所以这里我处理方式为
宽度:当EXACITY时:width = widthsize,当其他模式时,width=sizewidth和屏幕宽度的较小值(这里注意sizeWidth的值为父组件传给自己的宽度值,所以如果当前组件处于第一层级,sizeWidth=屏幕宽度)
高度:当EXACITY时:height = heightsize,当其他模式时,计算行数,height=行数一行的高度(height+2marginheight)
再执行measureChildren

onLayout方法

protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        int left;
        int top;
        int right;
        int bottom;
        for (int i = 0; i < childCount; i++) {
            int row = i / ITEM_NUM;
            int column = i % ITEM_NUM;
            View child = getChildAt(i);
            left = (int) ((MARGIN_WIDTH + column * (ITEM_WIDTH + 2 * MARGIN_WIDTH)) * mItemScale);
            top = (int) ((MARGIN_HEIGHT + row * (ITEM_HEIGHT + 2 * MARGIN_HEIGHT)) * mItemScale);
            right = (int) (left + ITEM_WIDTH * mItemScale);
            bottom = (int) (top + ITEM_HEIGHT * mItemScale);
            child.layout(left, top, right, bottom);
        }

    }

所以onlayout也就比较好理解了,利用for循环遍历child,计算每个child所在的行和列,再通过child.layout()布局。

2.onTouch事件

public boolean onTouchEvent(MotionEvent event) {
        float x = event.getX();
        float y = event.getY();
        if(isMove){
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    mBeginX = x;
                    mBeginY = y;
                    mTouchIndex = findChildIndex(x, y);
                    mOldIndex = mTouchIndex;
                    if (mTouchIndex != -1) {
                        mTouchChildView = getChildAt(mTouchIndex);
                        mTouchChildView.clearAnimation();
                        //mTouchChildView.bringToFront();
                    }

                    break;
                case MotionEvent.ACTION_MOVE:
                    if (mTouchIndex != -1 && mTouchChildView != null) {
                        moveTouchView(x, y);
                        //拖动过程中的View的index
                        int resultIndex = findChildIndex(x, y);
                        if (resultIndex != -1 && (resultIndex != mOldIndex)
                                && ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
                                || (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
                                ) {
                            beginAnimation(Math.min(mOldIndex, resultIndex)
                                    , Math.max(mOldIndex, resultIndex)
                                    , mOldIndex < resultIndex);
                            mOldIndex = resultIndex;
                            mOnHover = true;
                        }
                    }

                    break;
                case MotionEvent.ACTION_UP:
                    setTouchIndex(x, y);
                    mOnHover = false;
                    mTouchIndex = -1;
                    mTouchChildView = null;
                    return  true;
            }
        }
        return super.onTouchEvent(event);
    }

这个方法算是这个效果的主要方法了,详细分析一下吧。首先看DOWN事件

case MotionEvent.ACTION_DOWN:
                    mBeginX = x;
                    mBeginY = y;
                    mTouchIndex = findChildIndex(x, y);
                    mOldIndex = mTouchIndex;
                    if (mTouchIndex != -1) {
                        mTouchChildView = getChildAt(mTouchIndex);
                        mTouchChildView.clearAnimation();
                        //mTouchChildView.bringToFront();
                    }

                    break;

可以看到,首先我先记录了触摸位置的x,y坐标,通过findChildIndex方法确定触摸位置的child的index。

/**
     * 通过触摸位置确定触摸位置的View
     */
    private int findChildIndex(float x, float y) {
        int row = (int) (y / ((ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale));
        int column = (int) (x / ((ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale));
        int index = row * ITEM_NUM + column;
        if (index > getChildCount() - 1) {
            return -1;
        }
        return index;
    }

因为最初分析的时候已经说到了
一行的高度 = 组件的高度+2marginheight
一列的宽度 = 组件的宽度+2
marginwidth
所以当我们得到触摸位置的x,y,就可以通过y/行高得到行数,x/列宽
当触摸位置没有child时返回-1。

得到触摸坐标后,获得通过getChildAt()获得触摸坐标的child,通过clearAnimation停止抖动。

MOVE事件:

case MotionEvent.ACTION_MOVE:
                    if (mTouchIndex != -1 && mTouchChildView != null) {
                        moveTouchView(x, y);
                        //拖动过程中的View的index
                        int resultIndex = findChildIndex(x, y);
                        if (resultIndex != -1 && (resultIndex != mOldIndex)
                                && ((Math.abs(x - mBeginX) > mItemScale * 2 * MARGIN_WIDTH)
                                || (Math.abs(y - mBeginY) > mItemScale * 2 * MARGIN_HEIGHT))
                                ) {
                            beginAnimation(Math.min(mOldIndex, resultIndex)
                                    , Math.max(mOldIndex, resultIndex)
                                    , mOldIndex < resultIndex);
                            mOldIndex = resultIndex;
                            mOnHover = true;
                        }
                    }

                    break;

首先根据move过程中的x,y,通过moveTouchView移动拖动的view随手指移动。

    private void moveTouchView(float x, float y) {
        int left = (int) (x - mTouchChildView.getWidth() / 2);
        int top = (int) (y - mTouchChildView.getHeight() / 2);
        mTouchChildView.layout(left, top
                , (left + mTouchChildView.getWidth())
                , (top + mTouchChildView.getHeight()));
        mTouchChildView.invalidate();
    }

这里有个细节,在移动的时候,将触摸的位置移动到大概child的中心位置,这样看起来正常一下,也就是我对x和y分别减去了child宽高的一半,不然会使得手指触摸的位置一直在child的左上角(坐标原点),看起来很变扭。最后通过layout和invalidate方法重绘child。

移动其他view

这个应该算是这个组件最难实现的地方,我在这上面花了最长的时间。
1)首先什么时候执行位移动画,反过来想就是什么时候不执行位移动画
这里分了四种情况:
(1)拖动的位置没有标签,也就是图上的从标签9往右拖
(2)拖动的位置和上一次位置相同(也就是没动)
(3)移动的位置不到一行的高度(也就是没有脱离当前标签的区域)
(4)移动的位置不到一列的宽度(也就是没有脱离当前标签的区域)

2)执行位移动画,下面会分析

3)mOldIndex = resultIndex这里是为了保存上一次移动的坐标位置

4)mOnHover=true,记录拖动不放的情况(和拖动就释放的情况有区分)

/**
     * 移动动画
     *
     * @param forward 拖动组件与经过的index的前后顺序 touchindex < resultindex
     *                true-拖动的组件在经过的index前
     *                false-拖动的组件在经过的index后
     */
    private void beginAnimation(int startIndex, int endIndex, final boolean forward) {
        TranslateAnimation animation;
        ViewHolder holder;
        List<TranslateAnimation> animList = new ArrayList<>();
        int startI = forward ? startIndex + 1 : startIndex;
        int endI = forward ? endIndex + 1 : endIndex;//for循环用的是<,取不到最后一个
        if (mOnHover) {//拖动没有释放情况
            if (mTouchIndex > startIndex) {
                if (mTouchIndex < endIndex) {
                    startI = startIndex;
                    endI = endIndex + 1;
                } else {
                    startI = startIndex;
                    endI = endIndex;
                }
            } else {
                startI = startIndex + 1;
                endI = endIndex + 1;
            }
        }

        //X轴的单位移动距离
        final float moveX = (ITEM_WIDTH + 2 * MARGIN_WIDTH) * mItemScale;
        //y轴的单位移动距离
        final float moveY = (ITEM_HEIGHT + 2 * MARGIN_HEIGHT) * mItemScale;
        //x轴移动方向
        final int directX = forward ? -1 : 1;
        final int directY = forward ? 1 : -1;
        boolean isMoveY = false;
        for (int i = startI; i < endI; i++) {
            if (i == mTouchIndex) {
                continue;
            }
            final View child = getChildAt(i);
            holder = (ViewHolder) child.getTag();
            child.clearAnimation();
            if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (i % ITEM_NUM == 0 && forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (mOnHover && holder.row < i / ITEM_NUM) {
                //onHover 下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
            } else if (mOnHover && holder.row > i / ITEM_NUM) {
                //onHover 上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
            } else {//y轴不动,仅x轴移动
                holder.column += directX;
                isMoveY = false;
                animation = new TranslateAnimation(0, directX * moveX, 0, 0);
            }
            animation.setDuration(mDuration);
            animation.setFillAfter(true);
            final boolean finalIsMoveY = isMoveY;
            animation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {

                }

                @Override
                public void onAnimationEnd(Animation animation) {
                    child.clearAnimation();
                    if (finalIsMoveY) {
                        child.offsetLeftAndRight((int) (directY * (ITEM_NUM - 1) * moveX));
                        child.offsetTopAndBottom((int) (directX * moveY));
                    } else {
                        child.offsetLeftAndRight((int) (directX * moveX));
                    }
                }

                @Override
                public void onAnimationRepeat(Animation animation) {

                }
            });
            child.setAnimation(animation);
            animList.add(animation);
        }
        for (TranslateAnimation anim : animList) {
            anim.startNow();
        }


    }

位移动画,这段代码怎么解释哪...我写的时候是发现一个bug改一种情况,最后实现了这段代码。


初步效果

1)这里首先确定开始位移的view的坐标和结束位移的坐标
这里分为两种情况:
case1:手指拖动后抬起(down->move->up);
case2:手指来回拖动不放(down->move->move)

case1:是常见情况,这里我们就可以按照forward再分为两种情况
case1.1:标签0->标签1(forward =true);
case1.2:标签5->标签1(forward=false)

case1.1:
标签0移动到标签1,标签0随手指移动,所以需要执行位移动画的只有标签1,所以startI = 1,endI = 2(for循环<,所以取不到最后一个),而startindex = 0,endindex = 1;
所以forward = true,startI = startIndex+1,endI=endIndex+1;
case1.2:
标签4移动到标签0,标签4随手指移动,所以需要执行位移动画的是标签0~标签3,所以startI=0,endI=4,所以而startindex=0,endindex=5;
所以forward = false,startI = startIndex,endI = endIndex

case2:是指手指拖动不放,来回拖动,所以通过mOnHover=true参数来确定是否是拖动没放情况,这里面又要细分为三种情况
case2.1:标签0->标签2->标签1,将标签0拖动到2,再回到0的位置,这是标签0一直随手指移动,
后面这段动画,startindex = 1,endindex = 2,touchindex = 0,只有标签2需要执行动画,标签1不动,所以startI = 2,endI = 3
所以mOnHover = true,touchindex<starindex,startI = startIndex + 1;endI = endindex+1;

case2.2:标签8->标签4->标签5,将标签8拖到4,在拖到5的位置,后面这段动画,startindex = 4,endindex = 5,touchindex = 8,只有标签4需要执行动画,其他标签不动,所以startI=4,endI=5
所以mOnHover = true,startIndex<endIndex<touchindex,startI=startIndex,endI=endIndex

case2.3:标签8->标签4->标签5->标签9,后面这段动画,startindex = 5,endindex = 9,touchindex = 8,执行动画的有标签5~标签9,所以startI=5,endI=10
所以mOnHover=true.startIndex<touchindex<endIndex,startI=startIndex,endI=endIndex+1

接下来就是for循环的移动动画,可以看到这里可以大致分为三种情况
case1:X轴的平移动画,Y轴不动;
case2:Y轴上移一行,X轴左移三个(也就是一行的第一个上移到上一行的最后一个);
case3:Y轴下移一行,X轴右移三个(也就是一行的最后一个下移到下一行的第一个);
可以看到我还是总体分为了mOnHover=false和mOnHover=true两种情况,我在初始化时,将每个child的所在行和列以Tag的形式保存到了child中

if (i % ITEM_NUM == (ITEM_NUM - 1) && !forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (i % ITEM_NUM == 0 && forward
                    && holder.row == i / ITEM_NUM && holder.column == i % ITEM_NUM) {
                //上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, directY * (ITEM_NUM - 1) * moveX, 0, directX * moveY);
            } else if (mOnHover && holder.row < i / ITEM_NUM) {
                //onHover 下移
                holder.row++;
                isMoveY = true;
                animation = new TranslateAnimation(0, -(ITEM_NUM - 1) * moveX, 0, moveY);
            } else if (mOnHover && holder.row > i / ITEM_NUM) {
                //onHover 上移
                holder.row--;
                isMoveY = true;
                animation = new TranslateAnimation(0, (ITEM_NUM - 1) * moveX, 0, -moveY);
            } else {//y轴不动,仅x轴移动
                holder.column += directX;
                isMoveY = false;
                animation = new TranslateAnimation(0, directX * moveX, 0, 0);
            }

case1:当是一行的最后一个,forward=false(后面的标签往前挤),标签的Tag中的x,y没有变化(也就是第一次拖动和mOnHover=true区分),这时下移
case2:当是一行的第一个,forward=true(上面的标签往下挤),标签的Tag中的x,y没有变化(也就是第一次拖动和mOnHover=true区分),这时上移
case3:当mOnHover=true,标签当前所在行<标签初始所在行,这时下移
case4:当mOnHover=true,标签当前所在行>标签初始所在行,这时上移
case5:X轴的平移,y轴不动

后面设置了child的动画监听,当动画结束后,需要将child的实际位置设置为当前位置(因为这里用的不是属性动画,所以执行动画后child的实际位置并没有变化,还是原始位置)

UP事件:

case MotionEvent.ACTION_UP:
                    setTouchIndex(x, y);
                    mOnHover = false;
                    mTouchIndex = -1;
                    mTouchChildView = null;
                    return  true;

这里主要看setTouchIndex事件

/**
     * ---up事件触发
     * 设置拖动的View的位置
     * @param x
     * @param y
     */
    private void setTouchIndex(float x,float y){
        if(mTouchChildView!= null){
            int resultIndex = findChildIndex(x, y);
            Log.e("resultindex", "" + resultIndex);
            if(resultIndex == mTouchIndex||resultIndex == -1){
                refreshView(mTouchIndex);
            }else{
                swapView(mTouchIndex, resultIndex);
            }
        }
    }

可以看到,这里拖动结束后就需要将拖动位置变化的child实际改变它在ViewGroup中的位置
这里有两种情况
case1:拖动到最后,child的顺序没有改变,只有touchview小浮动的位置变化,这时只需要刷新touchview即可
case2:将位置变换的child刷新其在viewgroup中的顺序。

/**
     *刷新View
     * ------------------------------重要------------------------------
     * 移除前需要先移除View的动画效果,不然无法移除,可看源码
     */
    private void refreshView(int index) {
        //移除原来的View
        getChildAt(index).clearAnimation();
        removeViewAt(index);
        //添加一个View
        TextView tv = new TextView(mContext);
        LayoutParams params = new ViewGroup.LayoutParams((int) (mItemScale * ITEM_WIDTH),
                (int) (mItemScale * ITEM_HEIGHT));
        tv.setText(mData.get(index));
        tv.setTextColor(TEXT_COLOR);
        tv.setBackgroundResource(ITEM_BACKGROUND);
        tv.setGravity(Gravity.CENTER);
        tv.setTextSize(TypedValue.COMPLEX_UNIT_PX,TEXT_SIZE);
        tv.setTag(new ViewHolder(index / ITEM_NUM, index % ITEM_NUM));
        this.addView(tv,index ,params);
        tv.startAnimation(mSnake);
    }

刷新index的View,这里有个需要注意的点,因为每个child都在执行抖动动画,这时候直接removeViewAt是没有办法起效果的,需要先clearAnimation再执行,具体我已经写了一篇博客从源码分析了
Animation导致removeView无效(源码分析)

 private void swapView(int fromIndex, int toIndex) {
        if(fromIndex < toIndex){
            mData.add(toIndex+1,mData.get(fromIndex));
            mData.remove(fromIndex);
        }else{
            mData.add(toIndex,mData.get(fromIndex));
            mData.remove(fromIndex+1);
        }

        for (int i = Math.min(fromIndex, toIndex); i <= Math.max(fromIndex, toIndex); i++) {
            refreshView(i);
        }
    }

这里交换touch和最终位置的child,所以首先实际改变Data数据集,再利用for循环,通过refreshView函数,刷新位置变化的child。

实现过程比较坎坷,但也是一种实现思路供大家参考吧,写完后对于自定义ViewGroup和动画能有很好的学习,这里再放一下Github地址TabMoveLayout,喜欢的可以star一下~~~

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

推荐阅读更多精彩内容