2

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?

prom85
  • 16,896
  • 17
  • 122
  • 242

1 Answers1

1

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)
}
Zain
  • 37,492
  • 7
  • 60
  • 84
  • Reflection + manual code adjustments... Really ugly, but a working solution... I would handle the `enableTouchOutside` and `adjustTouchoutside` functions inside the custom class only though - I think that's possible with some custom wrapper for the click listener... If nothing better is suggested, I may really go this route for now... I will at least open a github issue in the material components repo as well. Thanks for the suggestion – prom85 Jun 14 '22 at 10:07
  • @prom85 thanks for the comment. Totally agree; this has been refined by moving the `enableTouchOutside` and `adjustTouchoutside` functions inside the custom view; thanks to [this answer](https://stackoverflow.com/a/66733216/9851608) for the `onItemClickListener` part. Regarding the reflection; hopefully Google can offer some API publicly to developers. Pls have a look at the updated answer. – Zain Jun 14 '22 at 16:01
  • A couple of recently noticed things, registering the menu listeners in `onPreDraw()` is not good as it will be recalled when the menu pops up or pops off. So, a global listener is created in the `init` method. Second, if you dismiss the menu with the soft keyboard button, then the next touch event will be dropped; this is fixed by registering `onKeyPreIme` in the updated answer. – Zain Jun 16 '22 at 16:34
  • 1
    Answer is ok as there seems to be no better solution... Regarding `dispatchTouchEvent` - you mean you use a custom root view inside our fragment/activity which handles this and manages the `autoCTV` correct? – prom85 Jun 20 '22 at 06:20
  • I used `dispatchTouchEvent()` in activity; it intercepts any touch event on the screen before dispatching that to the target view; for fragments there is no such method; one possible solution to register a listener between the fragment and activity to send the ACTV status; or you could have an access to the fragment from the activity to get the ACTV instance directly in the activity & normally register `dispatchTouchEvent`. There could be better solutions in the fragment let me check it out – Zain Jun 20 '22 at 18:51
  • Tried to registering onTouchListener for the fragment's root view when the ACTV is in a fragment; it works; but the problem that if you lay other views on the fragment; and the user dismisses the ACTV menu by hitting any of these views; it won't work unless you repeat the same for all those potential views that the user can hit; otherwise you can access the ACTV from the activity (updated the answer with that) and continue using `dispatchTouchEvent()` – Zain Jun 20 '22 at 19:46