android: Add search for settings

This commit is contained in:
Charles Lombardo 2023-08-24 16:11:08 -04:00
parent d786d19880
commit fd5c7b21dd
8 changed files with 372 additions and 1 deletions

View file

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

View file

@ -13,12 +13,14 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
import org.yuzu.yuzu_emu.model.SettingsViewModel import org.yuzu.yuzu_emu.model.SettingsViewModel
class SettingsFragment : Fragment() { 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() presenter.onViewCreated()
setInsets() setInsets()
} }
override fun onResume() {
super.onResume()
settingsViewModel.setIsUsingSearch(false)
}
override fun onDetach() { override fun onDetach() {
super.onDetach() super.onDetach()
settingsAdapter?.closeDialog() settingsAdapter?.closeDialog()

View file

@ -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"
}
}

View file

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

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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_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="add_games_warning_help">https://yuzu-emu.org/help/quickstart/#dumping-games</string>
<string name="home_search_games">Search 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="games_dir_selected">Games directory selected</string>
<string name="install_prod_keys">Install prod.keys</string> <string name="install_prod_keys">Install prod.keys</string>
<string name="install_prod_keys_description">Required to decrypt retail games</string> <string name="install_prod_keys_description">Required to decrypt retail games</string>