(Android) Enable haptic feedback for sliders, toggles, etc.

This commit is contained in:
codokie 2025-01-04 22:18:41 +02:00
parent 9b3b6bea9d
commit 4de5c51552
7 changed files with 110 additions and 15 deletions

View File

@ -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() + "%"
}

View File

@ -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<GameViewHolder>(),
@ -39,7 +40,7 @@ class GameAdapter : RecyclerView.Adapter<GameViewHolder>(),
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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