Last active
September 4, 2023 06:06
-
-
Save kidinov/6900164 to your computer and use it in GitHub Desktop.
Undo/Redo with batches for any TextView descendant.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* THIS CLASS IS PROVIDED TO THE PUBLIC DOMAIN FOR FREE WITHOUT ANY | |
* RESTRICTIONS OR ANY WARRANTY. | |
*/ | |
import android.content.SharedPreferences; | |
import android.content.SharedPreferences.Editor; | |
import android.text.Editable; | |
import android.text.Selection; | |
import android.text.TextUtils; | |
import android.text.TextWatcher; | |
import android.text.style.UnderlineSpan; | |
import android.util.Log; | |
import android.widget.TextView; | |
import java.util.LinkedList; | |
public class UndoRedoHelper { | |
private static final String TAG = UndoRedoHelper.class.getCanonicalName(); | |
private boolean mIsUndoOrRedo = false; | |
private EditHistory mEditHistory; | |
private EditTextChangeListener mChangeListener; | |
private TextView mTextView; | |
// =================================================================== // | |
public UndoRedoHelper(TextView textView) { | |
mTextView = textView; | |
mEditHistory = new EditHistory(); | |
mChangeListener = new EditTextChangeListener(); | |
mTextView.addTextChangedListener(mChangeListener); | |
} | |
// =================================================================== // | |
public void disconnect() { | |
mTextView.removeTextChangedListener(mChangeListener); | |
} | |
public void setMaxHistorySize(int maxHistorySize) { | |
mEditHistory.setMaxHistorySize(maxHistorySize); | |
} | |
public void clearHistory() { | |
mEditHistory.clear(); | |
} | |
public boolean getCanUndo() { | |
return (mEditHistory.mmPosition > 0); | |
} | |
public void undo() { | |
EditItem edit = mEditHistory.getPrevious(); | |
if (edit == null) { | |
return; | |
} | |
Editable text = mTextView.getEditableText(); | |
int start = edit.mmStart; | |
int end = start + (edit.mmAfter != null ? edit.mmAfter.length() : 0); | |
mIsUndoOrRedo = true; | |
text.replace(start, end, edit.mmBefore); | |
mIsUndoOrRedo = false; | |
for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { | |
text.removeSpan(o); | |
} | |
Selection.setSelection(text, edit.mmBefore == null ? start : (start + edit.mmBefore.length())); | |
} | |
public boolean getCanRedo() { | |
return (mEditHistory.mmPosition < mEditHistory.mmHistory.size()); | |
} | |
public void redo() { | |
EditItem edit = mEditHistory.getNext(); | |
if (edit == null) { | |
return; | |
} | |
Editable text = mTextView.getEditableText(); | |
int start = edit.mmStart; | |
int end = start + (edit.mmBefore != null ? edit.mmBefore.length() : 0); | |
mIsUndoOrRedo = true; | |
text.replace(start, end, edit.mmAfter); | |
mIsUndoOrRedo = false; | |
// This will get rid of underlines inserted when editor tries to come | |
// up with a suggestion. | |
for (Object o : text.getSpans(0, text.length(), UnderlineSpan.class)) { | |
text.removeSpan(o); | |
} | |
Selection.setSelection(text, edit.mmAfter == null ? start | |
: (start + edit.mmAfter.length())); | |
} | |
public void storePersistentState(Editor editor, String prefix) { | |
// Store hash code of text in the editor so that we can check if the | |
// editor contents has changed. | |
editor.putString(prefix + ".hash", | |
String.valueOf(mTextView.getText().toString().hashCode())); | |
editor.putInt(prefix + ".maxSize", mEditHistory.mmMaxHistorySize); | |
editor.putInt(prefix + ".position", mEditHistory.mmPosition); | |
editor.putInt(prefix + ".size", mEditHistory.mmHistory.size()); | |
int i = 0; | |
for (EditItem ei : mEditHistory.mmHistory) { | |
String pre = prefix + "." + i; | |
editor.putInt(pre + ".start", ei.mmStart); | |
editor.putString(pre + ".before", ei.mmBefore.toString()); | |
editor.putString(pre + ".after", ei.mmAfter.toString()); | |
i++; | |
} | |
} | |
public boolean restorePersistentState(SharedPreferences sp, String prefix) | |
throws IllegalStateException { | |
boolean ok = doRestorePersistentState(sp, prefix); | |
if (!ok) { | |
mEditHistory.clear(); | |
} | |
return ok; | |
} | |
private boolean doRestorePersistentState(SharedPreferences sp, String prefix) { | |
String hash = sp.getString(prefix + ".hash", null); | |
if (hash == null) { | |
// No state to be restored. | |
return true; | |
} | |
if (Integer.valueOf(hash) != mTextView.getText().toString().hashCode()) { | |
return false; | |
} | |
mEditHistory.clear(); | |
mEditHistory.mmMaxHistorySize = sp.getInt(prefix + ".maxSize", -1); | |
int count = sp.getInt(prefix + ".size", -1); | |
if (count == -1) { | |
return false; | |
} | |
for (int i = 0; i < count; i++) { | |
String pre = prefix + "." + i; | |
int start = sp.getInt(pre + ".start", -1); | |
String before = sp.getString(pre + ".before", null); | |
String after = sp.getString(pre + ".after", null); | |
if (start == -1 || before == null || after == null) { | |
return false; | |
} | |
mEditHistory.add(new EditItem(start, before, after)); | |
} | |
mEditHistory.mmPosition = sp.getInt(prefix + ".position", -1); | |
if (mEditHistory.mmPosition == -1) { | |
return false; | |
} | |
return true; | |
} | |
// =================================================================== // | |
private final class EditHistory { | |
private int mmPosition = 0; | |
private int mmMaxHistorySize = -1; | |
private final LinkedList<EditItem> mmHistory = new LinkedList<EditItem>(); | |
private void clear() { | |
mmPosition = 0; | |
mmHistory.clear(); | |
} | |
private void add(EditItem item) { | |
while (mmHistory.size() > mmPosition) { | |
mmHistory.removeLast(); | |
} | |
mmHistory.add(item); | |
mmPosition++; | |
if (mmMaxHistorySize >= 0) { | |
trimHistory(); | |
} | |
} | |
private void setMaxHistorySize(int maxHistorySize) { | |
mmMaxHistorySize = maxHistorySize; | |
if (mmMaxHistorySize >= 0) { | |
trimHistory(); | |
} | |
} | |
private void trimHistory() { | |
while (mmHistory.size() > mmMaxHistorySize) { | |
mmHistory.removeFirst(); | |
mmPosition--; | |
} | |
if (mmPosition < 0) { | |
mmPosition = 0; | |
} | |
} | |
private EditItem getCurrent() { | |
if (mmPosition == 0) { | |
return null; | |
} | |
return mmHistory.get(mmPosition - 1); | |
} | |
private EditItem getPrevious() { | |
if (mmPosition == 0) { | |
return null; | |
} | |
mmPosition--; | |
return mmHistory.get(mmPosition); | |
} | |
private EditItem getNext() { | |
if (mmPosition >= mmHistory.size()) { | |
return null; | |
} | |
EditItem item = mmHistory.get(mmPosition); | |
mmPosition++; | |
return item; | |
} | |
} | |
private final class EditItem { | |
private int mmStart; | |
private CharSequence mmBefore; | |
private CharSequence mmAfter; | |
public EditItem(int start, CharSequence before, CharSequence after) { | |
mmStart = start; | |
mmBefore = before; | |
mmAfter = after; | |
} | |
@Override | |
public String toString() { | |
return "EditItem{" + | |
"mmStart=" + mmStart + | |
", mmBefore=" + mmBefore + | |
", mmAfter=" + mmAfter + | |
'}'; | |
} | |
} | |
enum ActionType { | |
INSERT, DELETE, PASTE, NOT_DEF; | |
} | |
private final class EditTextChangeListener implements TextWatcher { | |
private CharSequence mBeforeChange; | |
private CharSequence mAfterChange; | |
private ActionType lastActionType = ActionType.NOT_DEF; | |
private long lastActionTime = 0; | |
public void beforeTextChanged(CharSequence s, int start, int count, int after) { | |
if (mIsUndoOrRedo) { | |
return; | |
} | |
mBeforeChange = s.subSequence(start, start + count); | |
} | |
public void onTextChanged(CharSequence s, int start, int before, int count) { | |
if (mIsUndoOrRedo) { | |
return; | |
} | |
mAfterChange = s.subSequence(start, start + count); | |
makeBatch(start); | |
} | |
private void makeBatch(int start) { | |
ActionType at = getActionType(); | |
EditItem editItem = mEditHistory.getCurrent(); | |
if ((lastActionType != at || ActionType.PASTE == at || System.currentTimeMillis() - lastActionTime > 1000) || editItem == null) { | |
mEditHistory.add(new EditItem(start, mBeforeChange, mAfterChange)); | |
} else { | |
if (at == ActionType.DELETE) { | |
editItem.mmStart = start; | |
editItem.mmBefore = TextUtils.concat(mBeforeChange, editItem.mmBefore); | |
} else { | |
editItem.mmAfter = TextUtils.concat(editItem.mmAfter, mAfterChange); | |
} | |
} | |
lastActionType = at; | |
lastActionTime = System.currentTimeMillis(); | |
} | |
private ActionType getActionType() { | |
if (!TextUtils.isEmpty(mBeforeChange) && TextUtils.isEmpty(mAfterChange)) { | |
return ActionType.DELETE; | |
} else if (TextUtils.isEmpty(mBeforeChange) && !TextUtils.isEmpty(mAfterChange)) { | |
return ActionType.INSERT; | |
} else { | |
return ActionType.PASTE; | |
} | |
} | |
public void afterTextChanged(Editable s) { | |
} | |
} | |
} |
Thank you so much for posting this.. a nice piece of code!
// initialization
UndoManager mUndoManager = new UndoManager(mEditText);
...
...
//undo action
if (mUndoManager.getCanUndo()) {
mUndoManager.undo();
}
...
...
//redo action
if (mUndoManager.getCanRedo()) {
mUndoManager.redo();
}
Thank you so much
how I could use this code for jetpack compose ??
How I could use this code for jetpack compose ??
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Please. basic sample how to implement this in EditText?