仿新浪微博添加和删除#话题#的EditText实现

需求

产品上线了,项目差不多算是稳定下来了,接下来就是一个个的版本迭代了。这周又增加了几个新功能,其中一个就是题目中讲的,要仿新浪微博(如下图)的输入框里的文字效果。作为整体的字体两边是井号,并且包括井号要变色,删除的时候,当光标到右侧的井号,再次点击删除的时候,会将井号包裹的内容作为一个整体删除掉,同时焦点不能在变色的字符之间,也就是说当点击变色的字符时,光标会自动地落在井号两侧。

拆分问题

看上去是一个挺简单合理的需求,随便一想,这不就是字符串匹配嘛!可是实际行动起来,就会发现各种坑爹问题,远不是想象中的那么简单。我这做下来的感受是,必须要有一个明确清晰的思路,总结下来,其实可以分为下面几个步骤。

  • 首先,新浪微博这个功能的入口是,点击井号,进入到新的界面里选择字符串,然后自动添加到 EditText 框里,我们要实现这样一个 Demo ,也没必要进入新的界面,直接做一个按钮,点击添加即可;
  • 然后,需要明确的一点是,在 Android 提供的原生的 API 里,删除动作,一次只能删掉一个字符,而无法删除批量的字符,一次只能删除一个 letter ,而不是一个 word ,删除动作是我们这个课题的关键所在;
  • 处理完了增加和删除字符串功能,接着就是匹配符合规则的字符串做变色处理了;
  • 再接着就是处理点击字符串,光标的所处位置的问题;
  • 完善需要补充和修改的细节。

通过以上五个步骤将课题拆分为四个小问题,然后再去一一解决即可。

添加字符串

这一步基本上算是最简单的一步了。我们只需要做到点击一个 button ,将字符串 append 到 EditText 里就可以了。当然,为了让我们的操作更加有广泛的适用性,单纯地增加字符串就有点太不够看了,而实际应用里,这样做基本上也没有什么意义。

我们要做的就是,创建一个实体类,而这个字符串作为一个值,所对应的变量就是这个实体类的成员变量,我们通过不断往实体类集合里添加 new 出来的对象,来往 EditText 里追加字符串。同时,这样做还有一个好处就是,实际应用里,往往伴随字符串使用的可能还有其他的变量,要处理与字符串一一对应的其他变量,最好的方法就是在增加和删除字符串的同时,将字符串对应变量所在实体从实体集合中移除掉。

下面就是我的实体类:

public class BookEntity implements Serializable {
 private static final long serialVersionUID = 1L;
 private String bookId;
 private String bookName;
 public BookEntity(String bookName, int bookId) {
 super();
 this.bookName = bookName;
 this.bookId = bookId;
 }
 public String getBookId() {
 return bookId;
 }
 public void setBookId(String bookId) {
 this.bookId = bookId;
 }
 public String getBookName() {
 return bookName;
 }
 public void setBookName(String bookName) {
 this.bookName = bookName;
 }
}

添加字符操作:

/** 执行增加字符串的操作 */
private Button button;
private ArrayList<BookEntity> mList = new ArrayList<BookEntity>();
private EditText editText;
View view = inflater.inflate(R.layout.fragment_main, container, false);
editText = (EditText) view.findViewById(R.id.edit_text);
button = (Button) view.findViewById(R.id.bt);
buttont.setOnClickListener(new OnClickListener() {
 @Override
 public void onClick(View v) {
 int nextInt = new Random().nextInt(100);
 String str = "#测试测试" + nextInt + "# ";
 editText.setText(editText.getText());
 editText.append(str);
 editText.setSelection(editText.getText().toString().length());
 mList.add(new BookEntity(str, nextInt));
 }
});

这样我们增加字符串操作就做完了。

删除符合条件的字符串

删除操作是这个小程序的关键。做删除操作时,我们需要搞清楚这样几个问题:什么时候删除?从哪儿开始删?删到哪里算结束?删除结束之后,光标位置应该在哪里?接下来一个一个解决。

  1. 删除操作当然应该在按下软键盘的删除键时执行,这里有一个细节,在 Android 点击键盘按键的事件,其实是分开处理的,按下键盘时会出发动作,弹起键盘时同样会触发动作;
  2. 如果是不符合筛选条件(”#”+bookEntity.getBookName()+”#”),点击一次就删除一个字符;如果符合条件,就一次性将整个带左右井号的字符串都删掉;
  3. 而光标则应该一直保持在最末尾处。
/** 监听删除按键,执行删除动作 */
editText.setOnKeyListener(new OnKeyListener() {
 @Override
 public boolean onKey(View v, int keyCode, KeyEvent event) {
 if (keyCode == KeyEvent.KEYCODE_DEL && event.getAction() == KeyEvent.ACTION_DOWN) { //当为删除键并且是按下动作时执行
 int selectionStart = editText.getSelectionStart();
 int lastPos = 0;
 for (int i = 0; i < mList.size(); i++) { //循环遍历整个输入框的所有字符
 if ((lastPos = editText.getText().toString().indexOf(mList.get(i).getBookName(), lastPos)) != -1) {
 if (selectionStart != 0 && selectionStart >= lastPos && selectionStart <= (lastPos + mList.get(i).getBookName().length())) {
 String sss = editText.getText().toString();
 editText.setText(sss.substring(0, lastPos) + sss.substring(lastPos + mList.get(i).getBookName().length())); //字符串替换,删掉符合条件的字符串
 mList.remove(i); //删除对应实体
 editText.setSelection(lastPos); //设置光标位置
 return true;
 }
 } else {
 lastPos += ("#" + mList.get(i).getBookName() + "#").length();
 }
 }
 }
 return false;
 }
});

处理变色问题

变色,使符合条件的字符串颜色高亮,更多像是锦上添花的功能,给用户更直观的感知,却是整个小程序里实现起来最复杂的部分。由于 Android 自身的一些原因,无论是增加还是删除,或者是锁屏等事件,都会造成界面重绘的问题,因此其核心就在于要设置字符改变的监听状态,当字符改变时,剩余的字符的颜色随之发生变化。

具体的实现,我们需要一个 TextWatcher 的实现类,然后 new 出来一个对象,作为参数给控件 editText 设置的 addTextChangedListener 监听。

class MyTextWatcher implements TextWatcher {
 @Override
 public synchronized void afterTextChanged(Editable s) {
 AddNewArticleUI.this.etWriteDynamic.removeTextChangedListener(watcher);
 TEXT_CHANGE_LISTENER_FLAG = 0;
 int findPos = 0;
 int copyPos = 0;
 String sText = s.toString();
 List<Integer> spanIndexes = new ArrayList<Integer>();
 s.clear();
 for (int i = 0; i < bookList.size(); i++) {
 String tempBookName = "#" + bookList.get(i).getBookName() + "#";
 if ((findPos = sText.indexOf(tempBookName, findPos)) != -1) {
 spanIndexes.add(findPos);//bookName 的开始索引,键值为偶数,从0开始
 spanIndexes.add(findPos + tempBookName.length()); //bookName 的结束索引,键值为奇数,从1开始
 }
 }
 if (spanIndexes != null && spanIndexes.size() != 0) {
 for (int i = 0; i < spanIndexes.size(); i++) {
 if (i % 2 == 0) {
 s.append(sText.substring(copyPos, spanIndexes.get(i)));
 } else {
 Spanned htmlText = Html.fromHtml("<font color='blue'>" + sText.substring(copyPos, spanIndexes.get(i)) + "</font>");
 s.append(htmlText);
 }
 copyPos = spanIndexes.get(i);
 }
 s.append(sText.substring(copyPos));
 } else {
 s.append(sText);
 }
 }

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

 @Override
 public void onTextChanged(CharSequence s, int start, int before, int count) {}
}

这里需要注意的是在 addTextChangedListener 的 afterTextChange 方法里,不能够操作 Editable 去给控件设置值,而且也不能在全局设置 TextChanged 监听,否则必然会因为循环调用 StackOverFlow 。也就是说我们只能在文字改变之前去调用,还要发屏幕解锁屏广播,在屏幕解锁时去调用。

private int TEXT_CHANGE_LISTENER_FLAG = 0;
/** 监听文字变化,并重新设置颜色 */
if (TEXT_CHANGE_LISTENER_FLAG == 0) {
 editText.addTextChangedListener(watcher);
 TEXT_CHANGE_LISTENER_FLAG = 1;
}

下面是解锁屏的广播(此段代码来自网络)

public class ScreenListener {
 private Context mContext;
 private ScreenBroadcastReceiver mScreenReceiver;
 private ScreenStateListener mScreenStateListener;

 public ScreenListener(Context context) {
 mContext = context;
 mScreenReceiver = new ScreenBroadcastReceiver();
 }

 /**
 * screen状态广播接收者
 */
 private class ScreenBroadcastReceiver extends BroadcastReceiver {
 private String action = null;

 @Override
 public void onReceive(Context context, Intent intent) {
 action = intent.getAction();
 if (Intent.ACTION_SCREEN_ON.equals(action)) { // 开屏
 mScreenStateListener.onScreenOn();
 } else if (Intent.ACTION_SCREEN_OFF.equals(action)) { // 锁屏
 mScreenStateListener.onScreenOff();
 } else if (Intent.ACTION_USER_PRESENT.equals(action)) { // 解锁
 mScreenStateListener.onUserPresent();
 }
 }
 }

 /**
 * 开始监听screen状态
 * 
 * @param listener
 */
 public void begin(ScreenStateListener listener) {
 mScreenStateListener = listener;
 registerListener();
 getScreenState();
 }

 /**
 * 获取screen状态
 */
 private void getScreenState() {
 PowerManager manager = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
 if (manager.isScreenOn()) {
 if (mScreenStateListener != null) {
 mScreenStateListener.onScreenOn();
 }
 } else {
 if (mScreenStateListener != null) {
 mScreenStateListener.onScreenOff();
 }
 }
 }

 /**
 * 停止screen状态监听
 */
 public void unregisterListener() {
 mContext.unregisterReceiver(mScreenReceiver);
 }

 /**
 * 启动screen状态广播接收器
 */
 private void registerListener() {
 IntentFilter filter = new IntentFilter();
 filter.addAction(Intent.ACTION_SCREEN_ON);
 filter.addAction(Intent.ACTION_SCREEN_OFF);
 filter.addAction(Intent.ACTION_USER_PRESENT);
 mContext.registerReceiver(mScreenReceiver, filter);
 }

 public interface ScreenStateListener {// 返回给调用者屏幕状态信息
 public void onScreenOn();

 public void onScreenOff();

 public void onUserPresent();
 }
}

注册广播

ScreenListener screenListener = new ScreenListener(this);
screenListener.begin(new ScreenStateListener() {

 @Override
 public void onUserPresent() {
 Log.e("onUserPresent", "onUserPresent");
 }

 @Override
 public void onScreenOn() {
 Log.e("onScreenOn", "onScreenOn");
 if (TEXT_CHANGE_LISTENER_FLAG == 0) {
 editText.addTextChangedListener(watcher);
 TEXT_CHANGE_LISTENER_FLAG = 1;
 }
 }

 @Override
 public void onScreenOff() {
 Log.e("onScreenOff", "onScreenOff");
 }
});

最后,不要忘记取消注册广播。

设置光标位置

最后就是在手指触摸到所匹配的字符时,设置光标的位置,其实跟上面的删除处理逻辑一样,只是不需要再去替换字符串,判断的逻辑都是一样的。

editText.setOnClickListener(new OnClickListener() {
 @Override
 public void onClick(View v) {
 Log.i("TAG", ((EditText) v).getSelectionStart() + "");
 int selectionStart = ((EditText) v).getSelectionStart();
 int lastPos = 0;
 for (int i = 0; i < mList.size(); i++) {
 if ((lastPos = editText.getText().toString().indexOf(mList.get(i).getBookName(), lastPos)) != -1) {
 if (selectionStart >= lastPos && selectionStart <= (lastPos + mList.get(i).getBookName().length())) {
 editText.setSelection(lastPos + mList.get(i).getBookName().length());
 }
 } else {
 lastPos += ("#" + mList.get(i).getBookName() + "#").length();
 }
 }
 }
});

至此,一个类似新浪微博输入框的小程序就完成了。当然比起新浪微博,这里少了一步点击删除第一次选中被匹配到字符串,这个功能,新浪微博的实现是,点击删除,先选中字符串,再点击删除,就删掉字符串。具体情况,不再赘述。

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

推荐阅读更多精彩内容