開發聊天功能,需要在群聊中實現@xxx功能,網上沒有找到現成的東西可以直接拿來用的,那就自己擼一個好了
專案地址 github.com/sunhapper/S…
用法說明 SpEditTool使用指南
歡迎star,提PR、issue
ScreenShot
功能分析
- 可以插入@xxx這樣的特殊字串
- 需要有高亮等效果
- 特殊字串作為一個整體,要一起刪除,游標不能進入特殊字串內部
- 特殊字串應當對應一個自定義的資料結構儲存@的物件的id,名字等資訊
實現思路
繼承EditText
本來不想使用繼承這樣侵入的方式去實現,但是需要監聽游標的變化,而sdk並沒有提供設定游標監聽的方法。
記錄特殊字串的位置和代表的資訊
這個是實現功能的關鍵點,總結了下網上的方案
這個庫中使用了正規表示式去匹配字串中的特殊字串,而且必須嚴格的@開頭空格結尾,這種方式對於特殊字串中間帶@或者空格的的情況無法處理,對只想把@視為普通字元的情況也無法處理
這個庫自己維護了一個List,記錄了特殊字串的內容,在刪除或者游標變化時遍歷這個List判斷游標是否處在特殊字串的位置 最初自己咋一看覺得可以滿足需求,在List的元素中加一個欄位就可以記錄@xxx的資料結構了,但是簡單用了之後發現一個很嚴重的問題:像@11 @1這樣前面是相同內容的字串處理的時候遍歷算出的位置是不對的,而且很容易觸發setSelection的遞迴呼叫導致StackOverflow
自己寫的庫,容我自賣自誇一下 這裡利用了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