From f880f3331fe3270597a66eeb6b6fac3312b8aa51 Mon Sep 17 00:00:00 2001
From: Taras Smakula <tarassmakula@gmail.com>
Date: Tue, 24 Oct 2023 18:23:34 +0300
Subject: [PATCH] Add autocomplete to core

---
 .../markdown/mentions/MentionsPresenter.kt    |   2 +-
 .../org/futo/circles/view/MarkdownEditText.kt |   6 +-
 .../core/feature/autocomplete/Autocomplete.kt | 415 +++++++++++++++
 .../autocomplete/AutocompleteCallback.kt      |  26 +
 .../autocomplete/AutocompletePolicy.kt        |  60 +++
 .../feature/autocomplete/AutocompletePopup.kt | 476 ++++++++++++++++++
 .../autocomplete/AutocompletePresenter.kt     |  97 ++++
 .../core/feature/autocomplete/CharPolicy.kt   | 171 +++++++
 .../autocomplete/RecyclerViewPresenter.kt     | 121 +++++
 9 files changed, 1370 insertions(+), 4 deletions(-)
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt
 create mode 100644 core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt

diff --git a/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt b/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt
index e269d0d51..1024ed548 100644
--- a/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt
+++ b/app/src/main/java/org/futo/circles/feature/timeline/post/markdown/mentions/MentionsPresenter.kt
@@ -2,7 +2,7 @@ package org.futo.circles.feature.timeline.post.markdown.mentions
 
 import android.content.Context
 import androidx.recyclerview.widget.RecyclerView
-import com.otaliastudios.autocomplete.RecyclerViewPresenter
+import org.futo.circles.core.feature.autocomplete.RecyclerViewPresenter
 import org.futo.circles.core.mapping.toUserListItem
 import org.futo.circles.core.model.UserListItem
 import org.futo.circles.core.provider.MatrixSessionProvider
diff --git a/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt b/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt
index 72760f9b4..04efcc95a 100644
--- a/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt
+++ b/app/src/main/java/org/futo/circles/view/MarkdownEditText.kt
@@ -14,9 +14,6 @@ import android.view.View
 import androidx.appcompat.widget.AppCompatEditText
 import androidx.core.content.ContextCompat
 import androidx.core.widget.doOnTextChanged
-import com.otaliastudios.autocomplete.Autocomplete
-import com.otaliastudios.autocomplete.AutocompleteCallback
-import com.otaliastudios.autocomplete.CharPolicy
 import io.noties.markwon.LinkResolverDef
 import io.noties.markwon.Markwon
 import io.noties.markwon.core.spans.BulletListItemSpan
@@ -26,6 +23,9 @@ import io.noties.markwon.core.spans.StrongEmphasisSpan
 import io.noties.markwon.ext.tasklist.TaskListDrawable
 import io.noties.markwon.ext.tasklist.TaskListSpan
 import org.futo.circles.R
+import org.futo.circles.core.feature.autocomplete.Autocomplete
+import org.futo.circles.core.feature.autocomplete.AutocompleteCallback
+import org.futo.circles.core.feature.autocomplete.CharPolicy
 import org.futo.circles.core.model.UserListItem
 import org.futo.circles.extensions.getGivenSpansAt
 import org.futo.circles.feature.timeline.post.markdown.EnhancedMovementMethod
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt
new file mode 100644
index 000000000..e9724b73e
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/Autocomplete.kt
@@ -0,0 +1,415 @@
+package org.futo.circles.core.feature.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.widget.EditText
+import android.widget.PopupWindow
+import org.futo.circles.core.feature.autocomplete.Autocomplete.SimplePolicy
+
+/**
+ * Entry point for adding Autocomplete behavior to a [EditText].
+ *
+ * You can construct a `Autocomplete` using the builder provided by [Autocomplete.on].
+ * Building is enough, but you can hold a reference to this class to call its public methods.
+ *
+ * Requires:
+ * - [EditText]: this is both the anchor for the popup, and the source of text events that we listen to
+ * - [AutocompletePresenter]: this presents items in the popup window. See class for more info.
+ * - [AutocompleteCallback]: if specified, this listens to click events and visibility changes
+ * - [AutocompletePolicy]: if specified, this controls how and when to show the popup based on text events
+ * If not, this defaults to [SimplePolicy]: shows the popup when text.length() bigger than 0.
+ */
+class Autocomplete<T> private constructor(builder: Builder<T>) : TextWatcher,
+    SpanWatcher {
+    /**
+     * Builder for building [Autocomplete].
+     * The only mandatory item is a presenter, [.with].
+     *
+     * @param <T> the data model
+    </T> */
+    class Builder<T>(source: EditText) {
+        internal var source: EditText?
+        internal var presenter: AutocompletePresenter<T>? = null
+        internal var policy: AutocompletePolicy? = null
+        internal var callback: AutocompleteCallback<T>? = null
+        internal var backgroundDrawable: Drawable? = null
+        internal var elevationDp = 6f
+
+        init {
+            this.source = source
+        }
+
+        /**
+         * Registers the [AutocompletePresenter] to be used, responsible for showing
+         * items. See the class for info.
+         *
+         * @param presenter desired presenter
+         * @return this for chaining
+         */
+        fun with(presenter: AutocompletePresenter<T>): Builder<T> {
+            this.presenter = presenter
+            return this
+        }
+
+        /**
+         * Registers the [AutocompleteCallback] to be used, responsible for listening to
+         * clicks provided by the presenter, and visibility changes.
+         *
+         * @param callback desired callback
+         * @return this for chaining
+         */
+        fun with(callback: AutocompleteCallback<T>): Builder<T> {
+            this.callback = callback
+            return this
+        }
+
+        /**
+         * Registers the [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
+         */
+        fun with(policy: AutocompletePolicy?): Builder<T> {
+            this.policy = policy
+            return this
+        }
+
+        /**
+         * Sets a background drawable for the popup.
+         *
+         * @param backgroundDrawable drawable
+         * @return this for chaining
+         */
+        fun with(backgroundDrawable: Drawable?): Builder<T> {
+            this.backgroundDrawable = backgroundDrawable
+            return this
+        }
+
+        /**
+         * Sets elevation for the popup. Defaults to 6 dp.
+         *
+         * @param elevationDp popup elevation, in DP
+         * @return this for chaning.
+         */
+        fun with(elevationDp: Float): Builder<T> {
+            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
+         */
+        fun build(): Autocomplete<T> {
+            if (source == null) throw RuntimeException("Autocomplete needs a source!")
+            if (presenter == null) throw RuntimeException("Autocomplete needs a presenter!")
+            if (policy == null) policy = SimplePolicy()
+            return Autocomplete(this)
+        }
+
+        fun clear() {
+            source = null
+            presenter = null
+            callback = null
+            policy = null
+            backgroundDrawable = null
+            elevationDp = 6f
+        }
+    }
+
+    private val policy: AutocompletePolicy
+    private val popup: AutocompletePopup
+    private val presenter: AutocompletePresenter<T>
+    private val callback: AutocompleteCallback<T>?
+    private val source: EditText
+    private var block = false
+    private var disabled = false
+    private var openBefore = false
+    private var lastQuery = "null"
+
+    init {
+        policy = builder.policy!!
+        presenter = builder.presenter!!
+        callback = builder.callback
+        source = builder.source!!
+
+        // Set up popup
+        popup = AutocompletePopup(source.context)
+        popup.setAnchorView(source)
+        popup.setGravity(Gravity.START)
+        popup.isModal = false
+        popup.setBackgroundDrawable(builder.backgroundDrawable)
+        popup.setElevation(
+            TypedValue.applyDimension(
+                TypedValue.COMPLEX_UNIT_DIP, builder.elevationDp,
+                source.context.resources.displayMetrics
+            )
+        )
+
+        // popup dimensions
+        val dim: AutocompletePresenter.PopupDimensions = presenter.popupDimensions
+        popup.width = dim.width
+        popup.height = dim.height
+        popup.setMaxWidth(dim.maxWidth)
+        popup.setMaxHeight(dim.maxHeight)
+
+        // Fire visibility events
+        popup.setOnDismissListener(PopupWindow.OnDismissListener {
+            lastQuery = "null"
+            callback?.onPopupVisibilityChanged(false)
+            val saved = block
+            block = true
+            policy.onDismiss(source.text)
+            block = saved
+            presenter.hideView()
+        })
+
+        // Set up source
+        source.text.setSpan(this, 0, source.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE)
+        source.addTextChangedListener(this)
+
+        // Set up presenter
+        presenter.registerClickProvider(object : AutocompletePresenter.ClickProvider<T> {
+            override fun click(item: T) {
+                val callback: AutocompleteCallback<T>? = callback
+                val edit = source
+                if (callback == null) return
+                val saved = block
+                block = true
+                val dismiss: Boolean = callback.onPopupItemClicked(edit.text, 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 [PopupWindow] input method mode
+     */
+    fun setInputMethodMode(mode: Int) {
+        popup.setInputMethodMode(mode)
+    }
+
+    /**
+     * Sets the operating mode for the soft input area.
+     *
+     * @param mode The desired mode, see [WindowManager.LayoutParams.softInputMode]
+     */
+    fun setSoftInputMode(mode: Int) {
+        popup.softInputMode = 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 [AutocompletePolicy].
+     *
+     * @param query query text.
+     */
+    fun showPopup(query: CharSequence) {
+        if (isPopupShowing && lastQuery == query.toString()) return
+        lastQuery = query.toString()
+        log("showPopup: called with filter $query")
+        if (!isPopupShowing) {
+            log("showPopup: showing")
+            presenter.registerDataSetObserver(Observer()) // Calling new to avoid leaking... maybe...
+            popup.setView(presenter.view)
+            presenter.showView()
+            popup.show()
+            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 [AutocompletePolicy].
+     */
+    fun dismissPopup() {
+        if (isPopupShowing) {
+            popup.dismiss()
+        }
+    }
+
+    val isPopupShowing: Boolean
+        /**
+         * Returns true if the popup is showing.
+         * @return whether the popup is currently showing
+         */
+        get() = 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
+     */
+    fun setEnabled(enabled: Boolean) {
+        disabled = !enabled
+    }
+
+    /**
+     * Sets the gravity for the popup. Basically only [Gravity.START] and [Gravity.END]
+     * do work.
+     *
+     * @param gravity gravity for the popup
+     */
+    fun setGravity(gravity: Int) {
+        popup.setGravity(gravity)
+    }
+
+    /**
+     * Controls the vertical offset of the popup from the EditText anchor.
+     *
+     * @param offset offset in pixels.
+     */
+    fun setOffsetFromAnchor(offset: Int) {
+        popup.setVerticalOffset(offset)
+    }
+
+    /**
+     * Controls whether the popup should listen to clicks outside its boundaries.
+     *
+     * @param outsideTouchable true to listen to outside clicks
+     */
+    fun setOutsideTouchable(outsideTouchable: Boolean) {
+        popup.isOutsideTouchable = outsideTouchable
+    }
+
+    override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
+        if (block || disabled) return
+        openBefore = isPopupShowing
+    }
+
+    override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+        if (block || disabled) return
+        if (openBefore && !isPopupShowing) {
+            return  // Copied from somewhere.
+        }
+        if (s !is Spannable) {
+            source.setText(SpannableString(s))
+            return
+        }
+        val sp = s
+        val cursor = source.selectionEnd
+        log("onTextChanged: cursor end position is $cursor")
+        if (cursor == -1) { // No cursor present.
+            dismissPopup()
+            return
+        }
+        val 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 fun afterTextChanged(s: Editable) {}
+    override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) {}
+    override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) {}
+    override fun onSpanChanged(
+        text: Spannable,
+        what: Any,
+        ostart: Int,
+        oend: Int,
+        nstart: Int,
+        nend: Int
+    ) {
+        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")
+            val b = block
+            block = true
+            if (!isPopupShowing && policy.shouldShowPopup(text, nstart)) {
+                showPopup(policy.getQuery(text))
+            }
+            block = b
+        }
+    }
+
+    private inner class Observer : DataSetObserver(), Runnable {
+        private val ui = Handler(Looper.getMainLooper())
+        override fun onChanged() {
+            // ??? Not sure this is needed...
+            ui.post(this)
+        }
+
+        override fun run() {
+            if (isPopupShowing) {
+                // Call show again to revisit width and height.
+                popup.show()
+            }
+        }
+    }
+
+    /**
+     * A very simple [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.
+     */
+    class SimplePolicy : AutocompletePolicy {
+        override fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean {
+            return text.length > 0
+        }
+
+        override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean {
+            return text.length == 0
+        }
+
+        override fun getQuery(text: Spannable): CharSequence {
+            return text
+        }
+
+        override fun onDismiss(text: Spannable) {}
+    }
+
+    companion object {
+        private val TAG = Autocomplete::class.java.simpleName
+        private const val DEBUG = false
+        private fun log(log: String) {
+            if (DEBUG) Log.e(TAG, log)
+        }
+
+        /**
+         * Entry point for building autocomplete on a certain [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
+        </T> */
+        fun <T> on(anchor: EditText): Builder<T> {
+            return Builder<T>(anchor)
+        }
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt
new file mode 100644
index 000000000..85077087f
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompleteCallback.kt
@@ -0,0 +1,26 @@
+package org.futo.circles.core.feature.autocomplete
+
+import android.text.Editable
+
+/**
+ * Optional callback to be passed to [Autocomplete.Builder].
+ */
+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. `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
+     */
+    fun onPopupItemClicked(editable: Editable, item: T): Boolean
+
+    /**
+     * Called when popup visibility state changes.
+     *
+     * @param shown true if the popup was just shown, false if it was just hidden
+     */
+    fun onPopupVisibilityChanged(shown: Boolean)
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt
new file mode 100644
index 000000000..c21271109
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePolicy.kt
@@ -0,0 +1,60 @@
+package org.futo.circles.core.feature.autocomplete
+
+import android.text.Spannable
+
+/**
+ * This interface controls when to show or hide the popup window, and, in the first case,
+ * what text should be passed to the popup [AutocompletePresenter].
+ *
+ * @see Autocomplete.SimplePolicy for the simplest possible implementation
+ */
+interface AutocompletePolicy {
+    /**
+     * Called to understand whether the popup should be shown. Some naive examples:
+     * - Show when there's text: `return text.length() > 0`
+     * - Show when last char is @: `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
+     */
+    fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean
+
+    /**
+     * Called to understand whether a currently shown popup should be closed, maybe
+     * because text is invalid. A reasonable implementation is
+     * `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
+     */
+    fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean
+
+    /**
+     * Called to understand which query should be passed to [AutocompletePresenter]
+     * for a showing popup. If this is called, [.shouldShowPopup] just returned
+     * true, or [.shouldDismissPopup] 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 [.shouldShowPopup],
+     * and get the span position here.
+     *
+     * @param text current text, along with its Spans
+     * @return the query for presenter
+     */
+    fun getQuery(text: Spannable): CharSequence
+
+    /**
+     * 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
+     */
+    fun onDismiss(text: Spannable)
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt
new file mode 100644
index 000000000..cd239fb63
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePopup.kt
@@ -0,0 +1,476 @@
+package org.futo.circles.core.feature.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.StyleRes
+import androidx.core.view.ViewCompat
+import androidx.core.widget.PopupWindowCompat
+import kotlin.math.min
+
+/**
+ * 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 [AutocompletePresenter].
+ *
+ */
+internal class AutocompletePopup(private val mContext: Context) {
+    private var mView: ViewGroup? = null
+    /**
+     * @return The height of the popup window in pixels.
+     */
+    /**
+     * Sets the height of the popup window in pixels. Can also be MATCH_PARENT.
+     * @param height Height of the popup window.
+     */
+    @get:Suppress("unused")
+    var height = ViewGroup.LayoutParams.WRAP_CONTENT
+    /**
+     * @return The width of the popup window in pixels.
+     */
+    /**
+     * Sets the width of the popup window in pixels. Can also be MATCH_PARENT
+     * or WRAP_CONTENT.
+     * @param width Width of the popup window.
+     */
+    @get:Suppress("unused")
+    var width = ViewGroup.LayoutParams.WRAP_CONTENT
+    private var mMaxHeight = Int.MAX_VALUE
+    private var mMaxWidth = Int.MAX_VALUE
+    private var mUserMaxHeight = Int.MAX_VALUE
+    private var mUserMaxWidth = Int.MAX_VALUE
+    private var mHorizontalOffset = 0
+    private var mVerticalOffset = 0
+    private var mVerticalOffsetSet = false
+    private var mGravity = Gravity.NO_GRAVITY
+    /**
+     * @return Whether the drop-down is visible under special conditions.
+     */
+    /**
+     * Sets whether the drop-down should remain visible under certain conditions.
+     *
+     * The drop-down will occupy the entire screen below [.getAnchorView] regardless
+     * of the size or content of the list.  [.getBackground] will fill any space
+     * that is not used by the list.
+     * @param dropDownAlwaysVisible Whether to keep the drop-down visible.
+     */
+    @get:Suppress("unused")
+    @set:Suppress("unused")
+    var isDropDownAlwaysVisible = false
+    private var mOutsideTouchable = true
+
+    /**
+     * Returns the view that will be used to anchor this popup.
+     * @return The popup's anchor view
+     */
+    var anchorView: View? = null
+        private set
+    private val mTempRect = Rect()
+    private var mModal = false
+    private val mPopup: PopupWindow = PopupWindow(mContext)
+
+    /**
+     * Create a new, empty popup window capable of displaying items from a ListAdapter.
+     * Backgrounds should be set using [.setBackgroundDrawable].
+     *
+     * @param context Context used for contained views.
+     */
+    init {
+        mPopup.inputMethodMode = PopupWindow.INPUT_METHOD_NEEDED
+    }
+
+    @get:Suppress("unused")
+    var isModal: Boolean
+        /**
+         * Returns whether the popup window will be modal when shown.
+         * @return `true` if the popup window will be modal, `false` otherwise.
+         */
+        get() = mModal
+        /**
+         * Set whether this window should be modal when shown.
+         *
+         *
+         * 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 `true` if the popup window should be modal, `false` otherwise.
+         */
+        set(modal) {
+            mModal = modal
+            mPopup.isFocusable = modal
+        }
+
+    fun setElevation(elevationPx: Float) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) mPopup.elevation = elevationPx
+    }
+
+    var isOutsideTouchable: Boolean
+        get() = mOutsideTouchable && !isDropDownAlwaysVisible
+        set(outsideTouchable) {
+            mOutsideTouchable = outsideTouchable
+        }
+
+    @get:Suppress("unused")
+    var softInputMode: Int
+        /**
+         * Returns the current value in [.setSoftInputMode].
+         * @see .setSoftInputMode
+         * @see android.view.WindowManager.LayoutParams.softInputMode
+         */
+        get() = mPopup.softInputMode
+        /**
+         * Sets the operating mode for the soft input area.
+         * @param mode The desired mode, see
+         * [android.view.WindowManager.LayoutParams.softInputMode]
+         * for the full list
+         * @see android.view.WindowManager.LayoutParams.softInputMode
+         *
+         * @see .getSoftInputMode
+         */
+        set(mode) {
+            mPopup.softInputMode = mode
+        }
+
+    @get:Suppress("unused")
+    val background: Drawable?
+        /**
+         * @return The background drawable for the popup window.
+         */
+        get() = mPopup.background
+
+    /**
+     * Sets a drawable to be the background for the popup window.
+     * @param d A drawable to set as the background.
+     */
+    fun setBackgroundDrawable(d: Drawable?) {
+        mPopup.setBackgroundDrawable(d)
+    }
+
+    @get:StyleRes
+    @get:Suppress("unused")
+    @set:Suppress("unused")
+    var animationStyle: Int
+        /**
+         * Returns the animation style that will be used when the popup window is
+         * shown or dismissed.
+         * @return Animation style that will be used.
+         */
+        get() = mPopup.animationStyle
+        /**
+         * Set an animation style to use when the popup window is shown or dismissed.
+         * @param animationStyle Animation style to use.
+         */
+        set(animationStyle) {
+            mPopup.animationStyle = animationStyle
+        }
+
+    /**
+     * 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.
+     */
+    fun setAnchorView(anchor: View) {
+        anchorView = 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.
+     */
+    @Suppress("unused")
+    fun setHorizontalOffset(offset: Int) {
+        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.
+     */
+    fun setVerticalOffset(offset: Int) {
+        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
+     */
+    fun setGravity(gravity: Int) {
+        mGravity = gravity
+    }
+
+    /**
+     * 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.
+     */
+    @Suppress("unused")
+    fun setContentWidth(contentWidth: Int) {
+        var width = contentWidth
+        val popupBackground = mPopup.background
+        if (popupBackground != null) {
+            popupBackground.getPadding(mTempRect)
+            width += mTempRect.left + mTempRect.right
+        }
+        this.width = width
+    }
+
+    fun setMaxWidth(width: Int) {
+        if (width > 0) {
+            mUserMaxWidth = width
+        }
+    }
+
+    /**
+     * 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.
+     */
+    @Suppress("unused")
+    fun setContentHeight(contentHeight: Int) {
+        var height = contentHeight
+        val popupBackground = mPopup.background
+        if (popupBackground != null) {
+            popupBackground.getPadding(mTempRect)
+            height += mTempRect.top + mTempRect.bottom
+        }
+        this.height = height
+    }
+
+    fun setMaxHeight(height: Int) {
+        if (height > 0) {
+            mUserMaxHeight = height
+        }
+    }
+
+    fun setOnDismissListener(listener: PopupWindow.OnDismissListener?) {
+        mPopup.setOnDismissListener(listener)
+    }
+
+    /**
+     * Show the popup list. If the list is already showing, this method
+     * will recalculate the popup's size and position.
+     */
+    fun show() {
+        if (!ViewCompat.isAttachedToWindow(anchorView!!)) return
+        val height = buildDropDown()
+        val noInputMethod = isInputMethodNotNeeded
+        val 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 (this.height == ViewGroup.LayoutParams.MATCH_PARENT) {
+                val tempWidth =
+                    if (width == ViewGroup.LayoutParams.MATCH_PARENT) ViewGroup.LayoutParams.MATCH_PARENT else 0
+                if (noInputMethod) {
+                    mPopup.width = tempWidth
+                    mPopup.height = 0
+                } else {
+                    mPopup.width = tempWidth
+                    mPopup.height = 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.
+            var widthSpec: Int
+            widthSpec = if (width == ViewGroup.LayoutParams.MATCH_PARENT) {
+                -1
+            } else if (width == ViewGroup.LayoutParams.WRAP_CONTENT) {
+                anchorView!!.width
+            } else {
+                width
+            }
+            widthSpec = Math.min(widthSpec, mMaxWidth)
+            widthSpec = if (widthSpec < 0) -1 else widthSpec
+
+            // Height.
+            var heightSpec: Int
+            heightSpec = if (this.height == ViewGroup.LayoutParams.MATCH_PARENT) {
+                if (noInputMethod) height else ViewGroup.LayoutParams.MATCH_PARENT
+            } else if (this.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
+                height
+            } else {
+                this.height
+            }
+            heightSpec = Math.min(heightSpec, mMaxHeight)
+            heightSpec = if (heightSpec < 0) -1 else heightSpec
+
+            // Update.
+            mPopup.isOutsideTouchable = isOutsideTouchable
+            if (heightSpec == 0) {
+                dismiss()
+            } else {
+                mPopup.update(anchorView, mHorizontalOffset, mVerticalOffset, widthSpec, heightSpec)
+            }
+        } else {
+            var widthSpec: Int
+            widthSpec = if (width == ViewGroup.LayoutParams.MATCH_PARENT) {
+                ViewGroup.LayoutParams.MATCH_PARENT
+            } else if (width == ViewGroup.LayoutParams.WRAP_CONTENT) {
+                anchorView!!.width
+            } else {
+                width
+            }
+            widthSpec = Math.min(widthSpec, mMaxWidth)
+            var heightSpec: Int
+            heightSpec = if (this.height == ViewGroup.LayoutParams.MATCH_PARENT) {
+                ViewGroup.LayoutParams.MATCH_PARENT
+            } else if (this.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
+                height
+            } else {
+                this.height
+            }
+            heightSpec = Math.min(heightSpec, mMaxHeight)
+
+            // Set width and height.
+            mPopup.width = widthSpec
+            mPopup.height = heightSpec
+            mPopup.isClippingEnabled = 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.isOutsideTouchable = isOutsideTouchable
+            PopupWindowCompat.showAsDropDown(
+                mPopup,
+                anchorView!!, mHorizontalOffset, mVerticalOffset, mGravity
+            )
+        }
+    }
+
+    /**
+     * Dismiss the popup window.
+     */
+    fun dismiss() {
+        mPopup.dismiss()
+        mPopup.contentView = 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.
+     *
+     *
+     * 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 [.show]
+     * method.
+     *
+     * @see .show
+     */
+    fun setInputMethodMode(mode: Int) {
+        mPopup.inputMethodMode = mode
+    }
+
+    val isShowing: Boolean
+        /**
+         * @return `true` if the popup is currently showing, `false` otherwise.
+         */
+        get() = mPopup.isShowing
+    val isInputMethodNotNeeded: Boolean
+        /**
+         * @return `true` if this popup is configured to assume the user does not need
+         * to interact with the IME while it is showing, `false` otherwise.
+         */
+        get() = mPopup.inputMethodMode == PopupWindow.INPUT_METHOD_NOT_NEEDED
+
+    fun setView(view: ViewGroup?) {
+        mView = view
+        mView!!.isFocusable = true
+        mView!!.isFocusableInTouchMode = true
+        val dropDownView = mView
+        mPopup.contentView = dropDownView
+        val params = mView!!.layoutParams
+        if (params != null) {
+            if (params.height > 0) height = params.height
+            if (params.width > 0) width = params.width
+        }
+    }
+
+    /**
+     *
+     * Builds the popup window's content and returns the height the popup
+     * should have. Returns -1 when the content already exists.
+     *
+     * @return the content's wrap content height or -1 if content already exists
+     */
+    private fun buildDropDown(): Int {
+        var otherHeights = 0
+
+        // getMaxAvailableHeight() subtracts the padding, so we put it back
+        // to get the available height for the whole window.
+        val paddingVert: Int
+        val paddingHoriz: Int
+        val background = mPopup.background
+        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.
+        val ignoreBottomDecorations = mPopup.inputMethodMode == PopupWindow.INPUT_METHOD_NOT_NEEDED
+        val maxContentHeight =
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) mPopup.getMaxAvailableHeight(
+                anchorView!!, mVerticalOffset, ignoreBottomDecorations
+            ) else mPopup.getMaxAvailableHeight(
+                anchorView!!, mVerticalOffset
+            )
+        val maxContentWidth = mContext.resources.displayMetrics.widthPixels - paddingHoriz
+        mMaxHeight = min(maxContentHeight + paddingVert, mUserMaxHeight)
+        mMaxWidth = min(maxContentWidth + paddingHoriz, mUserMaxWidth)
+        // if (mHeight > 0) mHeight = Math.min(mHeight, maxContentHeight);
+        // if (mWidth > 0) mWidth = Math.min(mWidth, maxContentWidth);
+        if (isDropDownAlwaysVisible || height == ViewGroup.LayoutParams.MATCH_PARENT) {
+            return mMaxHeight
+        }
+        val childWidthSpec: Int = when (width) {
+            ViewGroup.LayoutParams.WRAP_CONTENT -> View.MeasureSpec.makeMeasureSpec(
+                maxContentWidth,
+                View.MeasureSpec.AT_MOST
+            )
+
+            ViewGroup.LayoutParams.MATCH_PARENT -> View.MeasureSpec.makeMeasureSpec(
+                maxContentWidth,
+                View.MeasureSpec.EXACTLY
+            )
+
+            else -> View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY)
+        }
+
+        // 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)
+        )
+        val viewHeight = mView!!.measuredHeight
+        if (viewHeight > 0) {
+            otherHeights += paddingVert + mView!!.paddingTop + mView!!.paddingBottom
+        }
+        return Math.min(viewHeight + otherHeights, mMaxHeight)
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt
new file mode 100644
index 000000000..c9108733c
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/AutocompletePresenter.kt
@@ -0,0 +1,97 @@
+package org.futo.circles.core.feature.autocomplete
+
+import android.content.Context
+import android.database.DataSetObserver
+import android.view.ViewGroup
+
+/**
+ * Base class for presenting items inside a popup. This is abstract and must be implemented.
+ *
+ * Most important methods are [.getView] and [.onQuery].
+ */
+abstract class AutocompletePresenter<T>(
+    /**
+     * @return this presenter context
+     */
+    protected val context: Context
+) {
+
+    /**
+     * @return whether we are showing currently
+     */
+    @get:Suppress("unused")
+    private var isShowing = false
+
+    /**
+     * At this point the presenter is passed the [ClickProvider].
+     * The contract is that [ClickProvider.click] 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.
+     */
+    open fun registerClickProvider(provider: ClickProvider<T>?) {}
+
+    /**
+     * Useful if you wish to change width/height based on content height.
+     * The contract is to call [DataSetObserver.onChanged] when your view has
+     * changes.
+     *
+     * This is called after [.getView].
+     *
+     * @param observer the observer.
+     */
+    open fun registerDataSetObserver(observer: DataSetObserver) {}
+    abstract val view: ViewGroup
+    val popupDimensions: PopupDimensions
+        /**
+         * Provide the [PopupDimensions] for this popup. Called just once.
+         * You can use fixed dimensions or [android.view.ViewGroup.LayoutParams.WRAP_CONTENT] and
+         * [android.view.ViewGroup.LayoutParams.MATCH_PARENT].
+         *
+         * @return a PopupDimensions object
+         */
+        get() = PopupDimensions()
+
+    /**
+     * Perform firther initialization here. Called after [.getView],
+     * each time the popup is shown.
+     */
+    protected abstract fun 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
+     */
+    abstract fun onQuery(query: CharSequence?)
+
+    /**
+     * Called when the popup is hidden, to release resources.
+     */
+    protected abstract fun onViewHidden()
+    fun showView() {
+        isShowing = true
+        onViewShown()
+    }
+
+    fun hideView() {
+        isShowing = false
+        onViewHidden()
+    }
+
+    interface ClickProvider<T> {
+        fun click(item: T)
+    }
+
+    /**
+     * Provides width, height, maxWidth and maxHeight for the popup.
+     * @see .getPopupDimensions
+     */
+    class PopupDimensions {
+        var width = ViewGroup.LayoutParams.WRAP_CONTENT
+        var height = ViewGroup.LayoutParams.WRAP_CONTENT
+        var maxWidth = Int.MAX_VALUE
+        var maxHeight = Int.MAX_VALUE
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt
new file mode 100644
index 000000000..dfa91f349
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/CharPolicy.kt
@@ -0,0 +1,171 @@
+package org.futo.circles.core.feature.autocomplete
+
+import android.text.Spannable
+import android.text.Spanned
+import android.util.Log
+
+/**
+ * A special [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 [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"
+ */
+class CharPolicy : AutocompletePolicy {
+    private val CH: Char
+    private val INT = IntArray(2)
+    private var needSpaceBefore = true
+
+    /**
+     * Constructs a char policy for the given character.
+     *
+     * @param trigger the triggering character.
+     */
+    constructor(trigger: Char) {
+        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
+     */
+    @Suppress("unused")
+    constructor(trigger: Char, needSpaceBefore: Boolean) {
+        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
+     */
+    protected fun isValidChar(ch: Char): Boolean {
+        return !Character.isWhitespace(ch)
+    }
+
+    private fun checkText(text: Spannable, cursorPosParam: Int): IntArray? {
+        var cursorPos = cursorPosParam
+        val spanEnd = cursorPos
+        var 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) {
+            val ch = text[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) {
+            val ch = text[cursorPos - 1]
+            if (!Character.isWhitespace(ch)) {
+                log("checkText: char before is not whitespace, returning NULL")
+                return null
+            }
+        }
+
+        // All seems OK.
+        val 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 fun shouldShowPopup(text: Spannable, cursorPos: Int): Boolean {
+        // Returning true if, right before cursorPos, we have a word starting with @.
+        log("shouldShowPopup: text is $text")
+        log("shouldShowPopup: cursorPos is $cursorPos")
+        val show = checkText(text, cursorPos)
+        if (show != null) {
+            text.setSpan(QuerySpan(), show[0], show[1], Spanned.SPAN_INCLUSIVE_INCLUSIVE)
+            return true
+        }
+        log("shouldShowPopup: returning false")
+        return false
+    }
+
+    override fun shouldDismissPopup(text: Spannable, cursorPos: Int): Boolean {
+        log("shouldDismissPopup: text is $text")
+        log("shouldDismissPopup: cursorPos is $cursorPos")
+        val dismiss = checkText(text, cursorPos) == null
+        log("shouldDismissPopup: returning $dismiss")
+        return dismiss
+    }
+
+    override fun getQuery(text: Spannable): CharSequence {
+        val span = text.getSpans(0, text.length, QuerySpan::class.java)
+        if (span == null || span.size == 0) {
+            // Should never happen.
+            log("getQuery: there's no span!")
+            return ""
+        }
+        log("getQuery: found spans: " + span.size)
+        val sp = span[0]
+        log("getQuery: span start is " + text.getSpanStart(sp))
+        log("getQuery: span end is " + text.getSpanEnd(sp))
+        val seq = text.subSequence(text.getSpanStart(sp), text.getSpanEnd(sp))
+        log("getQuery: returning $seq")
+        return seq
+    }
+
+    override fun onDismiss(text: Spannable) {
+        // Remove any span added by shouldShow. Should be useless, but anyway.
+        val span = text.getSpans(0, text.length, QuerySpan::class.java)
+        for (s in span) {
+            text.removeSpan(s)
+        }
+    }
+
+    private class QuerySpan
+    companion object {
+        private val TAG = CharPolicy::class.java.simpleName
+        private const val DEBUG = false
+        private fun log(log: String) {
+            if (DEBUG) Log.e(TAG, log)
+        }
+
+        /**
+         * Returns the current query out of the given Spannable.
+         * @param text the anchor text
+         * @return an int[] with query start and query end positions
+         */
+        fun getQueryRange(text: Spannable): IntArray? {
+            val span = text.getSpans(0, text.length, QuerySpan::class.java)
+            if (span == null || span.size == 0) return null
+            if (span.size > 1) {
+                // Won't happen
+                log("getQueryRange:  ERR: MORE THAN ONE QuerySpan.")
+            }
+            val sp = span[0]
+            return intArrayOf(text.getSpanStart(sp), text.getSpanEnd(sp))
+        }
+    }
+}
\ No newline at end of file
diff --git a/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt b/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt
new file mode 100644
index 000000000..a805a8921
--- /dev/null
+++ b/core/src/main/java/org/futo/circles/core/feature/autocomplete/RecyclerViewPresenter.kt
@@ -0,0 +1,121 @@
+package org.futo.circles.core.feature.autocomplete
+
+import android.content.Context
+import android.database.DataSetObserver
+import android.view.ViewGroup
+import androidx.annotation.CallSuper
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
+
+/**
+ * Simple [AutocompletePresenter] implementation that hosts a [RecyclerView].
+ * Supports [android.view.ViewGroup.LayoutParams.WRAP_CONTENT] natively.
+ * The only contract is to
+ *
+ * - provide a [RecyclerView.Adapter] in [.instantiateAdapter]
+ * - call [.dispatchClick] when an object is clicked
+ * - update your data during [.onQuery]
+ *
+ * @param <T> your model object (the object displayed by the list)
+</T> */
+abstract class RecyclerViewPresenter<T>(context: Context) : AutocompletePresenter<T>(context) {
+    @get:Suppress("unused")
+    private var recyclerView: RecyclerView? = null
+        private set
+    private var clicks: ClickProvider<T>? = null
+    private var observer: Observer? = null
+    override fun registerClickProvider(provider: ClickProvider<T>?) {
+        clicks = provider
+    }
+
+    override fun registerDataSetObserver(observer: DataSetObserver) {
+        this.observer = Observer(observer)
+    }
+
+    override val view: ViewGroup
+        get() {
+            recyclerView = RecyclerView(context)
+            val adapter = instantiateAdapter()
+            recyclerView!!.adapter = adapter
+            recyclerView!!.layoutManager = instantiateLayoutManager()
+            if (observer != null) {
+                adapter.registerAdapterDataObserver(observer!!)
+                observer = null
+            }
+            return recyclerView!!
+        }
+
+    override fun onViewShown() {}
+
+    @CallSuper
+    override fun onViewHidden() {
+        recyclerView = null
+        observer = null
+    }
+
+    /**
+     * Dispatch click event to [AutocompleteCallback].
+     * Should be called when items are clicked.
+     *
+     * @param item the clicked item.
+     */
+    protected fun dispatchClick(item: T) {
+        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 [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 .
+     */
+    @Suppress("unused")
+    protected fun dispatchLayoutChange() {
+        observer?.onChanged()
+    }
+
+    /**
+     * Provide an adapter for the recycler.
+     * This should be a fresh instance every time this is called.
+     *
+     * @return a new adapter.
+     */
+    protected abstract fun instantiateAdapter(): RecyclerView.Adapter<*>
+
+    /**
+     * 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.
+     */
+    protected fun instantiateLayoutManager(): RecyclerView.LayoutManager {
+        return LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
+    }
+
+    private class Observer(private val root: DataSetObserver) :
+        AdapterDataObserver() {
+        override fun onChanged() {
+            root.onChanged()
+        }
+
+        override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
+            root.onChanged()
+        }
+
+        override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
+            root.onChanged()
+        }
+
+        override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
+            root.onChanged()
+        }
+
+        override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
+            root.onChanged()
+        }
+    }
+}
\ No newline at end of file
-- 
GitLab