Android 自定义可展开收回能够@xxx和#话题的TextView(仿小红书效果)

前段时间接到一个需求,需要完成以下效果。


image.png

大致功能和小红书效果类似 可以 展开 和收起 也可以@xxx 还能加#话题

  • 1、内容超过指定行数需要折叠起来;
  • 2、内容中含有@+“内容”,需要携带“内容”跳转指定页面。
  • 3、有可能会在“展开”或者“收回”前面附加显示其他内容

实现思路:

可以自定义View继承TextView,在自定义View里面去处理所有的逻辑,这样方便后期维护扩展。

具体实现

在开始写代码之前,我们需要考虑几个点

  • 怎么保证“展开”或者“收回”放在文字的最后面
  • 如何识别文字中的@用户和#话题
  • 处理@用户,链接和“展开”或者“收回”三者的高亮显示和点击事件

问题处理

一、怎么保证“展开”或者“收回”放在文字的最后面

这个确实挺难处理的!在此之前也是让我头疼的一个问题,不过后来我遇到了DynamicLayout,使用它我们可以获取行的最后位置,行的开始位置,行的行宽以及指定内容的所占的行数。

        //用来计算内容的大小
        DynamicLayout mDynamicLayout =
                new DynamicLayout(mFormatData.formatedContent, mPaint, mWidth, Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f,
                        true);
        //获取行数
        int mLineCount = mDynamicLayout.getLineCount();
        int index = currentLines - 1;
        //获取指定行的最后位置
        int endPosition = mDynamicLayout.getLineEnd(index);
        //获取指定行的开始位置
        int startPosition = mDynamicLayout.getLineStart(index);
        //获取指定行的行宽
        float lineWidth = mDynamicLayout.getLineWidth(index);
image.png

有了这些东西经过简单的计算我们就可以获取到我们需要截取的内容长度。对原内容进行截取再拼接上“展开”或“收回”即可!

/**
    * 计算原内容被裁剪的长度
    *
    * @param endPosition
    * @param startPosition
    * @param lineWidth
    * @param endStringWith
    * @param offset
    * @return
    */
   private int getFitPosition(int endPosition, int startPosition, float lineWidth,
                              float endStringWith, float offset, String aimContent) {
       //最后一行需要添加的文字的字数                       
       int position = (int) ((lineWidth - (endStringWith + offset)) * (endPosition - startPosition)/ lineWidth);

       if (position < 0) return endPosition;
       //计算最后一行需要显示的正文的长度
       float measureText = mPaint.measureText(
               (aimContent.substring(startPosition, startPosition + position)));
       //如果最后一行需要显示的正文的长度比最后一行的长减去“展开”文字的长度要短就可以了  否则加个空格继续算
       if (measureText <= lineWidth - endStringWith) {
           return startPosition + position;
       } else {
           return getFitPosition(endPosition, startPosition, lineWidth, endStringWith, offset + mPaint.measureText(" "));
       }
   }

二、如何识别文字中的@用户

//对@用户 进行正则匹配
    Pattern pattern = Pattern.compile(regexp_mention, Pattern.CASE_INSENSITIVE);
    Matcher matcher = pattern.matcher(newResult.toString());
    List<FormatData.PositionData> datasMention = new ArrayList<>();
    while (matcher.find()) {
        //将匹配到的内容进行统计处理
        datasMention.add(new FormatData.PositionData(matcher.start(), matcher.end(), matcher.group(), LinkType.MENTION_TYPE));
    }

三、处理@用户,链接和“展开”或者“收回”三者的高亮显示和点击事件

对于@用户,链接和“展开”或者“收回”三者的实现,最终都是使用SpannableStringBuilder来处理。之前我们在对原内容进行解析的时候,将匹配到的链接或者@用户进行了存储,并且存储了他们所在的位置(start,end)以及类型。

    //定义类型的枚举类型
    public enum LinkType {
        //普通链接
        LINK_TYPE,
        //@用户
        MENTION_TYPE
    }

复制代码有了这些数据的集合,我们只需要遍历这些数据,并分别对这些数据进行setSpan处理,并且在setSpan的过程中设置字体颜色,以及点击事件的回调即可。

//处理链接或者@用户
    private void dealLinksOrMention(FormatData formatData,SpannableStringBuilder ssb) {
        List<FormatData.PositionData> positionDatas = formatData.getPositionDatas();
        HH:
        for (FormatData.PositionData data : positionDatas) {
            if (data.getType().equals(LinkType.LINK_TYPE)) {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    SelfImageSpan imageSpan = new SelfImageSpan(mLinkDrawable, ImageSpan.ALIGN_BASELINE);
                    //设置链接图标
                    ssb.setSpan(imageSpan, data.getStart(), data.getStart() + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
                    //设置链接文字样式
                    int endPosition = data.getEnd();
                    if (fitPosition > data.getStart() + 1 && fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    if (data.getStart() + 1 < fitPosition) {
                        ssb.setSpan(new ClickableSpan() {
                            @Override
                            public void onClick(View widget) {
                                if (linkClickListener != null)
                                    linkClickListener.onLinkClickListener(LinkType.LINK_TYPE, data.getUrl());
                            }

                            @Override
                            public void updateDrawState(TextPaint ds) {
                                ds.setColor(mLinkTextColor);
                                ds.setUnderlineText(false);
                            }
                        }, data.getStart() + 1, endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                    }
                }
            } else {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    int endPosition = data.getEnd();
                    if (fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    ssb.setSpan(new ClickableSpan() {
                        @Override
                        public void onClick(View widget) {
                            if (linkClickListener != null)
                                linkClickListener.onLinkClickListener(LinkType.MENTION_TYPE, data.getUrl());
                        }

                        @Override
                        public void updateDrawState(TextPaint ds) {
                            ds.setColor(mLinkTextColor);
                            ds.setUnderlineText(false);
                        }
                    }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }
    
    /**
     * 设置 "展开"
     * @param ssb
     * @param formatData
     */
    private void setExpandSpan(SpannableStringBuilder ssb,FormatData formatData){
        int index = currentLines - 1;
        int endPosition = mDynamicLayout.getLineEnd(index);
        int startPosition = mDynamicLayout.getLineStart(index);
        float lineWidth = mDynamicLayout.getLineWidth(index);

        String endString = getHideEndContent();

        //计算原内容被截取的位置下标
        int fitPosition =
                getFitPosition(endPosition, startPosition, lineWidth, mPaint.measureText(endString), 0);

        ssb.append(formatData.formatedContent.substring(0, fitPosition));

        //在被截断的文字后面添加 展开 文字
        ssb.append(endString);

        int expendLength = TextUtils.isEmpty(mEndExpandContent) ? 0 : 2 + mEndExpandContent.length();
        ssb.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                action();
            }

            @Override
            public void updateDrawState(TextPaint ds) {
                super.updateDrawState(ds);
                ds.setColor(mExpandTextColor);
                ds.setUnderlineText(false);
            }
        }, ssb.length() - TEXT_EXPEND.length() - expendLength, ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }

复制代码在处理这一块的时候有个细节需要注意,那就是假如在文字切割后的末尾正好有个一个链接,而这个地方又要显示“展开”或者“收回”,这个地方要特别注意链接setSpan的范围,一不注意就可能连同把后面的“展开”或者“收回”也一起设置了,导致事件不对。处理“收回”是差不多的,就不贴代码了。最后还有一个附加功能就是在最后添加时间串的功能,其实也就是在“展开”和“收回”前面加一个串,做好这方面的判断就好了,代码里面已经做了处理。

下面是所有源码实现:

public class CustomExpandableTextView extends AppCompatTextView {
   private static final int DEF_MAX_LINE = 4;
   public static String TEXT_CONTRACT = "收起";
   public static String TEXT_EXPEND = "展开";
   public static final String Space = " ";
   public static String TEXT_TARGET = "网页链接";
   public static final String IMAGE_TARGET = "图";
   public static final String TARGET = IMAGE_TARGET + TEXT_TARGET;
   public static final String DEFAULT_CONTENT = "                                                                                                                                                                                                                                                                                                                           ";

   private static int retryTime = 0;

   public static final String regexp_mention = "@[^\\n\\s]{1,80}\\s{1}";
   public static final String regexp_topic = "#[^\\n\\s]{1,80}\\s{1}";
   //匹配自定义链接的正则表达式
   public static final String self_regex = "\\[([^\\[]*)\\]\\(([^\\(]*)\\)";

   private TextPaint mPaint;

   boolean linkHit;

   private Context mContext;

   /**
    * 记录当前的model
    */
   private ExpandableStatusFix mModel;

   /**
    * 计算的layout
    */
   private DynamicLayout mDynamicLayout;

   //hide状态下,展示多少行开始省略
   private int mLimitLines;

   private int currentLines;

   private int mWidth;

   private Drawable mLinkDrawable = null;

   /**
    * 链接和@用户的事件点击
    */
   private OnLinkClickListener linkClickListener;

   /**
    * 点击展开或者收回按钮的时候 是否真的执行操作
    */
   private boolean needRealExpandOrContract = true;

   /**
    * 展开或者收回事件监听
    */
   private OnExpandOrContractClickListener expandOrContractClickListener;

   /**
    * 是否需要收起
    */
   private boolean mNeedContract = true;

   private FormatData mFormatData;

   /**
    * 是否需要展开功能
    */
   private boolean mNeedExpend = true;

   /**
    * 是否需要转换url成网页链接四个字
    */
   private boolean mNeedConvertUrl = true;

   /**
    * 是否需要@用户的功能
    */
   private boolean mNeedMention = true;

   /**
    * 是否需要#用户的功能
    */
   private boolean mNeedTopic = true;

   /**
    * 是否需要对链接进行处理
    */
   private boolean mNeedLink = true;

   /**
    * 是否需要对自定义情况进行处理
    */
   private boolean mNeedSelf = false;

   /**
    * 是否需要永远将展开或收回显示在最右边
    */
   private boolean mNeedAlwaysShowRight = false;

   /**
    * 是否需要动画 默认开启动画
    */
   private boolean mNeedAnimation = true;

   private int mLineCount;

   private CharSequence mContent;

   /**
    * 展开文字的颜色
    */
   private int mExpandTextColor;
   /**
    * 展开文字的颜色
    */
   private int mMentionTextColor;

   private int mTopicTextColor;


   /**
    * 链接的字体颜色
    */
   private int mLinkTextColor;

   /**
    * 自定义规则的字体颜色
    */
   private int mSelfTextColor;

   /**
    * 收起的文字的颜色
    */
   private int mContractTextColor;

   /**
    * 展开的文案
    */
   private String mExpandString;
   /**
    * 收起的文案
    */
   private String mContractString;

   /**
    * 在收回和展开前面添加的内容
    */
   private String mEndExpandContent;

   /**
    * 在收回和展开前面添加的内容的字体颜色
    */
   private int mEndExpandTextColor;

   //是否AttachedToWindow
   private boolean isAttached;

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

   public ExpandableTextView(Context context, @Nullable AttributeSet attrs) {
       this(context, attrs, -1);
   }

   public ExpandableTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
       init(context, attrs, defStyleAttr);
       setMovementMethod(LocalLinkMovementMethod.getInstance());
       addOnAttachStateChangeListener(new OnAttachStateChangeListener() {
           @Override
           public void onViewAttachedToWindow(View v) {
               if (isAttached == false)
                   doSetContent();
               isAttached = true;
           }

           @Override
           public void onViewDetachedFromWindow(View v) {

           }
       });
   }

   private void init(Context context, AttributeSet attrs, int defStyleAttr) {
       //适配英文版
       TEXT_CONTRACT = context.getString(R.string.social_contract);
       TEXT_EXPEND = context.getString(R.string.social_expend);
       TEXT_TARGET = context.getString(R.string.social_text_target);

       if (attrs != null) {
           TypedArray a =
                   getContext().obtainStyledAttributes(attrs, R.styleable.ExpandableTextView,
                           defStyleAttr, 0);

           mLimitLines = a.getInt(R.styleable.ExpandableTextView_ep_max_line, DEF_MAX_LINE);
           mNeedExpend = a.getBoolean(R.styleable.ExpandableTextView_ep_need_expand, true);
           mNeedContract = a.getBoolean(R.styleable.ExpandableTextView_ep_need_contract, false);
           mNeedAnimation = a.getBoolean(R.styleable.ExpandableTextView_ep_need_animation, true);
           mNeedSelf = a.getBoolean(R.styleable.ExpandableTextView_ep_need_self, false);
           mNeedMention = a.getBoolean(R.styleable.ExpandableTextView_ep_need_mention, true);
           mNeedTopic = a.getBoolean(R.styleable.ExpandableTextView_ep_need_mention, true);

           mNeedLink = a.getBoolean(R.styleable.ExpandableTextView_ep_need_link, true);
           mNeedAlwaysShowRight = a.getBoolean(R.styleable.ExpandableTextView_ep_need_always_showright, false);
           mNeedConvertUrl = a.getBoolean(R.styleable.ExpandableTextView_ep_need_convert_url, true);
           mContractString = a.getString(R.styleable.ExpandableTextView_ep_contract_text);
           mExpandString = a.getString(R.styleable.ExpandableTextView_ep_expand_text);
           if (TextUtils.isEmpty(mExpandString)) {
               mExpandString = TEXT_EXPEND;
           }
           if (TextUtils.isEmpty(mContractString)) {
               mContractString = TEXT_CONTRACT;
           }
           mExpandTextColor = a.getColor(R.styleable.ExpandableTextView_ep_expand_color,
                   Color.parseColor("#999999"));
           mEndExpandTextColor = a.getColor(R.styleable.ExpandableTextView_ep_expand_color,
                   Color.parseColor("#999999"));
           mContractTextColor = a.getColor(R.styleable.ExpandableTextView_ep_contract_color,
                   Color.parseColor("#999999"));
           mLinkTextColor = a.getColor(R.styleable.ExpandableTextView_ep_link_color,
                   Color.parseColor("#FF6200"));
           mSelfTextColor = a.getColor(R.styleable.ExpandableTextView_ep_self_color,
                   Color.parseColor("#FF6200"));
           mMentionTextColor = a.getColor(R.styleable.ExpandableTextView_ep_mention_color,
                   Color.parseColor("#FF6200"));

           mTopicTextColor = a.getColor(R.styleable.ExpandableTextView_ep_topic_color,
                   Color.parseColor("#FF6200"));
           int resId = a.getResourceId(R.styleable.ExpandableTextView_ep_link_res, R.mipmap.link);
           mLinkDrawable = getResources().getDrawable(resId);
           currentLines = mLimitLines;
           a.recycle();
       } else {
           mLinkDrawable = context.getResources().getDrawable(R.mipmap.link);
       }

       mContext = context;

       mPaint = getPaint();
       mPaint.setStyle(Paint.Style.FILL_AND_STROKE);

       //初始化link的图片
       mLinkDrawable.setBounds(0, 0, 30, 30); //必须设置图片大小,否则不显示
   }

   private SpannableStringBuilder setRealContent(CharSequence content,boolean isHide) {
       //处理给定的数据
       mFormatData = formatData(content);
       //用来计算内容的大小
       mDynamicLayout =
               new DynamicLayout(mFormatData.getFormatedContent(), mPaint, mWidth, Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f,
                       true);
       //获取行数
       mLineCount = mDynamicLayout.getLineCount();

       if (onGetLineCountListener != null) {
           onGetLineCountListener.onGetLineCount(mLineCount, mLineCount > mLimitLines);
       }

       if (!mNeedExpend || mLineCount <= mLimitLines) {
           //不需要展开功能 直接处理链接模块
           return dealLink(mFormatData, false,false);
       } else {
           return dealLink(mFormatData, true,isHide);
       }
   }

   /**
    * 设置追加的内容
    *
    * @param endExpendContent
    */
   public void setEndExpendContent(String endExpendContent) {
       this.mEndExpandContent = endExpendContent;
   }

   /**
    * 设置内容
    *
    * @param content
    */
   public void setContent(final String content) {
       mContent = content;
       if (isAttached)
           doSetContent();
   }

   /**
    * 实际设置内容的
    */
   private void doSetContent() {
       if (mContent == null) {
           return;
       }
       currentLines = mLimitLines;

       if (mWidth <= 0) {
           if (getWidth() > 0)
               mWidth = getWidth() - getPaddingLeft() - getPaddingRight();
       }

       if (mWidth <= 0) {
           if (retryTime > 10) {
               setText(DEFAULT_CONTENT);
           }
           this.post(new Runnable() {
               @Override
               public void run() {
                   retryTime++;
                   setContent(mContent.toString());
               }
           });
       } else {
           setRealContent(mContent.toString(),false);
       }
   }

   /**
    * 设置最后的收起文案
    *
    * @return
    */
   private String getExpandEndContent() {
       if (TextUtils.isEmpty(mEndExpandContent)) {
           return String.format(Locale.getDefault(), "  %s",
                   mContractString);
       } else {
           return String.format(Locale.getDefault(), "  %s  %s",
                   mEndExpandContent, mContractString);
       }
   }

   /**
    * 设置展开的文案
    *
    * @return
    */
   private String getHideEndContent() {
       if (TextUtils.isEmpty(mEndExpandContent)) {
           return String.format(Locale.getDefault(), mNeedAlwaysShowRight ? "  %s" : "...  %s",
                   mExpandString);
       } else {
           return String.format(Locale.getDefault(), mNeedAlwaysShowRight ? "  %s  %s" : "...  %s  %s",
                   mEndExpandContent, mExpandString);
       }
   }

   /**
    * 处理文字中的链接问题
    *
    * @param formatData
    * @param ignoreMore
    */
   private SpannableStringBuilder dealLink(FormatData formatData, boolean ignoreMore,boolean mIsHide) {
       SpannableStringBuilder ssb = new SpannableStringBuilder();
       //获取存储的状态
       if (mModel != null && mModel.getStatus() != null) {
           boolean isHide = false;
           if (mModel.getStatus() != null) {
               if (mModel.getStatus().equals(StatusType.STATUS_CONTRACT)) {
                   //收起
                   isHide = true;
               } else {
                   //展开
                   isHide = false;
               }
           }
           if (isHide) {
               currentLines = mLimitLines + ((mLineCount - mLimitLines));
           } else {
               if (mNeedContract)
                   currentLines = mLimitLines;
           }
           mIsHide = isHide;
       }
       //处理折叠操作
       if (ignoreMore) {
           if (currentLines < mLineCount) {
               int index = currentLines - 1;
               int endPosition = mDynamicLayout.getLineEnd(index);
               int startPosition = mDynamicLayout.getLineStart(index);
               float lineWidth = mDynamicLayout.getLineWidth(index);

               String endString = getHideEndContent();

               //计算原内容被截取的位置下标
               int fitPosition =
                       getFitPosition(endString, endPosition, startPosition, lineWidth, mPaint.measureText(endString), 0);
               String substring = formatData.getFormatedContent().substring(0, fitPosition);
               if (substring.endsWith("\n")) {
                   substring = substring.substring(0, substring.length() - "\n".length());
               }
               ssb.append(substring);

               if (mNeedAlwaysShowRight) {
                   //计算一下最后一行有没有充满
                   float lastLineWidth = 0;
                   for (int i = 0; i < index; i++) {
                       lastLineWidth += mDynamicLayout.getLineWidth(i);
                   }
                   lastLineWidth = lastLineWidth / (index);
                   float emptyWidth = lastLineWidth - lineWidth - mPaint.measureText(endString);
                   if (emptyWidth > 0) {
                       float measureText = mPaint.measureText(Space);
                       int count = 0;
                       while (measureText * count < emptyWidth) {
                           count++;
                       }
                       count = count - 1;
                       for (int i = 0; i < count; i++) {
                           ssb.append(Space);
                       }
                   }
               }

               //在被截断的文字后面添加 展开 文字
               ssb.append(endString);

               int expendLength = TextUtils.isEmpty(mEndExpandContent) ? 0 : 2 + mEndExpandContent.length();
               ssb.setSpan(new ClickableSpan() {
                   @Override
                   public void onClick(View widget) {
                       if (needRealExpandOrContract) {
                           if (mModel != null) {
                               mModel.setStatus(StatusType.STATUS_CONTRACT);
                               action(mModel.getStatus());
                           } else {
                               action();
                           }
                       }
                       if (expandOrContractClickListener != null) {
                           expandOrContractClickListener.onClick(StatusType.STATUS_EXPAND);
                       }
                   }

                   @Override
                   public void updateDrawState(TextPaint ds) {
                       super.updateDrawState(ds);
                       ds.setColor(mExpandTextColor);
                       ds.setUnderlineText(false);
                   }
               }, ssb.length() - mExpandString.length() - expendLength, ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
           } else {
               ssb.append(formatData.getFormatedContent());
               if (mNeedContract) {
                   String endString = getExpandEndContent();

                   if (mNeedAlwaysShowRight) {
                       //计算一下最后一行有没有充满
                       int index = mDynamicLayout.getLineCount() - 1;
                       float lineWidth = mDynamicLayout.getLineWidth(index);
                       float lastLineWidth = 0;
                       for (int i = 0; i < index; i++) {
                           lastLineWidth += mDynamicLayout.getLineWidth(i);
                       }
                       lastLineWidth = lastLineWidth / (index);
                       float emptyWidth = lastLineWidth - lineWidth - mPaint.measureText(endString);
                       if (emptyWidth > 0) {
                           float measureText = mPaint.measureText(Space);
                           int count = 0;
                           while (measureText * count < emptyWidth) {
                               count++;
                           }
                           count = count - 1;
                           for (int i = 0; i < count; i++) {
                               ssb.append(Space);
                           }
                       }
                   }

                   ssb.append(endString);

                   int expendLength = TextUtils.isEmpty(mEndExpandContent) ? 0 : 2 + mEndExpandContent.length();
                   ssb.setSpan(new ClickableSpan() {
                       @Override
                       public void onClick(View widget) {
                           if (mModel != null) {
                               mModel.setStatus(StatusType.STATUS_EXPAND);
                               action(mModel.getStatus());
                           } else {
                               action();
                           }
                           if (expandOrContractClickListener != null) {
                               expandOrContractClickListener.onClick(StatusType.STATUS_CONTRACT);
                           }
                       }

                       @Override
                       public void updateDrawState(TextPaint ds) {
                           super.updateDrawState(ds);
                           ds.setColor(mContractTextColor);
                           ds.setUnderlineText(false);
                       }
                   }, ssb.length() - mContractString.length() - expendLength, ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
               } else {
                   if (!TextUtils.isEmpty(mEndExpandContent)) {
                       ssb.append(mEndExpandContent);
                       ssb.setSpan(new ForegroundColorSpan(mEndExpandTextColor), ssb.length() - mEndExpandContent.length(), ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                   }
               }
           }
       } else {
           ssb.append(formatData.getFormatedContent());
           if (!TextUtils.isEmpty(mEndExpandContent)) {
               ssb.append(mEndExpandContent);
               ssb.setSpan(new ForegroundColorSpan(mEndExpandTextColor), ssb.length() - mEndExpandContent.length(), ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
           }
       }
       //处理链接或者@用户
       List<FormatData.PositionData> positionDatas = formatData.getPositionDatas();
       HH:
       for (FormatData.PositionData data : positionDatas) {
           if (ssb.length() >= data.getEnd()) {
               if (data.getType().equals(LinkType.LINK_TYPE)) {
                   if (mNeedExpend && ignoreMore) {
                       int fitPosition = ssb.length() - getHideEndContent().length();
                       if (data.getStart() < fitPosition) {
                           SelfImageSpan imageSpan = new SelfImageSpan(mLinkDrawable, ImageSpan.ALIGN_BASELINE);
                           //设置链接图标
                           ssb.setSpan(imageSpan, data.getStart(), data.getStart() + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
                           //设置链接文字样式
                           int endPosition = data.getEnd();
                           if (currentLines < mLineCount) {
                               if (fitPosition > data.getStart() + 1 && fitPosition < data.getEnd()) {
                                   endPosition = fitPosition;
                               }
                           }
                           if (data.getStart() + 1 < fitPosition) {
                               addUrl(ssb, data, endPosition);
                           }
                       }
                   } else {
                       SelfImageSpan imageSpan = new SelfImageSpan(mLinkDrawable, ImageSpan.ALIGN_BASELINE);
                       //设置链接图标
                       ssb.setSpan(imageSpan, data.getStart(), data.getStart() + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
                       addUrl(ssb, data, data.getEnd());
                   }
               } else if (data.getType().equals(LinkType.MENTION_TYPE)) {
                   //如果需要展开
                   if (mNeedExpend && ignoreMore) {
                       int fitPosition = ssb.length() - getHideEndContent().length();
                       if (data.getStart() < fitPosition) {
                           int endPosition = data.getEnd();
                           if (currentLines < mLineCount) {
                               if (fitPosition < data.getEnd()) {
                                   endPosition = fitPosition;
                               }
                           }
                           addMention(ssb, data, endPosition);
                       }
                   } else {
                       addMention(ssb, data, data.getEnd());
                   }
               } else if (data.getType().equals(LinkType.TOPIC_TYPE)) {
                   //如果需要展开
                   if (mNeedExpend && ignoreMore) {
                       int fitPosition = ssb.length() -(mIsHide?getExpandEndContent().length():getHideEndContent().length()) ;
                       if (data.getStart() < fitPosition) {
                           int endPosition = data.getEnd();
                           if (currentLines < mLineCount) {
                               if (fitPosition < data.getEnd()) {
                                   endPosition = fitPosition;
                               }
                           }
                           addTopic(ssb, data, endPosition);
                       }
                   } else {
                       addTopic(ssb, data, data.getEnd());
                   }
               }else if (data.getType().equals(LinkType.SELF)) {
                   //自定义
                   //如果需要展开
                   if (mNeedExpend && ignoreMore) {
                       int fitPosition = ssb.length() - getHideEndContent().length();
                       if (data.getStart() < fitPosition) {
                           int endPosition = data.getEnd();
                           if (currentLines < mLineCount) {
                               if (fitPosition < data.getEnd()) {
                                   endPosition = fitPosition;
                               }
                           }
                           addSelf(ssb, data, endPosition);
                       }
                   } else {
                       addSelf(ssb, data, data.getEnd());
                   }
               }
           }
       }
       //清除链接点击时背景效果
       setHighlightColor(Color.TRANSPARENT);
       //将内容设置到控件中
       setText(ssb);
       return ssb;
   }

   /**
    * 获取需要插入的空格
    *
    * @param emptyWidth
    * @param endStringWidth
    * @return
    */
   private int getFitSpaceCount(float emptyWidth, float endStringWidth) {
       float measureText = mPaint.measureText(Space);
       int count = 0;
       while (endStringWidth + measureText * count < emptyWidth) {
           count++;
       }
       return --count;
   }


   /**
    * 添加自定义规则
    *
    * @param ssb
    * @param data
    * @param endPosition
    */
   private void addSelf(SpannableStringBuilder ssb, final FormatData.PositionData data, int endPosition) {
       ssb.setSpan(new ClickableSpan() {
           @Override
           public void onClick(View widget) {
               if (linkClickListener != null)
                   linkClickListener.onLinkClickListener(LinkType.SELF, data.getSelfAim(), data.getSelfContent());
           }

           @Override
           public void updateDrawState(TextPaint ds) {
               ds.setColor(mSelfTextColor);
               ds.setUnderlineText(false);
           }
       }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
   }


   /**
    * 添加@用户的Span
    *
    * @param ssb
    * @param data
    * @param endPosition
    */
   private void addMention(SpannableStringBuilder ssb, final FormatData.PositionData data, int endPosition) {
       ssb.setSpan(new ClickableSpan() {
           @Override
           public void onClick(View widget) {
               if (linkClickListener != null)
                   linkClickListener.onLinkClickListener(LinkType.MENTION_TYPE, data.getUrl(), null);
           }

           @Override
           public void updateDrawState(TextPaint ds) {
               ds.setColor(mMentionTextColor);
               ds.setUnderlineText(false);
           }
       }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
   }

   /**
    * 添加@用户的Span
    *
    * @param ssb
    * @param data
    * @param endPosition
    */
   private void addTopic(SpannableStringBuilder ssb, final FormatData.PositionData data, int endPosition) {
       ssb.setSpan(new ClickableSpan() {
           @Override
           public void onClick(View widget) {
               if (linkClickListener != null)
                   linkClickListener.onLinkClickListener(LinkType.TOPIC_TYPE, data.getUrl(), null);
           }

           @Override
           public void updateDrawState(TextPaint ds) {
               ds.setColor(mTopicTextColor);
               ds.setUnderlineText(false);
           }
       }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
   }

   /**
    * 添加链接的span
    *
    * @param ssb
    * @param data
    * @param endPosition
    */
   private void addUrl(SpannableStringBuilder ssb, final FormatData.PositionData data, int endPosition) {
       ssb.setSpan(new ClickableSpan() {
           @Override
           public void onClick(View widget) {
               if (linkClickListener != null) {
                   linkClickListener.onLinkClickListener(LinkType.LINK_TYPE, data.getUrl(), null);
               } else {
                   //如果没有设置监听 则调用默认的打开浏览器显示连接
                   Intent intent = new Intent();
                   intent.setAction("android.intent.action.VIEW");
                   intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                   Uri url = Uri.parse(data.getUrl());
                   intent.setData(url);
                   mContext.startActivity(intent);
               }
           }

           @Override
           public void updateDrawState(TextPaint ds) {
               ds.setColor(mLinkTextColor);
               ds.setUnderlineText(false);
           }
       }, data.getStart() + 1, endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
   }

   /**
    * 设置当前的状态
    *
    * @param type
    */
   public void setCurrStatus(StatusType type) {
       action(type);
   }

   private void action() {
       action(null);
   }

   /**
    * 执行展开和收回的动作
    */
   private void action(StatusType type) {
       boolean isHide = currentLines < mLineCount;
       if (type != null) {
           mNeedAnimation = false;
       }
       if (mNeedAnimation) {
           ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1);
           final boolean finalIsHide = isHide;
           valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
               @Override
               public void onAnimationUpdate(ValueAnimator animation) {
                   Float value = (Float) animation.getAnimatedValue();
                   if (finalIsHide) {
                       currentLines = mLimitLines + (int) ((mLineCount - mLimitLines) * value);
                   } else {
                       if (mNeedContract)
                           currentLines = mLimitLines + (int) ((mLineCount - mLimitLines) * (1 - value));
                   }
                   setText(setRealContent(mContent,finalIsHide));
               }
           });
           valueAnimator.setDuration(100);
           valueAnimator.start();
       } else {
           if (isHide) {
               currentLines = mLimitLines + ((mLineCount - mLimitLines));
           } else {
               if (mNeedContract)
                   currentLines = mLimitLines;
           }
           setText(setRealContent(mContent,isHide));
       }
   }

   /**
    * 计算原内容被裁剪的长度
    *
    * @param endString
    * @param endPosition   指定行最后文字的位置
    * @param startPosition 指定行文字开始的位置
    * @param lineWidth     指定行文字的宽度
    * @param endStringWith 最后添加的文字的宽度
    * @param offset        偏移量
    * @return
    */
   private int getFitPosition(String endString, int endPosition, int startPosition, float lineWidth,
                              float endStringWith, float offset) {
       //最后一行需要添加的文字的字数
       int position = (int) ((lineWidth - (endStringWith + offset)) * (endPosition - startPosition)
               / lineWidth);

       if (position <= endString.length()) return endPosition;

       //计算最后一行需要显示的正文的长度
       float measureText = mPaint.measureText(
               (mFormatData.getFormatedContent().substring(startPosition, startPosition + position)));

       //如果最后一行需要显示的正文的长度比最后一行的长减去“展开”文字的长度要短就可以了  否则加个空格继续算
       if (measureText <= lineWidth - endStringWith) {
           return startPosition + position;
       } else {
           return getFitPosition(endString, endPosition, startPosition, lineWidth, endStringWith, offset + mPaint.measureText(Space));
       }
   }

   /**
    * 对传入的数据进行正则匹配并处理
    *
    * @param content
    * @return
    */
   private FormatData formatData(CharSequence content) {
       FormatData formatData = new FormatData();
       List<FormatData.PositionData> datas = new ArrayList<>();
       //对链接进行正则匹配
//        Pattern pattern = Pattern.compile(regexp, Pattern.CASE_INSENSITIVE);
       Pattern pattern = Pattern.compile(self_regex, Pattern.CASE_INSENSITIVE);
       Matcher matcher = pattern.matcher(content);
       StringBuffer newResult = new StringBuffer();
       int start = 0;
       int end = 0;
       int temp = 0;
       Map<String, String> convert = new HashMap<>();
       //对自定义的进行正则匹配
       if (mNeedSelf) {
           List<FormatData.PositionData> datasMention = new ArrayList<>();
           while (matcher.find()) {
               start = matcher.start();
               end = matcher.end();
               newResult.append(content.toString().substring(temp, start));
               //将匹配到的内容进行统计处理
               String result = matcher.group();
               if (!TextUtils.isEmpty(result)) {
                   //解析数据
                   String aimSrt = result.substring(result.indexOf("[") + 1, result.indexOf("]"));
                   String contentSrt = result.substring(result.indexOf("(") + 1, result.indexOf(")"));
                   String key = UUIDUtils.getUuid(aimSrt.length());
                   datasMention.add(new FormatData.PositionData(newResult.length() + 1, newResult.length() + 2 + aimSrt.length(), aimSrt, contentSrt, LinkType.SELF));
                   convert.put(key, aimSrt);
                   newResult.append(" " + key + " ");
                   temp = end;
               }
           }
           datas.addAll(datasMention);
       }
       //重置状态
       newResult.append(content.toString().substring(end, content.toString().length()));
       content = newResult.toString();
       newResult = new StringBuffer();
       start = 0;
       end = 0;
       temp = 0;

       if (mNeedLink) {
           pattern = AUTOLINK_WEB_URL;
           matcher = pattern.matcher(content);
           while (matcher.find()) {
               start = matcher.start();
               end = matcher.end();
               newResult.append(content.toString().substring(temp, start));
               if (mNeedConvertUrl) {
                   //将匹配到的内容进行统计处理
                   datas.add(new FormatData.PositionData(newResult.length() + 1, newResult.length() + 2 + TARGET.length(), matcher.group(), LinkType.LINK_TYPE));
                   newResult.append(" " + TARGET + " ");
               } else {
                   String result = matcher.group();
                   String key = UUIDUtils.getUuid(result.length());
                   datas.add(new FormatData.PositionData(newResult.length(), newResult.length() + 2 + key.length(), result, LinkType.LINK_TYPE));
                   convert.put(key, result);
                   newResult.append(" " + key + " ");
               }
               temp = end;
           }
       }
       newResult.append(content.toString().substring(end, content.toString().length()));
       //对@用户 进行正则匹配
       if (mNeedMention) {
           pattern = Pattern.compile(regexp_mention, Pattern.CASE_INSENSITIVE);
           matcher = pattern.matcher(newResult.toString());
           List<FormatData.PositionData> datasMention = new ArrayList<>();
           while (matcher.find()) {
               //将匹配到的内容进行统计处理
               datasMention.add(new FormatData.PositionData(matcher.start(), matcher.end(), matcher.group(), LinkType.MENTION_TYPE));
           }
           datas.addAll(0, datasMention);
       }
       if (mNeedTopic) {
           pattern = Pattern.compile(regexp_topic, Pattern.CASE_INSENSITIVE);
           matcher = pattern.matcher(newResult.toString());
           List<FormatData.PositionData> datasMention = new ArrayList<>();
           while (matcher.find()) {
               //将匹配到的内容进行统计处理
               datasMention.add(new FormatData.PositionData(matcher.start(), matcher.end(), matcher.group(), LinkType.TOPIC_TYPE));
           }
           datas.addAll(0, datasMention);
       }


       if (!convert.isEmpty()) {
           String resultData = newResult.toString();
           for (Map.Entry<String, String> entry : convert.entrySet()) {
               resultData = resultData.replaceAll(entry.getKey(), entry.getValue());
           }
           newResult = new StringBuffer(resultData);
       }
       formatData.setFormatedContent(newResult.toString());
       formatData.setPositionDatas(datas);
       return formatData;
   }

   /**
    * 自定义ImageSpan 让Image 在行内居中显示
    */
   class SelfImageSpan extends ImageSpan {
       private Drawable drawable;

       public SelfImageSpan(Drawable d, int verticalAlignment) {
           super(d, verticalAlignment);
           this.drawable = d;
       }

       @Override
       public Drawable getDrawable() {
           return drawable;
       }

       @Override
       public void draw(@NonNull Canvas canvas, CharSequence text,
                        int start, int end, float x,
                        int top, int y, int bottom, @NonNull Paint paint) {
           // image to draw
           Drawable b = getDrawable();
           // font metrics of text to be replaced
           Paint.FontMetricsInt fm = paint.getFontMetricsInt();
           int transY = (y + fm.descent + y + fm.ascent) / 2
                   - b.getBounds().bottom / 2;
           canvas.save();
           canvas.translate(x, transY);
           b.draw(canvas);
           canvas.restore();
       }
   }

   /**
    * 绑定状态
    *
    * @param model
    */
   public void bind(ExpandableStatusFix model) {
       mModel = model;
   }

   public static class LocalLinkMovementMethod extends LinkMovementMethod {
       static LocalLinkMovementMethod sInstance;


       public static LocalLinkMovementMethod getInstance() {
           if (sInstance == null)
               sInstance = new LocalLinkMovementMethod();

           return sInstance;
       }

       @Override
       public boolean onTouchEvent(TextView widget,
                                   Spannable buffer, MotionEvent event) {
           int action = event.getAction();

           if (action == MotionEvent.ACTION_UP ||
                   action == MotionEvent.ACTION_DOWN) {
               int x = (int) event.getX();
               int y = (int) event.getY();

               x -= widget.getTotalPaddingLeft();
               y -= widget.getTotalPaddingTop();

               x += widget.getScrollX();
               y += widget.getScrollY();

               Layout layout = widget.getLayout();
               int line = layout.getLineForVertical(y);
               int off = layout.getOffsetForHorizontal(line, x);

               ClickableSpan[] link = buffer.getSpans(
                       off, off, ClickableSpan.class);

               if (link.length != 0) {
                   if (action == MotionEvent.ACTION_UP) {
                       link[0].onClick(widget);
                   } else if (action == MotionEvent.ACTION_DOWN) {
                       Selection.setSelection(buffer,
                               buffer.getSpanStart(link[0]),
                               buffer.getSpanEnd(link[0]));
                   }

                   if (widget instanceof ExpandableTextView) {
                       ((ExpandableTextView) widget).linkHit = true;
                   }
                   return true;
               } else {
                   Selection.removeSelection(buffer);
                   Touch.onTouchEvent(widget, buffer, event);
                   return false;
               }
           }
           return Touch.onTouchEvent(widget, buffer, event);
       }
   }

   boolean dontConsumeNonUrlClicks = true;

   @Override
   public boolean onTouchEvent(MotionEvent event) {
       int action = event.getAction();
       linkHit = false;
       boolean res = super.onTouchEvent(event);

       if (dontConsumeNonUrlClicks)
           return linkHit;

       //防止选择复制的状态不消失
       if (action == MotionEvent.ACTION_UP) {
           this.setTextIsSelectable(false);
       }

       return res;
   }

   public interface OnLinkClickListener {
       void onLinkClickListener(LinkType type, String content, String selfContent);
   }

   public interface OnGetLineCountListener {
       /**
        * lineCount 预估可能占有的行数
        * canExpand 是否达到可以展开的条件
        */
       void onGetLineCount(int lineCount, boolean canExpand);
   }

   private OnGetLineCountListener onGetLineCountListener;

   public OnGetLineCountListener getOnGetLineCountListener() {
       return onGetLineCountListener;
   }

   public void setOnGetLineCountListener(OnGetLineCountListener onGetLineCountListener) {
       this.onGetLineCountListener = onGetLineCountListener;
   }

   public interface OnExpandOrContractClickListener {
       void onClick(StatusType type);
   }

   public OnLinkClickListener getLinkClickListener() {
       return linkClickListener;
   }

   public void setLinkClickListener(OnLinkClickListener linkClickListener) {
       this.linkClickListener = linkClickListener;
   }

   public boolean ismNeedMention() {
       return mNeedMention;
   }

   public void setNeedMention(boolean mNeedMention) {
       this.mNeedMention = mNeedMention;
   }

   public Drawable getLinkDrawable() {
       return mLinkDrawable;
   }

   public void setLinkDrawable(Drawable mLinkDrawable) {
       this.mLinkDrawable = mLinkDrawable;
   }

   public boolean isNeedContract() {
       return mNeedContract;
   }

   public void setNeedContract(boolean mNeedContract) {
       this.mNeedContract = mNeedContract;
   }

   public boolean isNeedExpend() {
       return mNeedExpend;
   }

   public void setNeedExpend(boolean mNeedExpend) {
       this.mNeedExpend = mNeedExpend;
   }

   public boolean isNeedAnimation() {
       return mNeedAnimation;
   }

   public void setNeedAnimation(boolean mNeedAnimation) {
       this.mNeedAnimation = mNeedAnimation;
   }

   public int getExpandableLineCount() {
       return mLineCount;
   }

   public void setExpandableLineCount(int mLineCount) {
       this.mLineCount = mLineCount;
   }

   public int getExpandTextColor() {
       return mExpandTextColor;
   }

   public void setExpandTextColor(int mExpandTextColor) {
       this.mExpandTextColor = mExpandTextColor;
   }

   public int getExpandableLinkTextColor() {
       return mLinkTextColor;
   }

   public void setExpandableLinkTextColor(int mLinkTextColor) {
       this.mLinkTextColor = mLinkTextColor;
   }

   public int getContractTextColor() {
       return mContractTextColor;
   }

   public void setContractTextColor(int mContractTextColor) {
       this.mContractTextColor = mContractTextColor;
   }

   public String getExpandString() {
       return mExpandString;
   }

   public void setExpandString(String mExpandString) {
       this.mExpandString = mExpandString;
   }

   public String getContractString() {
       return mContractString;
   }

   public void setContractString(String mContractString) {
       this.mContractString = mContractString;
   }

   public int getEndExpandTextColor() {
       return mEndExpandTextColor;
   }

   public void setEndExpandTextColor(int mEndExpandTextColor) {
       this.mEndExpandTextColor = mEndExpandTextColor;
   }

   public boolean isNeedLink() {
       return mNeedLink;
   }

   public void setNeedLink(boolean mNeedLink) {
       this.mNeedLink = mNeedLink;
   }

   public int getSelfTextColor() {
       return mSelfTextColor;
   }

   public void setSelfTextColor(int mSelfTextColor) {
       this.mSelfTextColor = mSelfTextColor;
   }

   public boolean isNeedSelf() {
       return mNeedSelf;
   }

   public void setNeedSelf(boolean mNeedSelf) {
       this.mNeedSelf = mNeedSelf;
   }

   public boolean isNeedAlwaysShowRight() {
       return mNeedAlwaysShowRight;
   }

   public void setNeedAlwaysShowRight(boolean mNeedAlwaysShowRight) {
       this.mNeedAlwaysShowRight = mNeedAlwaysShowRight;
   }

   public OnExpandOrContractClickListener getExpandOrContractClickListener() {
       return expandOrContractClickListener;
   }

   public void setExpandOrContractClickListener(OnExpandOrContractClickListener expandOrContractClickListener) {
       this.expandOrContractClickListener = expandOrContractClickListener;
   }

   public void setExpandOrContractClickListener(OnExpandOrContractClickListener expandOrContractClickListener, boolean needRealExpandOrContract) {
       this.expandOrContractClickListener = expandOrContractClickListener;
       this.needRealExpandOrContract = needRealExpandOrContract;
   }
}

//定义类型的枚举类型
public enum LinkType {
    //普通链接
    LINK_TYPE,
    //@用户
    MENTION_TYPE,
    TOPIC_TYPE,
    //自定义规则
    SELF
}

public enum StatusType {
    //展开
    STATUS_EXPAND,
    //收起
    STATUS_CONTRACT
}

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">ExpandableTextViewLibrary</string>
  <string name="social_contract">收起</string>
  <string name="social_expend">展开</string>
  <string name="social_text_target">网页链接</string>
  <declare-styleable name="ExpandableTextView">
      <!--保留的行数-->
      <attr format="integer" name="ep_max_line"/>
      <!--是否需要展开-->
      <attr format="boolean" name="ep_need_expand"/>
      <!--是否需要收起 这个是建立在开启展开的基础上的-->
      <attr format="boolean" name="ep_need_contract"/>
      <!--是否需要@用户 -->
      <attr format="boolean" name="ep_need_mention"/>
      <!--是否需要对链接进行处理 -->
      <attr format="boolean" name="ep_need_link"/>
      <!--是否需要动画-->
      <attr format="boolean" name="ep_need_animation"/>
      <!--是否需要永远将展开或者收回放置在最后边-->
      <attr format="boolean" name="ep_need_always_showright"/>
      <!--是否需要将连接转换成网页链接显示 默认为true-->
      <attr format="boolean" name="ep_need_convert_url"/>
      <!--是否需要自定义规则-->
      <attr format="boolean" name="ep_need_self"/>
      <!--收起的文案-->
      <attr format="string" name="ep_contract_text"/>
      <!--展开的文案-->
      <attr format="string" name="ep_expand_text"/>
      <!--展开的文字的颜色-->
      <attr format="color" name="ep_expand_color"/>
      <!--收起的文字的颜色-->
      <attr format="color" name="ep_contract_color"/>
      <!--在收回和展开前面添加的内容的字体颜色-->
      <attr format="color" name="ep_end_color"/>
      <!--链接的文字的颜色-->
      <attr format="color" name="ep_link_color"/>
      <!--@用户的文字的颜色-->
      <attr format="color" name="ep_mention_color"/>

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

推荐阅读更多精彩内容