Browse Source

Merge pull request #12642 from t895/adapter-refactor

android: Refactor list adapters
liamwhite 2 years ago
parent
commit
fcb0dff67c
22 changed files with 665 additions and 588 deletions
  1. 33 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt
  2. 98 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt
  3. 105 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt
  4. 8 28
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt
  5. 36 52
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt
  6. 22 35
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt
  7. 28 73
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt
  8. 7 29
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt
  9. 77 115
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
  10. 9 19
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt
  11. 27 45
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
  12. 13 23
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt
  13. 17 33
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
  14. 16 29
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
  15. 52 24
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt
  16. 27 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt
  17. 49 80
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt
  18. 9 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt
  19. 3 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  20. 0 3
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
  21. 18 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt
  22. 11 0
      src/android/app/src/main/res/menu/menu_driver_manager.xml

+ 33 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt

@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.annotation.SuppressLint
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate
+ * code used in every [RecyclerView].
+ * Type assigned to [Model] must inherit from [Object] in order to be compared properly.
+ */
+abstract class AbstractDiffAdapter<Model : Any, Holder : AbstractViewHolder<Model>> :
+    ListAdapter<Model, Holder>(AsyncDifferConfig.Builder(DiffCallback<Model>()).build()) {
+    override fun onBindViewHolder(holder: Holder, position: Int) =
+        holder.bind(currentList[position])
+
+    private class DiffCallback<Model> : DiffUtil.ItemCallback<Model>() {
+        override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
+            return oldItem === newItem
+        }
+
+        @SuppressLint("DiffUtilEquals")
+        override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
+            return oldItem == newItem
+        }
+    }
+}

+ 98 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt

@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.annotation.SuppressLint
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
+
+/**
+ * Generic list class meant to take care of basic lists
+ * @param currentList The list to show initially
+ */
+abstract class AbstractListAdapter<Model : Any, Holder : AbstractViewHolder<Model>>(
+    open var currentList: List<Model>
+) : RecyclerView.Adapter<Holder>() {
+    override fun onBindViewHolder(holder: Holder, position: Int) =
+        holder.bind(currentList[position])
+
+    override fun getItemCount(): Int = currentList.size
+
+    /**
+     * Adds an item to [currentList] and notifies the underlying adapter of the change. If no parameter
+     * is passed in for position, [item] is added to the end of the list. Invokes [callback] last.
+     * @param item The item to add to the list
+     * @param position Index where [item] will be added
+     * @param callback Lambda that's called at the end of the list changes and has the added list
+     * position passed in as a parameter
+     */
+    open fun addItem(item: Model, position: Int = -1, callback: ((position: Int) -> Unit)? = null) {
+        val newList = currentList.toMutableList()
+        val positionToUpdate: Int
+        if (position == -1) {
+            newList.add(item)
+            currentList = newList
+            positionToUpdate = currentList.size - 1
+        } else {
+            newList.add(position, item)
+            currentList = newList
+            positionToUpdate = position
+        }
+        onItemAdded(positionToUpdate, callback)
+    }
+
+    protected fun onItemAdded(position: Int, callback: ((Int) -> Unit)? = null) {
+        notifyItemInserted(position)
+        callback?.invoke(position)
+    }
+
+    /**
+     * Replaces the [item] at [position] in the [currentList] and notifies the underlying adapter
+     * of the change. Invokes [callback] last.
+     * @param item New list item
+     * @param position Index where [item] will replace the existing list item
+     * @param callback Lambda that's called at the end of the list changes and has the changed list
+     * position passed in as a parameter
+     */
+    fun changeItem(item: Model, position: Int, callback: ((position: Int) -> Unit)? = null) {
+        val newList = currentList.toMutableList()
+        newList[position] = item
+        currentList = newList
+        onItemChanged(position, callback)
+    }
+
+    protected fun onItemChanged(position: Int, callback: ((Int) -> Unit)? = null) {
+        notifyItemChanged(position)
+        callback?.invoke(position)
+    }
+
+    /**
+     * Removes the list item at [position] in [currentList] and notifies the underlying adapter
+     * of the change. Invokes [callback] last.
+     * @param position Index where the list item will be removed
+     * @param callback Lambda that's called at the end of the list changes and has the removed list
+     * position passed in as a parameter
+     */
+    fun removeItem(position: Int, callback: ((position: Int) -> Unit)? = null) {
+        val newList = currentList.toMutableList()
+        newList.removeAt(position)
+        currentList = newList
+        onItemRemoved(position, callback)
+    }
+
+    protected fun onItemRemoved(position: Int, callback: ((Int) -> Unit)? = null) {
+        notifyItemRemoved(position)
+        callback?.invoke(position)
+    }
+
+    /**
+     * Replaces [currentList] with [newList] and notifies the underlying adapter of the change.
+     * @param newList The new list to replace [currentList]
+     */
+    @SuppressLint("NotifyDataSetChanged")
+    open fun replaceList(newList: List<Model>) {
+        currentList = newList
+        notifyDataSetChanged()
+    }
+}

+ 105 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt

@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import org.yuzu.yuzu_emu.model.SelectableItem
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
+
+/**
+ * Generic list class meant to take care of single selection UI updates
+ * @param currentList The list to show initially
+ * @param defaultSelection The default selection to use if no list items are selected by
+ * [SelectableItem.selected] or if the currently selected item is removed from the list
+ */
+abstract class AbstractSingleSelectionList<
+    Model : SelectableItem,
+    Holder : AbstractViewHolder<Model>
+    >(
+    final override var currentList: List<Model>,
+    private val defaultSelection: DefaultSelection = DefaultSelection.Start
+) : AbstractListAdapter<Model, Holder>(currentList) {
+    var selectedItem = getDefaultSelection()
+
+    init {
+        findSelectedItem()
+    }
+
+    /**
+     * Changes the selection state of the [SelectableItem] that was selected and the previously selected
+     * item and notifies the underlying adapter of the change for those items. Invokes [callback] last.
+     * Does nothing if [position] is the same as the currently selected item.
+     * @param position Index of the item that was selected
+     * @param callback Lambda that's called at the end of the list changes and has the selected list
+     * position passed in as a parameter
+     */
+    fun selectItem(position: Int, callback: ((position: Int) -> Unit)? = null) {
+        if (position == selectedItem) {
+            return
+        }
+
+        val previouslySelectedItem = selectedItem
+        selectedItem = position
+        if (currentList.indices.contains(selectedItem)) {
+            currentList[selectedItem].onSelectionStateChanged(true)
+        }
+        if (currentList.indices.contains(previouslySelectedItem)) {
+            currentList[previouslySelectedItem].onSelectionStateChanged(false)
+        }
+        onItemChanged(previouslySelectedItem)
+        onItemChanged(selectedItem)
+        callback?.invoke(position)
+    }
+
+    /**
+     * Removes a given item from the list and notifies the underlying adapter of the change. If the
+     * currently selected item was the item that was removed, the item at the position provided
+     * by [defaultSelection] will be made the new selection. Invokes [callback] last.
+     * @param position Index of the item that was removed
+     * @param callback Lambda that's called at the end of the list changes and has the removed and
+     * selected list positions passed in as parameters
+     */
+    fun removeSelectableItem(
+        position: Int,
+        callback: ((removedPosition: Int, selectedPosition: Int) -> Unit)?
+    ) {
+        removeItem(position)
+        if (position == selectedItem) {
+            selectedItem = getDefaultSelection()
+            currentList[selectedItem].onSelectionStateChanged(true)
+            onItemChanged(selectedItem)
+        } else if (position < selectedItem) {
+            selectedItem--
+        }
+        callback?.invoke(position, selectedItem)
+    }
+
+    override fun addItem(item: Model, position: Int, callback: ((Int) -> Unit)?) {
+        super.addItem(item, position, callback)
+        if (position <= selectedItem && position != -1) {
+            selectedItem++
+        }
+    }
+
+    override fun replaceList(newList: List<Model>) {
+        super.replaceList(newList)
+        findSelectedItem()
+    }
+
+    private fun findSelectedItem() {
+        for (i in currentList.indices) {
+            if (currentList[i].selected) {
+                selectedItem = i
+                break
+            }
+        }
+    }
+
+    private fun getDefaultSelection(): Int =
+        when (defaultSelection) {
+            DefaultSelection.Start -> currentList.indices.first
+            DefaultSelection.End -> currentList.indices.last
+        }
+
+    enum class DefaultSelection { Start, End }
+}

+ 8 - 28
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt

@@ -5,48 +5,28 @@ package org.yuzu.yuzu_emu.adapters
 
 import android.view.LayoutInflater
 import android.view.ViewGroup
-import androidx.recyclerview.widget.AsyncDifferConfig
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
 import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding
 import org.yuzu.yuzu_emu.model.Addon
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class AddonAdapter : ListAdapter<Addon, AddonAdapter.AddonViewHolder>(
-    AsyncDifferConfig.Builder(DiffCallback()).build()
-) {
+class AddonAdapter : AbstractDiffAdapter<Addon, AddonAdapter.AddonViewHolder>() {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
         ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
             .also { return AddonViewHolder(it) }
     }
 
-    override fun getItemCount(): Int = currentList.size
-
-    override fun onBindViewHolder(holder: AddonViewHolder, position: Int) =
-        holder.bind(currentList[position])
-
     inner class AddonViewHolder(val binding: ListItemAddonBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        fun bind(addon: Addon) {
+        AbstractViewHolder<Addon>(binding) {
+        override fun bind(model: Addon) {
             binding.root.setOnClickListener {
                 binding.addonSwitch.isChecked = !binding.addonSwitch.isChecked
             }
-            binding.title.text = addon.title
-            binding.version.text = addon.version
+            binding.title.text = model.title
+            binding.version.text = model.version
             binding.addonSwitch.setOnCheckedChangeListener { _, checked ->
-                addon.enabled = checked
+                model.enabled = checked
             }
-            binding.addonSwitch.isChecked = addon.enabled
-        }
-    }
-
-    private class DiffCallback : DiffUtil.ItemCallback<Addon>() {
-        override fun areItemsTheSame(oldItem: Addon, newItem: Addon): Boolean {
-            return oldItem == newItem
-        }
-
-        override fun areContentsTheSame(oldItem: Addon, newItem: Addon): Boolean {
-            return oldItem == newItem
+            binding.addonSwitch.isChecked = model.enabled
         }
     }
 }

+ 36 - 52
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt

@@ -4,13 +4,11 @@
 package org.yuzu.yuzu_emu.adapters
 
 import android.view.LayoutInflater
-import android.view.View
 import android.view.ViewGroup
 import android.widget.Toast
 import androidx.core.content.res.ResourcesCompat
 import androidx.fragment.app.FragmentActivity
 import androidx.navigation.findNavController
-import androidx.recyclerview.widget.RecyclerView
 import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
@@ -19,72 +17,58 @@ import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
 import org.yuzu.yuzu_emu.model.Applet
 import org.yuzu.yuzu_emu.model.AppletInfo
 import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class AppletAdapter(val activity: FragmentActivity, var applets: List<Applet>) :
-    RecyclerView.Adapter<AppletAdapter.AppletViewHolder>(),
-    View.OnClickListener {
-
+class AppletAdapter(val activity: FragmentActivity, applets: List<Applet>) :
+    AbstractListAdapter<Applet, AppletAdapter.AppletViewHolder>(applets) {
     override fun onCreateViewHolder(
         parent: ViewGroup,
         viewType: Int
     ): AppletAdapter.AppletViewHolder {
         CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-            .apply { root.setOnClickListener(this@AppletAdapter) }
             .also { return AppletViewHolder(it) }
     }
 
-    override fun onBindViewHolder(holder: AppletViewHolder, position: Int) =
-        holder.bind(applets[position])
-
-    override fun getItemCount(): Int = applets.size
-
-    override fun onClick(view: View) {
-        val applet = (view.tag as AppletViewHolder).applet
-        val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId)
-        if (appletPath.isEmpty()) {
-            Toast.makeText(
-                YuzuApplication.appContext,
-                R.string.applets_error_applet,
-                Toast.LENGTH_SHORT
-            ).show()
-            return
-        }
-
-        if (applet.appletInfo == AppletInfo.Cabinet) {
-            view.findNavController()
-                .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment)
-            return
-        }
-
-        NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId)
-        val appletGame = Game(
-            title = YuzuApplication.appContext.getString(applet.titleId),
-            path = appletPath
-        )
-        val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame)
-        view.findNavController().navigate(action)
-    }
-
     inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        lateinit var applet: Applet
-
-        init {
-            itemView.tag = this
-        }
-
-        fun bind(applet: Applet) {
-            this.applet = applet
-
-            binding.title.setText(applet.titleId)
-            binding.description.setText(applet.descriptionId)
+        AbstractViewHolder<Applet>(binding) {
+        override fun bind(model: Applet) {
+            binding.title.setText(model.titleId)
+            binding.description.setText(model.descriptionId)
             binding.icon.setImageDrawable(
                 ResourcesCompat.getDrawable(
                     binding.icon.context.resources,
-                    applet.iconId,
+                    model.iconId,
                     binding.icon.context.theme
                 )
             )
+
+            binding.root.setOnClickListener { onClick(model) }
+        }
+
+        fun onClick(applet: Applet) {
+            val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId)
+            if (appletPath.isEmpty()) {
+                Toast.makeText(
+                    binding.root.context,
+                    R.string.applets_error_applet,
+                    Toast.LENGTH_SHORT
+                ).show()
+                return
+            }
+
+            if (applet.appletInfo == AppletInfo.Cabinet) {
+                binding.root.findNavController()
+                    .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment)
+                return
+            }
+
+            NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId)
+            val appletGame = Game(
+                title = YuzuApplication.appContext.getString(applet.titleId),
+                path = appletPath
+            )
+            val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame)
+            binding.root.findNavController().navigate(action)
         }
     }
 }

+ 22 - 35
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt

@@ -4,12 +4,10 @@
 package org.yuzu.yuzu_emu.adapters
 
 import android.view.LayoutInflater
-import android.view.View
 import android.view.ViewGroup
 import androidx.core.content.res.ResourcesCompat
 import androidx.fragment.app.Fragment
 import androidx.navigation.fragment.findNavController
-import androidx.recyclerview.widget.RecyclerView
 import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.NativeLibrary
 import org.yuzu.yuzu_emu.R
@@ -19,54 +17,43 @@ import org.yuzu.yuzu_emu.model.CabinetMode
 import org.yuzu.yuzu_emu.adapters.CabinetLauncherDialogAdapter.CabinetModeViewHolder
 import org.yuzu.yuzu_emu.model.AppletInfo
 import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class CabinetLauncherDialogAdapter(val fragment: Fragment) :
-    RecyclerView.Adapter<CabinetModeViewHolder>(),
-    View.OnClickListener {
-    private val cabinetModes = CabinetMode.values().copyOfRange(1, CabinetMode.values().size)
+    AbstractListAdapter<CabinetMode, CabinetModeViewHolder>(
+        CabinetMode.values().copyOfRange(1, CabinetMode.entries.size).toList()
+    ) {
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CabinetModeViewHolder {
         DialogListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-            .apply { root.setOnClickListener(this@CabinetLauncherDialogAdapter) }
             .also { return CabinetModeViewHolder(it) }
     }
 
-    override fun getItemCount(): Int = cabinetModes.size
-
-    override fun onBindViewHolder(holder: CabinetModeViewHolder, position: Int) =
-        holder.bind(cabinetModes[position])
-
-    override fun onClick(view: View) {
-        val mode = (view.tag as CabinetModeViewHolder).cabinetMode
-        val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.Cabinet.entryId)
-        NativeLibrary.setCurrentAppletId(AppletInfo.Cabinet.appletId)
-        NativeLibrary.setCabinetMode(mode.id)
-        val appletGame = Game(
-            title = YuzuApplication.appContext.getString(R.string.cabinet_applet),
-            path = appletPath
-        )
-        val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame)
-        fragment.findNavController().navigate(action)
-    }
-
     inner class CabinetModeViewHolder(val binding: DialogListItemBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        lateinit var cabinetMode: CabinetMode
-
-        init {
-            itemView.tag = this
-        }
-
-        fun bind(cabinetMode: CabinetMode) {
-            this.cabinetMode = cabinetMode
+        AbstractViewHolder<CabinetMode>(binding) {
+        override fun bind(model: CabinetMode) {
             binding.icon.setImageDrawable(
                 ResourcesCompat.getDrawable(
                     binding.icon.context.resources,
-                    cabinetMode.iconId,
+                    model.iconId,
                     binding.icon.context.theme
                 )
             )
-            binding.title.setText(cabinetMode.titleId)
+            binding.title.setText(model.titleId)
+
+            binding.root.setOnClickListener { onClick(model) }
+        }
+
+        private fun onClick(mode: CabinetMode) {
+            val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.Cabinet.entryId)
+            NativeLibrary.setCurrentAppletId(AppletInfo.Cabinet.appletId)
+            NativeLibrary.setCabinetMode(mode.id)
+            val appletGame = Game(
+                title = YuzuApplication.appContext.getString(R.string.cabinet_applet),
+                path = appletPath
+            )
+            val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame)
+            fragment.findNavController().navigate(action)
         }
     }
 }

+ 28 - 73
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt

@@ -7,65 +7,39 @@ import android.text.TextUtils
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import androidx.recyclerview.widget.AsyncDifferConfig
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.CardDriverOptionBinding
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.model.Driver
 import org.yuzu.yuzu_emu.model.DriverViewModel
-import org.yuzu.yuzu_emu.utils.GpuDriverHelper
-import org.yuzu.yuzu_emu.utils.GpuDriverMetadata
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class DriverAdapter(private val driverViewModel: DriverViewModel) :
-    ListAdapter<Pair<String, GpuDriverMetadata>, DriverAdapter.DriverViewHolder>(
-        AsyncDifferConfig.Builder(DiffCallback()).build()
+    AbstractSingleSelectionList<Driver, DriverAdapter.DriverViewHolder>(
+        driverViewModel.driverList.value
     ) {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder {
-        val binding =
-            CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        return DriverViewHolder(binding)
-    }
-
-    override fun getItemCount(): Int = currentList.size
-
-    override fun onBindViewHolder(holder: DriverViewHolder, position: Int) =
-        holder.bind(currentList[position])
-
-    private fun onSelectDriver(position: Int) {
-        driverViewModel.setSelectedDriverIndex(position)
-        notifyItemChanged(driverViewModel.previouslySelectedDriver)
-        notifyItemChanged(driverViewModel.selectedDriver)
-    }
-
-    private fun onDeleteDriver(driverData: Pair<String, GpuDriverMetadata>, position: Int) {
-        if (driverViewModel.selectedDriver > position) {
-            driverViewModel.setSelectedDriverIndex(driverViewModel.selectedDriver - 1)
-        }
-        if (GpuDriverHelper.customDriverSettingData == driverData.second) {
-            driverViewModel.setSelectedDriverIndex(0)
-        }
-        driverViewModel.driversToDelete.add(driverData.first)
-        driverViewModel.removeDriver(driverData)
-        notifyItemRemoved(position)
-        notifyItemChanged(driverViewModel.selectedDriver)
+        CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return DriverViewHolder(it) }
     }
 
     inner class DriverViewHolder(val binding: CardDriverOptionBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        private lateinit var driverData: Pair<String, GpuDriverMetadata>
-
-        fun bind(driverData: Pair<String, GpuDriverMetadata>) {
-            this.driverData = driverData
-            val driver = driverData.second
-
+        AbstractViewHolder<Driver>(binding) {
+        override fun bind(model: Driver) {
             binding.apply {
-                radioButton.isChecked = driverViewModel.selectedDriver == bindingAdapterPosition
+                radioButton.isChecked = model.selected
                 root.setOnClickListener {
-                    onSelectDriver(bindingAdapterPosition)
+                    selectItem(bindingAdapterPosition) {
+                        driverViewModel.onDriverSelected(it)
+                        driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global)
+                    }
                 }
                 buttonDelete.setOnClickListener {
-                    onDeleteDriver(driverData, bindingAdapterPosition)
+                    removeSelectableItem(
+                        bindingAdapterPosition
+                    ) { removedPosition: Int, selectedPosition: Int ->
+                        driverViewModel.onDriverRemoved(removedPosition, selectedPosition)
+                        driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global)
+                    }
                 }
 
                 // Delay marquee by 3s
@@ -80,38 +54,19 @@ class DriverAdapter(private val driverViewModel: DriverViewModel) :
                     },
                     3000
                 )
-                if (driver.name == null) {
-                    title.setText(R.string.system_gpu_driver)
-                    description.text = ""
-                    version.text = ""
-                    version.visibility = View.GONE
-                    description.visibility = View.GONE
-                    buttonDelete.visibility = View.GONE
-                } else {
-                    title.text = driver.name
-                    version.text = driver.version
-                    description.text = driver.description
+                title.text = model.title
+                version.text = model.version
+                description.text = model.description
+                if (model.description.isNotEmpty()) {
                     version.visibility = View.VISIBLE
                     description.visibility = View.VISIBLE
                     buttonDelete.visibility = View.VISIBLE
+                } else {
+                    version.visibility = View.GONE
+                    description.visibility = View.GONE
+                    buttonDelete.visibility = View.GONE
                 }
             }
         }
     }
-
-    private class DiffCallback : DiffUtil.ItemCallback<Pair<String, GpuDriverMetadata>>() {
-        override fun areItemsTheSame(
-            oldItem: Pair<String, GpuDriverMetadata>,
-            newItem: Pair<String, GpuDriverMetadata>
-        ): Boolean {
-            return oldItem.first == newItem.first
-        }
-
-        override fun areContentsTheSame(
-            oldItem: Pair<String, GpuDriverMetadata>,
-            newItem: Pair<String, GpuDriverMetadata>
-        ): Boolean {
-            return oldItem.second == newItem.second
-        }
-    }
 }

+ 7 - 29
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt

@@ -8,19 +8,14 @@ import android.text.TextUtils
 import android.view.LayoutInflater
 import android.view.ViewGroup
 import androidx.fragment.app.FragmentActivity
-import androidx.recyclerview.widget.AsyncDifferConfig
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
 import org.yuzu.yuzu_emu.databinding.CardFolderBinding
 import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment
 import org.yuzu.yuzu_emu.model.GameDir
 import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) :
-    ListAdapter<GameDir, FolderAdapter.FolderViewHolder>(
-        AsyncDifferConfig.Builder(DiffCallback()).build()
-    ) {
+    AbstractDiffAdapter<GameDir, FolderAdapter.FolderViewHolder>() {
     override fun onCreateViewHolder(
         parent: ViewGroup,
         viewType: Int
@@ -29,18 +24,11 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie
             .also { return FolderViewHolder(it) }
     }
 
-    override fun onBindViewHolder(holder: FolderAdapter.FolderViewHolder, position: Int) =
-        holder.bind(currentList[position])
-
     inner class FolderViewHolder(val binding: CardFolderBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        private lateinit var gameDir: GameDir
-
-        fun bind(gameDir: GameDir) {
-            this.gameDir = gameDir
-
+        AbstractViewHolder<GameDir>(binding) {
+        override fun bind(model: GameDir) {
             binding.apply {
-                path.text = Uri.parse(gameDir.uriString).path
+                path.text = Uri.parse(model.uriString).path
                 path.postDelayed(
                     {
                         path.isSelected = true
@@ -50,7 +38,7 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie
                 )
 
                 buttonEdit.setOnClickListener {
-                    GameFolderPropertiesDialogFragment.newInstance(this@FolderViewHolder.gameDir)
+                    GameFolderPropertiesDialogFragment.newInstance(model)
                         .show(
                             activity.supportFragmentManager,
                             GameFolderPropertiesDialogFragment.TAG
@@ -58,19 +46,9 @@ class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesVie
                 }
 
                 buttonDelete.setOnClickListener {
-                    gamesViewModel.removeFolder(this@FolderViewHolder.gameDir)
+                    gamesViewModel.removeFolder(model)
                 }
             }
         }
     }
-
-    private class DiffCallback : DiffUtil.ItemCallback<GameDir>() {
-        override fun areItemsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
-            return oldItem == newItem
-        }
-
-        override fun areContentsTheSame(oldItem: GameDir, newItem: GameDir): Boolean {
-            return oldItem == newItem
-        }
-    }
 }

+ 77 - 115
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt

@@ -9,7 +9,6 @@ import android.graphics.drawable.LayerDrawable
 import android.net.Uri
 import android.text.TextUtils
 import android.view.LayoutInflater
-import android.view.View
 import android.view.ViewGroup
 import android.widget.ImageView
 import android.widget.Toast
@@ -25,10 +24,6 @@ import androidx.lifecycle.ViewModelProvider
 import androidx.lifecycle.lifecycleScope
 import androidx.navigation.findNavController
 import androidx.preference.PreferenceManager
-import androidx.recyclerview.widget.AsyncDifferConfig
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -36,122 +31,26 @@ import org.yuzu.yuzu_emu.HomeNavigationDirections
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.activities.EmulationActivity
-import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
 import org.yuzu.yuzu_emu.databinding.CardGameBinding
 import org.yuzu.yuzu_emu.model.Game
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.utils.GameIconUtils
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class GameAdapter(private val activity: AppCompatActivity) :
-    ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
-    View.OnClickListener,
-    View.OnLongClickListener {
+    AbstractDiffAdapter<Game, GameAdapter.GameViewHolder>() {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
-        // Create a new view.
-        val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        binding.cardGame.setOnClickListener(this)
-        binding.cardGame.setOnLongClickListener(this)
-
-        // Use that view to create a ViewHolder.
-        return GameViewHolder(binding)
-    }
-
-    override fun onBindViewHolder(holder: GameViewHolder, position: Int) =
-        holder.bind(currentList[position])
-
-    override fun getItemCount(): Int = currentList.size
-
-    /**
-     * Launches the game that was clicked on.
-     *
-     * @param view The card representing the game the user wants to play.
-     */
-    override fun onClick(view: View) {
-        val holder = view.tag as GameViewHolder
-
-        val gameExists = DocumentFile.fromSingleUri(
-            YuzuApplication.appContext,
-            Uri.parse(holder.game.path)
-        )?.exists() == true
-        if (!gameExists) {
-            Toast.makeText(
-                YuzuApplication.appContext,
-                R.string.loader_error_file_not_found,
-                Toast.LENGTH_LONG
-            ).show()
-
-            ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
-            return
-        }
-
-        val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
-        preferences.edit()
-            .putLong(
-                holder.game.keyLastPlayedTime,
-                System.currentTimeMillis()
-            )
-            .apply()
-
-        val openIntent = Intent(YuzuApplication.appContext, EmulationActivity::class.java).apply {
-            action = Intent.ACTION_VIEW
-            data = Uri.parse(holder.game.path)
-        }
-
-        activity.lifecycleScope.launch {
-            withContext(Dispatchers.IO) {
-                val layerDrawable = ResourcesCompat.getDrawable(
-                    YuzuApplication.appContext.resources,
-                    R.drawable.shortcut,
-                    null
-                ) as LayerDrawable
-                layerDrawable.setDrawableByLayerId(
-                    R.id.shortcut_foreground,
-                    GameIconUtils.getGameIcon(activity, holder.game)
-                        .toDrawable(YuzuApplication.appContext.resources)
-                )
-                val inset = YuzuApplication.appContext.resources
-                    .getDimensionPixelSize(R.dimen.icon_inset)
-                layerDrawable.setLayerInset(1, inset, inset, inset, inset)
-                val shortcut =
-                    ShortcutInfoCompat.Builder(YuzuApplication.appContext, holder.game.path)
-                        .setShortLabel(holder.game.title)
-                        .setIcon(
-                            IconCompat.createWithAdaptiveBitmap(
-                                layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888)
-                            )
-                        )
-                        .setIntent(openIntent)
-                        .build()
-                ShortcutManagerCompat.pushDynamicShortcut(YuzuApplication.appContext, shortcut)
-            }
-        }
-
-        val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game, true)
-        view.findNavController().navigate(action)
-    }
-
-    override fun onLongClick(view: View): Boolean {
-        val holder = view.tag as GameViewHolder
-        val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(holder.game)
-        view.findNavController().navigate(action)
-        return true
+        CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return GameViewHolder(it) }
     }
 
     inner class GameViewHolder(val binding: CardGameBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        lateinit var game: Game
-
-        init {
-            binding.cardGame.tag = this
-        }
-
-        fun bind(game: Game) {
-            this.game = game
-
+        AbstractViewHolder<Game>(binding) {
+        override fun bind(model: Game) {
             binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
-            GameIconUtils.loadGameIcon(game, binding.imageGameScreen)
+            GameIconUtils.loadGameIcon(model, binding.imageGameScreen)
 
-            binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
+            binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ")
 
             binding.textGameTitle.postDelayed(
                 {
@@ -160,16 +59,79 @@ class GameAdapter(private val activity: AppCompatActivity) :
                 },
                 3000
             )
+
+            binding.cardGame.setOnClickListener { onClick(model) }
+            binding.cardGame.setOnLongClickListener { onLongClick(model) }
         }
-    }
 
-    private class DiffCallback : DiffUtil.ItemCallback<Game>() {
-        override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
-            return oldItem == newItem
+        fun onClick(game: Game) {
+            val gameExists = DocumentFile.fromSingleUri(
+                YuzuApplication.appContext,
+                Uri.parse(game.path)
+            )?.exists() == true
+            if (!gameExists) {
+                Toast.makeText(
+                    YuzuApplication.appContext,
+                    R.string.loader_error_file_not_found,
+                    Toast.LENGTH_LONG
+                ).show()
+
+                ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+                return
+            }
+
+            val preferences =
+                PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+            preferences.edit()
+                .putLong(
+                    game.keyLastPlayedTime,
+                    System.currentTimeMillis()
+                )
+                .apply()
+
+            val openIntent =
+                Intent(YuzuApplication.appContext, EmulationActivity::class.java).apply {
+                    action = Intent.ACTION_VIEW
+                    data = Uri.parse(game.path)
+                }
+
+            activity.lifecycleScope.launch {
+                withContext(Dispatchers.IO) {
+                    val layerDrawable = ResourcesCompat.getDrawable(
+                        YuzuApplication.appContext.resources,
+                        R.drawable.shortcut,
+                        null
+                    ) as LayerDrawable
+                    layerDrawable.setDrawableByLayerId(
+                        R.id.shortcut_foreground,
+                        GameIconUtils.getGameIcon(activity, game)
+                            .toDrawable(YuzuApplication.appContext.resources)
+                    )
+                    val inset = YuzuApplication.appContext.resources
+                        .getDimensionPixelSize(R.dimen.icon_inset)
+                    layerDrawable.setLayerInset(1, inset, inset, inset, inset)
+                    val shortcut =
+                        ShortcutInfoCompat.Builder(YuzuApplication.appContext, game.path)
+                            .setShortLabel(game.title)
+                            .setIcon(
+                                IconCompat.createWithAdaptiveBitmap(
+                                    layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888)
+                                )
+                            )
+                            .setIntent(openIntent)
+                            .build()
+                    ShortcutManagerCompat.pushDynamicShortcut(YuzuApplication.appContext, shortcut)
+                }
+            }
+
+            val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true)
+            binding.root.findNavController().navigate(action)
         }
 
-        override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
-            return oldItem == newItem
+        fun onLongClick(game: Game): Boolean {
+            val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game)
+            binding.root.findNavController().navigate(action)
+            return true
         }
     }
 }

+ 9 - 19
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt

@@ -12,23 +12,22 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.RecyclerView
 import kotlinx.coroutines.launch
 import org.yuzu.yuzu_emu.databinding.CardInstallableIconBinding
 import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding
 import org.yuzu.yuzu_emu.model.GameProperty
 import org.yuzu.yuzu_emu.model.InstallableProperty
 import org.yuzu.yuzu_emu.model.SubmenuProperty
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class GamePropertiesAdapter(
     private val viewLifecycle: LifecycleOwner,
     private var properties: List<GameProperty>
-) :
-    RecyclerView.Adapter<GamePropertiesAdapter.GamePropertyViewHolder>() {
+) : AbstractListAdapter<GameProperty, AbstractViewHolder<GameProperty>>(properties) {
     override fun onCreateViewHolder(
         parent: ViewGroup,
         viewType: Int
-    ): GamePropertyViewHolder {
+    ): AbstractViewHolder<GameProperty> {
         val inflater = LayoutInflater.from(parent.context)
         return when (viewType) {
             PropertyType.Submenu.ordinal -> {
@@ -51,11 +50,6 @@ class GamePropertiesAdapter(
         }
     }
 
-    override fun getItemCount(): Int = properties.size
-
-    override fun onBindViewHolder(holder: GamePropertyViewHolder, position: Int) =
-        holder.bind(properties[position])
-
     override fun getItemViewType(position: Int): Int {
         return when (properties[position]) {
             is SubmenuProperty -> PropertyType.Submenu.ordinal
@@ -63,14 +57,10 @@ class GamePropertiesAdapter(
         }
     }
 
-    sealed class GamePropertyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-        abstract fun bind(property: GameProperty)
-    }
-
     inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) :
-        GamePropertyViewHolder(binding.root) {
-        override fun bind(property: GameProperty) {
-            val submenuProperty = property as SubmenuProperty
+        AbstractViewHolder<GameProperty>(binding) {
+        override fun bind(model: GameProperty) {
+            val submenuProperty = model as SubmenuProperty
 
             binding.root.setOnClickListener {
                 submenuProperty.action.invoke()
@@ -108,9 +98,9 @@ class GamePropertiesAdapter(
     }
 
     inner class InstallablePropertyViewHolder(val binding: CardInstallableIconBinding) :
-        GamePropertyViewHolder(binding.root) {
-        override fun bind(property: GameProperty) {
-            val installableProperty = property as InstallableProperty
+        AbstractViewHolder<GameProperty>(binding) {
+        override fun bind(model: GameProperty) {
+            val installableProperty = model as InstallableProperty
 
             binding.title.setText(installableProperty.titleId)
             binding.description.setText(installableProperty.descriptionId)

+ 27 - 45
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt

@@ -14,69 +14,37 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
-import androidx.recyclerview.widget.RecyclerView
 import kotlinx.coroutines.launch
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
 import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
 import org.yuzu.yuzu_emu.model.HomeSetting
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
 class HomeSettingAdapter(
     private val activity: AppCompatActivity,
     private val viewLifecycle: LifecycleOwner,
-    var options: List<HomeSetting>
-) :
-    RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(),
-    View.OnClickListener {
+    options: List<HomeSetting>
+) : AbstractListAdapter<HomeSetting, HomeSettingAdapter.HomeOptionViewHolder>(options) {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
-        val binding =
-            CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        binding.root.setOnClickListener(this)
-        return HomeOptionViewHolder(binding)
-    }
-
-    override fun getItemCount(): Int {
-        return options.size
-    }
-
-    override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
-        holder.bind(options[position])
-    }
-
-    override fun onClick(view: View) {
-        val holder = view.tag as HomeOptionViewHolder
-        if (holder.option.isEnabled.invoke()) {
-            holder.option.onClick.invoke()
-        } else {
-            MessageDialogFragment.newInstance(
-                activity,
-                titleId = holder.option.disabledTitleId,
-                descriptionId = holder.option.disabledMessageId
-            ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
-        }
+        CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return HomeOptionViewHolder(it) }
     }
 
     inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        lateinit var option: HomeSetting
-
-        init {
-            itemView.tag = this
-        }
-
-        fun bind(option: HomeSetting) {
-            this.option = option
-            binding.optionTitle.text = activity.resources.getString(option.titleId)
-            binding.optionDescription.text = activity.resources.getString(option.descriptionId)
+        AbstractViewHolder<HomeSetting>(binding) {
+        override fun bind(model: HomeSetting) {
+            binding.optionTitle.text = activity.resources.getString(model.titleId)
+            binding.optionDescription.text = activity.resources.getString(model.descriptionId)
             binding.optionIcon.setImageDrawable(
                 ResourcesCompat.getDrawable(
                     activity.resources,
-                    option.iconId,
+                    model.iconId,
                     activity.theme
                 )
             )
 
-            when (option.titleId) {
+            when (model.titleId) {
                 R.string.get_early_access ->
                     binding.optionLayout.background =
                         ContextCompat.getDrawable(
@@ -85,7 +53,7 @@ class HomeSettingAdapter(
                         )
             }
 
-            if (!option.isEnabled.invoke()) {
+            if (!model.isEnabled.invoke()) {
                 binding.optionTitle.alpha = 0.5f
                 binding.optionDescription.alpha = 0.5f
                 binding.optionIcon.alpha = 0.5f
@@ -93,7 +61,7 @@ class HomeSettingAdapter(
 
             viewLifecycle.lifecycleScope.launch {
                 viewLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
-                    option.details.collect { updateOptionDetails(it) }
+                    model.details.collect { updateOptionDetails(it) }
                 }
             }
             binding.optionDetail.postDelayed(
@@ -103,6 +71,20 @@ class HomeSettingAdapter(
                 },
                 3000
             )
+
+            binding.root.setOnClickListener { onClick(model) }
+        }
+
+        private fun onClick(model: HomeSetting) {
+            if (model.isEnabled.invoke()) {
+                model.onClick.invoke()
+            } else {
+                MessageDialogFragment.newInstance(
+                    activity,
+                    titleId = model.disabledTitleId,
+                    descriptionId = model.disabledMessageId
+                ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
+            }
         }
 
         private fun updateOptionDetails(detailString: String) {

+ 13 - 23
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt

@@ -6,43 +6,33 @@ package org.yuzu.yuzu_emu.adapters
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
 import org.yuzu.yuzu_emu.databinding.CardInstallableBinding
 import org.yuzu.yuzu_emu.model.Installable
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class InstallableAdapter(private val installables: List<Installable>) :
-    RecyclerView.Adapter<InstallableAdapter.InstallableViewHolder>() {
+class InstallableAdapter(installables: List<Installable>) :
+    AbstractListAdapter<Installable, InstallableAdapter.InstallableViewHolder>(installables) {
     override fun onCreateViewHolder(
         parent: ViewGroup,
         viewType: Int
     ): InstallableAdapter.InstallableViewHolder {
-        val binding =
-            CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        return InstallableViewHolder(binding)
+        CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return InstallableViewHolder(it) }
     }
 
-    override fun getItemCount(): Int = installables.size
-
-    override fun onBindViewHolder(holder: InstallableAdapter.InstallableViewHolder, position: Int) =
-        holder.bind(installables[position])
-
     inner class InstallableViewHolder(val binding: CardInstallableBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        lateinit var installable: Installable
-
-        fun bind(installable: Installable) {
-            this.installable = installable
-
-            binding.title.setText(installable.titleId)
-            binding.description.setText(installable.descriptionId)
+        AbstractViewHolder<Installable>(binding) {
+        override fun bind(model: Installable) {
+            binding.title.setText(model.titleId)
+            binding.description.setText(model.descriptionId)
 
-            if (installable.install != null) {
+            if (model.install != null) {
                 binding.buttonInstall.visibility = View.VISIBLE
-                binding.buttonInstall.setOnClickListener { installable.install.invoke() }
+                binding.buttonInstall.setOnClickListener { model.install.invoke() }
             }
-            if (installable.export != null) {
+            if (model.export != null) {
                 binding.buttonExport.visibility = View.VISIBLE
-                binding.buttonExport.setOnClickListener { installable.export.invoke() }
+                binding.buttonExport.setOnClickListener { model.export.invoke() }
             }
         }
     }

+ 17 - 33
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt

@@ -7,49 +7,33 @@ import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import androidx.appcompat.app.AppCompatActivity
-import androidx.recyclerview.widget.RecyclerView
-import androidx.recyclerview.widget.RecyclerView.ViewHolder
-import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
 import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment
 import org.yuzu.yuzu_emu.model.License
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List<License>) :
-    RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(),
-    View.OnClickListener {
+class LicenseAdapter(private val activity: AppCompatActivity, licenses: List<License>) :
+    AbstractListAdapter<License, LicenseAdapter.LicenseViewHolder>(licenses) {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
-        val binding =
-            ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        binding.root.setOnClickListener(this)
-        return LicenseViewHolder(binding)
+        ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return LicenseViewHolder(it) }
     }
 
-    override fun getItemCount(): Int = licenses.size
+    inner class LicenseViewHolder(val binding: ListItemSettingBinding) :
+        AbstractViewHolder<License>(binding) {
+        override fun bind(model: License) {
+            binding.apply {
+                textSettingName.text = root.context.getString(model.titleId)
+                textSettingDescription.text = root.context.getString(model.descriptionId)
+                textSettingValue.visibility = View.GONE
 
-    override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) {
-        holder.bind(licenses[position])
-    }
-
-    override fun onClick(view: View) {
-        val license = (view.tag as LicenseViewHolder).license
-        LicenseBottomSheetDialogFragment.newInstance(license)
-            .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
-    }
-
-    inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) {
-        lateinit var license: License
-
-        init {
-            itemView.tag = this
+                root.setOnClickListener { onClick(model) }
+            }
         }
 
-        fun bind(license: License) {
-            this.license = license
-
-            val context = YuzuApplication.appContext
-            binding.textSettingName.text = context.getString(license.titleId)
-            binding.textSettingDescription.text = context.getString(license.descriptionId)
-            binding.textSettingValue.visibility = View.GONE
+        private fun onClick(license: License) {
+            LicenseBottomSheetDialogFragment.newInstance(license)
+                .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
         }
     }
 }

+ 16 - 29
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt

@@ -10,7 +10,6 @@ import android.view.ViewGroup
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.res.ResourcesCompat
 import androidx.lifecycle.ViewModelProvider
-import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.button.MaterialButton
 import org.yuzu.yuzu_emu.databinding.PageSetupBinding
 import org.yuzu.yuzu_emu.model.HomeViewModel
@@ -18,31 +17,19 @@ import org.yuzu.yuzu_emu.model.SetupCallback
 import org.yuzu.yuzu_emu.model.SetupPage
 import org.yuzu.yuzu_emu.model.StepState
 import org.yuzu.yuzu_emu.utils.ViewUtils
+import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder
 
-class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) :
-    RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() {
+class SetupAdapter(val activity: AppCompatActivity, pages: List<SetupPage>) :
+    AbstractListAdapter<SetupPage, SetupAdapter.SetupPageViewHolder>(pages) {
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
-        val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
-        return SetupPageViewHolder(binding)
+        PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+            .also { return SetupPageViewHolder(it) }
     }
 
-    override fun getItemCount(): Int = pages.size
-
-    override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
-        holder.bind(pages[position])
-
     inner class SetupPageViewHolder(val binding: PageSetupBinding) :
-        RecyclerView.ViewHolder(binding.root), SetupCallback {
-        lateinit var page: SetupPage
-
-        init {
-            itemView.tag = this
-        }
-
-        fun bind(page: SetupPage) {
-            this.page = page
-
-            if (page.stepCompleted.invoke() == StepState.COMPLETE) {
+        AbstractViewHolder<SetupPage>(binding), SetupCallback {
+        override fun bind(model: SetupPage) {
+            if (model.stepCompleted.invoke() == StepState.COMPLETE) {
                 binding.buttonAction.visibility = View.INVISIBLE
                 binding.textConfirmation.visibility = View.VISIBLE
             }
@@ -50,31 +37,31 @@ class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>)
             binding.icon.setImageDrawable(
                 ResourcesCompat.getDrawable(
                     activity.resources,
-                    page.iconId,
+                    model.iconId,
                     activity.theme
                 )
             )
-            binding.textTitle.text = activity.resources.getString(page.titleId)
+            binding.textTitle.text = activity.resources.getString(model.titleId)
             binding.textDescription.text =
-                Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
+                Html.fromHtml(activity.resources.getString(model.descriptionId), 0)
 
             binding.buttonAction.apply {
-                text = activity.resources.getString(page.buttonTextId)
-                if (page.buttonIconId != 0) {
+                text = activity.resources.getString(model.buttonTextId)
+                if (model.buttonIconId != 0) {
                     icon = ResourcesCompat.getDrawable(
                         activity.resources,
-                        page.buttonIconId,
+                        model.buttonIconId,
                         activity.theme
                     )
                 }
                 iconGravity =
-                    if (page.leftAlignedIcon) {
+                    if (model.leftAlignedIcon) {
                         MaterialButton.ICON_GRAVITY_START
                     } else {
                         MaterialButton.ICON_GRAVITY_END
                     }
                 setOnClickListener {
-                    page.buttonAction.invoke(this@SetupPageViewHolder)
+                    model.buttonAction.invoke(this@SetupPageViewHolder)
                 }
             }
         }

+ 52 - 24
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt

@@ -3,6 +3,7 @@
 
 package org.yuzu.yuzu_emu.fragments
 
+import android.annotation.SuppressLint
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
@@ -13,20 +14,26 @@ import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.updatePadding
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
 import androidx.navigation.findNavController
 import androidx.navigation.fragment.navArgs
 import androidx.recyclerview.widget.GridLayoutManager
 import com.google.android.material.transition.MaterialSharedAxis
-import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.adapters.DriverAdapter
 import org.yuzu.yuzu_emu.databinding.FragmentDriverManagerBinding
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver
 import org.yuzu.yuzu_emu.model.DriverViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.utils.FileUtil
 import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import org.yuzu.yuzu_emu.utils.NativeConfig
 import java.io.File
 import java.io.IOException
 
@@ -55,12 +62,43 @@ class DriverManagerFragment : Fragment() {
         return binding.root
     }
 
+    // This is using the correct scope, lint is just acting up
+    @SuppressLint("UnsafeRepeatOnLifecycleDetector")
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
         homeViewModel.setNavigationVisibility(visible = false, animated = true)
         homeViewModel.setStatusBarShadeVisibility(visible = false)
 
         driverViewModel.onOpenDriverManager(args.game)
+        if (NativeConfig.isPerGameConfigLoaded()) {
+            binding.toolbarDrivers.inflateMenu(R.menu.menu_driver_manager)
+            driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global)
+            binding.toolbarDrivers.setOnMenuItemClickListener {
+                when (it.itemId) {
+                    R.id.menu_driver_clear -> {
+                        StringSetting.DRIVER_PATH.global = true
+                        driverViewModel.updateDriverList()
+                        (binding.listDrivers.adapter as DriverAdapter)
+                            .replaceList(driverViewModel.driverList.value)
+                        driverViewModel.showClearButton(false)
+                        true
+                    }
+
+                    else -> false
+                }
+            }
+
+            viewLifecycleOwner.lifecycleScope.apply {
+                launch {
+                    repeatOnLifecycle(Lifecycle.State.STARTED) {
+                        driverViewModel.showClearButton.collect {
+                            binding.toolbarDrivers.menu
+                                .findItem(R.id.menu_driver_clear).isVisible = it
+                        }
+                    }
+                }
+            }
+        }
 
         if (!driverViewModel.isInteractionAllowed.value) {
             DriversLoadingDialogFragment().show(
@@ -85,25 +123,6 @@ class DriverManagerFragment : Fragment() {
             adapter = DriverAdapter(driverViewModel)
         }
 
-        viewLifecycleOwner.lifecycleScope.apply {
-            launch {
-                driverViewModel.driverList.collectLatest {
-                    (binding.listDrivers.adapter as DriverAdapter).submitList(it)
-                }
-            }
-            launch {
-                driverViewModel.newDriverInstalled.collect {
-                    if (_binding != null && it) {
-                        (binding.listDrivers.adapter as DriverAdapter).apply {
-                            notifyItemChanged(driverViewModel.previouslySelectedDriver)
-                            notifyItemChanged(driverViewModel.selectedDriver)
-                            driverViewModel.setNewDriverInstalled(false)
-                        }
-                    }
-                }
-            }
-        }
-
         setInsets()
     }
 
@@ -160,7 +179,7 @@ class DriverManagerFragment : Fragment() {
                 false
             ) {
                 val driverPath =
-                    "${GpuDriverHelper.driverStoragePath}/${FileUtil.getFilename(result)}"
+                    "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}"
                 val driverFile = File(driverPath)
 
                 // Ignore file exceptions when a user selects an invalid zip
@@ -177,12 +196,21 @@ class DriverManagerFragment : Fragment() {
 
                 val driverData = GpuDriverHelper.getMetadataFromZip(driverFile)
                 val driverInList =
-                    driverViewModel.driverList.value.firstOrNull { it.second == driverData }
+                    driverViewModel.driverData.firstOrNull { it.second == driverData }
                 if (driverInList != null) {
                     return@newInstance getString(R.string.driver_already_installed)
                 } else {
-                    driverViewModel.addDriver(Pair(driverPath, driverData))
-                    driverViewModel.setNewDriverInstalled(true)
+                    driverViewModel.onDriverAdded(Pair(driverPath, driverData))
+                    withContext(Dispatchers.Main) {
+                        if (_binding != null) {
+                            val adapter = binding.listDrivers.adapter as DriverAdapter
+                            adapter.addItem(driverData.toDriver())
+                            adapter.selectItem(adapter.currentList.indices.last)
+                            driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global)
+                            binding.listDrivers
+                                .smoothScrollToPosition(adapter.currentList.indices.last)
+                        }
+                    }
                 }
                 return@newInstance Any()
             }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG)

+ 27 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt

@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import org.yuzu.yuzu_emu.utils.GpuDriverMetadata
+
+data class Driver(
+    override var selected: Boolean,
+    val title: String,
+    val version: String = "",
+    val description: String = ""
+) : SelectableItem {
+    override fun onSelectionStateChanged(selected: Boolean) {
+        this.selected = selected
+    }
+
+    companion object {
+        fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver =
+            Driver(
+                selected,
+                this.name ?: "",
+                this.version ?: "",
+                this.description ?: ""
+            )
+    }
+}

+ 49 - 80
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt

@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.launch
@@ -17,11 +18,10 @@ import org.yuzu.yuzu_emu.R
 import org.yuzu.yuzu_emu.YuzuApplication
 import org.yuzu.yuzu_emu.features.settings.model.StringSetting
 import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
-import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver
 import org.yuzu.yuzu_emu.utils.GpuDriverHelper
 import org.yuzu.yuzu_emu.utils.GpuDriverMetadata
 import org.yuzu.yuzu_emu.utils.NativeConfig
-import java.io.BufferedOutputStream
 import java.io.File
 
 class DriverViewModel : ViewModel() {
@@ -38,97 +38,81 @@ class DriverViewModel : ViewModel() {
             !loading && ready && !deleting
         }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = false)
 
-    private val _driverList = MutableStateFlow(GpuDriverHelper.getDrivers())
-    val driverList: StateFlow<MutableList<Pair<String, GpuDriverMetadata>>> get() = _driverList
+    var driverData = GpuDriverHelper.getDrivers()
 
-    var previouslySelectedDriver = 0
-    var selectedDriver = -1
+    private val _driverList = MutableStateFlow(emptyList<Driver>())
+    val driverList: StateFlow<List<Driver>> get() = _driverList
 
     // Used for showing which driver is currently installed within the driver manager card
     private val _selectedDriverTitle = MutableStateFlow("")
     val selectedDriverTitle: StateFlow<String> get() = _selectedDriverTitle
 
-    private val _newDriverInstalled = MutableStateFlow(false)
-    val newDriverInstalled: StateFlow<Boolean> get() = _newDriverInstalled
+    private val _showClearButton = MutableStateFlow(false)
+    val showClearButton = _showClearButton.asStateFlow()
 
-    val driversToDelete = mutableListOf<String>()
+    private val driversToDelete = mutableListOf<String>()
 
     init {
-        val currentDriverMetadata = GpuDriverHelper.installedCustomDriverData
-        findSelectedDriver(currentDriverMetadata)
-
-        // If a user had installed a driver before the manager was implemented, this zips
-        // the installed driver to UserData/gpu_drivers/CustomDriver.zip so that it can
-        // be indexed and exported as expected.
-        if (selectedDriver == -1) {
-            val driverToSave =
-                File(GpuDriverHelper.driverStoragePath, "CustomDriver.zip")
-            driverToSave.createNewFile()
-            FileUtil.zipFromInternalStorage(
-                File(GpuDriverHelper.driverInstallationPath!!),
-                GpuDriverHelper.driverInstallationPath!!,
-                BufferedOutputStream(driverToSave.outputStream())
-            )
-            _driverList.value.add(Pair(driverToSave.path, currentDriverMetadata))
-            setSelectedDriverIndex(_driverList.value.size - 1)
-        }
+        updateDriverList()
+        updateDriverNameForGame(null)
+    }
 
-        // If a user had installed a driver before the config was reworked to be multiplatform,
-        // we have save the path of the previously selected driver to the new setting.
-        if (StringSetting.DRIVER_PATH.getString(true).isEmpty() && selectedDriver > 0 &&
-            StringSetting.DRIVER_PATH.global
-        ) {
-            StringSetting.DRIVER_PATH.setString(_driverList.value[selectedDriver].first)
-            NativeConfig.saveGlobalConfig()
-        } else {
-            findSelectedDriver(GpuDriverHelper.customDriverSettingData)
+    fun reloadDriverData() {
+        _areDriversLoading.value = true
+        driverData = GpuDriverHelper.getDrivers()
+        updateDriverList()
+        _areDriversLoading.value = false
+    }
+
+    fun updateDriverList() {
+        val selectedDriver = GpuDriverHelper.customDriverSettingData
+        val newDriverList = mutableListOf(
+            Driver(
+                selectedDriver == GpuDriverMetadata(),
+                YuzuApplication.appContext.getString(R.string.system_gpu_driver)
+            )
+        )
+        driverData.forEach {
+            newDriverList.add(it.second.toDriver(it.second == selectedDriver))
         }
-        updateDriverNameForGame(null)
+        _driverList.value = newDriverList
     }
 
-    fun setSelectedDriverIndex(value: Int) {
-        if (selectedDriver != -1) {
-            previouslySelectedDriver = selectedDriver
+    fun onOpenDriverManager(game: Game?) {
+        if (game != null) {
+            SettingsFile.loadCustomConfig(game)
         }
-        selectedDriver = value
+        updateDriverList()
     }
 
-    fun setNewDriverInstalled(value: Boolean) {
-        _newDriverInstalled.value = value
+    fun showClearButton(value: Boolean) {
+        _showClearButton.value = value
     }
 
-    fun addDriver(driverData: Pair<String, GpuDriverMetadata>) {
-        val driverIndex = _driverList.value.indexOfFirst { it == driverData }
-        if (driverIndex == -1) {
-            _driverList.value.add(driverData)
-            setSelectedDriverIndex(_driverList.value.size - 1)
-            _selectedDriverTitle.value = driverData.second.name
-                ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver)
+    fun onDriverSelected(position: Int) {
+        if (position == 0) {
+            StringSetting.DRIVER_PATH.setString("")
         } else {
-            setSelectedDriverIndex(driverIndex)
+            StringSetting.DRIVER_PATH.setString(driverData[position - 1].first)
         }
     }
 
-    fun removeDriver(driverData: Pair<String, GpuDriverMetadata>) {
-        _driverList.value.remove(driverData)
+    fun onDriverRemoved(removedPosition: Int, selectedPosition: Int) {
+        driversToDelete.add(driverData[removedPosition - 1].first)
+        driverData.removeAt(removedPosition - 1)
+        onDriverSelected(selectedPosition)
     }
 
-    fun onOpenDriverManager(game: Game?) {
-        if (game != null) {
-            SettingsFile.loadCustomConfig(game)
-        }
-
-        val driverPath = StringSetting.DRIVER_PATH.getString()
-        if (driverPath.isEmpty()) {
-            setSelectedDriverIndex(0)
-        } else {
-            findSelectedDriver(GpuDriverHelper.getMetadataFromZip(File(driverPath)))
+    fun onDriverAdded(driver: Pair<String, GpuDriverMetadata>) {
+        if (driversToDelete.contains(driver.first)) {
+            driversToDelete.remove(driver.first)
         }
+        driverData.add(driver)
+        onDriverSelected(driverData.size)
     }
 
     fun onCloseDriverManager(game: Game?) {
         _isDeletingDrivers.value = true
-        StringSetting.DRIVER_PATH.setString(driverList.value[selectedDriver].first)
         updateDriverNameForGame(game)
         if (game == null) {
             NativeConfig.saveGlobalConfig()
@@ -181,20 +165,6 @@ class DriverViewModel : ViewModel() {
         }
     }
 
-    private fun findSelectedDriver(currentDriverMetadata: GpuDriverMetadata) {
-        if (driverList.value.size == 1) {
-            setSelectedDriverIndex(0)
-            return
-        }
-
-        driverList.value.forEachIndexed { i: Int, driver: Pair<String, GpuDriverMetadata> ->
-            if (driver.second == currentDriverMetadata) {
-                setSelectedDriverIndex(i)
-                return
-            }
-        }
-    }
-
     fun updateDriverNameForGame(game: Game?) {
         if (!GpuDriverHelper.supportsCustomDriverLoading()) {
             return
@@ -217,7 +187,6 @@ class DriverViewModel : ViewModel() {
 
     private fun setDriverReady() {
         _isDriverReady.value = true
-        _selectedDriverTitle.value = GpuDriverHelper.customDriverSettingData.name
-            ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver)
+        updateName()
     }
 }

+ 9 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt

@@ -0,0 +1,9 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+interface SelectableItem {
+    var selected: Boolean
+    fun onSelectionStateChanged(selected: Boolean)
+}

+ 3 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt

@@ -41,6 +41,7 @@ import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment
 import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
 import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
 import org.yuzu.yuzu_emu.model.AddonViewModel
+import org.yuzu.yuzu_emu.model.DriverViewModel
 import org.yuzu.yuzu_emu.model.GamesViewModel
 import org.yuzu.yuzu_emu.model.HomeViewModel
 import org.yuzu.yuzu_emu.model.TaskState
@@ -58,6 +59,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
     private val gamesViewModel: GamesViewModel by viewModels()
     private val taskViewModel: TaskViewModel by viewModels()
     private val addonViewModel: AddonViewModel by viewModels()
+    private val driverViewModel: DriverViewModel by viewModels()
 
     override var themeId: Int = 0
 
@@ -689,6 +691,7 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
                 NativeLibrary.initializeSystem(true)
                 NativeConfig.initializeGlobalConfig()
                 gamesViewModel.reloadGames(false)
+                driverViewModel.reloadDriverData()
 
                 return@newInstance getString(R.string.user_data_import_success)
             }.show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)

+ 0 - 3
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt

@@ -62,9 +62,6 @@ object GpuDriverHelper {
                 ?.sortedByDescending { it: Pair<String, GpuDriverMetadata> -> it.second.name }
                 ?.distinct()
                 ?.toMutableList() ?: mutableListOf()
-
-        // TODO: Get system driver information
-        drivers.add(0, Pair("", GpuDriverMetadata()))
         return drivers
     }
 

+ 18 - 0
src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt

@@ -0,0 +1,18 @@
+// SPDX-FileCopyrightText: 2024 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.viewholder
+
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import org.yuzu.yuzu_emu.adapters.AbstractDiffAdapter
+import org.yuzu.yuzu_emu.adapters.AbstractListAdapter
+
+/**
+ * [RecyclerView.ViewHolder] meant to work together with a [AbstractDiffAdapter] or a
+ * [AbstractListAdapter] so we can run [bind] on each list item without needing a manual hookup.
+ */
+abstract class AbstractViewHolder<Model>(binding: ViewBinding) :
+    RecyclerView.ViewHolder(binding.root) {
+    abstract fun bind(model: Model)
+}

+ 11 - 0
src/android/app/src/main/res/menu/menu_driver_manager.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/menu_driver_clear"
+        android:icon="@drawable/ic_clear"
+        android:title="@string/clear"
+        app:showAsAction="always" />
+
+</menu>