前言
這個功能看似簡單,網上搜出來的都說以@+uid+空格這樣的格式處理,但實際實現會發現有個問題:如果使用者名稱之間有空格,那麼就無法正確解析出要@的使用者了,而且如果有同名使用者,也無法區分。因此若要以這樣簡單的方式處理,那麼對使用者名稱就需要一個複雜的限制,顯然現在去修改早已定下的規則是不現實的。
在segmentfault上找到一個我認為最靠譜的實現方案,seg上的文章連結找不到了,github地址如下:
https://github.com/luckyandyz…
根據業務需求,作了比較大的改動,大致如下:
-
只能通過mentionUser這個方法增加mention string
-
簡化了對輸入的監視
-
完善了對range的管理
-
通過convertMention的方法,將@uid轉換為指定格式的字串並返回
QQ和微信@人功能對比
QQ:@之後彈出使用者選擇介面,選擇使用者後輸出“@使用者名稱 ”格式,無法在@與使用者名稱之間插入任何字元,刪除時是整個刪除
微信:@之後彈出使用者選擇介面,選擇使用者後輸出“@使用者名稱 ”格式,可以在@與使用者名稱之間插入字元,刪除時也是作為整個刪除
實現原理:
在呼叫mentionUser之後,會在游標所在位置插入@username的span,並且建立一個range儲存到arraylist中,該range會記錄所插入span的起始、終止位置還有插入的使用者資訊。
luckyandyzhang的實現是在每一次textchanged後都會掃描整個字串,生成對應的span。
程式碼
private final String mMentionTextFormat = "[Mention:%s, %s]";
private Runnable mAction;
private int mMentionTextColor;
private boolean mIsSelected;
private Range mLastSelectedRange;
private ArrayList<Range> mRangeArrayList;
private OnMentionInputListener mOnMentionInputListener;
public MentionEditText(Context context) {
super(context);
init();
}
public MentionEditText(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public MentionEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
return new HackInputConnection(super.onCreateInputConnection(outAttrs), true, this);
}
@Override
public void setText(final CharSequence text, TextView.BufferType type) {
super.setText(text, type);
//hack, put the cursor at the end of text after calling setText() method
if (mAction == null) {
mAction = new Runnable() {
@Override
public void run() {
setSelection(getText().length());
}
};
}
post(mAction);
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
//avoid infinite recursion after calling setSelection()
if (mLastSelectedRange != null && mLastSelectedRange.isEqual(selStart, selEnd)) {
return;
}
//if user cancel a selection of mention string, reset the state of `mIsSelected`
Range closestRange = getRangeOfClosestMentionString(selStart, selEnd);
if (closestRange != null && closestRange.to == selEnd) {
mIsSelected = false;
}
Range nearbyRange = getRangeOfNearbyMentionString(selStart, selEnd);
//if there is no mention string nearby the cursor, just skip
if (nearbyRange == null) {
return;
}
//forbid cursor located in the mention string.
if (selStart == selEnd) {
setSelection(nearbyRange.getAnchorPosition(selStart));
} else {
if (selEnd < nearbyRange.to) {
setSelection(selStart, nearbyRange.to);
}
if (selStart > nearbyRange.from) {
setSelection(nearbyRange.from, selEnd);
}
}
}
/**
* set highlight color of mention string
*
* @param color value from `getResources().getColor()` or `Color.parseColor()` etc.
*/
public void setMentionTextColor(int color) {
mMentionTextColor = color;
}
/**
* 插入mention string
* 在呼叫該方法前,請先插入一個字元(如`@`),之後插入的name將會和該字元組成一個整體
* @param uid 使用者id
* @param name 使用者名稱字
*/
public void mentionUser(int uid, String name) {
Editable editable = getText();
int start = getSelectionStart();
int end = start + name.length();
editable.insert(start, name);
editable.setSpan(new ForegroundColorSpan(mMentionTextColor), start - 1, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
mRangeArrayList.add(new Range(uid, name, start - 1, end));
}
/**
* 將所有mention string以指定格式輸出
* @return 以指定格式輸出的字串
*/
public String convertMetionString() {
String text = getText().toString();
if (mRangeArrayList.isEmpty()) {
return text;
}
StringBuilder builder = new StringBuilder("");
int lastRangeTo = 0;
Collections.sort(mRangeArrayList);
for (Range range : mRangeArrayList) {
String newChar = String.format(mMentionTextFormat, range.id, range.name);
builder.append(text.substring(lastRangeTo, range.from));
builder.append(newChar);
lastRangeTo = range.to;
}
clear();
return builder.toString();
}
public void clear() {
mRangeArrayList.clear();
setText("");
}
/**
* set listener for mention character(`@`)
*
* @param onMentionInputListener MentionEditText.OnMentionInputListener
*/
public void setOnMentionInputListener(OnMentionInputListener onMentionInputListener) {
mOnMentionInputListener = onMentionInputListener;
}
private void init() {
mRangeArrayList = new ArrayList<>();
mMentionTextColor = Color.RED;
//disable suggestion
setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
addTextChangedListener(new MentionTextWatcher());
}
private Range getRangeOfClosestMentionString(int selStart, int selEnd) {
if (mRangeArrayList == null) {
return null;
}
for (Range range : mRangeArrayList) {
if (range.contains(selStart, selEnd)) {
return range;
}
}
return null;
}
private Range getRangeOfNearbyMentionString(int selStart, int selEnd) {
if (mRangeArrayList == null) {
return null;
}
for (Range range : mRangeArrayList) {
if (range.isWrappedBy(selStart, selEnd)) {
return range;
}
}
return null;
}
private class MentionTextWatcher implements TextWatcher {
//若從整串string中間插入字元,需要將插入位置後面的range相應地挪位
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
Editable editable = getText();
//在末尾增加就不需要處理了
if (start >= editable.length()) {
return;
}
int end = start + count;
int offset = after - count;
//清理start 到 start + count之間的span
//如果range.from = 0,也會被getSpans(0,0,ForegroundColorSpan.class)獲取到
if (start != end && !mRangeArrayList.isEmpty()) {
ForegroundColorSpan[] spans = editable.getSpans(start, end, ForegroundColorSpan.class);
for (ForegroundColorSpan span : spans) {
editable.removeSpan(span);
}
}
//清理arraylist中上面已經清理掉的range
//將end之後的span往後挪offset個位置
Iterator iterator = mRangeArrayList.iterator();
while (iterator.hasNext()) {
Range range = (Range) iterator.next();
if (range.isWrapped(start, end)) {
iterator.remove();
continue;
}
if (range.from >= end) {
range.setOffset(offset);
}
}
}
@Override
public void onTextChanged(CharSequence charSequence, int index, int i1, int count) {
if (count == 1 && !TextUtils.isEmpty(charSequence)) {
char mentionChar = charSequence.toString().charAt(index);
if (`@` == mentionChar && mOnMentionInputListener != null) {
mOnMentionInputListener.onMentionCharacterInput();
}
}
}
@Override
public void afterTextChanged(Editable editable) {
}
}
//handle the deletion action for mention string, such as `@test`
private class HackInputConnection extends InputConnectionWrapper {
private EditText editText;
private HackInputConnection(InputConnection target, boolean mutable, MentionEditText editText) {
super(target, mutable);
this.editText = editText;
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
int selectionStart = editText.getSelectionStart();
int selectionEnd = editText.getSelectionEnd();
Range closestRange = getRangeOfClosestMentionString(selectionStart, selectionEnd);
if (closestRange == null) {
mIsSelected = false;
return super.sendKeyEvent(event);
}
//if mention string has been selected or the cursor is at the beginning of mention string, just use default action(delete)
if (mIsSelected || selectionStart == closestRange.from) {
mIsSelected = false;
return super.sendKeyEvent(event);
} else {
//select the mention string
mIsSelected = true;
mLastSelectedRange = closestRange;
setSelection(closestRange.to, closestRange.from);
}
return true;
}
return super.sendKeyEvent(event);
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
if (beforeLength == 1 && afterLength == 0) {
return sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
&& sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
}
return super.deleteSurroundingText(beforeLength, afterLength);
}
}
//helper class to record the position of mention string in EditText
private class Range implements Comparable{
int id;
String name;
int from;
int to;
private Range(int id, String name, int from, int to) {
this.id = id;
this.name = name;
this.from = from;
this.to = to;
}
private boolean isWrapped(int start, int end) {
return from >= start && to <= end;
}
private boolean isWrappedBy(int start, int end) {
return (start > from && start < to) || (end > from && end < to);
}
private boolean contains(int start, int end) {
return from <= start && to >= end;
}
private boolean isEqual(int start, int end) {
return (from == start && to == end) || (from == end && to == start);
}
private int getAnchorPosition(int value) {
if ((value - from) - (to - value) >= 0) {
return to;
} else {
return from;
}
}
private void setOffset(int offset) {
from += offset;
to += offset;
}
@Override
public int compareTo(@NonNull Object o) {
return from - ((Range)o).from;
}
}
/**
* Listener for `@` character
*/
public interface OnMentionInputListener {
/**
* call when `@` character is inserted into EditText
*/
void onMentionCharacterInput();
}