ソースを参照

android: Add search for settings

Charles Lombardo 2 年 前
コミット
fd5c7b21dd

+ 2 - 1
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt

@@ -12,6 +12,7 @@ import android.view.LayoutInflater
 import android.view.ViewGroup
 import android.widget.TextView
 import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
 import androidx.navigation.findNavController
 import androidx.recyclerview.widget.AsyncDifferConfig
@@ -37,7 +38,7 @@ import org.yuzu.yuzu_emu.features.settings.ui.viewholder.*
 import org.yuzu.yuzu_emu.model.SettingsViewModel
 
 class SettingsAdapter(
-    private val fragment: SettingsFragment,
+    private val fragment: Fragment,
     private val context: Context
 ) : ListAdapter<SettingsItem, SettingViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
     DialogInterface.OnClickListener {

+ 34 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt

@@ -13,12 +13,14 @@ import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updatePadding
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
 import androidx.navigation.fragment.navArgs
 import androidx.recyclerview.widget.LinearLayoutManager
 import com.google.android.material.divider.MaterialDividerItemDecoration
 import com.google.android.material.transition.MaterialSharedAxis
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
 import org.yuzu.yuzu_emu.model.SettingsViewModel
 
 class SettingsFragment : Fragment() {
@@ -84,11 +86,43 @@ class SettingsFragment : Fragment() {
             }
         }
 
+        settingsViewModel.isUsingSearch.observe(viewLifecycleOwner) {
+            if (it) {
+                reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
+                exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
+            } else {
+                reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+                exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+            }
+        }
+
+        if (args.menuTag == SettingsFile.FILE_NAME_CONFIG) {
+            binding.toolbarSettings.inflateMenu(R.menu.menu_settings)
+            binding.toolbarSettings.setOnMenuItemClickListener {
+                when (it.itemId) {
+                    R.id.action_search -> {
+                        reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
+                        exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
+                        view.findNavController()
+                            .navigate(R.id.action_settingsFragment_to_settingsSearchFragment)
+                        true
+                    }
+
+                    else -> false
+                }
+            }
+        }
+
         presenter.onViewCreated()
 
         setInsets()
     }
 
+    override fun onResume() {
+        super.onResume()
+        settingsViewModel.setIsUsingSearch(false)
+    }
+
     override fun onDetach() {
         super.onDetach()
         settingsAdapter?.closeDialog()

+ 189 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SettingsSearchFragment.kt

@@ -0,0 +1,189 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.divider.MaterialDividerItemDecoration
+import com.google.android.material.transition.MaterialSharedAxis
+import info.debatty.java.stringsimilarity.Cosine
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentSettingsSearchBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import org.yuzu.yuzu_emu.model.SettingsViewModel
+import org.yuzu.yuzu_emu.utils.NativeConfig
+
+class SettingsSearchFragment : Fragment() {
+    private var _binding: FragmentSettingsSearchBinding? = null
+    private val binding get() = _binding!!
+
+    private var settingsAdapter: SettingsAdapter? = null
+
+    private val settingsViewModel: SettingsViewModel by activityViewModels()
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
+        returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
+    }
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        _binding = FragmentSettingsSearchBinding.inflate(layoutInflater)
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        settingsViewModel.setIsUsingSearch(true)
+
+        if (savedInstanceState != null) {
+            binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
+        }
+
+        settingsAdapter = SettingsAdapter(this, requireContext())
+
+        val dividerDecoration = MaterialDividerItemDecoration(
+            requireContext(),
+            LinearLayoutManager.VERTICAL
+        )
+        dividerDecoration.isLastItemDecorated = false
+        binding.settingsList.apply {
+            adapter = settingsAdapter
+            layoutManager = LinearLayoutManager(requireContext())
+            addItemDecoration(dividerDecoration)
+        }
+
+        focusSearch()
+
+        binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) }
+        binding.searchBackground.setOnClickListener { focusSearch() }
+        binding.clearButton.setOnClickListener { binding.searchText.setText("") }
+        binding.searchText.doOnTextChanged { _, _, _, _ ->
+            search()
+            binding.settingsList.smoothScrollToPosition(0)
+        }
+        settingsViewModel.shouldReloadSettingsList.observe(viewLifecycleOwner) {
+            if (it) {
+                settingsViewModel.setShouldReloadSettingsList(false)
+                search()
+            }
+        }
+
+        search()
+
+        setInsets()
+    }
+
+    override fun onDetach() {
+        super.onDetach()
+        settingsAdapter?.closeDialog()
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
+    }
+
+    private fun search() {
+        val searchTerm = binding.searchText.text.toString().lowercase()
+        binding.clearButton.visibility =
+            if (searchTerm.isEmpty()) View.INVISIBLE else View.VISIBLE
+        if (searchTerm.isEmpty()) {
+            binding.noResultsView.visibility = View.VISIBLE
+            settingsAdapter?.submitList(emptyList())
+            return
+        }
+
+        val baseList = SettingsItem.settingsItems
+        val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1)
+        val sortedList: List<SettingsItem> = baseList.mapNotNull { item ->
+            val title = getString(item.value.nameId).lowercase()
+            val similarity = similarityAlgorithm.similarity(searchTerm, title)
+            if (similarity > 0.08) {
+                Pair(similarity, item)
+            } else {
+                null
+            }
+        }.sortedByDescending { it.first }.mapNotNull {
+            val item = it.second.value
+            val pairedSettingKey = item.setting.pairedSettingKey
+            val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) {
+                val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false)
+                if (pairedSettingValue) it.second.value else null
+            } else {
+                it.second.value
+            }
+            optionalSetting
+        }
+        settingsAdapter?.submitList(sortedList)
+        binding.noResultsView.visibility =
+            if (sortedList.isEmpty()) View.VISIBLE else View.INVISIBLE
+    }
+
+    private fun focusSearch() {
+        binding.searchText.requestFocus()
+        val imm = requireActivity()
+            .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
+        imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
+    }
+
+    private fun setInsets() =
+        ViewCompat.setOnApplyWindowInsetsListener(
+            binding.root
+        ) { _: View, windowInsets: WindowInsetsCompat ->
+            val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
+            val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge)
+            val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip)
+
+            val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+            val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+            val leftInsets = barInsets.left + cutoutInsets.left
+            val rightInsets = barInsets.right + cutoutInsets.right
+
+            binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing)
+            binding.frameSearch.updatePadding(
+                left = leftInsets + sideMargin,
+                top = barInsets.top + topMargin,
+                right = rightInsets + sideMargin
+            )
+            binding.noResultsView.updatePadding(
+                left = leftInsets,
+                right = rightInsets,
+                bottom = barInsets.bottom
+            )
+
+            val mlpSettingsList = binding.settingsList.layoutParams as ViewGroup.MarginLayoutParams
+            mlpSettingsList.leftMargin = leftInsets + sideMargin
+            mlpSettingsList.rightMargin = rightInsets + sideMargin
+            binding.settingsList.layoutParams = mlpSettingsList
+
+            val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
+            mlpDivider.leftMargin = leftInsets + sideMargin
+            mlpDivider.rightMargin = rightInsets + sideMargin
+            binding.divider.layoutParams = mlpDivider
+
+            windowInsets
+        }
+
+    companion object {
+        const val SEARCH_TEXT = "SearchText"
+    }
+}

+ 7 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SettingsViewModel.kt

@@ -27,6 +27,9 @@ class SettingsViewModel : ViewModel() {
     private val _shouldReloadSettingsList = MutableLiveData(false)
     val shouldReloadSettingsList: LiveData<Boolean> get() = _shouldReloadSettingsList
 
+    private val _isUsingSearch = MutableLiveData(false)
+    val isUsingSearch: LiveData<Boolean> get() = _isUsingSearch
+
     fun setToolbarTitle(value: String) {
         _toolbarTitle.value = value
     }
@@ -47,6 +50,10 @@ class SettingsViewModel : ViewModel() {
         _shouldReloadSettingsList.value = value
     }
 
+    fun setIsUsingSearch(value: Boolean) {
+        _isUsingSearch.value = value
+    }
+
     fun clear() {
         game = null
         shouldSave = false

+ 120 - 0
src/android/app/src/main/res/layout/fragment_settings_search.xml

@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <RelativeLayout
+        android:id="@+id/relativeLayout"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/divider">
+
+        <LinearLayout
+            android:id="@+id/no_results_view"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:gravity="center"
+            android:orientation="vertical">
+
+            <ImageView
+                android:id="@+id/icon_no_results"
+                android:layout_width="match_parent"
+                android:layout_height="80dp"
+                android:src="@drawable/ic_search" />
+
+            <com.google.android.material.textview.MaterialTextView
+                android:id="@+id/notice_text"
+                style="@style/TextAppearance.Material3.TitleLarge"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:gravity="center"
+                android:paddingTop="8dp"
+                android:text="@string/search_settings"
+                tools:visibility="visible" />
+
+        </LinearLayout>
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/settings_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:clipToPadding="false" />
+
+    </RelativeLayout>
+
+    <FrameLayout
+        android:id="@+id/frame_search"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:clipToPadding="false"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <com.google.android.material.card.MaterialCardView
+            android:id="@+id/search_background"
+            style="?attr/materialCardViewFilledStyle"
+            android:layout_width="match_parent"
+            android:layout_height="56dp"
+            app:cardCornerRadius="28dp">
+
+            <LinearLayout
+                android:id="@+id/search_container"
+                android:layout_width="match_parent"
+                android:layout_height="match_parent"
+                android:layout_marginEnd="56dp"
+                android:orientation="horizontal">
+
+                <Button
+                    android:id="@+id/back_button"
+                    style="?attr/materialIconButtonFilledTonalStyle"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_gravity="center_vertical"
+                    android:layout_marginStart="8dp"
+                    app:backgroundTint="@android:color/transparent"
+                    app:icon="@drawable/ic_back" />
+
+                <EditText
+                    android:id="@+id/search_text"
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent"
+                    android:background="@android:color/transparent"
+                    android:hint="@string/search_settings"
+                    android:imeOptions="flagNoFullscreen"
+                    android:inputType="text"
+                    android:maxLines="1" />
+
+            </LinearLayout>
+
+            <Button
+                android:id="@+id/clear_button"
+                style="?attr/materialIconButtonFilledTonalStyle"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center_vertical|end"
+                android:layout_marginEnd="8dp"
+                android:visibility="invisible"
+                app:backgroundTint="@android:color/transparent"
+                app:icon="@drawable/ic_clear"
+                tools:visibility="visible" />
+
+        </com.google.android.material.card.MaterialCardView>
+
+    </FrameLayout>
+
+    <com.google.android.material.divider.MaterialDivider
+        android:id="@+id/divider"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="20dp"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/frame_search" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 11 - 0
src/android/app/src/main/res/menu/menu_settings.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_search"
+        android:icon="@drawable/ic_search"
+        android:title="@string/home_search"
+        app:showAsAction="always" />
+
+</menu>

+ 8 - 0
src/android/app/src/main/res/navigation/settings_navigation.xml

@@ -15,10 +15,18 @@
             android:name="game"
             app:argType="org.yuzu.yuzu_emu.model.Game"
             app:nullable="true" />
+        <action
+            android:id="@+id/action_settingsFragment_to_settingsSearchFragment"
+            app:destination="@id/settingsSearchFragment" />
     </fragment>
 
     <action
         android:id="@+id/action_global_settingsFragment"
         app:destination="@id/settingsFragment" />
 
+    <fragment
+        android:id="@+id/settingsSearchFragment"
+        android:name="org.yuzu.yuzu_emu.fragments.SettingsSearchFragment"
+        android:label="SettingsSearchFragment" />
+
 </navigation>

+ 1 - 0
src/android/app/src/main/res/values/strings.xml

@@ -43,6 +43,7 @@
     <string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string>
     <string name="add_games_warning_help">https://yuzu-emu.org/help/quickstart/#dumping-games</string>
     <string name="home_search_games">Search games</string>
+    <string name="search_settings">Search settings</string>
     <string name="games_dir_selected">Games directory selected</string>
     <string name="install_prod_keys">Install prod.keys</string>
     <string name="install_prod_keys_description">Required to decrypt retail games</string>