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(), * @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 + get() = inputStateVersion + + val devicesChanged: LiveData + 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) : - RecyclerView.Adapter() { - private var controls = emptyArray() +class AdvancedMappingControlAdapter( + private val parentLifecycle: Lifecycle, + private val isInput: Boolean, + private val onClickCallback: Consumer +) : RecyclerView.Adapter() { + + private var controls = emptyArray() 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) { + fun setControls(controls: Array) { 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 -) : 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(name: String) { - this.name = name - binding.textName.text = name + 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 + } + } + + 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 = ControllerInterface.getAllDeviceStrings() + private lateinit var devices: Array 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) = - 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) + } +} diff --git a/Source/Android/app/src/main/res/layout-ldrtl/list_item_mapping.xml b/Source/Android/app/src/main/res/layout-ldrtl/list_item_mapping.xml index 94f219b4c1..e1a2118b36 100644 --- a/Source/Android/app/src/main/res/layout-ldrtl/list_item_mapping.xml +++ b/Source/Android/app/src/main/res/layout-ldrtl/list_item_mapping.xml @@ -22,7 +22,7 @@ android:layout_marginTop="@dimen/spacing_large" android:textSize="16sp" android:textAlignment="viewStart" - android:layout_toStartOf="@+id/button_advanced_settings" + android:layout_toStartOf="@id/control_state_bar" tools:text="Setting Name" /> + +