Превращаем EditText в SearchEditText

    image

    Пробовали ли Вы когда-нибудь настроить внешний вид или поведение стандартного компонента SearchView? Полагаю, да. В этом случае, я думаю что вы согласитесь, что далеко не все его настройки являются достаточно гибкими, для того, чтобы удовлетворить всем бизнес-требованиям отдельно взятой задачи. Одним из способов решения этой проблемы является написание собственного «кастомного» SearchView, чем мы сегодня и займемся. Поехали!

    Примечание: создаваемое view (далее – SearchEditText), не будет обладать всеми свойствами стандартного SearchView. В случае необходимости, вы можете без труда добавить дополнительные опции под конкретные нужды.

    План действий


    Есть несколько вещей, которые нам нужно сделать, для «превращения» EditText в SearchEditText. Если кратко, то нам нужно:

    • Унаследовать SearchEditText от AppCompatEditText
    • Добавить иконку «Поиск» в левом (или правом) углу SearchEditText, при нажатии на которую введённый поисковый запрос будет передаваться зарегистрированному слушателю
    • Добавить иконку «Очистка» в правом (или левом) углу SearchEditText, при нажатии на которую введённый текст в поисковой строке будет очищаться
    • Установить в параметре imeOptions SearchEditText-а значение IME_ACTION_SEARCH, для того, чтобы при появлении клавиатуры кнопка ввода текста выполняла роль кнопки «Поиск»

    SearchEditText во всей красе!


    import android.content.Context
    import android.util.AttributeSet
    import android.view.MotionEvent
    import android.view.View.OnTouchListener
    import android.view.inputmethod.EditorInfo
    import androidx.appcompat.widget.AppCompatEditText
    import androidx.core.widget.doAfterTextChanged
    
    class SearchEditText
    @JvmOverloads constructor(
        context: Context,
        attributeSet: AttributeSet? = null,
        defStyle: Int = androidx.appcompat.R.attr.editTextStyle
    ) : AppCompatEditText(context, attributeSet, defStyle) {
    
        init {
            setLeftDrawable(android.R.drawable.ic_menu_search)
            setTextChangeListener()
            setOnEditorActionListener()
            setDrawablesListener()
            imeOptions = EditorInfo.IME_ACTION_SEARCH
        }
    
        companion object {
            private const val DRAWABLE_LEFT_INDEX = 0
            private const val DRAWABLE_RIGHT_INDEX = 2
        }
    
        private var queryTextListener: QueryTextListener? = null
    
        private fun setTextChangeListener() {
            doAfterTextChanged {
                if (it.isNullOrBlank()) {
                    setRightDrawable(0)
                } else {
                    setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)
                }
                queryTextListener?.onQueryTextChange(it.toString())
            }
        }
        
        private fun setOnEditorActionListener() {
            setOnEditorActionListener { _, actionId, _ ->
                if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                    queryTextListener?.onQueryTextSubmit(text.toString())
                    true
                } else {
                    false
                }
            }
        }
        
        private fun setDrawablesListener() {
            setOnTouchListener(OnTouchListener { view, event ->
                view.performClick()
                if (event.action == MotionEvent.ACTION_UP) {
                    when {
                        rightDrawableClicked(event) -> {
                            setText("")
                            return@OnTouchListener true
                        }
                        leftDrawableClicked(event) -> {
                            queryTextListener?.onQueryTextSubmit(text.toString())
                            return@OnTouchListener true
                        }
                        else -> {
                            return@OnTouchListener false
                        }
                    }
                }
                false
            })
        }
    
        private fun rightDrawableClicked(event: MotionEvent): Boolean {
    
            val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]
    
            return if (rightDrawable == null) {
                false
            } else {
                val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight
                val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()
                startOfDrawable <= event.x && event.x <= endOfDrawable
            }
    
        }
    
        private fun leftDrawableClicked(event: MotionEvent): Boolean {
    
            val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]
    
            return if (leftDrawable == null) {
                false
            } else {
                val startOfDrawable = paddingLeft
                val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()
                startOfDrawable <= event.x && event.x <= endOfDrawable
            }
    
        }
    
        fun setQueryTextChangeListener(queryTextListener: QueryTextListener) {
            this.queryTextListener = queryTextListener
        }
    
        interface QueryTextListener {
            fun onQueryTextSubmit(query: String?)
            fun onQueryTextChange(newText: String?)
        }
    
    }
    

    В приведенном выше коде были использованы две extension-функции для установки правого и левого изображения EditText-а. Эти две функции выглядят следующим образом:

    import android.widget.TextView
    import androidx.annotation.DrawableRes
    import androidx.core.content.ContextCompat
    
    private const val DRAWABLE_LEFT_INDEX = 0
    private const val DRAWABLE_TOP_INDEX = 1
    private const val DRAWABLE_RIGHT_INDEX = 2
    private const val DRAWABLE_BOTTOM_INDEX = 3
    
    fun TextView.setLeftDrawable(@DrawableRes drawableResId: Int) {
    
        val leftDrawable = if (drawableResId != 0) {
            ContextCompat.getDrawable(context, drawableResId)
        } else {
            null
        }
        val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]
        val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]
        val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]
    
        setCompoundDrawablesWithIntrinsicBounds(
            leftDrawable,
            topDrawable,
            rightDrawable,
            bottomDrawable
        )
    
    }
    
    fun TextView.setRightDrawable(@DrawableRes drawableResId: Int) {
    
        val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]
        val topDrawable = compoundDrawables[DRAWABLE_TOP_INDEX]
        val rightDrawable = if (drawableResId != 0) {
            ContextCompat.getDrawable(context, drawableResId)
        } else {
            null
        }
        val bottomDrawable = compoundDrawables[DRAWABLE_BOTTOM_INDEX]
    
        setCompoundDrawablesWithIntrinsicBounds(
            leftDrawable,
            topDrawable,
            rightDrawable,
            bottomDrawable
        )
    
    }
    

    Наследование от AppCompatEditText


    class SearchEditText
    @JvmOverloads constructor(
        context: Context,
        attributeSet: AttributeSet? = null,
        defStyle: Int = androidx.appcompat.R.attr.editTextStyle
    ) : AppCompatEditText(context, attributeSet, defStyle)
    

    Как видите, из написанного конструктора мы передаём все необходимые параметры в конструктор AppCompatEditText. Важным моментом тут является то, что значением defStyle по-умолчанию является android.appcompat.R.attr.editTextStyle. Наследуясь от LinearLayout, FrameLayout и некоторых других view, мы, как правило, используем 0 в качестве значения по-умолчанию для defStyle. Однако в нашем случае это не подходит, иначе наш SearchEditText будет вести себя как TextView, а не как EditText.

    Обработка изменения текста


    Следующее, что нам нужно сделать, это «научиться» реагировать на события изменения текста нашего SearchEditText. Нам это необходимо по двум причинам:

    • отображение или скрытие иконки очистки в зависимости от того, введён ли текст
    • оповещение слушателя об изменении текста в SearchEditText

    Посмотрим на код слушателя:

    private fun setTextChangeListener() {
        doAfterTextChanged {
            if (it.isNullOrBlank()) {
                setRightDrawable(0)
            } else {
                setRightDrawable(android.R.drawable.ic_menu_close_clear_cancel)
            }
            queryTextListener?.onQueryTextChange(it.toString())
        }
    }
    

    Для обработки событий изменения текста использовалась extension-функция doAfterTextChanged из androidx.core:core-ktx.

    Обработка нажатия кнопки ввода на клавиатуре


    Когда пользователь нажимает клавишу ввода на клавиатуре, происходит проверка на то, является ли это действие IME_ACTION_SEARCH. Если это так, то мы сообщаем слушателю об этом действии и передаем ему текст из SearchEditText. Посмотрим как это происходит.

    private fun setOnEditorActionListener() {
        setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                queryTextListener?.onQueryTextSubmit(text.toString())
                true
            } else {
                false
            }
        }
    }
    

    Обработка нажатий на иконки


    Ну и наконец, последний по счету, но не по значению вопрос – обработка нажатия на иконки поиска и очистки текста. Здесь загвоздка заключается в том, что по-умолчанию drawables стандартного EditText-а не реагируют на события нажатия, а значит и официального слушателя, который мог бы их обрабатывать, нет.

    Для решения этой проблемы был зарегистрирован OnTouchListener в SearchEditText. При касании, с помощью функций leftDrawableClicked и rightDrawableClicked мы теперь можем обрабатывать клик по иконкам. Взглянем на код:

    private fun setDrawablesListener() {
        setOnTouchListener(OnTouchListener { view, event ->
            view.performClick()
            if (event.action == MotionEvent.ACTION_UP) {
                when {
                    rightDrawableClicked(event) -> {
                        setText("")
                        return@OnTouchListener true
                    }
                    leftDrawableClicked(event) -> {
                        queryTextListener?.onQueryTextSubmit(text.toString())
                        return@OnTouchListener true
                    }
                    else -> {
                        return@OnTouchListener false
                    }
                }
            }
            false
        })
    }
    
    private fun rightDrawableClicked(event: MotionEvent): Boolean {
    
        val rightDrawable = compoundDrawables[DRAWABLE_RIGHT_INDEX]
    
        return if (rightDrawable == null) {
            false
        } else {
            val startOfDrawable = width - rightDrawable.bounds.width() - paddingRight
            val endOfDrawable = startOfDrawable + rightDrawable.bounds.width()
            startOfDrawable <= event.x && event.x <= endOfDrawable
        }
    
    }
    
    private fun leftDrawableClicked(event: MotionEvent): Boolean {
    
        val leftDrawable = compoundDrawables[DRAWABLE_LEFT_INDEX]
    
        return if (leftDrawable == null) {
            false
        } else {
            val startOfDrawable = paddingLeft
            val endOfDrawable = startOfDrawable + leftDrawable.bounds.width()
            startOfDrawable <= event.x && event.x <= endOfDrawable
        }
    
    }
    

    В функциях leftDrawableClicked и RightDrawableClicked нет ничего сложного. Возьмём, к примеру, первую из них. Для левой иконки мы сначала рассчитываем startOfDrawable и endOfDrawable, а затем проверяем, находится ли x-координата точки касания в диапазоне [startofDrawable, endOfDrawable]. Если да, то это означает, что левая иконка была нажата. Функция rightDrawableClicked работает аналогичным образом.

    В зависимости от того, нажата ли левая или правая иконка, мы осуществляем те или иные действия. При нажатии на левую иконку (значок поиска) мы сообщаем об этом слушателю, вызывая его функцию onQueryTextSubmit. При нажатии на правую – очищаем текст SearchEditText.

    Вывод


    В этой статье мы рассмотрели вариант «превращения» стандартного EditText в более продвинутый SearchEditText. Как уже упоминалось ранее, готовое решение не поддерживает все параметры, предоставляемые SearchView, однако вы в любой момент можете его усовершенствовать, добавив дополнительные опции на свое усмотрение. Дерзайте!

    P.S:

    Доступ к исходному коду SearchEditText вы можете получить из этого репозитория GitHub.

    Комментарии 2

      +2

      Если что, то TextInputLayout + TextInputEditText уже это всё имеют на сколько я помню.

        +4
        Без картинок, как обычно, непонятно, что получилось, какие грабли встретились, что нужно доработать.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое