ui: migrate esoteric adapters to framework

Migrate all esoteric adapters to the new RecyclerView framework.

One of the shortcomings with the previous RecyclerView utilities was
how more esoteric adapters with data that does not implement Item
could not use the utlities. The new system, by comparison, is capable
of taking any type of data, so we can no migrate all of the esoteric
adapters to the new system.
This commit is contained in:
OxygenCobalt 2022-03-27 09:59:35 -06:00
parent 2406c371db
commit 05a5ef5c3f
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
22 changed files with 409 additions and 363 deletions

View file

@ -43,6 +43,8 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* TODO: Custom language support
*
* TODO: Rework menus [perhaps add multi-select]
*
* TODO: Rework navigation to be based on a viewmodel
*/
class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels()

View file

@ -17,71 +17,93 @@
package org.oxycblt.auxio.accent
import android.view.ViewGroup
import android.content.Context
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAccentBinding
import org.oxycblt.auxio.ui.BackingData
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getColorSafe
import org.oxycblt.auxio.util.getViewHolderAt
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.stateList
/**
* An adapter that displays the list of all possible accents, and highlights the current one.
* @author OxygenCobalt
* @param onSelect What to do when an accent is selected.
*/
class AccentAdapter(private var curAccent: Accent, private val onSelect: (accent: Accent) -> Unit) :
RecyclerView.Adapter<AccentAdapter.ViewHolder>() {
private var selectedViewHolder: ViewHolder? = null
/** An adapter that displays the accent palette. */
class AccentAdapter(listener: Listener) :
MonoAdapter<Accent, AccentAdapter.Listener, NewAccentViewHolder>(listener) {
var selectedAccent: Accent? = null
private set
private var selectedViewHolder: NewAccentViewHolder? = null
override fun getItemCount(): Int = ACCENT_COUNT
override val data = AccentData()
override val creator = NewAccentViewHolder.CREATOR
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemAccentBinding.inflate(parent.context.inflater))
override fun onBindViewHolder(viewHolder: NewAccentViewHolder, position: Int) {
super.onBindViewHolder(viewHolder, position)
if (data.getItem(position) == selectedAccent) {
selectedViewHolder?.setSelected(false)
selectedViewHolder = viewHolder
viewHolder.setSelected(true)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(Accent(position))
fun setSelectedAccent(accent: Accent, recycler: RecyclerView) {
if (accent == selectedAccent) return
selectedAccent = accent
selectedViewHolder?.setSelected(false)
selectedViewHolder = recycler.getViewHolderAt(accent.index) as NewAccentViewHolder?
selectedViewHolder?.setSelected(true)
}
private fun setAccent(accent: Accent) {
curAccent = accent
onSelect(accent)
interface Listener {
fun onAccentSelected(accent: Accent)
}
inner class ViewHolder(private val binding: ItemAccentBinding) :
RecyclerView.ViewHolder(binding.root) {
class AccentData : BackingData<Accent>() {
override fun getItem(position: Int) = Accent(position)
override fun getItemCount() = ACCENT_COUNT
}
}
fun bind(accent: Accent) {
setSelected(accent == curAccent)
class NewAccentViewHolder private constructor(private val binding: ItemAccentBinding) :
BindingViewHolder<Accent, AccentAdapter.Listener>(binding.root) {
override fun bind(item: Accent, listener: AccentAdapter.Listener) {
setSelected(false)
binding.accent.apply {
backgroundTintList = context.getColorSafe(accent.primary).stateList
contentDescription = context.getString(accent.name)
backgroundTintList = context.getColorSafe(item.primary).stateList
contentDescription = context.getString(item.name)
TooltipCompat.setTooltipText(this, contentDescription)
}
binding.accent.setOnClickListener {
setAccent(accent)
setSelected(true)
}
binding.accent.setOnClickListener { listener.onAccentSelected(item) }
}
private fun setSelected(isSelected: Boolean) {
fun setSelected(isSelected: Boolean) {
val context = binding.accent.context
binding.accent.isEnabled = !isSelected
binding.accent.imageTintList =
if (isSelected) {
// Switch out the currently selected ViewHolder with this one.
selectedViewHolder?.setSelected(false)
selectedViewHolder = this
context.getAttrColorSafe(R.attr.colorSurface).stateList
} else {
context.getColorSafe(android.R.color.transparent).stateList
}
}
companion object {
val CREATOR =
object : Creator<NewAccentViewHolder> {
override val viewType: Int
get() = throw UnsupportedOperationException()
override fun create(context: Context) =
NewAccentViewHolder(ItemAccentBinding.inflate(context.inflater))
}
}
}

View file

@ -31,9 +31,10 @@ import org.oxycblt.auxio.util.logD
* Dialog responsible for showing the list of accents to select.
* @author OxygenCobalt
*/
class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>() {
class AccentCustomizeDialog :
ViewBindingDialogFragment<DialogAccentBinding>(), AccentAdapter.Listener {
private val settingsManager = SettingsManager.getInstance()
private var pendingAccent = settingsManager.accent
private var accentAdapter = AccentAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater)
@ -41,9 +42,9 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>() {
builder.setTitle(R.string.set_accent)
builder.setPositiveButton(android.R.string.ok) { _, _ ->
if (pendingAccent != settingsManager.accent) {
if (accentAdapter.selectedAccent != settingsManager.accent) {
logD("Applying new accent")
settingsManager.accent = pendingAccent
settingsManager.accent = requireNotNull(accentAdapter.selectedAccent)
requireActivity().recreate()
}
@ -55,22 +56,30 @@ class AccentCustomizeDialog : ViewBindingDialogFragment<DialogAccentBinding>() {
}
override fun onBindingCreated(binding: DialogAccentBinding, savedInstanceState: Bundle?) {
savedInstanceState?.getInt(KEY_PENDING_ACCENT)?.let { index ->
pendingAccent = Accent(index)
}
accentAdapter.setSelectedAccent(
if (savedInstanceState != null) {
Accent(savedInstanceState.getInt(KEY_PENDING_ACCENT))
} else {
settingsManager.accent
},
binding.accentRecycler)
// --- UI SETUP ---
binding.accentRecycler.adapter =
AccentAdapter(pendingAccent) { accent ->
logD("Switching selected accent to $accent")
pendingAccent = accent
}
binding.accentRecycler.adapter = accentAdapter
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_PENDING_ACCENT, pendingAccent.index)
outState.putInt(KEY_PENDING_ACCENT, requireNotNull(accentAdapter.selectedAccent).index)
}
override fun onDestroyBinding(binding: DialogAccentBinding) {
binding.accentRecycler.adapter = null
}
override fun onAccentSelected(accent: Accent) {
accentAdapter.setSelectedAccent(accent, requireBinding().accentRecycler)
}
companion object {

View file

@ -22,7 +22,6 @@ import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import kotlin.math.min
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/**
@ -47,7 +46,6 @@ class SquareFrameTransform : Transformation {
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
if (dstSize != desiredWidth || dstSize != desiredHeight) {
logD("RETARD YOU STUPID FUCKING IDIOT $desiredWidth $desiredHeight")
try {
// Desired size differs from the cropped size, resize the bitmap.
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)

View file

@ -27,7 +27,6 @@ import androidx.recyclerview.widget.LinearSmoothScroller
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.detail.recycler.AlbumDetailItemListener
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -47,7 +46,7 @@ import org.oxycblt.auxio.util.showToast
* The [DetailFragment] for an album.
* @author OxygenCobalt
*/
class AlbumDetailFragment : DetailFragment(), AlbumDetailItemListener {
class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
private val args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this)

View file

@ -24,7 +24,7 @@ import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailItemListener
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for an artist.
* @author OxygenCobalt
*/
class ArtistDetailFragment : DetailFragment(), DetailItemListener {
class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this)

View file

@ -22,7 +22,7 @@ import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailItemListener
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.logW
* The [DetailFragment] for a genre.
* @author OxygenCobalt
*/
class GenreDetailFragment : DetailFragment(), DetailItemListener {
class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
private val args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this)

View file

@ -40,8 +40,8 @@ import org.oxycblt.auxio.util.textSafe
* An adapter for displaying [Album] information and it's children.
* @author OxygenCobalt
*/
class AlbumDetailAdapter(listener: AlbumDetailItemListener) :
DetailAdapter<AlbumDetailItemListener>(listener, DIFFER) {
class AlbumDetailAdapter(listener: Listener) :
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) {
private var highlightedSong: Song? = null
private var highlightedViewHolder: Highlightable? = null
@ -61,11 +61,7 @@ class AlbumDetailAdapter(listener: AlbumDetailItemListener) :
else -> null
}
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: AlbumDetailItemListener
) {
override fun onBind(viewHolder: RecyclerView.ViewHolder, item: Item, listener: Listener) {
super.onBind(viewHolder, item, listener)
when (item) {
@ -112,16 +108,16 @@ class AlbumDetailAdapter(listener: AlbumDetailItemListener) :
}
}
}
}
interface AlbumDetailItemListener : DetailItemListener {
interface Listener : DetailAdapter.Listener {
fun onNavigateToArtist()
}
}
private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Album, AlbumDetailItemListener>(binding.root) {
BindingViewHolder<Album, AlbumDetailAdapter.Listener>(binding.root) {
override fun bind(item: Album, listener: AlbumDetailItemListener) {
override fun bind(item: Album, listener: AlbumDetailAdapter.Listener) {
binding.detailCover.bindAlbumCover(item)
binding.detailName.textSafe = item.resolvedName

View file

@ -44,8 +44,8 @@ import org.oxycblt.auxio.util.textSafe
* one actually contains both album information and song information.
* @author OxygenCobalt
*/
class ArtistDetailAdapter(listener: DetailItemListener) :
DetailAdapter<DetailItemListener>(listener, DIFFER) {
class ArtistDetailAdapter(listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null
@ -70,11 +70,7 @@ class ArtistDetailAdapter(listener: DetailItemListener) :
else -> null
}
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
override fun onBind(viewHolder: RecyclerView.ViewHolder, item: Item, listener: Listener) {
super.onBind(viewHolder, item, listener)
when (item) {
is Artist -> (viewHolder as ArtistDetailViewHolder).bind(item, listener)
@ -140,9 +136,9 @@ class ArtistDetailAdapter(listener: DetailItemListener) :
}
private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Artist, DetailItemListener>(binding.root) {
BindingViewHolder<Artist, DetailAdapter.Listener>(binding.root) {
override fun bind(item: Artist, listener: DetailItemListener) {
override fun bind(item: Artist, listener: DetailAdapter.Listener) {
binding.detailCover.bindArtistImage(item)
binding.detailName.textSafe = item.resolvedName

View file

@ -39,7 +39,7 @@ import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.textSafe
abstract class DetailAdapter<L : DetailItemListener>(
abstract class DetailAdapter<L : DetailAdapter.Listener>(
listener: L,
diffCallback: DiffUtil.ItemCallback<Item>
) : MultiAdapter<L>(listener) {
@ -112,13 +112,19 @@ abstract class DetailAdapter<L : DetailItemListener>(
}
}
}
interface Listener : MenuItemListener {
fun onPlayParent()
fun onShuffleParent()
fun onShowSortMenu(anchor: View)
}
}
data class SortHeader(override val id: Long, @StringRes val string: Int) : Item()
class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
BindingViewHolder<SortHeader, DetailItemListener>(binding.root) {
override fun bind(item: SortHeader, listener: DetailItemListener) {
BindingViewHolder<SortHeader, DetailAdapter.Listener>(binding.root) {
override fun bind(item: SortHeader, listener: DetailAdapter.Listener) {
binding.headerTitle.textSafe = binding.context.getString(item.string)
binding.headerButton.apply {
TooltipCompat.setTooltipText(this, contentDescription)
@ -148,9 +154,3 @@ class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
interface Highlightable {
fun setHighlighted(isHighlighted: Boolean)
}
interface DetailItemListener : MenuItemListener {
fun onPlayParent()
fun onShuffleParent()
fun onShowSortMenu(anchor: View)
}

View file

@ -41,8 +41,8 @@ import org.oxycblt.auxio.util.textSafe
* An adapter for displaying genre information and it's children.
* @author OxygenCobalt
*/
class GenreDetailAdapter(listener: DetailItemListener) :
DetailAdapter<DetailItemListener>(listener, DIFFER) {
class GenreDetailAdapter(listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
@ -62,11 +62,7 @@ class GenreDetailAdapter(listener: DetailItemListener) :
else -> null
}
override fun onBind(
viewHolder: RecyclerView.ViewHolder,
item: Item,
listener: DetailItemListener
) {
override fun onBind(viewHolder: RecyclerView.ViewHolder, item: Item, listener: Listener) {
super.onBind(viewHolder, item, listener)
when (item) {
is Genre -> (viewHolder as GenreDetailViewHolder).bind(item, listener)
@ -115,8 +111,8 @@ class GenreDetailAdapter(listener: DetailItemListener) :
}
private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
BindingViewHolder<Genre, DetailItemListener>(binding.root) {
override fun bind(item: Genre, listener: DetailItemListener) {
BindingViewHolder<Genre, DetailAdapter.Listener>(binding.root) {
override fun bind(item: Genre, listener: DetailAdapter.Listener) {
binding.detailCover.bindGenreImage(item)
binding.detailName.textSafe = item.resolvedName
binding.detailSubhead.textSafe =

View file

@ -53,6 +53,16 @@ sealed class Tab(open val mode: DisplayMode) {
/** The default tab sequence, represented in integer form */
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
/**
* Maps between the integer code in the tab sequence and the actual [DisplayMode] instance
*/
private val MODE_TABLE =
arrayOf(
DisplayMode.SHOW_SONGS,
DisplayMode.SHOW_ALBUMS,
DisplayMode.SHOW_ARTISTS,
DisplayMode.SHOW_GENRES)
/** Convert an array [tabs] into a sequence of tabs. */
fun toSequence(tabs: Array<Tab>): Int {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
@ -64,8 +74,8 @@ sealed class Tab(open val mode: DisplayMode) {
for (tab in distinct) {
val bin =
when (tab) {
is Visible -> 1.shl(3) or tab.mode.ordinal
is Invisible -> tab.mode.ordinal
is Visible -> 1.shl(3) or MODE_TABLE.indexOf(tab.mode)
is Invisible -> MODE_TABLE.indexOf(tab.mode)
}
sequence = sequence or bin.shl(shift)
@ -84,14 +94,7 @@ sealed class Tab(open val mode: DisplayMode) {
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
val chunk = sequence.shr(shift) and 0b1111
val mode =
when (chunk and 7) {
0 -> DisplayMode.SHOW_SONGS
1 -> DisplayMode.SHOW_ALBUMS
2 -> DisplayMode.SHOW_ARTISTS
3 -> DisplayMode.SHOW_GENRES
else -> continue
}
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
// Figure out the visibility
tabs +=

View file

@ -18,70 +18,92 @@
package org.oxycblt.auxio.home.tabs
import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.ui.BackingData
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.util.inflater
class TabAdapter(
private val touchHelper: ItemTouchHelper,
private val getTabs: () -> Array<Tab>,
private val onTabSwitch: (Tab) -> Unit,
) : RecyclerView.Adapter<TabAdapter.TabViewHolder>() {
private val tabs: Array<Tab>
get() = getTabs()
class TabAdapter(listener: Listener) :
MonoAdapter<Tab, TabAdapter.Listener, TabViewHolder>(listener) {
override val data = TabData(this)
override val creator = TabViewHolder.CREATOR
override fun getItemCount(): Int = Tab.SEQUENCE_LEN
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder {
return TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
interface Listener {
fun onVisibilityToggled(displayMode: DisplayMode)
fun onPickUpTab(viewHolder: RecyclerView.ViewHolder)
}
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position])
class TabData(private val adapter: RecyclerView.Adapter<*>) : BackingData<Tab>() {
var tabs = arrayOf<Tab>()
private set
override fun getItem(position: Int) = tabs[position]
override fun getItemCount() = tabs.size
@Suppress("NotifyDatasetChanged")
fun submitTabs(newTabs: Array<Tab>) {
tabs = newTabs
adapter.notifyDataSetChanged()
}
inner class TabViewHolder(private val binding: ItemTabBinding) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
fun setTab(at: Int, tab: Tab) {
tabs[at] = tab
}
fun moveItems(from: Int, to: Int) {
val t = tabs[to]
val f = tabs[from]
tabs[from] = t
tabs[to] = f
adapter.notifyItemMoved(from, to)
}
}
}
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
BindingViewHolder<Tab, TabAdapter.Listener>(binding.root) {
@SuppressLint("ClickableViewAccessibility")
fun bind(tab: Tab) {
override fun bind(item: Tab, listener: TabAdapter.Listener) {
binding.root.apply {
setOnClickListener {
// Don't do a typical notifyDataSetChanged call here, because
// A. We don't have a real ViewModel state since this is a dialog
// B. Doing so would cause a relayout and the ripple effect to disappear
// Instead, simply notify a tab change and let TabCustomizeDialog handle it.
binding.tabIcon.isChecked = !binding.tabIcon.isChecked
onTabSwitch(tab)
listener.onVisibilityToggled(item.mode)
}
}
binding.tabIcon.apply {
setText(tab.mode.string)
isChecked = tab is Tab.Visible
setText(item.mode.string)
isChecked = item is Tab.Visible
}
// Roll our own drag handlers as the default ones suck
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchHelper.startDrag(this)
listener.onPickUpTab(this)
true
} else false
}
binding.root.setOnLongClickListener {
touchHelper.startDrag(this)
listener.onPickUpTab(this)
true
}
}
companion object {
val CREATOR =
object : Creator<TabViewHolder> {
override val viewType: Int
get() = throw UnsupportedOperationException()
override fun create(context: Context) =
TabViewHolder(ItemTabBinding.inflate(context.inflater))
}
}
}

View file

@ -21,21 +21,26 @@ import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.requireAttached
/**
* The dialog for customizing library tabs. This dialog does not rely on any specific ViewModel and
* serializes it's state instead of
* @author OxygenCobalt
*/
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>() {
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
private val settingsManager = SettingsManager.getInstance()
private var pendingTabs = settingsManager.libTabs
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var callback: TabDragCallback? = null
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@ -44,7 +49,7 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>() {
builder.setPositiveButton(android.R.string.ok) { _, _ ->
logD("Committing tab changes")
settingsManager.libTabs = pendingTabs
settingsManager.libTabs = tabAdapter.data.tabs
}
// Negative button just dismisses, no need for a listener.
@ -52,49 +57,73 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>() {
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
if (savedInstanceState != null) {
// Restore any pending tab configurations
val tabs = Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
if (tabs != null) {
pendingTabs = tabs
val savedTabs = findSavedTabState(savedInstanceState)
if (savedTabs != null) {
logD("Found saved tab state")
tabAdapter.data.submitTabs(savedTabs)
} else {
tabAdapter.data.submitTabs(settingsManager.libTabs)
}
}
// Set up adapter & drag callback
val callback = TabDragCallback { pendingTabs }
val helper = ItemTouchHelper(callback)
val tabAdapter = TabAdapter(helper, getTabs = { pendingTabs }, onTabSwitch = ::moveTabs)
callback.addTabAdapter(tabAdapter)
binding.tabRecycler.apply {
adapter = tabAdapter
helper.attachToRecyclerView(this)
requireTouchHelper().attachToRecyclerView(this)
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_TABS, Tab.toSequence(pendingTabs))
outState.putInt(KEY_TABS, Tab.toSequence(tabAdapter.data.tabs))
}
private fun moveTabs(tab: Tab) {
// Don't find the specific tab [Which might be outdated due to the nature
// of how ViewHolders are bound], but instead simply look for the mode in
// the list of pending tabs and update that instead.
val index = pendingTabs.indexOfFirst { it.mode == tab.mode }
if (index != -1) {
val curTab = pendingTabs[index]
logD("Updating tab $curTab to $tab")
pendingTabs[index] =
when (curTab) {
is Tab.Visible -> Tab.Invisible(curTab.mode)
is Tab.Invisible -> Tab.Visible(curTab.mode)
override fun onDestroyBinding(binding: DialogTabsBinding) {
super.onDestroyBinding(binding)
binding.tabRecycler.adapter = null
}
override fun onVisibilityToggled(displayMode: DisplayMode) {
// Tab viewholders bind with the initial tab state, which will drift from the actual
// state of the tabs over editing. So, this callback simply provides the displayMode
// for us to locate within the data and then update.
val index = tabAdapter.data.tabs.indexOfFirst { it.mode == displayMode }
if (index > -1) {
val tab = tabAdapter.data.tabs[index]
tabAdapter.data.setTab(
index,
when (tab) {
is Tab.Visible -> Tab.Invisible(tab.mode)
is Tab.Invisible -> Tab.Visible(tab.mode)
})
}
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
pendingTabs.filterIsInstance<Tab.Visible>().isNotEmpty()
tabAdapter.data.tabs.filterIsInstance<Tab.Visible>().isNotEmpty()
}
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
requireTouchHelper().startDrag(viewHolder)
}
private fun findSavedTabState(savedInstanceState: Bundle?): Array<Tab>? {
if (savedInstanceState != null) {
// Restore any pending tab configurations
return Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
}
return null
}
private fun requireTouchHelper(): ItemTouchHelper {
requireAttached()
val instance = touchHelper
if (instance != null) {
return instance
}
val newCallback = TabDragCallback(tabAdapter)
val newInstance = ItemTouchHelper(newCallback)
callback = newCallback
touchHelper = newInstance
return newInstance
}
companion object {

View file

@ -28,11 +28,7 @@ import androidx.recyclerview.widget.RecyclerView
* TODO: Consider unifying the shared behavior between this and QueueDragCallback into a single
* class.
*/
class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.Callback() {
private val tabs: Array<Tab>
get() = getTabs()
private lateinit var tabAdapter: TabAdapter
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
@ -63,8 +59,7 @@ class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.C
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
tabs.swap(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
tabAdapter.notifyItemMoved(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
adapter.data.moveItems(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
return true
}
@ -72,20 +67,4 @@ class TabDragCallback(private val getTabs: () -> Array<Tab>) : ItemTouchHelper.C
// We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled(): Boolean = false
/**
* Add the tab adapter to this callback. Done because there's a circular dependency between the
* two objects
*/
fun addTabAdapter(adapter: TabAdapter) {
tabAdapter = adapter
}
private fun <T : Any> Array<T>.swap(from: Int, to: Int) {
val t = get(to)
val f = get(from)
set(from, t)
set(to, f)
}
}

View file

@ -40,12 +40,14 @@ import org.oxycblt.auxio.util.showToast
* Dialog that manages the currently excluded directories.
* @author OxygenCobalt
*/
class ExcludedDialog : ViewBindingDialogFragment<DialogExcludedBinding>() {
class ExcludedDialog :
ViewBindingDialogFragment<DialogExcludedBinding>(), ExcludedAdapter.Listener {
private val excludedModel: ExcludedViewModel by viewModels {
ExcludedViewModel.Factory(requireContext())
}
private val playbackModel: PlaybackViewModel by activityViewModels()
private val excludedAdapter = ExcludedAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater)
@ -59,11 +61,10 @@ class ExcludedDialog : ViewBindingDialogFragment<DialogExcludedBinding>() {
}
override fun onBindingCreated(binding: DialogExcludedBinding, savedInstanceState: Bundle?) {
val adapter = ExcludedEntryAdapter { path -> excludedModel.removePath(path) }
val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
binding.excludedRecycler.adapter = adapter
binding.excludedRecycler.adapter = excludedAdapter
// Now that the dialog exists, we get the view manually when the dialog is shown
// and override its click listener so that the dialog does not auto-dismiss when we
@ -90,13 +91,22 @@ class ExcludedDialog : ViewBindingDialogFragment<DialogExcludedBinding>() {
// --- VIEWMODEL SETUP ---
excludedModel.paths.observe(viewLifecycleOwner) { paths -> updatePaths(paths, adapter) }
excludedModel.paths.observe(viewLifecycleOwner, ::updatePaths)
logD("Dialog created")
}
private fun updatePaths(paths: MutableList<String>, adapter: ExcludedEntryAdapter) {
adapter.submitList(paths)
override fun onDestroyBinding(binding: DialogExcludedBinding) {
super.onDestroyBinding(binding)
binding.excludedRecycler.adapter = null
}
override fun onRemovePath(path: String) {
excludedModel.removePath(path)
}
private fun updatePaths(paths: MutableList<String>) {
excludedAdapter.data.submitList(paths)
requireBinding().excludedEmpty.isVisible = paths.isEmpty()
}

View file

@ -17,10 +17,12 @@
package org.oxycblt.auxio.music.excluded
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import android.content.Context
import androidx.recyclerview.widget.DiffUtil
import org.oxycblt.auxio.databinding.ItemExcludedDirBinding
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.PrimitiveBackingData
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe
@ -28,37 +30,34 @@ import org.oxycblt.auxio.util.textSafe
* Adapter that shows the excluded directories and their "Clear" button.
* @author OxygenCobalt
*/
class ExcludedEntryAdapter(private val onClear: (String) -> Unit) :
RecyclerView.Adapter<ExcludedEntryAdapter.ViewHolder>() {
private var paths = mutableListOf<String>()
class ExcludedAdapter(listener: Listener) :
MonoAdapter<String, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
override val data = PrimitiveBackingData<String>(this)
override val creator = ExcludedViewHolder.CREATOR
override fun getItemCount() = paths.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemExcludedDirBinding.inflate(parent.context.inflater))
interface Listener {
fun onRemovePath(path: String)
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(paths[position])
/**
* The viewholder for [ExcludedAdapter]. Not intended for use in other adapters.
*/
class ExcludedViewHolder private constructor(private val binding: ItemExcludedDirBinding) :
BindingViewHolder<String, ExcludedAdapter.Listener>(binding.root) {
override fun bind(item: String, listener: ExcludedAdapter.Listener) {
binding.excludedPath.textSafe = item
binding.excludedClear.setOnClickListener { listener.onRemovePath(item) }
}
@SuppressLint("NotifyDataSetChanged")
fun submitList(newPaths: MutableList<String>) {
paths = newPaths
notifyDataSetChanged()
}
companion object {
val CREATOR =
object : Creator<ExcludedViewHolder> {
override val viewType: Int
get() = throw UnsupportedOperationException()
inner class ViewHolder(private val binding: ItemExcludedDirBinding) :
RecyclerView.ViewHolder(binding.root) {
init {
binding.root.layoutParams =
RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT)
}
fun bind(path: String) {
binding.excludedPath.textSafe = path
binding.excludedClear.setOnClickListener { onClear(path) }
override fun create(context: Context) =
ExcludedViewHolder(ItemExcludedDirBinding.inflate(context.inflater))
}
}
}

View file

@ -40,11 +40,8 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
val paths: LiveData<MutableList<String>>
get() = mPaths
private var dbPaths = listOf<String>()
/** Check if changes have been made to the ViewModel's paths. */
val isModified: Boolean
get() = dbPaths != paths.value
var isModified: Boolean = false
private set
init {
loadDatabasePaths()
@ -58,6 +55,7 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
if (!mPaths.value!!.contains(path)) {
mPaths.value!!.add(path)
mPaths.value = mPaths.value
isModified = true
}
}
@ -68,6 +66,7 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
fun removePath(path: String) {
mPaths.value!!.remove(path)
mPaths.value = mPaths.value
isModified = true
}
/** Save the pending paths to the database. [onDone] will be called on completion. */
@ -75,7 +74,7 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
excludedDatabase.writePaths(mPaths.value!!)
dbPaths = mPaths.value!!
isModified = false
onDone()
this@ExcludedViewModel.logD(
"Path save completed successfully in ${System.currentTimeMillis() - start}ms")
@ -86,8 +85,11 @@ class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewMo
private fun loadDatabasePaths() {
viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
dbPaths = excludedDatabase.readPaths()
isModified = false
val dbPaths = excludedDatabase.readPaths()
withContext(Dispatchers.Main) { mPaths.value = dbPaths.toMutableList() }
this@ExcludedViewModel.logD(
"Path load completed successfully in ${System.currentTimeMillis() - start}ms")
}

View file

@ -23,14 +23,16 @@ import android.graphics.drawable.ColorDrawable
import android.view.MotionEvent
import android.view.View
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.coil.bindAlbumCover
import org.oxycblt.auxio.databinding.ItemQueueSongBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.BackingData
import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.HybridBackingData
import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.ui.SongViewHolder
import org.oxycblt.auxio.util.disableDropShadowCompat
@ -105,3 +107,67 @@ private constructor(
val DIFFER = SongViewHolder.DIFFER
}
}
/**
* A list-backed [BackingData] that can be modified with both adapter primitives and
* [AsyncListDiffer]. This is incredibly dangerous and can probably crash the app if you look at it
* the wrong way, so please don't use it outside of the queue module.
*/
class HybridBackingData<T>(
private val adapter: RecyclerView.Adapter<*>,
diffCallback: DiffUtil.ItemCallback<T>
) : BackingData<T>() {
private var mCurrentList = mutableListOf<T>()
val currentList: List<T>
get() = mCurrentList
private val differ = AsyncListDiffer(adapter, diffCallback)
override fun getItem(position: Int): T = mCurrentList[position]
override fun getItemCount(): Int = mCurrentList.size
fun submitList(newData: List<T>, onDone: () -> Unit = {}) {
if (newData != mCurrentList) {
mCurrentList = newData.toMutableList()
differ.submitList(newData, onDone)
}
}
fun moveItems(from: Int, to: Int) {
mCurrentList.add(to, mCurrentList.removeAt(from))
differ.rewriteListUnsafe(mCurrentList)
adapter.notifyItemMoved(from, to)
}
fun removeItem(at: Int) {
mCurrentList.removeAt(at)
differ.rewriteListUnsafe(mCurrentList)
adapter.notifyItemRemoved(at)
}
/**
* Rewrites the AsyncListDiffer's internal list, cancelling any diffs that are currently in
* progress. I cannot describe in words how dangerous this is, but it's also the only thing I
* can do to marry the adapter primitives with DiffUtil.
*/
private fun <T> AsyncListDiffer<T>.rewriteListUnsafe(newList: List<T>) {
differMaxGenerationsField.set(this, (differMaxGenerationsField.get(this) as Int).inc())
differListField.set(this, newList.toMutableList())
differImmutableListField.set(this, newList)
}
companion object {
private val differListField =
AsyncListDiffer::class.java.getDeclaredField("mList").apply { isAccessible = true }
private val differImmutableListField =
AsyncListDiffer::class.java.getDeclaredField("mReadOnlyList").apply {
isAccessible = true
}
private val differMaxGenerationsField =
AsyncListDiffer::class.java.getDeclaredField("mMaxScheduledGeneration").apply {
isAccessible = true
}
}
}

View file

@ -178,15 +178,6 @@ class PrimitiveBackingData<T>(private val adapter: RecyclerView.Adapter<*>) : Ba
mCurrentList = newList.toMutableList()
adapter.notifyDataSetChanged()
}
/**
* Move an item from [from] to [to]. This calls [RecyclerView.Adapter.notifyItemMoved]
* internally.
*/
fun moveItems(from: Int, to: Int) {
mCurrentList.add(to, mCurrentList.removeAt(from))
adapter.notifyItemMoved(from, to)
}
}
/**
@ -215,80 +206,6 @@ class AsyncBackingData<T>(
}
}
/**
* A list-backed [BackingData] that can be modified with both adapter primitives and
* [AsyncListDiffer]. Never use this class unless absolutely necessary, such as when dealing with
* item dragging. This is mostly because the class is a terrible hacky mess that could easily crash
* the app if you are not careful with it. You have been warned.
*/
class HybridBackingData<T>(
private val adapter: RecyclerView.Adapter<*>,
diffCallback: DiffUtil.ItemCallback<T>
) : BackingData<T>() {
private var mCurrentList = mutableListOf<T>()
val currentList: List<T>
get() = mCurrentList
private val differ = AsyncListDiffer(adapter, diffCallback)
override fun getItem(position: Int): T = mCurrentList[position]
override fun getItemCount(): Int = mCurrentList.size
fun submitList(newData: List<T>, onDone: () -> Unit = {}) {
if (newData != mCurrentList) {
mCurrentList = newData.toMutableList()
differ.submitList(newData, onDone)
}
}
// @Suppress("NotifyDatasetChanged")
// fun submitListHard(newList: List<T>) {
// if (newList != mCurrentList) {
// mCurrentList = newList.toMutableList()
// differ.rewriteListUnsafe(mCurrentList)
// adapter.notifyDataSetChanged()
// }
// }
fun moveItems(from: Int, to: Int) {
mCurrentList.add(to, mCurrentList.removeAt(from))
differ.rewriteListUnsafe(mCurrentList)
adapter.notifyItemMoved(from, to)
}
fun removeItem(at: Int) {
mCurrentList.removeAt(at)
differ.rewriteListUnsafe(mCurrentList)
adapter.notifyItemRemoved(at)
}
/**
* Rewrites the AsyncListDiffer's internal list, cancelling any diffs that are currently in
* progress. I cannot describe in words how dangerous this is, but it's also the only thing I
* can do to marry the adapter primitives with DiffUtil.
*/
private fun <T> AsyncListDiffer<T>.rewriteListUnsafe(newList: List<T>) {
differMaxGenerationsField.set(this, (differMaxGenerationsField.get(this) as Int).inc())
differListField.set(this, newList.toMutableList())
differImmutableListField.set(this, newList)
}
companion object {
private val differListField =
AsyncListDiffer::class.java.getDeclaredField("mList").apply { isAccessible = true }
private val differImmutableListField =
AsyncListDiffer::class.java.getDeclaredField("mReadOnlyList").apply {
isAccessible = true
}
private val differMaxGenerationsField =
AsyncListDiffer::class.java.getDeclaredField("mMaxScheduledGeneration").apply {
isAccessible = true
}
}
}
/**
* A base [DiffUtil.ItemCallback] that automatically provides an implementation of
* [areContentsTheSame] any object that is derived from [Item].

View file

@ -122,7 +122,7 @@ class WidgetProvider : AppWidgetProvider() {
.size(min(metrics.widthPixels, metrics.heightPixels))
} else {
// Note: Explicitly use the "original" size as without it the scaling logic
// in SquareFrameTransform breaks down and results in an error.
// in coil breaks down and results in an error.
coverRequest.transformations(SquareFrameTransform()).size(Size.ORIGINAL)
}

View file

@ -58,9 +58,10 @@ the binding being obtained by calling `requireBinding`.
At times it may be more appropriate to use a `View` instead of a full blown fragment. This is okay as long as
view-binding is still used.
When creating a ViewHolder for a `RecyclerView`, one should use `BaseViewHolder` to standardize the binding process
and automate some code shared across all ViewHolders. The only exceptions to this case are for ViewHolders that
correspond to non-`BaseModel` data, in which a normal ViewHolder can be used instead.
Auxio uses `RecyclerView` for all list information. Due to the complexities of Auxio, the way one defines an
adapter differs quite heavily from the normal library. Generally, start with `MonoAdapter` for a list with one
type of data and `MultiAdapter` for lists with many types of data, then follow the documentation to see how
to fully implement the class.
#### Object communication
Auxio's codebase is mostly centered around 4 different types of code that communicates with each-other.