blob: d887cdb8a8e9bd7d0a86c01fb7c73d98089bb0fd [file] [log] [blame]
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugin.editing;
import android.text.Editable;
import android.text.Selection;
import android.text.SpannableStringBuilder;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.flutter.Log;
import io.flutter.embedding.engine.systemchannels.TextInputChannel;
import java.util.ArrayList;
/// The current editing state (text, selection range, composing range) the text input plugin holds.
///
/// As the name implies, this class also notifies its listeners when the editing state changes. When
/// there're ongoing batch edits, change notifications will be deferred until all batch edits end
/// (i.e. when the outermost batch edit ends). Listeners added during a batch edit will always be
/// notified when all batch edits end, even if there's no real change.
///
/// Adding/removing listeners or changing the editing state in a didChangeEditingState callback may
/// cause unexpected behavior.
//
// Currently this class does not notify its listeners on spans-only changes (e.g.,
// Selection.setSelection). Wrap them in a batch edit to trigger a change notification.
class ListenableEditingState extends SpannableStringBuilder {
interface EditingStateWatcher {
// Changing the editing state in a didChangeEditingState callback may cause unexpected
// behavior.
void didChangeEditingState(
boolean textChanged, boolean selectionChanged, boolean composingRegionChanged);
}
private static final String TAG = "ListenableEditingState";
private int mBatchEditNestDepth = 0;
// We don't support adding/removing listeners, or changing the editing state in a listener
// callback for now.
private int mChangeNotificationDepth = 0;
private ArrayList<EditingStateWatcher> mListeners = new ArrayList<>();
private ArrayList<EditingStateWatcher> mPendingListeners = new ArrayList<>();
private ArrayList<TextEditingDelta> mBatchTextEditingDeltas = new ArrayList<>();
private String mToStringCache;
private String mTextWhenBeginBatchEdit;
private int mSelectionStartWhenBeginBatchEdit;
private int mSelectionEndWhenBeginBatchEdit;
private int mComposingStartWhenBeginBatchEdit;
private int mComposingEndWhenBeginBatchEdit;
private BaseInputConnection mDummyConnection;
// The View is only used for creating a dummy BaseInputConnection for setComposingRegion. The View
// needs to have a non-null Context.
public ListenableEditingState(
@Nullable TextInputChannel.TextEditState initialState, @NonNull View view) {
super();
Editable self = this;
mDummyConnection =
new BaseInputConnection(view, true) {
@Override
public Editable getEditable() {
return self;
}
};
if (initialState != null) {
setEditingState(initialState);
}
}
public ArrayList<TextEditingDelta> extractBatchTextEditingDeltas() {
ArrayList<TextEditingDelta> currentBatchDeltas =
new ArrayList<TextEditingDelta>(mBatchTextEditingDeltas);
mBatchTextEditingDeltas.clear();
return currentBatchDeltas;
}
public void clearBatchDeltas() {
mBatchTextEditingDeltas.clear();
}
/// Starts a new batch edit during which change notifications will be put on hold until all batch
/// edits end.
///
/// Batch edits nest.
public void beginBatchEdit() {
mBatchEditNestDepth++;
if (mChangeNotificationDepth > 0) {
Log.e(TAG, "editing state should not be changed in a listener callback");
}
if (mBatchEditNestDepth == 1 && !mListeners.isEmpty()) {
mTextWhenBeginBatchEdit = toString();
mSelectionStartWhenBeginBatchEdit = getSelectionStart();
mSelectionEndWhenBeginBatchEdit = getSelectionEnd();
mComposingStartWhenBeginBatchEdit = getComposingStart();
mComposingEndWhenBeginBatchEdit = getComposingEnd();
}
}
/// Ends the current batch edit and flush pending change notifications if the current batch edit
/// is not nested (i.e. it is the last ongoing batch edit).
public void endBatchEdit() {
if (mBatchEditNestDepth == 0) {
Log.e(TAG, "endBatchEdit called without a matching beginBatchEdit");
return;
}
if (mBatchEditNestDepth == 1) {
for (final EditingStateWatcher listener : mPendingListeners) {
notifyListener(listener, true, true, true);
}
if (!mListeners.isEmpty()) {
Log.v(TAG, "didFinishBatchEdit with " + String.valueOf(mListeners.size()) + " listener(s)");
final boolean textChanged = !toString().equals(mTextWhenBeginBatchEdit);
final boolean selectionChanged =
mSelectionStartWhenBeginBatchEdit != getSelectionStart()
|| mSelectionEndWhenBeginBatchEdit != getSelectionEnd();
final boolean composingRegionChanged =
mComposingStartWhenBeginBatchEdit != getComposingStart()
|| mComposingEndWhenBeginBatchEdit != getComposingEnd();
notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged);
}
}
mListeners.addAll(mPendingListeners);
mPendingListeners.clear();
mBatchEditNestDepth--;
}
/// Update the composing region of the current editing state.
///
/// If the range is invalid or empty, the current composing region will be removed.
public void setComposingRange(int composingStart, int composingEnd) {
if (composingStart < 0 || composingStart >= composingEnd) {
BaseInputConnection.removeComposingSpans(this);
} else {
mDummyConnection.setComposingRegion(composingStart, composingEnd);
}
}
/// Called when the framework sends updates to the text input plugin.
///
/// This method will also update the composing region if it has changed.
public void setEditingState(TextInputChannel.TextEditState newState) {
beginBatchEdit();
replace(0, length(), newState.text);
if (newState.hasSelection()) {
Selection.setSelection(this, newState.selectionStart, newState.selectionEnd);
} else {
Selection.removeSelection(this);
}
setComposingRange(newState.composingStart, newState.composingEnd);
// Updates from the framework should not have a delta created for it as they have already been
// applied on the framework side.
clearBatchDeltas();
endBatchEdit();
}
public void addEditingStateListener(EditingStateWatcher listener) {
if (mChangeNotificationDepth > 0) {
Log.e(TAG, "adding a listener " + listener.toString() + " in a listener callback");
}
// It is possible for a listener to get added during a batch edit. When that happens we always
// notify the new listeners.
// This does not check if the listener is already in the list of existing listeners.
if (mBatchEditNestDepth > 0) {
Log.w(TAG, "a listener was added to EditingState while a batch edit was in progress");
mPendingListeners.add(listener);
} else {
mListeners.add(listener);
}
}
public void removeEditingStateListener(EditingStateWatcher listener) {
if (mChangeNotificationDepth > 0) {
Log.e(TAG, "removing a listener " + listener.toString() + " in a listener callback");
}
mListeners.remove(listener);
if (mBatchEditNestDepth > 0) {
mPendingListeners.remove(listener);
}
}
@Override
public SpannableStringBuilder replace(
int start, int end, CharSequence tb, int tbstart, int tbend) {
if (mChangeNotificationDepth > 0) {
Log.e(TAG, "editing state should not be changed in a listener callback");
}
final CharSequence oldText = toString();
boolean textChanged = end - start != tbend - tbstart;
for (int i = 0; i < end - start && !textChanged; i++) {
textChanged |= charAt(start + i) != tb.charAt(tbstart + i);
}
if (textChanged) {
mToStringCache = null;
}
final int selectionStart = getSelectionStart();
final int selectionEnd = getSelectionEnd();
final int composingStart = getComposingStart();
final int composingEnd = getComposingEnd();
final SpannableStringBuilder editable = super.replace(start, end, tb, tbstart, tbend);
mBatchTextEditingDeltas.add(
new TextEditingDelta(
oldText,
start,
end,
tb,
getSelectionStart(),
getSelectionEnd(),
getComposingStart(),
getComposingEnd()));
if (mBatchEditNestDepth > 0) {
return editable;
}
final boolean selectionChanged =
getSelectionStart() != selectionStart || getSelectionEnd() != selectionEnd;
final boolean composingRegionChanged =
getComposingStart() != composingStart || getComposingEnd() != composingEnd;
notifyListenersIfNeeded(textChanged, selectionChanged, composingRegionChanged);
return editable;
}
private void notifyListener(
EditingStateWatcher listener,
boolean textChanged,
boolean selectionChanged,
boolean composingChanged) {
mChangeNotificationDepth++;
listener.didChangeEditingState(textChanged, selectionChanged, composingChanged);
mChangeNotificationDepth--;
}
private void notifyListenersIfNeeded(
boolean textChanged, boolean selectionChanged, boolean composingChanged) {
if (textChanged || selectionChanged || composingChanged) {
for (final EditingStateWatcher listener : mListeners) {
notifyListener(listener, textChanged, selectionChanged, composingChanged);
}
}
}
public final int getSelectionStart() {
return Selection.getSelectionStart(this);
}
public final int getSelectionEnd() {
return Selection.getSelectionEnd(this);
}
public final int getComposingStart() {
return BaseInputConnection.getComposingSpanStart(this);
}
public final int getComposingEnd() {
return BaseInputConnection.getComposingSpanEnd(this);
}
@Override
public void setSpan(Object what, int start, int end, int flags) {
super.setSpan(what, start, end, flags);
// Setting a span does not involve mutating the text value in the editing state. Here we create
// a non text update delta with any updated selection and composing regions.
mBatchTextEditingDeltas.add(
new TextEditingDelta(
toString(),
getSelectionStart(),
getSelectionEnd(),
getComposingStart(),
getComposingEnd()));
}
@Override
public String toString() {
return mToStringCache != null ? mToStringCache : (mToStringCache = super.toString());
}
}