summaryrefslogtreecommitdiff
path: root/Source/Android/app/src/main/java
diff options
context:
space:
mode:
authorJordan Woyak <jordan.woyak@gmail.com>2025-07-20 17:46:47 -0500
committerGitHub <noreply@github.com>2025-07-20 17:46:47 -0500
commitc39c8277b7fd570a4101bc621a9179f3ece10e6b (patch)
treef008adea9d7dd39246e1834dbe985a7f3d886c02 /Source/Android/app/src/main/java
parentdccd8709d8c8df70493def8813dd8039dfe5ba16 (diff)
parent43b254aaad5add867b719857d6f8b4fe2d6b750d (diff)
Merge pull request #13674 from JosJuice/android-visualize-input
Android: Show input indicators in controller settings
Diffstat (limited to 'Source/Android/app/src/main/java')
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/adapters/GameAdapter.kt3
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatsAdapter.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt59
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/CoreDevice.kt2
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/view/InputMappingControlSetting.kt3
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlAdapter.kt33
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlViewHolder.kt60
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingDialog.kt38
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarHorizontal.kt25
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarImpl.kt28
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarVertical.kt30
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/viewholder/InputMappingControlSettingViewHolder.kt36
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/riivolution/ui/RiivolutionAdapter.kt2
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt14
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsAdapter.kt61
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt6
-rw-r--r--Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/LifecycleViewHolder.kt59
19 files changed, 419 insertions, 58 deletions
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..dbcfdd42e2 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
@@ -37,7 +37,8 @@ class GameAdapter : RecyclerView.Adapter<GameViewHolder>(),
* @return The created ViewHolder with references to all the child view's members.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
- val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
+ val inflater = LayoutInflater.from(parent.context)
+ val binding = CardGameBinding.inflate(inflater, parent, false)
binding.root.apply {
setOnClickListener(this@GameAdapter)
setOnLongClickListener(this@GameAdapter)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatsAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatsAdapter.kt
index c75330375d..732555ea32 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatsAdapter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/cheats/ui/CheatsAdapter.kt
@@ -43,17 +43,17 @@ class CheatsAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
CheatItem.TYPE_CHEAT -> {
- val listItemCheatBinding = ListItemCheatBinding.inflate(inflater)
+ val listItemCheatBinding = ListItemCheatBinding.inflate(inflater, parent, false)
addViewListeners(listItemCheatBinding.getRoot())
CheatViewHolder(listItemCheatBinding)
}
CheatItem.TYPE_HEADER -> {
- val listItemHeaderBinding = ListItemHeaderBinding.inflate(inflater)
+ val listItemHeaderBinding = ListItemHeaderBinding.inflate(inflater, parent, false)
addViewListeners(listItemHeaderBinding.root)
HeaderViewHolder(listItemHeaderBinding)
}
CheatItem.TYPE_ACTION -> {
- val listItemSubmenuBinding = ListItemSubmenuBinding.inflate(inflater)
+ val listItemSubmenuBinding = ListItemSubmenuBinding.inflate(inflater, parent, false)
addViewListeners(listItemSubmenuBinding.root)
ActionViewHolder(listItemSubmenuBinding)
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt
index 3bca59f7b7..e09e0dec99 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/ControllerInterface.kt
@@ -6,6 +6,7 @@ import android.content.Context
import android.hardware.input.InputManager
import android.os.Build
import android.os.Handler
+import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@@ -13,8 +14,11 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.annotation.Keep
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
import org.dolphinemu.dolphinemu.DolphinApplication
import org.dolphinemu.dolphinemu.utils.LooperThread
+import java.util.concurrent.atomic.AtomicBoolean
/**
* This class interfaces with the native ControllerInterface,
@@ -24,6 +28,16 @@ object ControllerInterface {
private var inputDeviceListener: InputDeviceListener? = null
private lateinit var looperThread: LooperThread
+ private var inputStateUpdatePending = AtomicBoolean(false)
+ private val inputStateVersion = MutableLiveData(0)
+ private val devicesVersion = MutableLiveData(0)
+
+ val inputStateChanged: LiveData<Int>
+ get() = inputStateVersion
+
+ val devicesChanged: LiveData<Int>
+ get() = devicesVersion
+
/**
* Activities which want to pass on inputs to native code
* should call this in their own dispatchKeyEvent method.
@@ -31,7 +45,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the event should be passed on to the default dispatchKeyEvent.
*/
- external fun dispatchKeyEvent(event: KeyEvent): Boolean
+ fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val result = dispatchKeyEventNative(event)
+ onInputStateChanged()
+ return result
+ }
+
+ private external fun dispatchKeyEventNative(event: KeyEvent): Boolean
/**
* Activities which want to pass on inputs to native code
@@ -40,7 +60,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the event should be passed on to the default dispatchGenericMotionEvent.
*/
- external fun dispatchGenericMotionEvent(event: MotionEvent): Boolean
+ fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ val result = dispatchGenericMotionEventNative(event)
+ onInputStateChanged()
+ return result
+ }
+
+ private external fun dispatchGenericMotionEventNative(event: MotionEvent): Boolean
/**
* [DolphinSensorEventListener] calls this for each axis of a received SensorEvent.
@@ -48,7 +74,13 @@ object ControllerInterface {
* @return true if the emulator core seems to be interested in this event.
* false if the sensor can be suspended to save battery.
*/
- external fun dispatchSensorEvent(
+ fun dispatchSensorEvent(deviceQualifier: String, axisName: String, value: Float): Boolean {
+ val result = dispatchSensorEventNative(deviceQualifier, axisName, value)
+ onInputStateChanged()
+ return result
+ }
+
+ private external fun dispatchSensorEventNative(
deviceQualifier: String,
axisName: String,
value: Float
@@ -76,6 +108,27 @@ object ControllerInterface {
external fun getDevice(deviceString: String): CoreDevice?
+ private fun onInputStateChanged() {
+ // When a single SensorEvent is dispatched, this method is likely to get called many times.
+ // For the sake of performance, let's batch input state updates so that observers only have
+ // to process one update.
+ if (!inputStateUpdatePending.getAndSet(true)) {
+ Handler(Looper.getMainLooper()).post {
+ if (inputStateUpdatePending.getAndSet(false)) {
+ inputStateVersion.value = inputStateVersion.value?.plus(1)
+ }
+ }
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ private fun onDevicesChanged() {
+ Handler(Looper.getMainLooper()).post {
+ devicesVersion.value = devicesVersion.value?.plus(1)
+ }
+ }
+
@Keep
@JvmStatic
private fun registerInputDeviceListener() {
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/CoreDevice.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/CoreDevice.kt
index 1483422c50..53f8864123 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/CoreDevice.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/CoreDevice.kt
@@ -18,6 +18,8 @@ class CoreDevice private constructor(private val pointer: Long) {
@Keep
inner class Control private constructor(private val pointer: Long) {
external fun getName(): String
+
+ external fun getState(): Double
}
protected external fun finalize()
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/view/InputMappingControlSetting.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/view/InputMappingControlSetting.kt
index 26a86a9411..4220e9fcab 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/view/InputMappingControlSetting.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/model/view/InputMappingControlSetting.kt
@@ -18,6 +18,9 @@ class InputMappingControlSetting(var control: Control, val controller: EmulatedC
controller.updateSingleControlReference(controlReference)
}
+ val state: Double
+ get() = controlReference.getState()
+
fun clearValue() {
value = ""
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlAdapter.kt
index 1c82616726..cb20809266 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlAdapter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlAdapter.kt
@@ -4,21 +4,27 @@ package org.dolphinemu.dolphinemu.features.input.ui
import android.view.LayoutInflater
import android.view.ViewGroup
+import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.RecyclerView
import org.dolphinemu.dolphinemu.databinding.ListItemAdvancedMappingControlBinding
+import org.dolphinemu.dolphinemu.features.input.model.CoreDevice
import java.util.function.Consumer
-class AdvancedMappingControlAdapter(private val onClickCallback: Consumer<String>) :
- RecyclerView.Adapter<AdvancedMappingControlViewHolder>() {
- private var controls = emptyArray<String>()
+class AdvancedMappingControlAdapter(
+ private val parentLifecycle: Lifecycle,
+ private val isInput: Boolean,
+ private val onClickCallback: Consumer<String>
+) : RecyclerView.Adapter<AdvancedMappingControlViewHolder>() {
+
+ private var controls = emptyArray<CoreDevice.Control>()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): AdvancedMappingControlViewHolder {
val inflater = LayoutInflater.from(parent.context)
- val binding = ListItemAdvancedMappingControlBinding.inflate(inflater)
- return AdvancedMappingControlViewHolder(binding, onClickCallback)
+ val binding = ListItemAdvancedMappingControlBinding.inflate(inflater, parent, false)
+ return AdvancedMappingControlViewHolder(binding, parentLifecycle, isInput, onClickCallback)
}
override fun onBindViewHolder(holder: AdvancedMappingControlViewHolder, position: Int) =
@@ -26,8 +32,23 @@ class AdvancedMappingControlAdapter(private val onClickCallback: Consumer<String
override fun getItemCount(): Int = controls.size
- fun setControls(controls: Array<String>) {
+ fun setControls(controls: Array<CoreDevice.Control>) {
this.controls = controls
notifyDataSetChanged()
}
+
+ override fun onViewRecycled(holder: AdvancedMappingControlViewHolder) {
+ super.onViewRecycled(holder)
+ holder.onViewRecycled()
+ }
+
+ override fun onViewAttachedToWindow(holder: AdvancedMappingControlViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ holder.onViewAttachedToWindow()
+ }
+
+ override fun onViewDetachedFromWindow(holder: AdvancedMappingControlViewHolder) {
+ super.onViewDetachedFromWindow(holder)
+ holder.onViewDetachedFromWindow()
+ }
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlViewHolder.kt
index dbfecfc94d..017bc1e642 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlViewHolder.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingControlViewHolder.kt
@@ -2,22 +2,68 @@
package org.dolphinemu.dolphinemu.features.input.ui
-import androidx.recyclerview.widget.RecyclerView
+import android.view.View
+import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.databinding.ListItemAdvancedMappingControlBinding
+import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
+import org.dolphinemu.dolphinemu.features.input.model.CoreDevice
+import org.dolphinemu.dolphinemu.utils.LifecycleViewHolder
+import java.text.DecimalFormat
import java.util.function.Consumer
+import kotlin.math.abs
class AdvancedMappingControlViewHolder(
private val binding: ListItemAdvancedMappingControlBinding,
+ private val parentLifecycle: Lifecycle,
+ private val isInput: Boolean,
onClickCallback: Consumer<String>
-) : RecyclerView.ViewHolder(binding.root) {
- private lateinit var name: String
+) : LifecycleViewHolder(binding.root, parentLifecycle) {
+
+ private lateinit var control: CoreDevice.Control
+
+ private var previousState = Float.POSITIVE_INFINITY
init {
- binding.root.setOnClickListener { onClickCallback.accept(name) }
+ binding.root.setOnClickListener { onClickCallback.accept(control.getName()) }
+ if (isInput) {
+ ControllerInterface.inputStateChanged.observe(this) {
+ updateInputValue()
+ }
+ } else {
+ binding.layoutState.visibility = View.GONE
+ }
+ }
+
+ fun bind(control: CoreDevice.Control) {
+ this.control = control
+ binding.textName.text = control.getName()
+ if (isInput) {
+ updateInputValue()
+ }
+ }
+
+ private fun updateInputValue() {
+ if (parentLifecycle.currentState == Lifecycle.State.DESTROYED) {
+ throw IllegalStateException("AdvancedMappingControlViewHolder leak")
+ }
+
+ var state = control.getState().toFloat()
+ if (abs(state - previousState) >= stateUpdateThreshold) {
+ previousState = state
+
+ // Don't print a minus sign for signed zeroes
+ if (state == -0.0f)
+ state = 0.0f
+
+ binding.textState.setText(stateFormat.format(state))
+ binding.controlStateBar.state = state
+ }
}
- fun bind(name: String) {
- this.name = name
- binding.textName.text = name
+ companion object {
+ private val stateFormat = DecimalFormat("#.####")
+
+ // For performance, require state to change by a certain threshold before we update the UI
+ private val stateUpdateThreshold = 0.00005f
}
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingDialog.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingDialog.kt
index 7d1c83a217..803f6e4cc6 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingDialog.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/AdvancedMappingDialog.kt
@@ -3,6 +3,8 @@
package org.dolphinemu.dolphinemu.features.input.ui
import android.content.Context
+import android.view.KeyEvent
+import android.view.MotionEvent
import android.view.View
import android.widget.AdapterView
import android.widget.AdapterView.OnItemClickListener
@@ -23,7 +25,7 @@ class AdvancedMappingDialog(
private val controlReference: ControlReference,
private val controller: EmulatedController
) : AlertDialog(context), OnItemClickListener {
- private val devices: Array<String> = ControllerInterface.getAllDeviceStrings()
+ private lateinit var devices: Array<String>
private val controlAdapter: AdvancedMappingControlAdapter
private lateinit var selectedDevice: String
@@ -34,12 +36,9 @@ class AdvancedMappingDialog(
binding.dropdownDevice.onItemClickListener = this
- val deviceAdapter =
- ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, devices)
- binding.dropdownDevice.setAdapter(deviceAdapter)
-
- controlAdapter =
- AdvancedMappingControlAdapter { control: String -> onControlClicked(control) }
+ controlAdapter = AdvancedMappingControlAdapter(lifecycle, controlReference.isInput()) {
+ control: String -> onControlClicked(control)
+ }
binding.listControl.adapter = controlAdapter
binding.listControl.layoutManager = LinearLayoutManager(context)
@@ -49,6 +48,12 @@ class AdvancedMappingDialog(
binding.editExpression.setText(controlReference.getExpression())
+ ControllerInterface.devicesChanged.observe(this) {
+ onDevicesChanged()
+ setSelectedDevice(selectedDevice)
+ }
+
+ onDevicesChanged()
selectDefaultDevice()
}
@@ -59,6 +64,23 @@ class AdvancedMappingDialog(
override fun onItemClick(adapterView: AdapterView<*>?, view: View, position: Int, id: Long) =
setSelectedDevice(devices[position])
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ ControllerInterface.dispatchKeyEvent(event)
+ return super.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ ControllerInterface.dispatchGenericMotionEvent(event)
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ private fun onDevicesChanged() {
+ devices = ControllerInterface.getAllDeviceStrings()
+ binding.dropdownDevice.setAdapter(
+ ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, devices)
+ )
+ }
+
private fun setSelectedDevice(deviceString: String) {
selectedDevice = deviceString
@@ -72,7 +94,7 @@ class AdvancedMappingDialog(
}
private fun setControls(controls: Array<CoreDevice.Control>) =
- controlAdapter.setControls(controls.map { it.getName() }.toTypedArray())
+ controlAdapter.setControls(controls)
private fun onControlClicked(control: String) {
val expression =
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarHorizontal.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarHorizontal.kt
new file mode 100644
index 0000000000..a7a02921e8
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarHorizontal.kt
@@ -0,0 +1,25 @@
+package org.dolphinemu.dolphinemu.features.input.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import org.dolphinemu.dolphinemu.databinding.ViewControlStateBarHorizontalBinding
+
+class ControlStateBarHorizontal @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ private val binding =
+ ViewControlStateBarHorizontalBinding.inflate(LayoutInflater.from(context), this)
+
+ private val impl = ControlStateBarImpl(binding.viewFilled, binding.viewUnfilled)
+
+ var state: Float
+ get() = impl.state
+ set(value) {
+ impl.state = value
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarImpl.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarImpl.kt
new file mode 100644
index 0000000000..027448b0e3
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarImpl.kt
@@ -0,0 +1,28 @@
+package org.dolphinemu.dolphinemu.features.input.ui
+
+import android.view.View
+import android.widget.LinearLayout
+import androidx.core.math.MathUtils
+
+class ControlStateBarImpl(private val filledView: View, private val unfilledView: View) {
+ private var _state = 0.0f
+
+ var state: Float
+ get() = _state
+ set(value) {
+ val clampedState = MathUtils.clamp(value, 0.0f, 1.0f)
+ if (clampedState != _state) {
+ setLinearLayoutWeight(filledView, clampedState)
+ setLinearLayoutWeight(unfilledView, 1.0f - clampedState)
+ }
+ _state = clampedState
+ }
+
+ companion object {
+ private fun setLinearLayoutWeight(view: View, weight: Float) {
+ val layoutParams = view.layoutParams as LinearLayout.LayoutParams
+ layoutParams.weight = weight
+ view.layoutParams = layoutParams
+ }
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarVertical.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarVertical.kt
new file mode 100644
index 0000000000..cb7c37ad70
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/ControlStateBarVertical.kt
@@ -0,0 +1,30 @@
+package org.dolphinemu.dolphinemu.features.input.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import org.dolphinemu.dolphinemu.databinding.ViewControlStateBarVerticalBinding
+
+class ControlStateBarVertical @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+ init {
+ // Setting this in the XML file has no effect for some reason
+ orientation = VERTICAL
+ }
+
+ private val binding =
+ ViewControlStateBarVerticalBinding.inflate(LayoutInflater.from(context), this)
+
+ private val impl = ControlStateBarImpl(binding.viewFilled, binding.viewUnfilled)
+
+ var state: Float
+ get() = impl.state
+ set(value) {
+ impl.state = value
+ }
+}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/viewholder/InputMappingControlSettingViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/viewholder/InputMappingControlSettingViewHolder.kt
index 5bbcbf07c4..312fc9f6a5 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/viewholder/InputMappingControlSettingViewHolder.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/input/ui/viewholder/InputMappingControlSettingViewHolder.kt
@@ -3,18 +3,30 @@
package org.dolphinemu.dolphinemu.features.input.ui.viewholder
import android.view.View
+import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.databinding.ListItemMappingBinding
+import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.input.model.view.InputMappingControlSetting
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter
import org.dolphinemu.dolphinemu.features.settings.ui.viewholder.SettingViewHolder
+import kotlin.math.abs
class InputMappingControlSettingViewHolder(
private val binding: ListItemMappingBinding,
adapter: SettingsAdapter
) : SettingViewHolder(binding.getRoot(), adapter) {
+
lateinit var setting: InputMappingControlSetting
+ private var previousState = Float.POSITIVE_INFINITY
+
+ init {
+ ControllerInterface.inputStateChanged.observe(this) {
+ updateInputValue()
+ }
+ }
+
override val item: SettingsItem
get() = setting
@@ -25,7 +37,12 @@ class InputMappingControlSettingViewHolder(
binding.textSettingDescription.text = setting.value
binding.buttonAdvancedSettings.setOnClickListener { clicked: View -> onLongClick(clicked) }
+ if (!setting.isInput) {
+ binding.controlStateBar.visibility = View.INVISIBLE
+ }
+
setStyle(binding.textSettingName, setting)
+ updateInputValue()
}
override fun onClick(clicked: View) {
@@ -52,4 +69,23 @@ class InputMappingControlSettingViewHolder(
return true
}
+
+ private fun updateInputValue() {
+ if (adapter.getFragmentLifecycle().currentState == Lifecycle.State.DESTROYED) {
+ throw IllegalStateException("InputMappingControlSettingViewHolder leak")
+ }
+
+ if (setting.isInput) {
+ val state = setting.state.toFloat()
+ if (abs(state - previousState) >= stateUpdateThreshold) {
+ previousState = state
+ binding.controlStateBar.state = state
+ }
+ }
+ }
+
+ companion object {
+ // For performance, require state to change by a certain threshold before we update the UI
+ private val stateUpdateThreshold = 0.01f
+ }
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/riivolution/ui/RiivolutionAdapter.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/riivolution/ui/RiivolutionAdapter.kt
index 9cae2f0bae..d64e10a111 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/riivolution/ui/RiivolutionAdapter.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/riivolution/ui/RiivolutionAdapter.kt
@@ -32,7 +32,7 @@ class RiivolutionAdapter(private val context: Context, private val patches: Riiv
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RiivolutionViewHolder {
val inflater = LayoutInflater.from(parent.context)
- val binding = ListItemRiivolutionBinding.inflate(inflater)
+ val binding = ListItemRiivolutionBinding.inflate(inflater, parent, false)
return RiivolutionViewHolder(binding.root, binding)
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt
index 63a3992487..e5db2f63d2 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsActivity.kt
@@ -11,7 +11,9 @@ import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.view.KeyEvent
import android.view.Menu
+import android.view.MotionEvent
import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
@@ -22,11 +24,11 @@ import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.appbar.CollapsingToolbarLayout
-import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.ActivitySettingsBinding
+import org.dolphinemu.dolphinemu.features.input.model.ControllerInterface
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsFragment.Companion.newInstance
import org.dolphinemu.dolphinemu.ui.main.MainPresenter
@@ -197,6 +199,16 @@ class SettingsActivity : AppCompatActivity(), SettingsActivityView {
}
}
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ ControllerInterface.dispatchKeyEvent(event)
+ return super.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ ControllerInterface.dispatchGenericMotionEvent(event)
+ return super.dispatchGenericMotionEvent(event)
+ }
+
private fun canonicalizeIfPossible(uri: Uri): Uri {
val canonicalizedUri = contentResolver.canonicalize(uri)
return canonicalizedUri ?: uri
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 59e76ae178..3ce937e6ab 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
@@ -16,6 +16,7 @@ import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.datepicker.CalendarConstraints
@@ -67,57 +68,48 @@ class SettingsAdapter(
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
SettingsItem.TYPE_HEADER -> HeaderViewHolder(
- ListItemHeaderBinding.inflate(inflater),
+ ListItemHeaderBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_SWITCH -> SwitchSettingViewHolder(
- ListItemSettingSwitchBinding.inflate(inflater),
+ ListItemSettingSwitchBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_STRING_SINGLE_CHOICE,
SettingsItem.TYPE_SINGLE_CHOICE_DYNAMIC_DESCRIPTIONS,
SettingsItem.TYPE_SINGLE_CHOICE -> SingleChoiceViewHolder(
- ListItemSettingBinding.inflate(inflater),
+ ListItemSettingBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_SLIDER -> SliderViewHolder(
- ListItemSettingBinding.inflate(
- inflater
- ), this, context
+ ListItemSettingBinding.inflate(inflater, parent, false),
+ this,
+ context
)
SettingsItem.TYPE_SUBMENU -> SubmenuViewHolder(
- ListItemSubmenuBinding.inflate(
- inflater
- ), this
+ ListItemSubmenuBinding.inflate(inflater, parent, false),
+ this
)
SettingsItem.TYPE_INPUT_MAPPING_CONTROL -> InputMappingControlSettingViewHolder(
- ListItemMappingBinding.inflate(inflater),
+ ListItemMappingBinding.inflate(inflater, parent, false),
this
)
SettingsItem.TYPE_FILE_PICKER -> FilePickerViewHolder(
- ListItemSettingBinding.inflate(
- inflater
- ), this
+ ListItemSettingBinding.inflate(inflater, parent, false),
+ this
)
SettingsItem.TYPE_RUN_RUNNABLE -> RunRunnableViewHolder(
- ListItemSettingBinding.inflate(
- inflater
- ), this, context
+ ListItemSettingBinding.inflate(inflater, parent, false),
+ this, context
)
SettingsItem.TYPE_STRING -> InputStringSettingViewHolder(
- ListItemSettingBinding.inflate(
- inflater
- ), this
+ ListItemSettingBinding.inflate(inflater, parent, false), this
)
SettingsItem.TYPE_HYPERLINK_HEADER -> HeaderHyperLinkViewHolder(
- ListItemHeaderBinding.inflate(
- inflater
- ), this
+ ListItemHeaderBinding.inflate(inflater, parent, false), this
)
SettingsItem.TYPE_DATETIME_CHOICE -> DateTimeSettingViewHolder(
- ListItemSettingBinding.inflate(
- inflater
- ), this
+ ListItemSettingBinding.inflate(inflater, parent, false), this
)
else -> throw IllegalArgumentException("Invalid view type: $viewType")
}
@@ -543,6 +535,25 @@ class SettingsAdapter(
}
}
+ override fun onViewRecycled(holder: SettingViewHolder) {
+ super.onViewRecycled(holder)
+ holder.onViewRecycled()
+ }
+
+ override fun onViewAttachedToWindow(holder: SettingViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ holder.onViewAttachedToWindow()
+ }
+
+ override fun onViewDetachedFromWindow(holder: SettingViewHolder) {
+ super.onViewDetachedFromWindow(holder)
+ holder.onViewDetachedFromWindow()
+ }
+
+ fun getFragmentLifecycle(): Lifecycle {
+ return fragmentView.getFragmentLifecycle()
+ }
+
private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
val valuesId = item.valuesId
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt
index 6a294ee139..991d3c3c2b 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragment.kt
@@ -18,6 +18,7 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
@@ -25,7 +26,6 @@ import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.databinding.FragmentSettingsBinding
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
-import org.dolphinemu.dolphinemu.ui.main.MainActivity
import org.dolphinemu.dolphinemu.ui.main.MainPresenter
import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult
import org.dolphinemu.dolphinemu.utils.SerializableHelper.serializable
@@ -200,6 +200,10 @@ class SettingsFragment : Fragment(), SettingsFragmentView {
.show()
}
+ override fun getFragmentLifecycle(): Lifecycle {
+ return lifecycle
+ }
+
private fun askForDriverFile() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt
index 076beaff6f..b0ad3e28b1 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/SettingsFragmentView.kt
@@ -4,6 +4,7 @@ package org.dolphinemu.dolphinemu.features.settings.ui
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.Lifecycle
import org.dolphinemu.dolphinemu.features.settings.model.Settings
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.utils.GpuDriverInstallResult
@@ -119,4 +120,9 @@ interface SettingsFragmentView {
* Shows a dialog asking the user to install or uninstall a GPU driver
*/
fun showGpuDriverDialog()
+
+ /**
+ * Returns the Lifecycle for the Fragment.
+ */
+ fun getFragmentLifecycle(): Lifecycle
}
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt
index 2054b08f43..dd5d4c97f0 100644
--- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/settings/ui/viewholder/SettingViewHolder.kt
@@ -9,15 +9,17 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.widget.TextView
import android.widget.Toast
-import androidx.recyclerview.widget.RecyclerView
+import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.dolphinemu.dolphinemu.DolphinApplication
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.features.settings.model.view.SettingsItem
import org.dolphinemu.dolphinemu.features.settings.ui.SettingsAdapter
+import org.dolphinemu.dolphinemu.utils.LifecycleViewHolder
abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
- RecyclerView.ViewHolder(itemView), View.OnClickListener, OnLongClickListener {
+ LifecycleViewHolder(itemView, adapter.getFragmentLifecycle()),
+ LifecycleOwner, View.OnClickListener, OnLongClickListener {
init {
itemView.setOnClickListener(this)
diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/LifecycleViewHolder.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/LifecycleViewHolder.kt
new file mode 100644
index 0000000000..47696ac4bb
--- /dev/null
+++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/utils/LifecycleViewHolder.kt
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.dolphinemu.dolphinemu.utils
+
+import android.view.View
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * A ViewHolder with an attached Lifecycle.
+ *
+ * @param itemView The view held by the ViewHolder.
+ * @param parentLifecycle If the passed-in Lifecycle changes state to DESTROYED, this ViewHolder
+ * will also change state to DESTROYED. This should normally be set to the
+ * Lifecycle of the containing Fragment or Activity to prevent instances
+ * of this class from leaking.
+ */
+abstract class LifecycleViewHolder(itemView: View, parentLifecycle: Lifecycle) :
+ RecyclerView.ViewHolder(itemView), LifecycleOwner {
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ init {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ parentLifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ // Make sure this ViewHolder doesn't leak if its lifecycle has observers
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
+ }
+ })
+ }
+
+ /**
+ * To be called from a function overriding [RecyclerView.Adapter.onViewRecycled].
+ */
+ fun onViewRecycled() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ }
+
+ /**
+ * To be called from a function overriding [RecyclerView.Adapter.onViewAttachedToWindow].
+ */
+ fun onViewAttachedToWindow() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ }
+
+ /**
+ * To be called from a function overriding [RecyclerView.Adapter.onViewDetachedFromWindow].
+ */
+ fun onViewDetachedFromWindow() {
+ lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
+ }
+}