From 4de5c515525801895c8957bff7d0eb367bb91e6d Mon Sep 17 00:00:00 2001 From: codokie <151087174+codokie@users.noreply.github.com> Date: Sat, 4 Jan 2025 22:18:41 +0200 Subject: [PATCH] (Android) Enable haptic feedback for sliders, toggles, etc. --- .../activities/EmulationActivity.kt | 17 ++-- .../dolphinemu/adapters/GameAdapter.kt | 3 +- .../features/cheats/ui/CheatViewHolder.kt | 3 +- .../features/settings/ui/SettingsAdapter.kt | 3 +- .../ui/viewholder/SwitchSettingViewHolder.kt | 6 +- .../fragments/GridOptionDialogFragment.kt | 11 ++- .../dolphinemu/utils/HapticListener.kt | 82 +++++++++++++++++++ 7 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticListener.kt diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt index cb2b0f89c9..1a1a1b517d 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/activities/EmulationActivity.kt @@ -58,6 +58,7 @@ import org.dolphinemu.dolphinemu.ui.main.ThemeProvider import org.dolphinemu.dolphinemu.utils.AfterDirectoryInitializationRunner import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.FileBrowserHelper +import org.dolphinemu.dolphinemu.utils.HapticListener import org.dolphinemu.dolphinemu.utils.ThemeHelper import kotlin.math.roundToInt @@ -702,9 +703,11 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { valueTo = 150f value = IntSetting.MAIN_CONTROL_SCALE.int.toFloat() stepSize = 1f - addOnChangeListener(Slider.OnChangeListener { _: Slider?, progress: Float, _: Boolean -> - dialogBinding.inputScaleValue.text = "${(progress.toInt() + 50)}%" - }) + addOnChangeListener( + HapticListener.wrapOnChangeListener({ _: Slider, progress: Float, _: Boolean -> + dialogBinding.inputScaleValue.text = "${(progress.toInt() + 50)}%" + }, value) + ) } inputScaleValue.text = "${(dialogBinding.inputScaleSlider.value.toInt() + 50)}%" @@ -713,9 +716,11 @@ class EmulationActivity : AppCompatActivity(), ThemeProvider { valueTo = 100f value = IntSetting.MAIN_CONTROL_OPACITY.int.toFloat() stepSize = 1f - addOnChangeListener(Slider.OnChangeListener { _: Slider?, progress: Float, _: Boolean -> - inputOpacityValue.text = progress.toInt().toString() + "%" - }) + addOnChangeListener( + HapticListener.wrapOnChangeListener({ _: Slider, progress: Float, _: Boolean -> + inputOpacityValue.text = progress.toInt().toString() + "%" + }, value) + ) } inputOpacityValue.text = inputOpacitySlider.value.toInt().toString() + "%" } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt index c1ef9c2b8f..01303396f9 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt @@ -23,6 +23,7 @@ import org.dolphinemu.dolphinemu.databinding.CardGameBinding import org.dolphinemu.dolphinemu.dialogs.GamePropertiesDialog import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting import org.dolphinemu.dolphinemu.utils.CoilUtils +import org.dolphinemu.dolphinemu.utils.HapticListener import java.util.ArrayList class GameAdapter : RecyclerView.Adapter(), @@ -39,7 +40,7 @@ class GameAdapter : RecyclerView.Adapter(), override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context)) binding.root.apply { - setOnClickListener(this@GameAdapter) + setOnClickListener(HapticListener.wrapOnClickListener(this@GameAdapter)) setOnLongClickListener(this@GameAdapter) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatViewHolder.kt index 15b51e7299..dbb813edc1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatViewHolder.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatViewHolder.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModelProvider import org.dolphinemu.dolphinemu.databinding.ListItemCheatBinding import org.dolphinemu.dolphinemu.features.cheats.model.Cheat import org.dolphinemu.dolphinemu.features.cheats.model.CheatsViewModel +import org.dolphinemu.dolphinemu.utils.HapticListener class CheatViewHolder(private val binding: ListItemCheatBinding) : CheatItemViewHolder(binding.getRoot()), @@ -25,7 +26,7 @@ class CheatViewHolder(private val binding: ListItemCheatBinding) : binding.textName.text = cheat.getName() binding.cheatSwitch.isChecked = cheat.getEnabled() binding.root.setOnClickListener(this) - binding.cheatSwitch.setOnCheckedChangeListener(this) + binding.cheatSwitch.setOnCheckedChangeListener(HapticListener.wrapOnCheckedChangeListener(this)) } override fun onClick(root: View) { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt index bcf68e67ec..63e2ae72dc 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt @@ -35,6 +35,7 @@ import org.dolphinemu.dolphinemu.features.settings.model.view.* import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.* import org.dolphinemu.dolphinemu.utils.DirectoryInitialization import org.dolphinemu.dolphinemu.utils.FileBrowserHelper +import org.dolphinemu.dolphinemu.utils.HapticListener import org.dolphinemu.dolphinemu.utils.Log import org.dolphinemu.dolphinemu.utils.PermissionsHandler import java.io.File @@ -261,7 +262,7 @@ class SettingsAdapter( } } slider.value = (seekbarProgress / slider.stepSize).roundToInt() * slider.stepSize - slider.addOnChangeListener(this) + slider.addOnChangeListener(HapticListener.wrapOnChangeListener(this, slider.value)) dialog = MaterialAlertDialogBuilder(fragmentView.fragmentActivity) .setTitle(item.name) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt index fc575ead48..eef0bfa79c 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -10,6 +10,7 @@ import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem import org.dolphinemu.dolphinemu.features.settings.model.view.SwitchSetting import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter import org.dolphinemu.dolphinemu.utils.DirectoryInitialization +import org.dolphinemu.dolphinemu.utils.HapticListener import java.io.File import java.util.* @@ -49,7 +50,8 @@ class SwitchSettingViewHolder( binding.settingSwitch.isEnabled = iplExists || !setting.isChecked } - binding.settingSwitch.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + binding.settingSwitch.setOnCheckedChangeListener( + HapticListener.wrapOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> // If a user has skip IPL disabled previously and deleted their IPL file, we need to allow // them to skip it or else their game will appear broken. However, once this is enabled, we // need to disable the option again to prevent the same issue from occurring. @@ -60,7 +62,7 @@ class SwitchSettingViewHolder( adapter.onBooleanClick(setting, binding.settingSwitch.isChecked) setStyle(binding.textSettingName, setting) - } + }) setStyle(binding.textSettingName, setting) } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/GridOptionDialogFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/GridOptionDialogFragment.kt index 16a526bb2a..72079483b8 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/GridOptionDialogFragment.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/fragments/GridOptionDialogFragment.kt @@ -15,6 +15,7 @@ import org.dolphinemu.dolphinemu.databinding.FragmentGridOptionsBinding import org.dolphinemu.dolphinemu.databinding.FragmentGridOptionsTvBinding import org.dolphinemu.dolphinemu.features.settings.model.NativeConfig import org.dolphinemu.dolphinemu.ui.main.MainView +import org.dolphinemu.dolphinemu.utils.HapticListener class GridOptionDialogFragment : BottomSheetDialogFragment() { @@ -69,13 +70,14 @@ class GridOptionDialogFragment : BottomSheetDialogFragment() { mBindingMobile.rootDownloadCovers.setOnClickListener { mBindingMobile.switchDownloadCovers.isChecked = !mBindingMobile.switchDownloadCovers.isChecked } - mBindingMobile.switchDownloadCovers.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + mBindingMobile.switchDownloadCovers.setOnCheckedChangeListener( + HapticListener.wrapOnCheckedChangeListener { _: CompoundButton, _: Boolean -> BooleanSetting.MAIN_USE_GAME_COVERS.setBoolean( NativeConfig.LAYER_BASE, mBindingMobile.switchDownloadCovers.isChecked ) (mView as Activity).recreate() - } + }) } private fun setUpTitleButtons() { @@ -83,13 +85,14 @@ class GridOptionDialogFragment : BottomSheetDialogFragment() { mBindingMobile.rootShowTitles.setOnClickListener { mBindingMobile.switchShowTitles.isChecked = !mBindingMobile.switchShowTitles.isChecked } - mBindingMobile.switchShowTitles.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + mBindingMobile.switchShowTitles.setOnCheckedChangeListener( + HapticListener.wrapOnCheckedChangeListener { _: CompoundButton, _: Boolean -> BooleanSetting.MAIN_SHOW_GAME_TITLES.setBoolean( NativeConfig.LAYER_BASE, mBindingMobile.switchShowTitles.isChecked ) mView.reloadGrid() - } + }) } // TODO: Remove this when leanback is removed diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticListener.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticListener.kt new file mode 100644 index 0000000000..d8cfe3ead1 --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/HapticListener.kt @@ -0,0 +1,82 @@ +package org.dolphinemu.dolphinemu.utils + +import android.view.View +import android.widget.CompoundButton +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.google.android.material.slider.Slider + +/** + * Wrapper object that enhances listeners with haptic feedback. + */ +object HapticListener { + + /** + * Wraps a [View.OnClickListener] with haptic feedback. + * + * @param listener The [View.OnClickListener] to be wrapped. Can be null. + * @param feedbackConstant The haptic feedback constant to be used for haptic feedback. + * Defaults to [HapticFeedbackConstantsCompat.CONTEXT_CLICK] if not specified. + * @return A new listener which wraps [listener] with haptic feedback. + */ + fun wrapOnClickListener( + listener: View.OnClickListener?, + feedbackConstant: Int = HapticFeedbackConstantsCompat.CONTEXT_CLICK + ): View.OnClickListener { + return View.OnClickListener { view: View -> + listener?.onClick(view) + ViewCompat.performHapticFeedback(view, feedbackConstant) + } + } + + /** + * Wraps a [Slider.OnChangeListener] with haptic feedback. + * Feedback is provided at intervals of 5% to prevent excessive vibrations. + * + * @param listener The [Slider.OnChangeListener] to be wrapped. Can be null. + * @param initialValue The value used to initialize the slider. + * @return A new listener which wraps [listener] with haptic feedback. + */ + fun wrapOnChangeListener(listener: Slider.OnChangeListener?, initialValue: Float): Slider.OnChangeListener { + var previousValue = initialValue + return Slider.OnChangeListener { slider: Slider, value: Float, fromUser: Boolean -> + listener?.onValueChange(slider, value, fromUser) + if (fromUser) { + val interval = (slider.valueTo - slider.valueFrom) * 0.05f + val previousInterval = kotlin.math.round(previousValue / interval) * interval + val valueChange = kotlin.math.abs(value - previousInterval) + val feedbackConstant = if (value == slider.valueFrom || value == slider.valueTo) { + HapticFeedbackConstantsCompat.CONTEXT_CLICK + } else if (value == previousValue || valueChange >= interval) { + HapticFeedbackConstantsCompat.CLOCK_TICK + } else { + HapticFeedbackConstantsCompat.NO_HAPTICS + } + if (feedbackConstant != HapticFeedbackConstantsCompat.NO_HAPTICS) { + ViewCompat.performHapticFeedback(slider, feedbackConstant) + previousValue = value + } + } else { + previousValue = value + } + } + } + + /** + * Wraps a [CompoundButton.OnCheckedChangeListener] with haptic feedback. + * + * @param listener The [CompoundButton.OnCheckedChangeListener] to be wrapped. Can be null. + * @return A new listener which wraps [listener] with haptic feedback. + */ + fun wrapOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener?): CompoundButton.OnCheckedChangeListener { + return CompoundButton.OnCheckedChangeListener { buttonView: CompoundButton, isChecked: Boolean -> + listener?.onCheckedChanged(buttonView, isChecked) + /** Using old constants because [HapticFeedbackConstantsCompat.TOGGLE_ON] + * and [HapticFeedbackConstantsCompat.TOGGLE_OFF] don't seem to work. */ + val feedbackConstant = if (buttonView.isChecked) { + HapticFeedbackConstantsCompat.CONTEXT_CLICK + } else HapticFeedbackConstantsCompat.CLOCK_TICK + ViewCompat.performHapticFeedback(buttonView, feedbackConstant) + } + } +}