Material Spinner (AutoCompleteTextView) - catch outside clicks

Viewed 181

When using a spinner, it's the default behaviour that clicks outside of the spinner close the spinner but are NOT handled by the view underneath the click.

When switching to TextInputLayout + AutoCompleteTextView this behaviour is different, clicking on something outside the "spinner" closes the spinner AND the view underneath the touch does get the click event as well - this is very annoying and imho unexpected.

Can I somehow disable this behaviour to get the same behaviour as I get when using an old spinner?

1 Answers

Unfortunately no chances to catch the touch outside event using setOnDismissListener or dismissDropDown(); the latter gets called when item is selected.

But that would be possible by registering OnDismissListener on the instance of the inner popup window of the AutoCompleteTextView which is of type ListPopupWindow through reflections:

private fun getPopup(): ListPopupWindow? {
    try {
        val field = AutoCompleteTextView::class.java.getDeclaredField("mPopup")
        field.isAccessible = true
        return field.get(this) as ListPopupWindow
    } catch (e: NoSuchFieldException) {
        e.printStackTrace()
    } catch (e: IllegalAccessException) {
        e.printStackTrace()
    }
    return null
}

Hopefully, the documentation can solve this to avoid this anti-pattern.

The OnDismissListener callback gets called on any type of dismissal of the menu; either clicking on items, touching outside the AutoCompleteTextView or hitting the soft keyboard back button. This can be distinguished by tagging the ACTT with the appropriate flag for each event; an enum is used for that in the below customized class):

import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.Log
import android.view.KeyEvent
import android.view.View
import android.view.ViewTreeObserver
import android.widget.AdapterView
import android.widget.AutoCompleteTextView
import android.widget.ListPopupWindow
import androidx.appcompat.widget.AppCompatAutoCompleteTextView

class TouchOutsideAutoCompleteTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : AppCompatAutoCompleteTextView(context, attrs), AdapterView.OnItemClickListener {

    init {

        super.setOnItemClickListener(this)

        viewTreeObserver.addOnGlobalLayoutListener(object :
            ViewTreeObserver.OnGlobalLayoutListener {
            override fun onGlobalLayout() {

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
                    viewTreeObserver
                        .removeOnGlobalLayoutListener(this)
                else
                    viewTreeObserver
                        .removeGlobalOnLayoutListener(this)

                // Registering the mPopup window OnDismissListener
                adjustTouchOutside()
            }

        })

    }

    private var consumerListener: AdapterView.OnItemClickListener? = null

    private enum class DismissEvent {
        ON_ITEM_CLICK, // Should be set to enable the next touch dispatch event whenever the dismiss is due to a click on the menu item.
        ON_TOUCH_OUTSIDE, // Should be set to disable the next touch dispatch event whenever the dismiss is due to a touch outside the menu.
        ON_BACK_PRESSED // Should be set to enable the next touch dispatch event whenever the dismiss is due to the software keyboard back button pressed.
    }

    /*
    * Called globally on any touch on the screen to consume the event if it returns true
    * */
    fun isDismissByTouchOutside() = tag == DismissEvent.ON_TOUCH_OUTSIDE

    private fun isDismissByItemClickOrBackPressed() =
        tag == DismissEvent.ON_ITEM_CLICK || tag == DismissEvent.ON_BACK_PRESSED

    private fun setDismissToItemClick() {
        tag = DismissEvent.ON_ITEM_CLICK
    }

    private fun setDismissToTouchOutside() {
        tag = DismissEvent.ON_TOUCH_OUTSIDE
    }

    private fun setDismissToBackPressed() {
        tag = DismissEvent.ON_BACK_PRESSED
    }


    fun clearDismissEvent() {
        tag = null
    }

    @SuppressLint("DiscouragedPrivateApi")
    private fun getPopup(): ListPopupWindow? {
        try {
            val field = AutoCompleteTextView::class.java.getDeclaredField("mPopup")
            field.isAccessible = true
            return field.get(this) as ListPopupWindow
        } catch (e: NoSuchFieldException) {
            e.printStackTrace()
        } catch (e: IllegalAccessException) {
            e.printStackTrace()
        }
        return null
    }

    private fun adjustTouchOutside() {
        getPopup()?.let {
            it.setOnDismissListener {
                if (isDismissByItemClickOrBackPressed()) {// Menu dismissal Event of clicking on the menu item or hitting the software back button
                    clearDismissEvent() // Neutralize the enum to allow the next touch dispatch event & for adding a chance of next dismissal decision

                } else {  // Menu dismissal Event of touching outside the menu
                    // Don't allow the next touch dispatch event
                    setDismissToTouchOutside()
                }
            }
        }
    }

    override fun onItemClick(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {
        setDismissToItemClick()
        consumerListener?.onItemClick(p0, p1, p2, p3)
    }

    override fun setOnItemClickListener(l: AdapterView.OnItemClickListener?) {
        // DO NOT CALL SUPER HERE
//        super.setOnItemClickListener(l)
        consumerListener = l
    }

    override fun onKeyPreIme(keyCode: Int, event: KeyEvent?): Boolean {
        if (keyCode == KeyEvent.KEYCODE_BACK && isPopupShowing)
            setDismissToBackPressed()
        return super.onKeyPreIme(keyCode, event)
    }

}

The usage

Overriding dispatchTouchEvent() in the activity is required to consume the event for touching outside by checking isTouchOutsideDisabled(); and neutralize the dismissal event:

If the AutoCompleteTextView is in the activity then:

lateinit var autoCTV: TouchOutsideAutoCompleteTextView

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
    if (autoCTV.isDismissByTouchOutside()) {
        autoCTV.clearDismissEvent()
        return true
    }
    return super.dispatchTouchEvent(ev)
}

If it is in some fragment, then the dispatchTouchEvent() need to have access to it:

override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {

    val fragment = supportFragmentManager.findFragmentByTag("MyFragmentTag") as MyFragment

    if (fragment.autoCTV.isDismissByTouchOutside()) {
        fragment.autoCTV.clearDismissEvent()
        return true
    }
    return super.dispatchTouchEvent(ev)
}
Related