安卓——EditText的撤销与反撤销功能实现

493 阅读9分钟

1.前言

在使用记事本等文本类app时,我们常常能看到编辑框的文本可以进行撤销和反撤销——即文本的即时记忆功能。那如何实现这样的功能呢?今天我会一步步讲明我实现此功能的步骤。 上图: 在这里插入图片描述

当然文末会有完整代码和我放在JitPack的依赖库,大家如果比较匆忙的话,可以直接下滑至标题3和4查看,以下的实现步骤是我对该知识点的梳理和实现逻辑,大家可看可不看。

2.实现步骤

在实现这一步之前,我们需要学习如下知识点的一些知识范围:

  1. Span:通过Span部分改变EditText的文本
  2. TextWatcher():记录文本变化

2.1 Span部分知识学习

首先我们需要了解Span的部分知识,Span在谷歌里的定义:

Span 是功能强大的标记对象,可用于在字符或段落级别为文本设置样式。通过将 Span附加到文本对象,能够以各种方式更改文本,包括添加颜色、使文本可点击、缩放文本大小以及以自定义方式绘制文本。Span 还可以更改TextPaint 属性、在 Canvas 上绘制,以及更改文本布局。

简言之,可以通过Span修改EditText里的部分范围内的文本,这个工具用处很多,但是在实现撤销与反撤销功能这里,我们只需要学习Span下的类——SpannableStringBuilder。 该类部分方法(重构方法就不列出了):

方法作用
insert(int where, CharSequence tb)在指定位置新增文本
delete(int start, int end)删除指定范围的文本
replace(final int start, final int end,CharSequence tb, int tbstart, int tbend)修改指定范围的文本。参数解析:start:文本变化起始位置;end:文本变化末位置;tb:变化文本;tbstart:截取变化文本的起始位置;tbend:截取变化文本的末位置

使用SpannableStringBuilder对文本进行新增实例代码:

        String test = "测试文本";
        SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(test);
        spannableStringBuilder.insert(0,"新增");
        textView.setText(spannableStringBuilder);

其他方法应用也是一样的,当然Span功能性很高,这些用法只是基础知识,不过我们现在只要学会这些就够了。

2.2 TextWatcher() 文本观察者

谷歌提供的这个接口,可以让我们监听到EditText控件文本变化,即在文本变化时回调以下三个抽象方法。 当然这三个方法在官方文档里讲得有点单调,以下有我个人的领悟,如有错漏的地方,还请指出。

方法名作用
beforeTextChanged(s: CharSequence!, start: Int, count: Int, after: Int)在文本变化前,回调此方法,用此方法来获取删除的文本。参数分析:s:变化前的文本;start:文本变化的起始位置;count:count:减少变化数;after:新增变化数
onTextChanged(s: CharSequence!, start: Int, before: Int, count: Int)在文本变化后,回调此方法,用此方法来获取新增的文本。 参数分析:s:变化之后的文本,start:文本变化的起始位置;before:减少文本数量;count:新增文本数量
afterTextChanged(Editable s)文本变化后,回调此方法。

在读懂以上方法之后,我们的思路便开始清晰起来了。 首先我们需要及时保存文本,这里我们采用栈Stack这个数据结构进行保存。 而文本变化有三种状态:1.删除、2.增加、3.替换。

  1. 在进行删除文本操作后,我们在方法beforeTextChanged()对被删除的文本进行范围性保存。 举例说明的话:测试文本->文本。 我们保留测试两字,以及测试0-1的两个位置数;
                    lastStack.push(new MemoryEditBean(s.toString().substring(start,start+count),start,start+count,-1));

  1. 在进行增加文本操作后,我们在方法onTextChanged()里对新增文本进行范围性保存。 举例说明:测试文本->测试第二次文本。 我们保留第二次三字,以及2~4的三个位置数
lastStack.push(new MemoryEditBean(s.toString().substring(start,count+start),start,start+count,0));
                }
  1. 在进行替换文本操作后,我们首先用方法beforeTextChanged()保存被替换的文本。之后用onTextChanged()保存替换后的文本。 举例说明:测试文本->显示文本 我们在beforeTextChanged()保留测试二字及位置,
                    // 修改前文本
                    lastStack.push(new MemoryEditBean(s.toString().substring(start, start + count), start, start + after, 1));

onTextChanged()保留显示二字及位置。

                    // 修改后文本
                    lastStack.push(new MemoryEditBean(s.toString().substring(start, count + start), start, start + count, 1));

如上已经整理好对文本变化过程中对文本的即时记忆思路。之后便是输出,即如何撤销与反撤销。

2.3 栈的应用和SpannableStringBuilder三方法的使用

2.3.1.撤销功能rollback的实现。

思路:

  1. 取出栈的数据,判断此数据是删除文本还是新增文本或者修改文本。(在我们自建bean类留字段State在new对象时进行指定。-1为删除文本;0为新增文本;1为替换文本) 如果为删除文本,则使用SpannableStringBuilder的insert()方法定向增加;如果为新增文本,则使用SpannableStringBuilder的delete()方法定向删除;如果为替换文本,则使用SpannableStringBuilder的replace()方法定向替换。
  2. 当然在对文本进行修改后,我们要移动光标在修改文本后提醒使用者,因为替换文本有多种状态,所以在移动光标的时候要小心
  3. 同时在对文本进行撤销和反撤销操作时,要进行标记,防止在撤销和反撤销文本时文本变化后回调方法里对文本不断入栈导致成死循环。 代码如下:
  4. 注意:我们在替换文本操作时入栈了两次,所以我们需要出栈两次才能找到替换前的文本。
// 撤销功能
    public void rollBack(){
        lastFlag = true;
        if (lastStack.size() == 0){
            return;
        }
        MemoryEditBean temp = lastStack.pop();
        spannableStringBuilder = new SpannableStringBuilder(editText.getText());
        nextStack.push(temp);
        if (temp.getState()==-1){
            // 删除状态
            spannableStringBuilder.insert(temp.getLastStart(),temp.getLastEdit());
            editText.setText(spannableStringBuilder);
            editText.setSelection(temp.getLastEnd());
        }else if (temp.getState() == 0){
            // 新增状态
            // 覆盖text的长度
            spannableStringBuilder.delete(temp.getLastStart(),temp.getLastEnd());
            editText.setText(spannableStringBuilder);
            editText.setSelection(temp.getLastStart());
        }else if (temp.getState() == 1){
            MemoryEditBean pop = lastStack.pop();
            nextStack.push(pop);
            spannableStringBuilder.replace(pop.getLastStart(),pop.getLastEnd(),pop.getLastEdit(),0,pop.getLastEdit().length());
            editText.setText(spannableStringBuilder);
            // 撤销途中:1.多->少
            if (pop.getCount()<pop.getLastEdit().length()){
                editText.setSelection(pop.getLastStart()+pop.getLastEdit().length());
            }else if (pop.getCount()== pop.getLastEdit().length()){
                // 2. 等位
                editText.setSelection(pop.getLastEnd());
            }else {
                // 3. 少->多
                editText.setSelection(pop.getLastEdit().length());
            }
        }
        lastFlag = false;
    }

2.3.2.撤销功能rollback的实现。

思路:相当于撤销功能的反操作,思路相差不多

  1. 需要关注的一点时,只有在撤销功能实现后,我们才能进行反撤销功能。所以对文本的性质的判断是相反的
  2. 即State==-1时,在撤销功能里是删除文本,需要使用insert()定向增加,在反撤销功能里它则为新增文本,需要使用delete()定向删除。
  3. 其他状态相反,只有替换方法不变,但其不用再出栈。
  4. 同时光标的定位也不同

详细请看代码:

    // 反撤销功能
    public void rollNext(){
        nextFlag = true;
        if (nextStack.size() == 0){
            return;
        }
        MemoryEditBean pop = nextStack.pop();
        // 入撤销栈
        lastStack.push(pop);
        SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(editText.getText());
        if (pop.getState()==-1){
            //
            spannableStringBuilder.delete(pop.getLastStart(),pop.getLastEnd());
            editText.setText(spannableStringBuilder);
            editText.setSelection(pop.getLastStart());
        }else if (pop.getState()==0){
            // 新增
            spannableStringBuilder.insert(pop.getLastStart(),pop.getLastEdit());
            editText.setText(spannableStringBuilder);
            editText.setSelection(pop.getLastEnd());
        }else if (pop.getState()==1){
            // 修改状态:
            MemoryEditBean temp = nextStack.pop();
            lastStack.push(temp);
            spannableStringBuilder.replace(temp.getLastStart(),temp.getLastEnd(),temp.getLastEdit(),0,temp.getLastEdit().length());
            editText.setText(spannableStringBuilder);
            if (temp.getCount()<temp.getLastEdit().length()){
                // 1. 多->少
                editText.setSelection(temp.getLastEdit().length());
            }else if (temp.getCount()==temp.getLastEdit().length()){
                // 2. 等位修改
                editText.setSelection(temp.getLastEnd());
            }else {
               // 3.少->多
                editText.setSelection(temp.getLastStart()+temp.getLastEdit().length());
            }
        }
        nextFlag = false;
    }

2.3.3.保存文本方法

这一个方法即在我们保存文本后,讲栈清空即可,算是一个很简单的思路

// 对笔记阶段性保存
    public void save(){
        // 清空栈
        lastStack.clear();
        nextStack.clear();
    }

2.3.4.暴露栈是否为空,控制撤销和反撤销按钮可否点击(这里后续可能会优化)

用get()方法暴露即可

    public Stack<MemoryEditBean> getLastStack() {
        return lastStack;
    }

    public Stack<MemoryEditBean> getNextStack() {
        return nextStack;
    }

2.3.5.控制器操作,相当对EditText装饰

老实说,写到这里,我感觉有点像装饰者模式,不过我对这个模式不是太了解,只是以前在学RV的翻页加载功能有接触到一点。

  1. 装饰:
        AppCompatEditText editText = findViewById(R.id.editView);
        EditMemory editMemory = new EditMemory(editText);
  1. 撤销: editMemory.rollNext();
  2. 反撤销: editMemory.rollBack();
  3. 对控件可点击性的监听
    public void iniSetCheckable(){
        if (editMemory.getLastStack().size()==0){
            back.setClickable(false);
        }else {
            back.setClickable(true);
        }
        if (editMemory.getNextStack().size()==0){
            nextBack.setClickable(false);
        }else {
            nextBack.setClickable(true);

        }
    }

3.完整代码如下

装饰类EditMemory代码 属性分析:

  1. 撤销栈和反撤销栈
  2. 撤销过程标记和反撤销功能标记
  3. 修改文本类对象
  4. 装饰控件
public class EditMemory {
    Stack<MemoryEditBean> lastStack = new Stack<>();
    Stack<MemoryEditBean> nextStack = new Stack<>();
    private boolean lastFlag = false;
    private boolean nextFlag = false;
    // 修改缓冲
    SpannableStringBuilder spannableStringBuilder;
    private final AppCompatEditText editText;
    public EditMemory(AppCompatEditText editText) {
        this.editText = editText;
        this.editText.addTextChangedListener(new TextWatcher() {
            @Override
            // start:开始 count:减少变化数 after:新增变化数
            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
                if (lastFlag||nextFlag){
                    return;
                }
                // 当不再使用撤销与反撤销功能,正常编辑后,清空下一步栈
                nextStack.clear();
                // 删除
                if (!(lastStack.isEmpty())&&count>0&&after==0){
                    lastStack.push(new MemoryEditBean(s.toString().substring(start,start+count),start,start+count,-1));
                }else if (count>0&&after>0){
                    // 修改前文本
                    lastStack.push(new MemoryEditBean(s.toString().substring(start, start + count), start, start + after, 1));
                }

            }
            @Override
            // start:开始位置 before:删除数量;count:新增数量
            public void onTextChanged(CharSequence s, int start, int before, int count) {
                if (lastFlag||nextFlag){
                    return;
                }
                if (before==0&&count>0){
                    // 新增
                    lastStack.push(new MemoryEditBean(s.toString().substring(start,count+start),start,start+count,0));
                }else if (before>0&&count>0){
                    // 修改后文本
                    lastStack.push(new MemoryEditBean(s.toString().substring(start, count + start), start, start + before, 1));
                }
            }
            @Override
            public void afterTextChanged(Editable s) {
            }
        });
    }
    // 撤销功能
    public void rollBack(){
        lastFlag = true;
        if (lastStack.size() == 0){
            return;
        }
        MemoryEditBean temp = lastStack.pop();
        spannableStringBuilder = new SpannableStringBuilder(editText.getText());
        nextStack.push(temp);
        if (temp.getState()==-1){
            // 删除状态
            spannableStringBuilder.insert(temp.getLastStart(),temp.getLastEdit());
            editText.setText(spannableStringBuilder);
            editText.setSelection(temp.getLastEnd());
        }else if (temp.getState() == 0){
            // 新增状态
            // 覆盖text的长度
            spannableStringBuilder.delete(temp.getLastStart(),temp.getLastEnd());
            editText.setText(spannableStringBuilder);
            editText.setSelection(temp.getLastStart());
        }else if (temp.getState() == 1){
            MemoryEditBean pop = lastStack.pop();
            nextStack.push(pop);
            spannableStringBuilder.replace(pop.getLastStart(),pop.getLastEnd(),pop.getLastEdit(),0,pop.getLastEdit().length());
            editText.setText(spannableStringBuilder);
            // 撤销途中:1.多->少
            if (pop.getCount()<pop.getLastEdit().length()){
                editText.setSelection(pop.getLastStart()+pop.getLastEdit().length());
            }else if (pop.getCount()== pop.getLastEdit().length()){
                // 2. 等位
                editText.setSelection(pop.getLastEnd());
            }else {
                // 3. 少->多
                editText.setSelection(pop.getLastEdit().length());
            }
        }
        lastFlag = false;
    }

    // 反撤销功能
    public void rollNext(){
        nextFlag = true;
        if (nextStack.size() == 0){
            return;
        }
        MemoryEditBean pop = nextStack.pop();
        // 入撤销栈
        lastStack.push(pop);
        SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(editText.getText());
        if (pop.getState()==-1){
            //
            spannableStringBuilder.delete(pop.getLastStart(),pop.getLastEnd());
            editText.setText(spannableStringBuilder);
            editText.setSelection(pop.getLastStart());
        }else if (pop.getState()==0){
            // 新增
            spannableStringBuilder.insert(pop.getLastStart(),pop.getLastEdit());
            editText.setText(spannableStringBuilder);
            editText.setSelection(pop.getLastEnd());
        }else if (pop.getState()==1){
            // 修改状态:
            MemoryEditBean temp = nextStack.pop();
            lastStack.push(temp);
            spannableStringBuilder.replace(temp.getLastStart(),temp.getLastEnd(),temp.getLastEdit(),0,temp.getLastEdit().length());
            editText.setText(spannableStringBuilder);
            if (temp.getCount()<temp.getLastEdit().length()){
                // 1. 多->少
                editText.setSelection(temp.getLastEdit().length());
            }else if (temp.getCount()==temp.getLastEdit().length()){
                // 2. 等位修改
                editText.setSelection(temp.getLastEnd());
            }else {
               // 3.少->多
                editText.setSelection(temp.getLastStart()+temp.getLastEdit().length());
            }
        }
        nextFlag = false;
    }
    // 对笔记阶段性保存
    public void save(){
        // 清空栈
        lastStack.clear();
        nextStack.clear();
    }

    public Stack<MemoryEditBean> getLastStack() {
        return lastStack;
    }

    public Stack<MemoryEditBean> getNextStack() {
        return nextStack;
    }
}

自建文本bean类

public class MemoryEditBean {
    // 修改文本
    private String lastEdit;
    // 修改起始位置
    private int lastStart;
    // 修改末位置
    private int lastEnd;
    private int state;
    public MemoryEditBean(String lastEdit, int lastStart, int lastEnd, int state) {
        this.lastEdit = lastEdit;
        this.lastStart = lastStart;
        this.lastEnd = lastEnd;
        this.state = state;
    }

    public String getLastEdit() {
        return lastEdit;
    }

    public int getLastStart() {
        return lastStart;
    }

    public int getLastEnd() {
        return lastEnd;
    }

    public int getState() {
        return state;
    }

    public int getCount() {
        return lastEnd-lastStart;
    }
}

4.考虑到有些同道比较急需,我创建了个依赖库,欢迎fock和star

添加依赖如下: 在工程包settings.gradle下添加

		maven { url 'https://jitpack.io' }

在app模块build.gradle下添加

    implementation 'com.github.Android5730:MemoryEdit:v0.0.1'

github源码地址 github.com/Android5730…

如果在使用中遇到bug还请及时通知我,方便我爬坑。

5.末尾

写在最后,其实我相信有更多更好的方法来实现这个功能,如果大家有更好的实现方法的话,可以在评论区留言探讨。 主要是我在实现项目时百度了一下,发现相关文章很少,这才动笔写下这一篇博客。老实说想分成两篇开水的(bushi),最后还是发成一篇出来。 如果有优化的想法,我后续还是会接着出第二篇的。