list: rework item arragement

Fix two issues with the ways items are laid out:
1. Remove the automatic span size lookup. Now that ConcatAdpater is
used, this basically becomes impossible to really leverage.
2. Use a divider item instead of a divider item decoration. The
latter is too buggy in many contexts, like the search view.

Resolves #426
Resolves #444
This commit is contained in:
Alexander Capehart 2023-05-18 14:52:22 -06:00
parent ded7956319
commit 08d36df905
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
21 changed files with 193 additions and 178 deletions

View file

@ -12,6 +12,8 @@ be parsed as images
- Fixed issue where searches would match song file names case-sensitively - Fixed issue where searches would match song file names case-sensitively
- Fixed issue where the notification would not respond to changes in the album cover setting - Fixed issue where the notification would not respond to changes in the album cover setting
- Fixed issue where short names starting with an article would not be correctly sorted (ex. "the 1") - Fixed issue where short names starting with an article would not be correctly sorted (ex. "the 1")
- Fixed incorrect item arrangement on landscape
- Fixed disappearing dividers in search view
#### Dev/Meta #### Dev/Meta
- Switched to androidx media3 (New Home of ExoPlayer) for backing player components - Switched to androidx media3 (New Home of ExoPlayer) for backing player components

View file

@ -37,16 +37,18 @@ object IntegerTable {
const val VIEW_TYPE_PLAYLIST = 0xA004 const val VIEW_TYPE_PLAYLIST = 0xA004
/** BasicHeaderViewHolder */ /** BasicHeaderViewHolder */
const val VIEW_TYPE_BASIC_HEADER = 0xA005 const val VIEW_TYPE_BASIC_HEADER = 0xA005
/** DividerViewHolder */
const val VIEW_TYPE_DIVIDER = 0xA006
/** SortHeaderViewHolder */ /** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA006 const val VIEW_TYPE_SORT_HEADER = 0xA007
/** AlbumSongViewHolder */ /** AlbumSongViewHolder */
const val VIEW_TYPE_ALBUM_SONG = 0xA007 const val VIEW_TYPE_ALBUM_SONG = 0xA008
/** ArtistAlbumViewHolder */ /** ArtistAlbumViewHolder */
const val VIEW_TYPE_ARTIST_ALBUM = 0xA008 const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
/** ArtistSongViewHolder */ /** ArtistSongViewHolder */
const val VIEW_TYPE_ARTIST_SONG = 0xA009 const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00A const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** "Music playback" notification code */ /** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */ /** "Music loading" notification code */

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -34,6 +35,8 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -45,6 +48,7 @@ import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.navigation.NavigationViewModel import org.oxycblt.auxio.navigation.NavigationViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
@ -95,7 +99,17 @@ class AlbumDetailFragment :
setOnMenuItemClickListener(this@AlbumDetailFragment) setOnMenuItemClickListener(this@AlbumDetailFragment)
} }
binding.detailRecycler.adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) binding.detailRecycler.apply {
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.albumList.value[it - 1]
item is Divider || item is Header || item is Disc
} else {
true
}
}
}
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -94,7 +97,17 @@ class ArtistDetailFragment :
setOnMenuItemClickListener(this@ArtistDetailFragment) setOnMenuItemClickListener(this@ArtistDetailFragment)
} }
binding.detailRecycler.adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter) binding.detailRecycler.apply {
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.artistList.value[it - 1]
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
@ -297,7 +298,9 @@ constructor(
private fun refreshAlbumList(album: Album, replace: Boolean = false) { private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album list") logD("Refreshing album list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
list.add(SortHeader(R.string.lbl_songs)) val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
val instructions = val instructions =
if (replace) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // Intentional so that the header item isn't replaced with the songs
@ -355,7 +358,9 @@ constructor(
logD("Release groups for this artist: ${byReleaseGroup.keys}") logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) { for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
list.add(BasicHeader(entry.key.headerTitleRes)) val header = BasicHeader(entry.key.headerTitleRes)
list.add(Divider(header))
list.add(header)
list.addAll(entry.value) list.addAll(entry.value)
} }
@ -363,7 +368,9 @@ constructor(
var instructions: UpdateInstructions = UpdateInstructions.Diff var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) { if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header") logD("Songs present in this artist, adding header")
list.add(SortHeader(R.string.lbl_songs)) val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size) instructions = UpdateInstructions.Replace(list.size)
@ -379,9 +386,14 @@ constructor(
logD("Refreshing genre list") logD("Refreshing genre list")
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs. // Genre is guaranteed to always have artists and songs.
list.add(BasicHeader(R.string.lbl_artists)) val artistHeader = BasicHeader(R.string.lbl_artists)
list.add(Divider(artistHeader))
list.add(artistHeader)
list.addAll(genre.artists) list.addAll(genre.artists)
list.add(SortHeader(R.string.lbl_songs))
val songHeader = SortHeader(R.string.lbl_songs)
list.add(Divider(songHeader))
list.add(songHeader)
val instructions = val instructions =
if (replace) { if (replace) {
// Intentional so that the header item isn't replaced with the songs // Intentional so that the header item isn't replaced with the songs
@ -400,7 +412,9 @@ constructor(
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
if (playlist.songs.isNotEmpty()) { if (playlist.songs.isNotEmpty()) {
list.add(SortHeader(R.string.lbl_songs)) val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) { if (replace) {
instructions = UpdateInstructions.Replace(list.size) instructions = UpdateInstructions.Replace(list.size)
} }

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -87,7 +90,17 @@ class GenreDetailFragment :
setOnMenuItemClickListener(this@GenreDetailFragment) setOnMenuItemClickListener(this@GenreDetailFragment)
} }
binding.detailRecycler.adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter) binding.detailRecycler.apply {
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.genreList.value[it - 1]
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -34,6 +35,8 @@ import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
@ -87,7 +90,17 @@ class PlaylistDetailFragment :
setOnMenuItemClickListener(this@PlaylistDetailFragment) setOnMenuItemClickListener(this@PlaylistDetailFragment)
} }
binding.detailRecycler.adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter) binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.playlistList.value[it - 1]
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.

View file

@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
} }
} }
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// The album and disc headers should be full-width in all configurations.
val item = getItem(position)
return item is Album || item is Disc
}
private companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =

View file

@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener<Music>) :
} }
} }
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Artist headers should be full-width in all configurations.
return getItem(position) is Artist
}
private companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =

View file

@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
abstract class DetailListAdapter( abstract class DetailListAdapter(
private val listener: Listener<*>, private val listener: Listener<*>,
private val diffCallback: DiffUtil.ItemCallback<Item> private val diffCallback: DiffUtil.ItemCallback<Item>
) : ) : SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Implement support for headers and sort headers // Implement support for headers and sort headers
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -61,6 +61,7 @@ abstract class DetailListAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
@ -73,12 +74,6 @@ abstract class DetailListAdapter(
} }
} }
override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = getItem(position)
return item is BasicHeader || item is SortHeader
}
/** An extended [SelectableListListener] for [DetailListAdapter] implementations. */ /** An extended [SelectableListListener] for [DetailListAdapter] implementations. */
interface Listener<in T : Music> : SelectableListListener<T> { interface Listener<in T : Music> : SelectableListListener<T> {
/** /**
@ -94,6 +89,8 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader -> oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader -> oldItem is SortHeader && newItem is SortHeader ->

View file

@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: Listener<Music>) :
} }
} }
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Genre headers should be full-width in all configurations
return getItem(position) is Genre
}
private companion object { private companion object {
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {

View file

@ -56,14 +56,6 @@ class PlaylistDetailListAdapter(private val listener: Listener<Song>) :
} }
} }
override fun isItemFullWidth(position: Int): Boolean {
if (super.isItemFullWidth(position)) {
return true
}
// Playlist headers should be full-width in all configurations
return getItem(position) is Playlist
}
companion object { companion object {
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {

View file

@ -40,3 +40,5 @@ interface Header : Item {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class BasicHeader(@StringRes override val titleRes: Int) : Header data class BasicHeader(@StringRes override val titleRes: Int) : Header
data class Divider(val anchor: Header?) : Item

View file

@ -23,14 +23,12 @@ import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [RecyclerView] with a few QoL extensions, such as: * A [RecyclerView] with a few QoL extensions, such as:
* - Automatic edge-to-edge support * - Automatic edge-to-edge support
* - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup * - Automatic [setHasFixedSize] setup
* *
* FIXME: Broken span configuration * FIXME: Broken span configuration
@ -49,7 +47,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents, // Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations. // so we can enable fixed-size optimizations.
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(HeaderItemDecoration(context))
} }
final override fun setHasFixedSize(hasFixedSize: Boolean) { final override fun setHasFixedSize(hasFixedSize: Boolean) {
@ -67,36 +64,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom) updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
return insets return insets
} }
override fun setAdapter(adapter: Adapter<*>?) {
super.setAdapter(adapter)
if (adapter is SpanSizeLookup) {
// This adapter has support for special span sizes, hook it up to the
// GridLayoutManager.
val glm = (layoutManager as GridLayoutManager)
val fullWidthSpanCount = glm.spanCount
glm.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
// Using the adapter implementation, if the adapter specifies that
// an item is full width, it will take up all of the spans, using a
// single span otherwise.
override fun getSpanSize(position: Int) =
if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1
}
}
}
/** A [RecyclerView.Adapter]-specific hook to control divider decoration visibility. */
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
interface SpanSizeLookup {
/**
* Get if the item at a position takes up the whole width of the [RecyclerView] or not.
*
* @param position The position of the item.
* @return true if the item is full-width, false otherwise.
*/
fun isItemFullWidth(position: Int): Boolean
}
} }

View file

@ -1,70 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* HeaderItemDecoration.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.BackportMaterialDividerItemDecoration
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
/**
* A [BackportMaterialDividerItemDecoration] that sets up the divider configuration to correctly
* separate content with headers.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class HeaderItemDecoration
@JvmOverloads
constructor(
context: Context,
attributeSet: AttributeSet? = null,
defStyleAttr: Int = R.attr.materialDividerStyle,
orientation: Int = LinearLayoutManager.VERTICAL
) : BackportMaterialDividerItemDecoration(context, attributeSet, defStyleAttr, orientation) {
override fun shouldDrawDivider(position: Int, adapter: RecyclerView.Adapter<*>?): Boolean {
if (adapter is ConcatAdapter) {
val adapterAndPosition =
try {
adapter.getWrappedAdapterAndPosition(position + 1)
} catch (e: IllegalArgumentException) {
return false
}
return hasHeaderAtPosition(adapterAndPosition.second, adapterAndPosition.first)
} else {
return hasHeaderAtPosition(position + 1, adapter)
}
}
private fun hasHeaderAtPosition(position: Int, adapter: RecyclerView.Adapter<*>?) =
try {
// Add a divider if the next item is a header. This organizes the divider to separate
// the ends of content rather than the beginning of content, alongside an added benefit
// of preventing top headers from having a divider applied.
(adapter as FlexibleListAdapter<*, *>).getItem(position) is Header
} catch (e: ClassCastException) {
false
} catch (e: IndexOutOfBoundsException) {
false
}
}

View file

@ -22,10 +22,12 @@ import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDividerBinding
import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
@ -246,7 +248,7 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Genre>() { object : SimpleDiffCallback<Genre>() {
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean = override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.name == newItem.name && oldItem.name == newItem.name &&
oldItem.artists.size == newItem.artists.size && oldItem.artists.size == newItem.artists.size &&
oldItem.songs.size == newItem.songs.size oldItem.songs.size == newItem.songs.size
@ -304,7 +306,7 @@ class PlaylistViewHolder private constructor(private val binding: ItemParentBind
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Playlist>() { object : SimpleDiffCallback<Playlist>() {
override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist): Boolean = override fun areContentsTheSame(oldItem: Playlist, newItem: Playlist) =
oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size oldItem.name == newItem.name && oldItem.songs.size == newItem.songs.size
} }
} }
@ -343,10 +345,38 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<BasicHeader>() { object : SimpleDiffCallback<BasicHeader>() {
override fun areContentsTheSame( override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
oldItem: BasicHeader, oldItem.titleRes == newItem.titleRes
newItem: BasicHeader }
): Boolean = oldItem.titleRes == newItem.titleRes }
}
/**
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DividerViewHolder private constructor(private val binding: ItemDividerBinding) :
RecyclerView.ViewHolder(binding.root) {
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DIVIDER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
DividerViewHolder(ItemDividerBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Divider>() {
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
oldItem.anchor == newItem.anchor
} }
} }
} }

View file

@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SearchAdapter(private val listener: SelectableListListener<Music>) : class SearchAdapter(private val listener: SelectableListListener<Music>) :
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK), SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
AuxioRecyclerView.SpanSizeLookup {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
is Artist -> ArtistViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE
is Genre -> GenreViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE
is Playlist -> PlaylistViewHolder.VIEW_TYPE is Playlist -> PlaylistViewHolder.VIEW_TYPE
is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -55,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent) PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent)
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
@ -71,8 +72,6 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
} }
} }
override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader
private companion object { private companion object {
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
@ -87,6 +86,10 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Genre && newItem is Genre -> oldItem is Genre && newItem is Genre ->
GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Playlist && newItem is Playlist ->
PlaylistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader -> oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false else -> false

View file

@ -29,10 +29,13 @@ import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.databinding.FragmentSearchBinding
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.list.selection.SelectionViewModel
@ -104,7 +107,13 @@ class SearchFragment : ListFragment<Music, FragmentSearchBinding>() {
} }
} }
binding.searchRecycler.adapter = searchAdapter binding.searchRecycler.apply {
adapter = searchAdapter
(layoutManager as GridLayoutManager).setFullWidthLookup {
val item = searchModel.searchResults.value[it]
item is Divider || item is Header
}
}
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
@ -138,23 +139,44 @@ constructor(
return buildList { return buildList {
results.artists?.let { results.artists?.let {
add(BasicHeader(R.string.lbl_artists)) val header = BasicHeader(R.string.lbl_artists)
add(header)
addAll(SORT.artists(it)) addAll(SORT.artists(it))
} }
results.albums?.let { results.albums?.let {
add(BasicHeader(R.string.lbl_albums)) val header = BasicHeader(R.string.lbl_albums)
if (isNotEmpty()) {
add(Divider(header))
}
add(header)
addAll(SORT.albums(it)) addAll(SORT.albums(it))
} }
results.playlists?.let { results.playlists?.let {
add(BasicHeader(R.string.lbl_playlists)) val header = BasicHeader(R.string.lbl_playlists)
if (isNotEmpty()) {
add(Divider(header))
}
add(header)
addAll(SORT.playlists(it)) addAll(SORT.playlists(it))
} }
results.genres?.let { results.genres?.let {
add(BasicHeader(R.string.lbl_genres)) val header = BasicHeader(R.string.lbl_genres)
if (isNotEmpty()) {
add(Divider(header))
}
add(header)
addAll(SORT.genres(it)) addAll(SORT.genres(it))
} }
results.songs?.let { results.songs?.let {
add(BasicHeader(R.string.lbl_songs)) val header = BasicHeader(R.string.lbl_songs)
if (isNotEmpty()) {
add(Divider(header))
}
add(header)
addAll(SORT.songs(it)) addAll(SORT.songs(it))
} }
} }

View file

@ -31,6 +31,7 @@ import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import java.lang.IllegalArgumentException import java.lang.IllegalArgumentException
@ -105,6 +106,20 @@ val ViewBinding.context: Context
*/ */
fun RecyclerView.canScroll() = computeVerticalScrollRange() > height fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
/**
* Shortcut to easily set up a [GridLayoutManager.SpanSizeLookup].
*
* @param isItemFullWidth Mapping expression that returns true if the item should take up all spans
* or just one.
*/
fun GridLayoutManager.setFullWidthLookup(isItemFullWidth: (Int) -> Boolean) {
spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int) =
if (isItemFullWidth(position)) spanCount else 1
}
}
/** /**
* Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5 * Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5
* or higher. * or higher.

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.divider.MaterialDivider xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" />