AndroidのSearchViewをMVVMとBindingAdapterで動的にカスタマイズした話

こんにちは。NewsPicks Androidアプリエンジニアの sefwgweo です。
今回はタイトル通りAndroidにおけるSearchViewのUIをMVVMでCustomした話をします。
なお、SearchViewの検索機能に関しては一切触れておりませんので予めご了承ください。

要件

  1. ユーザ属性に応じて、ツールバー及びSearchView内の各パーツごとの色が動的に変更されてほしい

  2. 色及びその他デザインの細かい仕様は以下とする

    2-1. Toolbar背景色は動的に変更できること

    2-2. 検索Box内背景色は動的に変更できること

    2-3. 検索Box内背景色アルファ値は動的に変更できること

    2-4. 検索Box内右端のxボタンの色、虫眼鏡アイコンの色、ヒントテキストの色の3つを動的に変更できること(以下スクショの赤色部分)

    2-5. 検索文字色、Toolbarに戻るボタンを付ける場合の色は動的に変更できること

    2-6. 検索Boxは角丸であること

    2-7. キーボード内の虫眼鏡タップで検索処理が実行されること

以下完成画像

苦労ポイント

  • 2-4のヒントテキスト左横に置く虫眼鏡(サンプルではあえて別のアイコンにしています)の色が中々変えられなかった

  • MVVMで簡潔に用件を満たすように書くのが少々手間だった

上記2点についてコードとともに見ていきます

SearchViewModel.kt.
必要な色情報が定義されています. 本来はActivity等外部から動的に渡ってきた内容が反映される想定です.
検索ボタンタップ時はLiveDataをPostし、Activity側で検索文字列をObserveします.

class SearchViewModel : ViewModel() {

  val searchData: MutableLiveData<String> = MutableLiveData()

  // 以下4色とアルファ値は本来外部から渡ってくる動的に変わりうる値
  val backgroundColor = Color.BLACK
  val textColor = Color.WHITE
  val hintTextColor = Color.RED
  val searchBoxBackgroundColor = Color.GREEN
  val searchBoxBackgroundAlpha = 100
    
  val clearColor = Color.TRANSPARENT// 検索Box内で下線を削除するために使用

  // 虫眼鏡タップ時BindingAdapter内でコールされる
  fun search(query: String) {
      // バリデートされた文字列をMainActivityにPostする
      searchData.postValue(query)
  }
}

activity_main.xml

基本的にはViewModelの値を反映しますが、いくつかの処理をBindingAdapterで処理しています.
ポイントとしては、OSで用意されている app:searchHintIconにNullを設定することで app:searchIconDrawableに設定されたアイコン(@android:drawable/ic_menu_searchを指定するとOSの用意した虫眼鏡アイコンになります)を2-4の要件に則って変更できるようにしている箇所になります.
また、 app:onQueryTextSubmitもBindingAdapter内で処理を完結できるようにしてActivity内にコードが増えるのを抑えています.

<androidx.appcompat.widget.Toolbar
  android:id="@+id/toolbar"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  app:bgColor="@{viewModel.backgroundColor}"
  app:iconColor="@{viewModel.textColor}"
  tools:navigationIcon="?homeAsUpIndicator">

  <androidx.appcompat.widget.SearchView
    android:id="@+id/search_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingVertical="6dp"
    android:background="@android:color/transparent"
    app:selectedTextColor="@{viewModel.textColor}"
    app:unSelectedTextColor="@{viewModel.hintTextColor}"
    app:overlayColor="@{viewModel.searchBoxBackgroundColor}"
    app:clearColor="@{viewModel.clearColor}"
    app:boxBgDrawable="@{@drawable/bg_search}"
    app:onQueryTextSubmit="@{(url) -> viewModel.search(url)}"
    app:searchIconDrawable="@{@android:drawable/ic_input_add}"
    app:searchHintIcon="@null"
    app:closeIcon="@android:drawable/ic_menu_close_clear_cancel"
    app:alpha="@{viewModel.searchBoxBackgroundAlpha}"
    app:hintText="@{@string/search_hint}">
    <requestFocus />
  </androidx.appcompat.widget.SearchView>
</androidx.appcompat.widget.Toolbar>

BindingAdapters.kt

Toolbarの各種色の変更、SearchViewの各種色変更及び検索処理部分を実装しています.

検索文字、検索ボックス内背景色、検索ボックス内XボタンPadding及びTint色、検索下線荷関しては androidx.appcompat.R.id.search_XX でアクセス可能だったため、findViewByIdを行うことでCustom可能だった.

SpannableStringBuilder で、アイコンプラスTextにする過程で各々の色を設定した

object BindingAdapters {
    interface OnQueryTextSubmit {
        fun onQueryTextSubmit(query: String)
    }

    @JvmStatic
    @BindingAdapter(value = ["bgColor", "iconColor"], requireAll = false)
    fun setToolBarColors(view: Toolbar, bgColor: Int, iconColor: Int) {
        view.let { toolbar ->
            toolbar.setBackgroundColor(bgColor)
            toolbar.navigationIcon?.setTint(iconColor)
        }
    }

    @JvmStatic
    @BindingAdapter(value = [
        "selectedTextColor",
        "unSelectedTextColor",
        "overlayColor",
        "alpha",
        "clearColor",
        "hintText",
        "boxBgDrawable",
        "searchIconDrawable",
        "onQueryTextSubmit"
    ], requireAll = false)
    fun setSearchViewColors(
        view: SearchView,
        textColor: Int,
        hintTextColor: Int,
        searchBgColor: Int,
        searchBgAlpha: Int,
        clearColor:Int,
        hintText: String,
        boxBgDrawable: Drawable,
        searchIconDrawable: Drawable,
        submit:OnQueryTextSubmit,
    ) {
        view.let { searchView ->
            searchView.isIconified = false
            searchView.setOnCloseListener { true }

            // 検索文字のサイズ及び色設定
            searchView.findViewById<SearchView.SearchAutoComplete>(androidx.appcompat.R.id.search_src_text)?.apply {
                setTextColor(textColor)
                textSize = 13.toFloat()
            }
            // 検索ボックス内背景色設定
            searchView.findViewById<ViewGroup>(androidx.appcompat.R.id.search_edit_frame)?.apply {
                val wrapDrawable = DrawableCompat.wrap(boxBgDrawable)
                wrapDrawable.setTint(searchBgColor)
                wrapDrawable.alpha = searchBgAlpha
                background = wrapDrawable
            }
            // 検索ボックス内XボタンPadding及びTint色設定
            searchView.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)?.apply {
                val removeIconPadding = resources.getDimensionPixelSize(R.dimen.padding_s)
                setPadding(removeIconPadding, removeIconPadding, removeIconPadding, removeIconPadding)
                setColorFilter(hintTextColor)
            }
            // 虫眼鏡アイコンTint色及びヒントテキストの文字と色設定(※虫眼鏡に関しては上記までの、androidx.appcompat.R.id.search_XXでの変更はできなかった)
            searchView.findViewById<ImageView>(androidx.appcompat.R.id.search_mag_icon)?.apply {
                val hintTextSize = resources.getDimensionPixelSize(R.dimen.text_s)
                val hintIconSize = resources.getDimensionPixelSize(R.dimen.text_l)
                val wrapDrawable = DrawableCompat.wrap(searchIconDrawable)// Tintを設定するためwrapします
                wrapDrawable.setTint(hintTextColor)
                wrapDrawable.setBounds(0, 0, hintIconSize, hintIconSize)

                // SpannableStringBuilderを用いて、アイコンプラス文字列を生成
                val sb = SpannableStringBuilder(" ").append(hintText)
                sb.setSpan(ImageSpan(wrapDrawable),0,1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                sb.setSpan(AbsoluteSizeSpan(hintTextSize), 0, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                sb.setSpan(ForegroundColorSpan(hintTextColor), 0, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                searchView.queryHint = sb
            }
            // 検索下線削除
            searchView.findViewById<View>(androidx.appcompat.R.id.search_plate)?.apply {
                setBackgroundColor(clearColor)
            }

            // 検索実行
            searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
                override fun onQueryTextSubmit(query: String?): Boolean {
                    searchView.clearFocus()

                    query?.trim()?.let {
                        if (it.isNotEmpty()) submit.onQueryTextSubmit(it)
                    }
                    return true
                }

                override fun onQueryTextChange(newText: String?): Boolean {
                    return false
                }
            })
        }
    }
}

以上がコードによる全容です。もしもMVVMかつSearchViewで同様の問題を抱えている場合の1つの解として参考になれば幸いです. 以下に今回解説したもののリポジトリを載せておきます. https://github.com/sefwgweo/CustomUISearchView

まとめ

今回も結構マニアックなケースについて取り上げてみましたがいかがだったでしょうか
こちらの記事が同様の機能をこれから開発する開発者様の助けになり、また弊社のサービスに興味を持っていただけるきっかけになりましたら幸いです

おわりに

ユーザベースではエンジニアを募集しています。ご興味ある方いらっしゃいましたらこちらからぜひご応募いただければと思います!

Page top