This commit is contained in:
Benoit Marty 2023-06-23 17:40:29 +02:00
parent f304e40d57
commit 3da1497d27
13 changed files with 1553 additions and 9 deletions

View File

@ -115,6 +115,7 @@ ext.groups = [
'com.linkedin.dexmaker',
'com.mapbox.mapboxsdk',
'com.nulab-inc',
'com.otaliastudios',
'com.otaliastudios.opengl',
'com.parse.bolts',
'com.pinterest',
@ -238,7 +239,6 @@ ext.groups = [
regex: [
],
group: [
'com.otaliastudios',
'com.yqritc',
// https://github.com/cmelchior/realmfieldnameshelper/issues/42
'dk.ilios',

View File

@ -0,0 +1,32 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
namespace "com.otaliastudios.autocomplete"
compileSdk versions.compileSdk
defaultConfig {
minSdk versions.minSdk
targetSdk versions.targetSdk
}
compileOptions {
sourceCompatibility versions.sourceCompat
targetCompatibility versions.targetCompat
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation libs.androidx.recyclerview
}
afterEvaluate {
tasks.findAll { it.name.startsWith("lint") }.each {
it.enabled = false
}
}

View File

@ -0,0 +1,434 @@
package com.otaliastudios.autocomplete;
import android.database.DataSetObserver;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.Selection;
import android.text.SpanWatcher;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextWatcher;
import android.util.Log;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.Window;
import android.view.WindowManager;
import android.widget.EditText;
import android.widget.PopupWindow;
import androidx.annotation.NonNull;
/**
* Entry point for adding Autocomplete behavior to a {@link EditText}.
*
* You can construct a {@code Autocomplete} using the builder provided by {@link Autocomplete#on(EditText)}.
* Building is enough, but you can hold a reference to this class to call its public methods.
*
* Requires:
* - {@link EditText}: this is both the anchor for the popup, and the source of text events that we listen to
* - {@link AutocompletePresenter}: this presents items in the popup window. See class for more info.
* - {@link AutocompleteCallback}: if specified, this listens to click events and visibility changes
* - {@link AutocompletePolicy}: if specified, this controls how and when to show the popup based on text events
* If not, this defaults to {@link SimplePolicy}: shows the popup when text.length() bigger than 0.
*/
public final class Autocomplete<T> implements TextWatcher, SpanWatcher {
private final static String TAG = Autocomplete.class.getSimpleName();
private final static boolean DEBUG = false;
private static void log(String log) {
if (DEBUG) Log.e(TAG, log);
}
/**
* Builder for building {@link Autocomplete}.
* The only mandatory item is a presenter, {@link #with(AutocompletePresenter)}.
*
* @param <T> the data model
*/
public final static class Builder<T> {
private EditText source;
private AutocompletePresenter<T> presenter;
private AutocompletePolicy policy;
private AutocompleteCallback<T> callback;
private Drawable backgroundDrawable;
private float elevationDp = 6;
private Builder(EditText source) {
this.source = source;
}
/**
* Registers the {@link AutocompletePresenter} to be used, responsible for showing
* items. See the class for info.
*
* @param presenter desired presenter
* @return this for chaining
*/
public Builder<T> with(AutocompletePresenter<T> presenter) {
this.presenter = presenter;
return this;
}
/**
* Registers the {@link AutocompleteCallback} to be used, responsible for listening to
* clicks provided by the presenter, and visibility changes.
*
* @param callback desired callback
* @return this for chaining
*/
public Builder<T> with(AutocompleteCallback<T> callback) {
this.callback = callback;
return this;
}
/**
* Registers the {@link AutocompletePolicy} to be used, responsible for showing / dismissing
* the popup when certain events happen (e.g. certain characters are typed).
*
* @param policy desired policy
* @return this for chaining
*/
public Builder<T> with(AutocompletePolicy policy) {
this.policy = policy;
return this;
}
/**
* Sets a background drawable for the popup.
*
* @param backgroundDrawable drawable
* @return this for chaining
*/
public Builder<T> with(Drawable backgroundDrawable) {
this.backgroundDrawable = backgroundDrawable;
return this;
}
/**
* Sets elevation for the popup. Defaults to 6 dp.
*
* @param elevationDp popup elevation, in DP
* @return this for chaning.
*/
public Builder<T> with(float elevationDp) {
this.elevationDp = elevationDp;
return this;
}
/**
* Builds an Autocomplete instance. This is enough for autocomplete to be set up,
* but you can hold a reference to the object and call its public methods.
*
* @return an Autocomplete instance, if you need it
*
* @throws RuntimeException if either EditText or the presenter are null
*/
public Autocomplete<T> build() {
if (source == null) throw new RuntimeException("Autocomplete needs a source!");
if (presenter == null) throw new RuntimeException("Autocomplete needs a presenter!");
if (policy == null) policy = new SimplePolicy();
return new Autocomplete<T>(this);
}
private void clear() {
source = null;
presenter = null;
callback = null;
policy = null;
backgroundDrawable = null;
elevationDp = 6;
}
}
/**
* Entry point for building autocomplete on a certain {@link EditText}.
* @param anchor the anchor for the popup, and the source of text events
* @param <T> your data model
* @return a Builder for set up
*/
public static <T> Builder<T> on(EditText anchor) {
return new Builder<T>(anchor);
}
private AutocompletePolicy policy;
private AutocompletePopup popup;
private AutocompletePresenter<T> presenter;
private AutocompleteCallback<T> callback;
private EditText source;
private boolean block;
private boolean disabled;
private boolean openBefore;
private String lastQuery = "null";
private Autocomplete(Builder<T> builder) {
policy = builder.policy;
presenter = builder.presenter;
callback = builder.callback;
source = builder.source;
// Set up popup
popup = new AutocompletePopup(source.getContext());
popup.setAnchorView(source);
popup.setGravity(Gravity.START);
popup.setModal(false);
popup.setBackgroundDrawable(builder.backgroundDrawable);
popup.setElevation(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, builder.elevationDp,
source.getContext().getResources().getDisplayMetrics()));
// popup dimensions
AutocompletePresenter.PopupDimensions dim = this.presenter.getPopupDimensions();
popup.setWidth(dim.width);
popup.setHeight(dim.height);
popup.setMaxWidth(dim.maxWidth);
popup.setMaxHeight(dim.maxHeight);
// Fire visibility events
popup.setOnDismissListener(new PopupWindow.OnDismissListener() {
@Override
public void onDismiss() {
lastQuery = "null";
if (callback != null) callback.onPopupVisibilityChanged(false);
boolean saved = block;
block = true;
policy.onDismiss(source.getText());
block = saved;
presenter.hideView();
}
});
// Set up source
source.getText().setSpan(this, 0, source.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
source.addTextChangedListener(this);
// Set up presenter
presenter.registerClickProvider(new AutocompletePresenter.ClickProvider<T>() {
@Override
public void click(@NonNull T item) {
AutocompleteCallback<T> callback = Autocomplete.this.callback;
EditText edit = Autocomplete.this.source;
if (callback == null) return;
boolean saved = block;
block = true;
boolean dismiss = callback.onPopupItemClicked(edit.getText(), item);
if (dismiss) dismissPopup();
block = saved;
}
});
builder.clear();
}
/**
* Controls how the popup operates with an input method.
*
* If the popup is showing, calling this method will take effect only
* the next time the popup is shown.
*
* @param mode a {@link PopupWindow} input method mode
*/
public void setInputMethodMode(int mode) {
popup.setInputMethodMode(mode);
}
/**
* Sets the operating mode for the soft input area.
*
* @param mode The desired mode, see {@link WindowManager.LayoutParams#softInputMode}
*/
public void setSoftInputMode(int mode) {
popup.setSoftInputMode(mode);
}
/**
* Shows the popup with the given query.
* There is rarely need to call this externally: it is already triggered by events on the anchor.
* To control when this is called, provide a good implementation of {@link AutocompletePolicy}.
*
* @param query query text.
*/
public void showPopup(@NonNull CharSequence query) {
if (isPopupShowing() && lastQuery.equals(query.toString())) return;
lastQuery = query.toString();
log("showPopup: called with filter "+query);
if (!isPopupShowing()) {
log("showPopup: showing");
presenter.registerDataSetObserver(new Observer()); // Calling new to avoid leaking... maybe...
popup.setView(presenter.getView());
presenter.showView();
popup.show();
if (callback != null) callback.onPopupVisibilityChanged(true);
}
log("showPopup: popup should be showing... "+isPopupShowing());
presenter.onQuery(query);
}
/**
* Dismisses the popup, if showing.
* There is rarely need to call this externally: it is already triggered by events on the anchor.
* To control when this is called, provide a good implementation of {@link AutocompletePolicy}.
*/
public void dismissPopup() {
if (isPopupShowing()) {
popup.dismiss();
}
}
/**
* Returns true if the popup is showing.
* @return whether the popup is currently showing
*/
public boolean isPopupShowing() {
return this.popup.isShowing();
}
/**
* Switch to control the autocomplete behavior. When disabled, no popup is shown.
* This is useful if you want to do runtime edits to the anchor text, without triggering
* the popup.
*
* @param enabled whether to enable autocompletion
*/
public void setEnabled(boolean enabled) {
disabled = !enabled;
}
/**
* Sets the gravity for the popup. Basically only {@link Gravity#START} and {@link Gravity#END}
* do work.
*
* @param gravity gravity for the popup
*/
public void setGravity(int gravity) {
popup.setGravity(gravity);
}
/**
* Controls the vertical offset of the popup from the EditText anchor.
*
* @param offset offset in pixels.
*/
public void setOffsetFromAnchor(int offset) { popup.setVerticalOffset(offset); }
/**
* Controls whether the popup should listen to clicks outside its boundaries.
*
* @param outsideTouchable true to listen to outside clicks
*/
public void setOutsideTouchable(boolean outsideTouchable) { popup.setOutsideTouchable(outsideTouchable); }
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
if (block || disabled) return;
openBefore = isPopupShowing();
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
if (block || disabled) return;
if (openBefore && !isPopupShowing()) {
return; // Copied from somewhere.
}
if (!(s instanceof Spannable)) {
source.setText(new SpannableString(s));
return;
}
Spannable sp = (Spannable) s;
int cursor = source.getSelectionEnd();
log("onTextChanged: cursor end position is "+cursor);
if (cursor == -1) { // No cursor present.
dismissPopup(); return;
}
if (cursor != source.getSelectionStart()) {
// Not sure about this. We should have no problems dealing with multi selections,
// we just take the end...
// dismissPopup(); return;
}
boolean b = block;
block = true; // policy might add spans or other stuff.
if (isPopupShowing() && policy.shouldDismissPopup(sp, cursor)) {
log("onTextChanged: dismissing");
dismissPopup();
} else if (isPopupShowing() || policy.shouldShowPopup(sp, cursor)) {
// LOG.now("onTextChanged: updating with filter "+policy.getQuery(sp));
showPopup(policy.getQuery(sp));
}
block = b;
}
@Override
public void afterTextChanged(Editable s) {}
@Override
public void onSpanAdded(Spannable text, Object what, int start, int end) {}
@Override
public void onSpanRemoved(Spannable text, Object what, int start, int end) {}
@Override
public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
if (disabled || block) return;
if (what == Selection.SELECTION_END) {
// Selection end changed from ostart to nstart. Trigger a check.
log("onSpanChanged: selection end moved from "+ostart+" to "+nstart);
log("onSpanChanged: block is "+block);
boolean b = block;
block = true;
if (!isPopupShowing() && policy.shouldShowPopup(text, nstart)) {
showPopup(policy.getQuery(text));
}
block = b;
}
}
private class Observer extends DataSetObserver implements Runnable {
private Handler ui = new Handler(Looper.getMainLooper());
@Override
public void onChanged() {
// ??? Not sure this is needed...
ui.post(this);
}
@Override
public void run() {
if (isPopupShowing()) {
// Call show again to revisit width and height.
popup.show();
}
}
}
/**
* A very simple {@link AutocompletePolicy} implementation.
* Popup is shown when text length is bigger than 0, and hidden when text is empty.
* The query string is the whole text.
*/
public static class SimplePolicy implements AutocompletePolicy {
@Override
public boolean shouldShowPopup(@NonNull Spannable text, int cursorPos) {
return text.length() > 0;
}
@Override
public boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos) {
return text.length() == 0;
}
@NonNull
@Override
public CharSequence getQuery(@NonNull Spannable text) {
return text;
}
@Override
public void onDismiss(@NonNull Spannable text) {}
}
}

View File

@ -0,0 +1,29 @@
package com.otaliastudios.autocomplete;
import android.text.Editable;
import androidx.annotation.NonNull;
/**
* Optional callback to be passed to {@link Autocomplete.Builder}.
*/
public interface AutocompleteCallback<T> {
/**
* Called when an item inside your list is clicked.
* This works if your presenter has dispatched a click event.
* At this point you can edit the text, e.g. {@code editable.append(item.toString())}.
*
* @param editable editable text that you can work on
* @param item item that was clicked
* @return true if the action is valid and the popup can be dismissed
*/
boolean onPopupItemClicked(@NonNull Editable editable, @NonNull T item);
/**
* Called when popup visibility state changes.
*
* @param shown true if the popup was just shown, false if it was just hidden
*/
void onPopupVisibilityChanged(boolean shown);
}

View File

@ -0,0 +1,64 @@
package com.otaliastudios.autocomplete;
import android.text.Spannable;
import androidx.annotation.NonNull;
/**
* This interface controls when to show or hide the popup window, and, in the first case,
* what text should be passed to the popup {@link AutocompletePresenter}.
*
* @see Autocomplete.SimplePolicy for the simplest possible implementation
*/
public interface AutocompletePolicy {
/**
* Called to understand whether the popup should be shown. Some naive examples:
* - Show when there's text: {@code return text.length() > 0}
* - Show when last char is @: {@code return text.getCharAt(text.length()-1) == '@'}
*
* @param text current text, along with its Spans
* @param cursorPos the position of the cursor
* @return true if popup should be shown
*/
boolean shouldShowPopup(@NonNull Spannable text, int cursorPos);
/**
* Called to understand whether a currently shown popup should be closed, maybe
* because text is invalid. A reasonable implementation is
* {@code return !shouldShowPopup(text, cursorPos)}.
*
* However this is defined so you can add or clear spans.
*
* @param text current text, along with its Spans
* @param cursorPos the position of the cursor
* @return true if popup should be hidden
*/
boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos);
/**
* Called to understand which query should be passed to {@link AutocompletePresenter}
* for a showing popup. If this is called, {@link #shouldShowPopup(Spannable, int)} just returned
* true, or {@link #shouldDismissPopup(Spannable, int)} just returned false.
*
* This is useful to understand which part of the text should be passed to presenters.
* For example, user might have typed '@john' to select a username, but you just want to
* search for 'john'.
*
* For more complex cases, you can add inclusive Spans in {@link #shouldShowPopup(Spannable, int)},
* and get the span position here.
*
* @param text current text, along with its Spans
* @return the query for presenter
*/
@NonNull
CharSequence getQuery(@NonNull Spannable text);
/**
* Called when popup is dismissed. This can be used, for instance, to clear custom Spans
* from the text.
*
* @param text text at the moment of dismissing
*/
void onDismiss(@NonNull Spannable text);
}

View File

@ -0,0 +1,521 @@
package com.otaliastudios.autocomplete;
import android.content.Context;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.PopupWindow;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.core.view.ViewCompat;
import androidx.core.widget.PopupWindowCompat;
/**
* A simplified version of andriod.widget.ListPopupWindow, which is the class used by
* AutocompleteTextView.
*
* Other than being simplified, this deals with Views rather than ListViews, so the content
* can be whatever. Lots of logic (clicks, selections etc.) has been removed because we manage that
* in {@link AutocompletePresenter}.
*
*/
class AutocompletePopup {
private Context mContext;
private ViewGroup mView;
private int mHeight = ViewGroup.LayoutParams.WRAP_CONTENT;
private int mWidth = ViewGroup.LayoutParams.WRAP_CONTENT;
private int mMaxHeight = Integer.MAX_VALUE;
private int mMaxWidth = Integer.MAX_VALUE;
private int mUserMaxHeight = Integer.MAX_VALUE;
private int mUserMaxWidth = Integer.MAX_VALUE;
private int mHorizontalOffset = 0;
private int mVerticalOffset = 0;
private boolean mVerticalOffsetSet;
private int mGravity = Gravity.NO_GRAVITY;
private boolean mAlwaysVisible = false;
private boolean mOutsideTouchable = true;
private View mAnchorView;
private final Rect mTempRect = new Rect();
private boolean mModal;
private PopupWindow mPopup;
/**
* Create a new, empty popup window capable of displaying items from a ListAdapter.
* Backgrounds should be set using {@link #setBackgroundDrawable(Drawable)}.
*
* @param context Context used for contained views.
*/
AutocompletePopup(@NonNull Context context) {
super();
mContext = context;
mPopup = new PopupWindow(context);
mPopup.setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
}
/**
* Set whether this window should be modal when shown.
*
* <p>If a popup window is modal, it will receive all touch and key input.
* If the user touches outside the popup window's content area the popup window
* will be dismissed.
* @param modal {@code true} if the popup window should be modal, {@code false} otherwise.
*/
@SuppressWarnings("SameParameterValue")
void setModal(boolean modal) {
mModal = modal;
mPopup.setFocusable(modal);
}
/**
* Returns whether the popup window will be modal when shown.
* @return {@code true} if the popup window will be modal, {@code false} otherwise.
*/
@SuppressWarnings("unused")
boolean isModal() {
return mModal;
}
void setElevation(float elevationPx) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) mPopup.setElevation(elevationPx);
}
/**
* Sets whether the drop-down should remain visible under certain conditions.
*
* The drop-down will occupy the entire screen below {@link #getAnchorView} regardless
* of the size or content of the list. {@link #getBackground()} will fill any space
* that is not used by the list.
* @param dropDownAlwaysVisible Whether to keep the drop-down visible.
*
*/
@SuppressWarnings("unused")
void setDropDownAlwaysVisible(boolean dropDownAlwaysVisible) {
mAlwaysVisible = dropDownAlwaysVisible;
}
/**
* @return Whether the drop-down is visible under special conditions.
*/
@SuppressWarnings("unused")
boolean isDropDownAlwaysVisible() {
return mAlwaysVisible;
}
void setOutsideTouchable(boolean outsideTouchable) {
mOutsideTouchable = outsideTouchable;
}
@SuppressWarnings("WeakerAccess")
boolean isOutsideTouchable() {
return mOutsideTouchable && !mAlwaysVisible;
}
/**
* Sets the operating mode for the soft input area.
* @param mode The desired mode, see
* {@link android.view.WindowManager.LayoutParams#softInputMode}
* for the full list
* @see android.view.WindowManager.LayoutParams#softInputMode
* @see #getSoftInputMode()
*/
void setSoftInputMode(int mode) {
mPopup.setSoftInputMode(mode);
}
/**
* Returns the current value in {@link #setSoftInputMode(int)}.
* @see #setSoftInputMode(int)
* @see android.view.WindowManager.LayoutParams#softInputMode
*/
@SuppressWarnings({"WeakerAccess", "unused"})
int getSoftInputMode() {
return mPopup.getSoftInputMode();
}
/**
* @return The background drawable for the popup window.
*/
@SuppressWarnings({"WeakerAccess", "unused"})
@Nullable
Drawable getBackground() {
return mPopup.getBackground();
}
/**
* Sets a drawable to be the background for the popup window.
* @param d A drawable to set as the background.
*/
void setBackgroundDrawable(@Nullable Drawable d) {
mPopup.setBackgroundDrawable(d);
}
/**
* Set an animation style to use when the popup window is shown or dismissed.
* @param animationStyle Animation style to use.
*/
@SuppressWarnings("unused")
void setAnimationStyle(@StyleRes int animationStyle) {
mPopup.setAnimationStyle(animationStyle);
}
/**
* Returns the animation style that will be used when the popup window is
* shown or dismissed.
* @return Animation style that will be used.
*/
@SuppressWarnings("unused")
@StyleRes
int getAnimationStyle() {
return mPopup.getAnimationStyle();
}
/**
* Returns the view that will be used to anchor this popup.
* @return The popup's anchor view
*/
@SuppressWarnings("WeakerAccess")
View getAnchorView() {
return mAnchorView;
}
/**
* Sets the popup's anchor view. This popup will always be positioned relative to
* the anchor view when shown.
* @param anchor The view to use as an anchor.
*/
void setAnchorView(@NonNull View anchor) {
mAnchorView = anchor;
}
/**
* Set the horizontal offset of this popup from its anchor view in pixels.
* @param offset The horizontal offset of the popup from its anchor.
*/
@SuppressWarnings("unused")
void setHorizontalOffset(int offset) {
mHorizontalOffset = offset;
}
/**
* Set the vertical offset of this popup from its anchor view in pixels.
* @param offset The vertical offset of the popup from its anchor.
*/
void setVerticalOffset(int offset) {
mVerticalOffset = offset;
mVerticalOffsetSet = true;
}
/**
* Set the gravity of the dropdown list. This is commonly used to
* set gravity to START or END for alignment with the anchor.
* @param gravity Gravity value to use
*/
void setGravity(int gravity) {
mGravity = gravity;
}
/**
* @return The width of the popup window in pixels.
*/
@SuppressWarnings("unused")
int getWidth() {
return mWidth;
}
/**
* Sets the width of the popup window in pixels. Can also be MATCH_PARENT
* or WRAP_CONTENT.
* @param width Width of the popup window.
*/
void setWidth(int width) {
mWidth = width;
}
/**
* Sets the width of the popup window by the size of its content. The final width may be
* larger to accommodate styled window dressing.
* @param width Desired width of content in pixels.
*/
@SuppressWarnings("unused")
void setContentWidth(int width) {
Drawable popupBackground = mPopup.getBackground();
if (popupBackground != null) {
popupBackground.getPadding(mTempRect);
width += mTempRect.left + mTempRect.right;
}
setWidth(width);
}
void setMaxWidth(int width) {
if (width > 0) {
mUserMaxWidth = width;
}
}
/**
* @return The height of the popup window in pixels.
*/
@SuppressWarnings("unused")
int getHeight() {
return mHeight;
}
/**
* Sets the height of the popup window in pixels. Can also be MATCH_PARENT.
* @param height Height of the popup window.
*/
void setHeight(int height) {
mHeight = height;
}
/**
* Sets the height of the popup window by the size of its content. The final height may be
* larger to accommodate styled window dressing.
* @param height Desired height of content in pixels.
*/
@SuppressWarnings("unused")
void setContentHeight(int height) {
Drawable popupBackground = mPopup.getBackground();
if (popupBackground != null) {
popupBackground.getPadding(mTempRect);
height += mTempRect.top + mTempRect.bottom;
}
setHeight(height);
}
void setMaxHeight(int height) {
if (height > 0) {
mUserMaxHeight = height;
}
}
void setOnDismissListener(PopupWindow.OnDismissListener listener) {
mPopup.setOnDismissListener(listener);
}
/**
* Show the popup list. If the list is already showing, this method
* will recalculate the popup's size and position.
*/
void show() {
if (!ViewCompat.isAttachedToWindow(getAnchorView())) return;
int height = buildDropDown();
final boolean noInputMethod = isInputMethodNotNeeded();
int mDropDownWindowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL;
PopupWindowCompat.setWindowLayoutType(mPopup, mDropDownWindowLayoutType);
if (mPopup.isShowing()) {
// First pass for this special case, don't know why.
if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
int tempWidth = mWidth == ViewGroup.LayoutParams.MATCH_PARENT ? ViewGroup.LayoutParams.MATCH_PARENT : 0;
if (noInputMethod) {
mPopup.setWidth(tempWidth);
mPopup.setHeight(0);
} else {
mPopup.setWidth(tempWidth);
mPopup.setHeight(ViewGroup.LayoutParams.MATCH_PARENT);
}
}
// The call to PopupWindow's update method below can accept -1
// for any value you do not want to update.
// Width.
int widthSpec;
if (mWidth == ViewGroup.LayoutParams.MATCH_PARENT) {
widthSpec = -1;
} else if (mWidth == ViewGroup.LayoutParams.WRAP_CONTENT) {
widthSpec = getAnchorView().getWidth();
} else {
widthSpec = mWidth;
}
widthSpec = Math.min(widthSpec, mMaxWidth);
widthSpec = (widthSpec < 0) ? - 1 : widthSpec;
// Height.
int heightSpec;
if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
heightSpec = noInputMethod ? height : ViewGroup.LayoutParams.MATCH_PARENT;
} else if (mHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
heightSpec = height;
} else {
heightSpec = mHeight;
}
heightSpec = Math.min(heightSpec, mMaxHeight);
heightSpec = (heightSpec < 0) ? - 1 : heightSpec;
// Update.
mPopup.setOutsideTouchable(isOutsideTouchable());
if (heightSpec == 0) {
dismiss();
} else {
mPopup.update(getAnchorView(), mHorizontalOffset, mVerticalOffset, widthSpec, heightSpec);
}
} else {
int widthSpec;
if (mWidth == ViewGroup.LayoutParams.MATCH_PARENT) {
widthSpec = ViewGroup.LayoutParams.MATCH_PARENT;
} else if (mWidth == ViewGroup.LayoutParams.WRAP_CONTENT) {
widthSpec = getAnchorView().getWidth();
} else {
widthSpec = mWidth;
}
widthSpec = Math.min(widthSpec, mMaxWidth);
int heightSpec;
if (mHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
heightSpec = ViewGroup.LayoutParams.MATCH_PARENT;
} else if (mHeight == ViewGroup.LayoutParams.WRAP_CONTENT) {
heightSpec = height;
} else {
heightSpec = mHeight;
}
heightSpec = Math.min(heightSpec, mMaxHeight);
// Set width and height.
mPopup.setWidth(widthSpec);
mPopup.setHeight(heightSpec);
mPopup.setClippingEnabled(true);
// use outside touchable to dismiss drop down when touching outside of it, so
// only set this if the dropdown is not always visible
mPopup.setOutsideTouchable(isOutsideTouchable());
PopupWindowCompat.showAsDropDown(mPopup, getAnchorView(), mHorizontalOffset, mVerticalOffset, mGravity);
}
}
/**
* Dismiss the popup window.
*/
void dismiss() {
mPopup.dismiss();
mPopup.setContentView(null);
mView = null;
}
/**
* Control how the popup operates with an input method: one of
* INPUT_METHOD_FROM_FOCUSABLE, INPUT_METHOD_NEEDED,
* or INPUT_METHOD_NOT_NEEDED.
*
* <p>If the popup is showing, calling this method will take effect only
* the next time the popup is shown or through a manual call to the {@link #show()}
* method.</p>
*
* @see #show()
*/
void setInputMethodMode(int mode) {
mPopup.setInputMethodMode(mode);
}
/**
* @return {@code true} if the popup is currently showing, {@code false} otherwise.
*/
boolean isShowing() {
return mPopup.isShowing();
}
/**
* @return {@code true} if this popup is configured to assume the user does not need
* to interact with the IME while it is showing, {@code false} otherwise.
*/
@SuppressWarnings("WeakerAccess")
boolean isInputMethodNotNeeded() {
return mPopup.getInputMethodMode() == PopupWindow.INPUT_METHOD_NOT_NEEDED;
}
void setView(ViewGroup view) {
mView = view;
mView.setFocusable(true);
mView.setFocusableInTouchMode(true);
ViewGroup dropDownView = mView;
mPopup.setContentView(dropDownView);
ViewGroup.LayoutParams params = mView.getLayoutParams();
if (params != null) {
if (params.height > 0) setHeight(params.height);
if (params.width > 0) setWidth(params.width);
}
}
/**
* <p>Builds the popup window's content and returns the height the popup
* should have. Returns -1 when the content already exists.</p>
*
* @return the content's wrap content height or -1 if content already exists
*/
private int buildDropDown() {
int otherHeights = 0;
// getMaxAvailableHeight() subtracts the padding, so we put it back
// to get the available height for the whole window.
final int paddingVert;
final int paddingHoriz;
final Drawable background = mPopup.getBackground();
if (background != null) {
background.getPadding(mTempRect);
paddingVert = mTempRect.top + mTempRect.bottom;
paddingHoriz = mTempRect.left + mTempRect.right;
// If we don't have an explicit vertical offset, determine one from
// the window background so that content will line up.
if (!mVerticalOffsetSet) {
mVerticalOffset = -mTempRect.top;
}
} else {
mTempRect.setEmpty();
paddingVert = 0;
paddingHoriz = 0;
}
// Redefine dimensions taking into account maxWidth and maxHeight.
final boolean ignoreBottomDecorations = mPopup.getInputMethodMode() == PopupWindow.INPUT_METHOD_NOT_NEEDED;
final int maxContentHeight = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ?
mPopup.getMaxAvailableHeight(getAnchorView(), mVerticalOffset, ignoreBottomDecorations) :
mPopup.getMaxAvailableHeight(getAnchorView(), mVerticalOffset);
final int maxContentWidth = mContext.getResources().getDisplayMetrics().widthPixels - paddingHoriz;
mMaxHeight = Math.min(maxContentHeight + paddingVert, mUserMaxHeight);
mMaxWidth = Math.min(maxContentWidth + paddingHoriz, mUserMaxWidth);
// if (mHeight > 0) mHeight = Math.min(mHeight, maxContentHeight);
// if (mWidth > 0) mWidth = Math.min(mWidth, maxContentWidth);
if (mAlwaysVisible || mHeight == ViewGroup.LayoutParams.MATCH_PARENT) {
return mMaxHeight;
}
final int childWidthSpec;
switch (mWidth) {
case ViewGroup.LayoutParams.WRAP_CONTENT:
childWidthSpec = View.MeasureSpec.makeMeasureSpec(maxContentWidth, View.MeasureSpec.AT_MOST); break;
case ViewGroup.LayoutParams.MATCH_PARENT:
childWidthSpec = View.MeasureSpec.makeMeasureSpec(maxContentWidth, View.MeasureSpec.EXACTLY); break;
default:
//noinspection Range
childWidthSpec = View.MeasureSpec.makeMeasureSpec(mWidth, View.MeasureSpec.EXACTLY); break;
}
// Add padding only if the list has items in it, that way we don't show
// the popup if it is not needed. For this reason, we measure as wrap_content.
mView.measure(childWidthSpec, View.MeasureSpec.makeMeasureSpec(maxContentHeight, View.MeasureSpec.AT_MOST));
final int viewHeight = mView.getMeasuredHeight();
if (viewHeight > 0) {
otherHeights += paddingVert + mView.getPaddingTop() + mView.getPaddingBottom();
}
return Math.min(viewHeight + otherHeights, mMaxHeight);
}
}

View File

@ -0,0 +1,129 @@
package com.otaliastudios.autocomplete;
import android.content.Context;
import android.database.DataSetObserver;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Base class for presenting items inside a popup. This is abstract and must be implemented.
*
* Most important methods are {@link #getView()} and {@link #onQuery(CharSequence)}.
*/
public abstract class AutocompletePresenter<T> {
private Context context;
private boolean isShowing;
@SuppressWarnings("WeakerAccess")
public AutocompletePresenter(@NonNull Context context) {
this.context = context;
}
/**
* At this point the presenter is passed the {@link ClickProvider}.
* The contract is that {@link ClickProvider#click(Object)} must be called when a list item
* is clicked. This ensure that the autocomplete callback will receive the event.
*
* @param provider a click provider for this presenter.
*/
protected void registerClickProvider(ClickProvider<T> provider) {
}
/**
* Useful if you wish to change width/height based on content height.
* The contract is to call {@link DataSetObserver#onChanged()} when your view has
* changes.
*
* This is called after {@link #getView()}.
*
* @param observer the observer.
*/
protected void registerDataSetObserver(@NonNull DataSetObserver observer) {}
/**
* Called each time the popup is shown. You are meant to inflate the view here.
* You can get a LayoutInflater using {@link #getContext()}.
*
* @return a ViewGroup for the popup
*/
@NonNull
protected abstract ViewGroup getView();
/**
* Provide the {@link PopupDimensions} for this popup. Called just once.
* You can use fixed dimensions or {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} and
* {@link android.view.ViewGroup.LayoutParams#MATCH_PARENT}.
*
* @return a PopupDimensions object
*/
// Called at first to understand which dimensions to use for the popup.
@NonNull
protected PopupDimensions getPopupDimensions() {
return new PopupDimensions();
}
/**
* Perform firther initialization here. Called after {@link #getView()},
* each time the popup is shown.
*/
protected abstract void onViewShown();
/**
* Called to update the view to filter results with the query.
* It is called any time the popup is shown, and any time the text changes and query is updated.
*
* @param query query from the edit text, to filter our results
*/
protected abstract void onQuery(@Nullable CharSequence query);
/**
* Called when the popup is hidden, to release resources.
*/
protected abstract void onViewHidden();
/**
* @return this presenter context
*/
@NonNull
protected final Context getContext() {
return context;
}
/**
* @return whether we are showing currently
*/
@SuppressWarnings("unused")
protected final boolean isShowing() {
return isShowing;
}
final void showView() {
isShowing = true;
onViewShown();
}
final void hideView() {
isShowing = false;
onViewHidden();
}
public interface ClickProvider<T> {
void click(@NonNull T item);
}
/**
* Provides width, height, maxWidth and maxHeight for the popup.
* @see #getPopupDimensions()
*/
@SuppressWarnings("WeakerAccess")
public static class PopupDimensions {
public int width = ViewGroup.LayoutParams.WRAP_CONTENT;
public int height = ViewGroup.LayoutParams.WRAP_CONTENT;
public int maxWidth = Integer.MAX_VALUE;
public int maxHeight = Integer.MAX_VALUE;
}
}

View File

@ -0,0 +1,184 @@
package com.otaliastudios.autocomplete;
import android.text.Spannable;
import android.text.Spanned;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* A special {@link AutocompletePolicy} for cases when you want to trigger the popup when a
* certain character is shown.
*
* For instance, this might be the case for hashtags ('#') or usernames ('@') or whatever you wish.
* Passing this to {@link Autocomplete.Builder} ensures the following behavior (assuming '@'):
* - text "@john" : presenter will be passed the query "john"
* - text "You should see this @j" : presenter will be passed the query "j"
* - text "You should see this @john @m" : presenter will be passed the query "m"
*/
public class CharPolicy implements AutocompletePolicy {
private final static String TAG = CharPolicy.class.getSimpleName();
private final static boolean DEBUG = false;
private static void log(@NonNull String log) {
if (DEBUG) Log.e(TAG, log);
}
private final char CH;
private final int[] INT = new int[2];
private boolean needSpaceBefore = true;
/**
* Constructs a char policy for the given character.
*
* @param trigger the triggering character.
*/
public CharPolicy(char trigger) {
CH = trigger;
}
/**
* Constructs a char policy for the given character.
* You can choose whether a whitespace is needed before 'trigger'.
*
* @param trigger the triggering character.
* @param needSpaceBefore whether we need a space before trigger
*/
@SuppressWarnings("unused")
public CharPolicy(char trigger, boolean needSpaceBefore) {
CH = trigger;
this.needSpaceBefore = needSpaceBefore;
}
/**
* Can be overriden to understand which characters are valid. The default implementation
* returns true for any character except whitespaces.
*
* @param ch the character
* @return whether it's valid part of a query
*/
@SuppressWarnings("WeakerAccess")
protected boolean isValidChar(char ch) {
return !Character.isWhitespace(ch);
}
@Nullable
private int[] checkText(@NonNull Spannable text, int cursorPos) {
final int spanEnd = cursorPos;
char last = 'x';
cursorPos -= 1; // If the cursor is at the end, we will have cursorPos = length. Go back by 1.
while (cursorPos >= 0 && last != CH) {
char ch = text.charAt(cursorPos);
log("checkText: char is "+ch);
if (isValidChar(ch)) {
// We are going back
log("checkText: char is valid");
cursorPos -= 1;
last = ch;
} else {
// We got a whitespace before getting a CH. This is invalid.
log("checkText: char is not valid, returning NULL");
return null;
}
}
cursorPos += 1; // + 1 because we end BEHIND the valid selection
// Start checking.
if (cursorPos == 0 && last != CH) {
// We got to the start of the string, and no CH was encountered. Nothing to do.
log("checkText: got to start but no CH, returning NULL");
return null;
}
// Additional checks for cursorPos - 1
if (cursorPos > 0 && needSpaceBefore) {
char ch = text.charAt(cursorPos-1);
if (!Character.isWhitespace(ch)) {
log("checkText: char before is not whitespace, returning NULL");
return null;
}
}
// All seems OK.
final int spanStart = cursorPos + 1; // + 1 because we want to exclude CH from the query
INT[0] = spanStart;
INT[1] = spanEnd;
log("checkText: found! cursorPos="+cursorPos);
log("checkText: found! spanStart="+spanStart);
log("checkText: found! spanEnd="+spanEnd);
return INT;
}
@Override
public boolean shouldShowPopup(@NonNull Spannable text, int cursorPos) {
// Returning true if, right before cursorPos, we have a word starting with @.
log("shouldShowPopup: text is "+text);
log("shouldShowPopup: cursorPos is "+cursorPos);
int[] show = checkText(text, cursorPos);
if (show != null) {
text.setSpan(new QuerySpan(), show[0], show[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE);
return true;
}
log("shouldShowPopup: returning false");
return false;
}
@Override
public boolean shouldDismissPopup(@NonNull Spannable text, int cursorPos) {
log("shouldDismissPopup: text is "+text);
log("shouldDismissPopup: cursorPos is "+cursorPos);
boolean dismiss = checkText(text, cursorPos) == null;
log("shouldDismissPopup: returning "+dismiss);
return dismiss;
}
@NonNull
@Override
public CharSequence getQuery(@NonNull Spannable text) {
QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class);
if (span == null || span.length == 0) {
// Should never happen.
log("getQuery: there's no span!");
return "";
}
log("getQuery: found spans: "+span.length);
QuerySpan sp = span[0];
log("getQuery: span start is "+text.getSpanStart(sp));
log("getQuery: span end is "+text.getSpanEnd(sp));
CharSequence seq = text.subSequence(text.getSpanStart(sp), text.getSpanEnd(sp));
log("getQuery: returning "+seq);
return seq;
}
@Override
public void onDismiss(@NonNull Spannable text) {
// Remove any span added by shouldShow. Should be useless, but anyway.
QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class);
for (QuerySpan s : span) {
text.removeSpan(s);
}
}
private static class QuerySpan {}
/**
* Returns the current query out of the given Spannable.
* @param text the anchor text
* @return an int[] with query start and query end positions
*/
@Nullable
public static int[] getQueryRange(@NonNull Spannable text) {
QuerySpan[] span = text.getSpans(0, text.length(), QuerySpan.class);
if (span == null || span.length == 0) return null;
if (span.length > 1) {
// Won't happen
log("getQueryRange: ERR: MORE THAN ONE QuerySpan.");
}
QuerySpan sp = span[0];
return new int[]{text.getSpanStart(sp), text.getSpanEnd(sp)};
}
}

View File

@ -0,0 +1,152 @@
package com.otaliastudios.autocomplete;
import android.content.Context;
import android.database.DataSetObserver;
import android.view.ViewGroup;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* Simple {@link AutocompletePresenter} implementation that hosts a {@link RecyclerView}.
* Supports {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} natively.
* The only contract is to
*
* - provide a {@link RecyclerView.Adapter} in {@link #instantiateAdapter()}
* - call {@link #dispatchClick(Object)} when an object is clicked
* - update your data during {@link #onQuery(CharSequence)}
*
* @param <T> your model object (the object displayed by the list)
*/
public abstract class RecyclerViewPresenter<T> extends AutocompletePresenter<T> {
private RecyclerView recycler;
private ClickProvider<T> clicks;
private Observer observer;
public RecyclerViewPresenter(@NonNull Context context) {
super(context);
}
@Override
protected final void registerClickProvider(@NonNull ClickProvider<T> provider) {
this.clicks = provider;
}
@Override
protected final void registerDataSetObserver(@NonNull DataSetObserver observer) {
this.observer = new Observer(observer);
}
@NonNull
@Override
protected ViewGroup getView() {
recycler = new RecyclerView(getContext());
RecyclerView.Adapter adapter = instantiateAdapter();
recycler.setAdapter(adapter);
recycler.setLayoutManager(instantiateLayoutManager());
if (observer != null) {
adapter.registerAdapterDataObserver(observer);
observer = null;
}
return recycler;
}
@Override
protected void onViewShown() {}
@CallSuper
@Override
protected void onViewHidden() {
recycler = null;
observer = null;
}
@SuppressWarnings("unused")
@Nullable
protected final RecyclerView getRecyclerView() {
return recycler;
}
/**
* Dispatch click event to {@link AutocompleteCallback}.
* Should be called when items are clicked.
*
* @param item the clicked item.
*/
protected final void dispatchClick(@NonNull T item) {
if (clicks != null) clicks.click(item);
}
/**
* Request that the popup should recompute its dimensions based on a recent change in
* the view being displayed.
*
* This is already managed internally for {@link RecyclerView} events.
* Only use it for changes in other views that you have added to the popup,
* and only if one of the dimensions for the popup is WRAP_CONTENT .
*/
@SuppressWarnings("unused")
protected final void dispatchLayoutChange() {
if (observer != null) observer.onChanged();
}
/**
* Provide an adapter for the recycler.
* This should be a fresh instance every time this is called.
*
* @return a new adapter.
*/
@NonNull
protected abstract RecyclerView.Adapter instantiateAdapter();
/**
* Provides a layout manager for the recycler.
* This should be a fresh instance every time this is called.
* Defaults to a vertical LinearLayoutManager, which is guaranteed to work well.
*
* @return a new layout manager.
*/
@SuppressWarnings("WeakerAccess")
@NonNull
protected RecyclerView.LayoutManager instantiateLayoutManager() {
return new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false);
}
private final static class Observer extends RecyclerView.AdapterDataObserver {
private DataSetObserver root;
Observer(@NonNull DataSetObserver root) {
this.root = root;
}
@Override
public void onChanged() {
root.onChanged();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
root.onChanged();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount, Object payload) {
root.onChanged();
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
root.onChanged();
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
root.onChanged();
}
}
}

View File

@ -12,6 +12,7 @@ include ':library:external:jsonviewer'
include ':library:external:diff-match-patch'
include ':library:external:dialpad'
include ':library:external:textdrawable'
include ':library:external:autocomplete'
include ':library:rustCrypto'
include ':matrix-sdk-android'

View File

@ -117,6 +117,7 @@ dependencies {
implementation project(":library:external:jsonviewer")
implementation project(":library:external:diff-match-patch")
implementation project(":library:external:textdrawable")
implementation project(":library:external:autocomplete")
implementation project(":library:ui-strings")
implementation project(":library:ui-styles")
implementation project(":library:core-utils")
@ -210,8 +211,6 @@ dependencies {
// Alerter
implementation 'com.github.tapadoo:alerter:7.2.4'
implementation 'com.otaliastudios:autocomplete:1.1.0'
// Shake detection
implementation 'com.squareup:seismic:1.0.3'

View File

@ -23,7 +23,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.otaliastudios.autocomplete.AutocompletePresenter
abstract class RecyclerViewPresenter<T>(context: Context?) : AutocompletePresenter<T>(context) {
abstract class RecyclerViewPresenter<T>(context: Context) : AutocompletePresenter<T>(context) {
private var recyclerView: RecyclerView? = null
private var clicks: ClickProvider<T>? = null

View File

@ -32,16 +32,15 @@ class CommandAutocompletePolicy @Inject constructor() : AutocompletePolicy {
return ""
}
override fun onDismiss(text: Spannable?) {
override fun onDismiss(text: Spannable) {
}
// Only if text which starts with '/' and without space
override fun shouldShowPopup(text: Spannable?, cursorPos: Int): Boolean {
return enabled && text?.startsWith("/") == true &&
!text.contains(" ")
override fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean {
return enabled && text.startsWith("/") && !text.contains(" ")
}
override fun shouldDismissPopup(text: Spannable?, cursorPos: Int): Boolean {
override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean {
return !shouldShowPopup(text, cursorPos)
}
}