Browse Source

Add onPause\onResume persistence to searchView. Fixes issue #3627 (#4494)

* Add onPause\onResume persistence to searchView. Fixes issue #3627

* New controller subclass with built-in SearchView support

* Implement new SearchableNucleusController in SourceController

* Add query to BasePresenter (for one field it is not worth create a subclass in my opinion), convert BrowseSourceController to inherit from SearchableNucleusController

* move to flows to fix an issue in GlobalSearch where it would trigger the search multiple times

* Continue conversion to SearchableNucleusController

* Convert LibraryController, convert to flows, Known ISSUE with empty string being posted after setting the query upon creation of UI

* Fix issues with the post being tide to the SearchView queue which is not processed until shown. Add COLLAPSING state capture which should wrap this up.

* refactoring & enforce @StringRes for queryHint
Antoine Gaudreau Simard 4 năm trước cách đây
mục cha
commit
2911fe7a1a

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/BaseController.kt

@@ -121,7 +121,7 @@ abstract class BaseController<VB : ViewBinding>(bundle: Bundle? = null) :
      * [expandActionViewFromInteraction] should be set to true in [onOptionsItemSelected] when the expandable item is selected
      * This method should be called as part of [MenuItem.OnActionExpandListener.onMenuItemActionExpand]
      */
-    fun invalidateMenuOnExpand(): Boolean {
+    open fun invalidateMenuOnExpand(): Boolean {
         return if (expandActionViewFromInteraction) {
             activity?.invalidateOptionsMenu()
             false

+ 196 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/controller/SearchableNucleusController.kt

@@ -0,0 +1,196 @@
+package eu.kanade.tachiyomi.ui.base.controller
+
+import android.app.Activity
+import android.os.Bundle
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import androidx.annotation.StringRes
+import androidx.appcompat.widget.SearchView
+import androidx.viewbinding.ViewBinding
+import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import reactivecircus.flowbinding.appcompat.QueryTextEvent
+import reactivecircus.flowbinding.appcompat.queryTextEvents
+
+/**
+ * Implementation of the NucleusController that has a built-in ViewSearch
+ */
+abstract class SearchableNucleusController<VB : ViewBinding, P : BasePresenter<*>>
+(bundle: Bundle? = null) : NucleusController<VB, P>(bundle) {
+
+    enum class SearchViewState { LOADING, LOADED, COLLAPSING, FOCUSED }
+
+    /**
+     * Used to bypass the initial searchView being set to empty string after an onResume
+     */
+    private var currentSearchViewState: SearchViewState = SearchViewState.LOADING
+
+    /**
+     * Store the query text that has not been submitted to reassign it after an onResume, UI-only
+     */
+    protected var nonSubmittedQuery: String = ""
+
+    /**
+     * To be called by classes that extend this subclass in onCreateOptionsMenu
+     */
+    protected fun createOptionsMenu(
+        menu: Menu,
+        inflater: MenuInflater,
+        menuId: Int,
+        searchItemId: Int,
+        @StringRes queryHint: Int? = null,
+        restoreCurrentQuery: Boolean = true
+    ) {
+        // Inflate menu
+        inflater.inflate(menuId, menu)
+
+        // Initialize search option.
+        val searchItem = menu.findItem(searchItemId)
+        val searchView = searchItem.actionView as SearchView
+        searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() })
+        searchView.maxWidth = Int.MAX_VALUE
+
+        searchView.queryTextEvents()
+            .onEach {
+                val newText = it.queryText.toString()
+
+                if (newText.isNotBlank() or acceptEmptyQuery()) {
+                    if (it is QueryTextEvent.QuerySubmitted) {
+                        // Abstract function for implementation
+                        // Run it first in case the old query data is needed (like BrowseSourceController)
+                        onSearchViewQueryTextSubmit(newText)
+                        presenter.query = newText
+                        nonSubmittedQuery = ""
+                    } else if ((it is QueryTextEvent.QueryChanged) && (presenter.query != newText)) {
+                        nonSubmittedQuery = newText
+
+                        // Abstract function for implementation
+                        onSearchViewQueryTextChange(newText)
+                    }
+                }
+                // clear the collapsing flag
+                setCurrentSearchViewState(SearchViewState.LOADED, SearchViewState.COLLAPSING)
+            }
+            .launchIn(viewScope)
+
+        val query = presenter.query
+
+        // Restoring a query the user had not submitted
+        if (nonSubmittedQuery.isNotBlank() and (nonSubmittedQuery != query)) {
+            searchItem.expandActionView()
+            searchView.setQuery(nonSubmittedQuery, false)
+            onSearchViewQueryTextChange(nonSubmittedQuery)
+        } else {
+            if (queryHint != null) {
+                searchView.queryHint = applicationContext?.getString(queryHint)
+            }
+
+            if (restoreCurrentQuery) {
+                // Restoring a query the user had submitted
+                if (query.isNotBlank()) {
+                    searchItem.expandActionView()
+                    searchView.setQuery(query, true)
+                    searchView.clearFocus()
+                    onSearchViewQueryTextChange(query)
+                    onSearchViewQueryTextSubmit(query)
+                }
+            }
+        }
+
+        // Workaround for weird behavior where searchView gets empty text change despite
+        // query being set already, prevents the query from being cleared
+        binding.root.post {
+            setCurrentSearchViewState(SearchViewState.LOADED, SearchViewState.LOADING)
+        }
+
+        searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
+            if (hasFocus) {
+                setCurrentSearchViewState(SearchViewState.FOCUSED)
+            } else {
+                setCurrentSearchViewState(SearchViewState.LOADED, SearchViewState.FOCUSED)
+            }
+        }
+
+        searchItem.setOnActionExpandListener(
+            object : MenuItem.OnActionExpandListener {
+                override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
+                    onSearchMenuItemActionExpand(item)
+                    return true
+                }
+
+                override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
+                    val localSearchView = searchItem.actionView as SearchView
+
+                    // if it is blank the flow event won't trigger so we would stay in a COLLAPSING state
+                    if (localSearchView.toString().isNotBlank()) {
+                        setCurrentSearchViewState(SearchViewState.COLLAPSING)
+                    }
+
+                    onSearchMenuItemActionCollapse(item)
+                    return true
+                }
+            }
+        )
+    }
+
+    override fun onActivityResumed(activity: Activity) {
+        super.onActivityResumed(activity)
+        // Until everything is up and running don't accept empty queries
+        setCurrentSearchViewState(SearchViewState.LOADING)
+    }
+
+    private fun acceptEmptyQuery(): Boolean {
+        return when (currentSearchViewState) {
+            SearchViewState.COLLAPSING, SearchViewState.FOCUSED -> true
+            else -> false
+        }
+    }
+
+    private fun setCurrentSearchViewState(to: SearchViewState, from: SearchViewState? = null) {
+        // When loading ignore all requests other than loaded
+        if ((currentSearchViewState == SearchViewState.LOADING) && (to != SearchViewState.LOADED)) {
+            return
+        }
+
+        // Prevent changing back to an unwanted state when using async flows (ie onFocus event doing
+        // COLLAPSING -> LOADED)
+        if ((from != null) && (currentSearchViewState != from)) {
+            return
+        }
+
+        currentSearchViewState = to
+    }
+
+    /**
+     * Called by the SearchView since since the implementation of these can vary in subclasses
+     * Not abstract as they are optional
+     */
+    protected open fun onSearchViewQueryTextChange(newText: String?) {
+    }
+
+    protected open fun onSearchViewQueryTextSubmit(query: String?) {
+    }
+
+    protected open fun onSearchMenuItemActionExpand(item: MenuItem?) {
+    }
+
+    protected open fun onSearchMenuItemActionCollapse(item: MenuItem?) {
+    }
+
+    /**
+     * During the conversion to SearchableNucleusController (after which I plan to merge its code
+     * into BaseController) this addresses an issue where the searchView.onTextFocus event is not
+     * triggered
+     */
+    override fun invalidateMenuOnExpand(): Boolean {
+        return if (expandActionViewFromInteraction) {
+            activity?.invalidateOptionsMenu()
+            setCurrentSearchViewState(SearchViewState.FOCUSED) // we are technically focused here
+            false
+        } else {
+            true
+        }
+    }
+}

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/base/presenter/BasePresenter.kt

@@ -12,6 +12,11 @@ open class BasePresenter<V> : RxPresenter<V>() {
 
     lateinit var presenterScope: CoroutineScope
 
+    /**
+     * Query from the view where applicable
+     */
+    var query: String = ""
+
     override fun onCreate(savedState: Bundle?) {
         try {
             super.onCreate(savedState)

+ 19 - 42
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourceController.kt

@@ -9,7 +9,6 @@ import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
-import androidx.appcompat.widget.SearchView
 import androidx.recyclerview.widget.LinearLayoutManager
 import com.afollestad.materialdialogs.MaterialDialog
 import com.afollestad.materialdialogs.list.listItems
@@ -25,19 +24,11 @@ import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding
 import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.Source
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
-import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
+import eu.kanade.tachiyomi.ui.base.controller.*
 import eu.kanade.tachiyomi.ui.browse.BrowseController
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.appcompat.QueryTextEvent
-import reactivecircus.flowbinding.appcompat.queryTextEvents
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
@@ -48,7 +39,7 @@ import uy.kohesive.injekt.api.get
  * [SourceAdapter.OnLatestClickListener] call function data on latest item click
  */
 class SourceController :
-    NucleusController<SourceMainControllerBinding, SourcePresenter>(),
+    SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(),
     FlexibleAdapter.OnItemClickListener,
     FlexibleAdapter.OnItemLongClickListener,
     SourceAdapter.OnSourceClickListener {
@@ -200,37 +191,6 @@ class SourceController :
         parentController!!.router.pushController(controller.withFadeTransaction())
     }
 
-    /**
-     * Adds items to the options menu.
-     *
-     * @param menu menu containing options.
-     * @param inflater used to load the menu xml.
-     */
-    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        // Inflate menu
-        inflater.inflate(R.menu.source_main, menu)
-
-        // Initialize search option.
-        val searchItem = menu.findItem(R.id.action_search)
-        val searchView = searchItem.actionView as SearchView
-        searchView.maxWidth = Int.MAX_VALUE
-
-        // Change hint to show global search.
-        searchView.queryHint = applicationContext?.getString(R.string.action_global_search_hint)
-
-        // Create query listener which opens the global search view.
-        searchView.queryTextEvents()
-            .filterIsInstance<QueryTextEvent.QuerySubmitted>()
-            .onEach { performGlobalSearch(it.queryText.toString()) }
-            .launchIn(viewScope)
-    }
-
-    private fun performGlobalSearch(query: String) {
-        parentController!!.router.pushController(
-            GlobalSearchController(query).withFadeTransaction()
-        )
-    }
-
     /**
      * Called when an option menu item has been selected by the user.
      *
@@ -290,4 +250,21 @@ class SourceController :
                 }
         }
     }
+
+    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
+        createOptionsMenu(
+            menu,
+            inflater,
+            R.menu.source_main,
+            R.id.action_search,
+            R.string.action_global_search_hint,
+            false // GlobalSearch handles the searching here
+        )
+    }
+
+    override fun onSearchViewQueryTextSubmit(query: String?) {
+        parentController!!.router.pushController(
+            GlobalSearchController(query).withFadeTransaction()
+        )
+    }
 }

+ 7 - 25
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceController.kt

@@ -8,7 +8,6 @@ import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
-import androidx.appcompat.widget.SearchView
 import androidx.core.view.isVisible
 import androidx.core.view.updatePadding
 import androidx.recyclerview.widget.GridLayoutManager
@@ -33,7 +32,7 @@ import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.model.FilterList
 import eu.kanade.tachiyomi.source.online.HttpSource
 import eu.kanade.tachiyomi.ui.base.controller.FabController
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
@@ -51,12 +50,8 @@ import eu.kanade.tachiyomi.widget.AutofitRecyclerView
 import eu.kanade.tachiyomi.widget.EmptyView
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.filterIsInstance
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.appcompat.QueryTextEvent
-import reactivecircus.flowbinding.appcompat.queryTextEvents
 import timber.log.Timber
 import uy.kohesive.injekt.injectLazy
 
@@ -64,7 +59,7 @@ import uy.kohesive.injekt.injectLazy
  * Controller to manage the catalogues available in the app.
  */
 open class BrowseSourceController(bundle: Bundle) :
-    NucleusController<SourceControllerBinding, BrowseSourcePresenter>(bundle),
+    SearchableNucleusController<SourceControllerBinding, BrowseSourcePresenter>(bundle),
     FabController,
     FlexibleAdapter.OnItemClickListener,
     FlexibleAdapter.OnItemLongClickListener,
@@ -259,25 +254,8 @@ open class BrowseSourceController(bundle: Bundle) :
     }
 
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        inflater.inflate(R.menu.source_browse, menu)
-
-        // Initialize search menu
+        createOptionsMenu(menu, inflater, R.menu.source_browse, R.id.action_search)
         val searchItem = menu.findItem(R.id.action_search)
-        val searchView = searchItem.actionView as SearchView
-        searchView.maxWidth = Int.MAX_VALUE
-
-        val query = presenter.query
-        if (query.isNotBlank()) {
-            searchItem.expandActionView()
-            searchView.setQuery(query, true)
-            searchView.clearFocus()
-        }
-
-        searchView.queryTextEvents()
-            .filter { router.backstack.lastOrNull()?.controller() == this@BrowseSourceController }
-            .filterIsInstance<QueryTextEvent.QuerySubmitted>()
-            .onEach { searchWithQuery(it.queryText.toString()) }
-            .launchIn(viewScope)
 
         searchItem.fixExpand(
             onExpand = { invalidateMenuOnExpand() },
@@ -300,6 +278,10 @@ open class BrowseSourceController(bundle: Bundle) :
         menu.findItem(displayItem).isChecked = true
     }
 
+    override fun onSearchViewQueryTextSubmit(query: String?) {
+        searchWithQuery(query ?: "")
+    }
+
     override fun onPrepareOptionsMenu(menu: Menu) {
         super.onPrepareOptionsMenu(menu)
 

+ 4 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt

@@ -66,12 +66,6 @@ open class BrowseSourcePresenter(
      */
     lateinit var source: CatalogueSource
 
-    /**
-     * Query from the view.
-     */
-    var query = searchQuery ?: ""
-        private set
-
     /**
      * Modifiable list of filters.
      */
@@ -108,6 +102,10 @@ open class BrowseSourcePresenter(
      */
     private var pageSubscription: Subscription? = null
 
+    init {
+        query = searchQuery ?: ""
+    }
+
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 

+ 32 - 41
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchController.kt

@@ -1,12 +1,7 @@
 package eu.kanade.tachiyomi.ui.browse.source.globalsearch
 
 import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.MenuInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
+import android.view.*
 import androidx.appcompat.widget.SearchView
 import androidx.core.view.isVisible
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -15,15 +10,10 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.databinding.GlobalSearchControllerBinding
 import eu.kanade.tachiyomi.source.CatalogueSource
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
+import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
 import eu.kanade.tachiyomi.ui.manga.MangaController
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import reactivecircus.flowbinding.appcompat.QueryTextEvent
-import reactivecircus.flowbinding.appcompat.queryTextEvents
 import uy.kohesive.injekt.injectLazy
 
 /**
@@ -34,7 +24,7 @@ import uy.kohesive.injekt.injectLazy
 open class GlobalSearchController(
     protected val initialQuery: String? = null,
     protected val extensionFilter: String? = null
-) : NucleusController<GlobalSearchControllerBinding, GlobalSearchPresenter>(),
+) : SearchableNucleusController<GlobalSearchControllerBinding, GlobalSearchPresenter>(),
     GlobalSearchCardAdapter.OnMangaClickListener,
     GlobalSearchAdapter.OnTitleClickListener {
 
@@ -45,6 +35,11 @@ open class GlobalSearchController(
      */
     protected var adapter: GlobalSearchAdapter? = null
 
+    /**
+     * Ref to the OptionsMenu.SearchItem created in onCreateOptionsMenu
+     */
+    private var optionsMenuSearchItem: MenuItem? = null
+
     init {
         setHasOptionsMenu(true)
     }
@@ -100,36 +95,32 @@ open class GlobalSearchController(
      * @param inflater used to load the menu xml.
      */
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        // Inflate menu.
-        inflater.inflate(R.menu.global_search, menu)
-
-        // Initialize search menu
-        val searchItem = menu.findItem(R.id.action_search)
-        val searchView = searchItem.actionView as SearchView
-        searchView.maxWidth = Int.MAX_VALUE
-
-        searchItem.setOnActionExpandListener(
-            object : MenuItem.OnActionExpandListener {
-                override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
-                    searchView.onActionViewExpanded() // Required to show the query in the view
-                    searchView.setQuery(presenter.query, false)
-                    return true
-                }
-
-                override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
-                    return true
-                }
-            }
+        createOptionsMenu(
+            menu,
+            inflater,
+            R.menu.global_search,
+            R.id.action_search,
+            null,
+            false // the onMenuItemActionExpand will handle this
         )
 
-        searchView.queryTextEvents()
-            .filterIsInstance<QueryTextEvent.QuerySubmitted>()
-            .onEach {
-                presenter.search(it.queryText.toString())
-                searchItem.collapseActionView()
-                setTitle() // Update toolbar title
-            }
-            .launchIn(viewScope)
+        optionsMenuSearchItem = menu.findItem(R.id.action_search)
+    }
+
+    override fun onSearchMenuItemActionExpand(item: MenuItem?) {
+        super.onSearchMenuItemActionExpand(item)
+        val searchView = optionsMenuSearchItem?.actionView as SearchView
+        searchView.onActionViewExpanded() // Required to show the query in the view
+
+        if (nonSubmittedQuery.isBlank()) {
+            searchView.setQuery(presenter.query, false)
+        }
+    }
+
+    override fun onSearchViewQueryTextSubmit(query: String?) {
+        presenter.search(query ?: "")
+        optionsMenuSearchItem?.collapseActionView()
+        setTitle() // Update toolbar title
     }
 
     /**

+ 0 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchPresenter.kt

@@ -47,12 +47,6 @@ open class GlobalSearchPresenter(
      */
     val sources by lazy { getSourcesToQuery() }
 
-    /**
-     * Query from the view.
-     */
-    var query = ""
-        private set
-
     /**
      * Fetches the different sources by user settings.
      */

+ 16 - 50
app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryController.kt

@@ -10,7 +10,6 @@ import android.view.View
 import android.view.ViewGroup
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.view.ActionMode
-import androidx.appcompat.widget.SearchView
 import androidx.core.graphics.drawable.DrawableCompat
 import androidx.core.view.isVisible
 import com.bluelinelabs.conductor.ControllerChangeHandler
@@ -27,21 +26,16 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.preference.asImmediateFlow
 import eu.kanade.tachiyomi.databinding.LibraryControllerBinding
 import eu.kanade.tachiyomi.source.LocalSource
-import eu.kanade.tachiyomi.ui.base.controller.NucleusController
-import eu.kanade.tachiyomi.ui.base.controller.RootController
-import eu.kanade.tachiyomi.ui.base.controller.TabbedController
-import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
+import eu.kanade.tachiyomi.ui.base.controller.*
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
 import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.util.system.getResourceColor
 import eu.kanade.tachiyomi.util.system.toast
 import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import reactivecircus.flowbinding.android.view.clicks
-import reactivecircus.flowbinding.appcompat.queryTextChanges
 import reactivecircus.flowbinding.viewpager.pageSelections
 import rx.Subscription
 import uy.kohesive.injekt.Injekt
@@ -50,7 +44,7 @@ import uy.kohesive.injekt.api.get
 class LibraryController(
     bundle: Bundle? = null,
     private val preferences: PreferencesHelper = Injekt.get()
-) : NucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
+) : SearchableNucleusController<LibraryControllerBinding, LibraryPresenter>(bundle),
     RootController,
     TabbedController,
     ActionMode.Callback,
@@ -67,11 +61,6 @@ class LibraryController(
      */
     private var actionMode: ActionMode? = null
 
-    /**
-     * Library search query.
-     */
-    private var query: String = ""
-
     /**
      * Currently selected mangas.
      */
@@ -212,7 +201,7 @@ class LibraryController(
         binding.btnGlobalSearch.clicks()
             .onEach {
                 router.pushController(
-                    GlobalSearchController(query).withFadeTransaction()
+                    GlobalSearchController(presenter.query).withFadeTransaction()
                 )
             }
             .launchIn(viewScope)
@@ -384,52 +373,21 @@ class LibraryController(
     }
 
     override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
-        inflater.inflate(R.menu.library, menu)
-
-        val searchItem = menu.findItem(R.id.action_search)
-        val searchView = searchItem.actionView as SearchView
-        searchView.maxWidth = Int.MAX_VALUE
-        searchItem.fixExpand(onExpand = { invalidateMenuOnExpand() })
-
-        if (query.isNotEmpty()) {
-            searchItem.expandActionView()
-            searchView.setQuery(query, true)
-            searchView.clearFocus()
-
-            performSearch()
-
-            // Workaround for weird behavior where searchview gets empty text change despite
-            // query being set already
-            searchView.postDelayed({ initSearchHandler(searchView) }, 500)
-        } else {
-            initSearchHandler(searchView)
-        }
-
+        createOptionsMenu(menu, inflater, R.menu.library, R.id.action_search)
         // Mutate the filter icon because it needs to be tinted and the resource is shared.
         menu.findItem(R.id.action_filter).icon.mutate()
     }
 
     fun search(query: String) {
-        this.query = query
-    }
-
-    private fun initSearchHandler(searchView: SearchView) {
-        searchView.queryTextChanges()
-            // Ignore events if this controller isn't at the top to avoid query being reset
-            .filter { router.backstack.lastOrNull()?.controller() == this }
-            .onEach {
-                query = it.toString()
-                performSearch()
-            }
-            .launchIn(viewScope)
+        presenter.query = query
     }
 
     private fun performSearch() {
-        searchRelay.call(query)
-        if (query.isNotEmpty()) {
+        searchRelay.call(presenter.query)
+        if (presenter.query.isNotEmpty()) {
             binding.btnGlobalSearch.isVisible = true
             binding.btnGlobalSearch.text =
-                resources?.getString(R.string.action_global_search_query, query)
+                resources?.getString(R.string.action_global_search_query, presenter.query)
         } else {
             binding.btnGlobalSearch.isVisible = false
         }
@@ -611,4 +569,12 @@ class LibraryController(
             selectInverseRelay.call(it)
         }
     }
+
+    override fun onSearchViewQueryTextChange(newText: String?) {
+        // Ignore events if this controller isn't at the top to avoid query being reset
+        if (router.backstack.lastOrNull()?.controller() == this) {
+            presenter.query = newText ?: ""
+            performSearch()
+        }
+    }
 }

+ 0 - 6
app/src/main/java/eu/kanade/tachiyomi/ui/setting/search/SettingsSearchPresenter.kt

@@ -12,12 +12,6 @@ import uy.kohesive.injekt.api.get
  */
 open class SettingsSearchPresenter : BasePresenter<SettingsSearchController>() {
 
-    /**
-     * Query from the view.
-     */
-    var query = ""
-        private set
-
     val preferences: PreferencesHelper = Injekt.get()
 
     override fun onCreate(savedState: Bundle?) {