自定義EditText輕鬆實現群聊@提及(@mention) #微博話題#等功能

sunhapper發表於2018-02-28

開發聊天功能,需要在群聊中實現@xxx功能,網上沒有找到現成的東西可以直接拿來用的,那就自己擼一個好了

專案地址 github.com/sunhapper/S…
用法說明 SpEditTool使用指南
歡迎star,提PR、issue

ScreenShot

ScreenShot

功能分析

  • 可以插入@xxx這樣的特殊字串
  • 需要有高亮等效果
  • 特殊字串作為一個整體,要一起刪除,游標不能進入特殊字串內部
  • 特殊字串應當對應一個自定義的資料結構儲存@的物件的id,名字等資訊

實現思路

繼承EditText

本來不想使用繼承這樣侵入的方式去實現,但是需要監聽游標的變化,而sdk並沒有提供設定游標監聽的方法。

記錄特殊字串的位置和代表的資訊

這個是實現功能的關鍵點,總結了下網上的方案

MentionEditText

這個庫中使用了正規表示式去匹配字串中的特殊字串,而且必須嚴格的@開頭空格結尾,這種方式對於特殊字串中間帶@或者空格的的情況無法處理,對只想把@視為普通字元的情況也無法處理

RichEditor

這個庫自己維護了一個List,記錄了特殊字串的內容,在刪除或者游標變化時遍歷這個List判斷游標是否處在特殊字串的位置 最初自己咋一看覺得可以滿足需求,在List的元素中加一個欄位就可以記錄@xxx的資料結構了,但是簡單用了之後發現一個很嚴重的問題:像@11 @1這樣前面是相同內容的字串處理的時候遍歷算出的位置是不對的,而且很容易觸發setSelection的遞迴呼叫導致StackOverflow

SpEditTool

自己寫的庫,容我自賣自誇一下 這裡利用了Spannable的setSpan方法為對應的特殊字串設定一個Object作為標記,好處有這麼兩點

  • 這個標記的位置是由EditText中的Editable物件來維護的,插入字元,刪除特殊字串位置自動就會變化,雖然偷懶,但是效果不錯
  • 因為標記和特殊字串是一一對應的,所以無論文字框的內容如何變化都不用擔心匹配出錯

主要程式碼:

 /**
   * 插入特殊字串,提供給外部呼叫
   * @param showContent 特殊字串顯示在文字框中的內容
   * @param rollBack 是否往前刪除一個字元,因為@的時候可能留了一個字元在輸入框裡
   * @param customData 特殊字串的資料結構
   * @param customSpan 特殊字串的樣式
   */
  public void insertSpecialStr(String showContent, boolean rollBack, Object customData,
      Object customSpan) {
    if (TextUtils.isEmpty(showContent)) {
      return;
    }
    int index = getSelectionStart();
    Editable editable = getText();
    SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(editable);
    //SpData中儲存了顯示內容和對應資料結構
    SpData spData = new SpData();
    spData.setShowContent(showContent);
    spData.setCustomData(customData);
    SpannableString spannableString = new SpannableString(showContent);
    spannableString
        .setSpan(spData, 0, spannableString.length(),
            SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
    //設定自定義樣式
    if (customSpan != null) {
      spannableString
          .setSpan(customSpan, 0, spannableString.length(),
              SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
    }
    //是否回刪一個字元
    if (rollBack) {
      spannableStringBuilder.delete(index - 1, index);
      index--;
    }
    spannableStringBuilder.insert(index, spannableString);
    setText(spannableStringBuilder);
    //將游標置到插入內容末尾
    setSelection(index + spannableString.length());
  }
複製程式碼

獲取插入的特殊字串

使用Spanned介面的getSpans方法

 public SpData[] getSpDatas() {
    Editable editable = getText();
    SpData[] spanneds = editable.getSpans(0, getText().length(), SpData.class);
    if (spanneds != null && spanneds.length > 0) {
      for (SpData spData : spanneds) {
        int start = editable.getSpanStart(spData);
        int end = editable.getSpanEnd(spData);
        //設定當前特殊字串的起止位置
        spData.setEnd(end);
        spData.setStart(start);
      }
      sortSpans(editable, spanneds, 0, spanneds.length - 1);//獲取到的資料可能是沒排過序的,所以快排排個序再返回
      return spanneds;
    } else {
      return new SpData[]{};
    }
  }
複製程式碼

監聽游標改變

覆蓋onSelectionChanged方法

/**
   * 監聽游標位置,對插入的特殊字元一起刪除
   */
  @Override
  protected void onSelectionChanged(int selStart, int selEnd) {
    super.onSelectionChanged(selStart, selEnd);
    SpData[] spDatas = getSpDatas();
    for (int i = 0; i < spDatas.length; i++) {
      SpData spData = spDatas[i];
      int startPostion = spData.start;
      int endPostion = spData.end;
      if (changeSelection(selStart, selEnd, startPostion, endPostion, false)) {
        return;
      }
    }
  }
複製程式碼

監聽刪除事件

使用EditText的setOnKeyListener,監聽刪除事件,如果碰到特殊字串整體刪除

 setOnKeyListener(new OnKeyListener() {
      @Override
      public boolean onKey(View v, int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN) {
        return onDeleteEvent();
        }

        return false;
      }
    });
複製程式碼
 private boolean onDeleteEvent() {
    int selectionStart = getSelectionStart();
    int selectionEnd = getSelectionEnd();
    if (selectionEnd!=selectionStart){
      return false;
    }
    SpData[] spDatas = getSpDatas();
    for (int i = 0; i < spDatas.length; i++) {
      SpData spData = spDatas[i];
      int rangeStart = spData.start;
      if (selectionStart == spData.end) {
        getEditableText().delete(rangeStart, selectionEnd);
        return true;
      }

    }
    return false;
  }
複製程式碼

響應文字框中@的輸入

EditText可以新增一個TextWatcher監聽文字的變化(並不是必要的,可以自己在外部處理)

addTextChangedListener(new TextWatcher() {
      @Override
      public void beforeTextChanged(CharSequence s, int start, int count, int after) {
      }

      @Override
      public void onTextChanged(CharSequence charSequence, int start, int before, int count) {
        //reactKeys是需要響應的字元列表,不僅僅可以響應@
        for (Character character : reactKeys) {
          if (count == 1 && !TextUtils.isEmpty(charSequence)) {
            char mentionChar = charSequence.toString().charAt(start);
            if (character.equals(mentionChar) && mKeyReactListener != null) {
             handKeyReactEvent(character);//在EditText內部,所以用回撥的方式通知外部有特殊的字元被輸入
              return;
            }
          }
        }
      }

      @Override
      public void afterTextChanged(Editable s) {

      }
    });
複製程式碼
  private void handKeyReactEvent(final Character character) {
    post(new Runnable() {
      @Override
      public void run() {
        mKeyReactListener.onKeyReact(character.toString());
      }
    });
  }
複製程式碼

Tips:post(Runnable runnabe)

onTextChanged中使用post(Runnable runnabe)去呼叫外部回撥,是因為在onTextChanged執行時,最初插入的@等字元的onSelectionChanged回撥還沒走

假設輸入了@,不使用post(Runnable runnabe),直接呼叫onKeyReact,在回撥中插入@sunhapper字串並設定游標位置,onSelectionChanged呼叫順序為onSelectionChanged(10,10)-->onSelectionChanged(1,1)導致游標位置位於插入字串前面而不是後面,不符合預期

使用post(Runnable runnabe)可以讓當前執行緒的程式碼執行完再去呼叫onKeyReact,onSelectionChanged呼叫順序為onSelectionChanged(1,1)-->onSelectionChanged(10,10),游標位置符合預期

總結

  • 繼承EditText
  • 利用setSpan方法將自定義的資料結構和樣式和插入的文字繫結
  • 利用getSpans方法獲取插入的資料
  • 監聽游標變化,主動改變游標位置,防止游標進入特殊字串內部
  • 監聽刪除事件,對特殊字串整體刪除

完成以上幾步,一個支援插入@ #話題#等各種要高亮要整體刪除的EditText就完成了

歡迎大家使用已有的輪子
專案地址github.com/sunhapper/S…
歡迎star,提PR、issue

相關文章