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:
parent
ded7956319
commit
08d36df905
21 changed files with 193 additions and 178 deletions
|
@ -12,6 +12,8 @@ be parsed as images
|
|||
- 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 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
|
||||
- Switched to androidx media3 (New Home of ExoPlayer) for backing player components
|
||||
|
|
|
@ -37,16 +37,18 @@ object IntegerTable {
|
|||
const val VIEW_TYPE_PLAYLIST = 0xA004
|
||||
/** BasicHeaderViewHolder */
|
||||
const val VIEW_TYPE_BASIC_HEADER = 0xA005
|
||||
/** DividerViewHolder */
|
||||
const val VIEW_TYPE_DIVIDER = 0xA006
|
||||
/** SortHeaderViewHolder */
|
||||
const val VIEW_TYPE_SORT_HEADER = 0xA006
|
||||
const val VIEW_TYPE_SORT_HEADER = 0xA007
|
||||
/** AlbumSongViewHolder */
|
||||
const val VIEW_TYPE_ALBUM_SONG = 0xA007
|
||||
const val VIEW_TYPE_ALBUM_SONG = 0xA008
|
||||
/** ArtistAlbumViewHolder */
|
||||
const val VIEW_TYPE_ARTIST_ALBUM = 0xA008
|
||||
const val VIEW_TYPE_ARTIST_ALBUM = 0xA009
|
||||
/** ArtistSongViewHolder */
|
||||
const val VIEW_TYPE_ARTIST_SONG = 0xA009
|
||||
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_HEADER = 0xA00A
|
||||
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
||||
/** "Music playback" notification code */
|
||||
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
||||
/** "Music loading" notification code */
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
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.list.AlbumDetailListAdapter
|
||||
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.ListFragment
|
||||
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.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.navigation.NavigationViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
@ -95,7 +99,17 @@ class 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 ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.list.ArtistDetailListAdapter
|
||||
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.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
|
@ -94,7 +97,17 @@ class 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 ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
|
|
|
@ -32,6 +32,7 @@ import kotlinx.coroutines.yield
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.list.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
|
@ -297,7 +298,9 @@ constructor(
|
|||
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
|
||||
logD("Refreshing album list")
|
||||
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 =
|
||||
if (replace) {
|
||||
// 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}")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -363,7 +368,9 @@ constructor(
|
|||
var instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
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) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
instructions = UpdateInstructions.Replace(list.size)
|
||||
|
@ -379,9 +386,14 @@ constructor(
|
|||
logD("Refreshing genre list")
|
||||
val list = mutableListOf<Item>()
|
||||
// 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.add(SortHeader(R.string.lbl_songs))
|
||||
|
||||
val songHeader = SortHeader(R.string.lbl_songs)
|
||||
list.add(Divider(songHeader))
|
||||
list.add(songHeader)
|
||||
val instructions =
|
||||
if (replace) {
|
||||
// Intentional so that the header item isn't replaced with the songs
|
||||
|
@ -400,7 +412,9 @@ constructor(
|
|||
val list = mutableListOf<Item>()
|
||||
|
||||
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) {
|
||||
instructions = UpdateInstructions.Replace(list.size)
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.list.DetailListAdapter
|
||||
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.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
|
@ -87,7 +90,17 @@ class 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 ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.fragment.app.activityViewModels
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.list.DetailListAdapter
|
||||
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.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
|
@ -87,7 +90,17 @@ class 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 ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
|
|
|
@ -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 {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
|
|
@ -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 {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
|
|
@ -27,6 +27,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
|
@ -47,13 +48,12 @@ import org.oxycblt.auxio.util.inflater
|
|||
abstract class DetailListAdapter(
|
||||
private val listener: Listener<*>,
|
||||
private val diffCallback: DiffUtil.ItemCallback<Item>
|
||||
) :
|
||||
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback),
|
||||
AuxioRecyclerView.SpanSizeLookup {
|
||||
) : SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(diffCallback) {
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (getItem(position)) {
|
||||
// Implement support for headers and sort headers
|
||||
is Divider -> DividerViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
|
@ -61,6 +61,7 @@ abstract class DetailListAdapter(
|
|||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
|
||||
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
||||
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
|
||||
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. */
|
||||
interface Listener<in T : Music> : SelectableListListener<T> {
|
||||
/**
|
||||
|
@ -94,6 +89,8 @@ abstract class DetailListAdapter(
|
|||
object : SimpleDiffCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Divider && newItem is Divider ->
|
||||
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is SortHeader && newItem is SortHeader ->
|
||||
|
|
|
@ -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 {
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
|
|
|
@ -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 {
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<Item>() {
|
||||
|
|
|
@ -40,3 +40,5 @@ interface Header : Item {
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
||||
data class Divider(val anchor: Header?) : Item
|
||||
|
|
|
@ -23,14 +23,12 @@ import android.util.AttributeSet
|
|||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A [RecyclerView] with a few QoL extensions, such as:
|
||||
* - Automatic edge-to-edge support
|
||||
* - Adapter-based [SpanSizeLookup] implementation
|
||||
* - Automatic [setHasFixedSize] setup
|
||||
*
|
||||
* 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,
|
||||
// so we can enable fixed-size optimizations.
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(HeaderItemDecoration(context))
|
||||
}
|
||||
|
||||
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
||||
|
@ -67,36 +64,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -22,10 +22,12 @@ import android.view.View
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDividerBinding
|
||||
import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
||||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
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. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<Genre>() {
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
oldItem.name == newItem.name &&
|
||||
oldItem.artists.size == newItem.artists.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. */
|
||||
val DIFF_CALLBACK =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -343,10 +345,38 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
|
|||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<BasicHeader>() {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: BasicHeader,
|
||||
newItem: BasicHeader
|
||||
): Boolean = oldItem.titleRes == newItem.titleRes
|
||||
override fun areContentsTheSame(oldItem: BasicHeader, newItem: BasicHeader) =
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
||||
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK),
|
||||
AuxioRecyclerView.SpanSizeLookup {
|
||||
SelectionIndicatorAdapter<Item, RecyclerView.ViewHolder>(DIFF_CALLBACK) {
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (getItem(position)) {
|
||||
|
@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
is Artist -> ArtistViewHolder.VIEW_TYPE
|
||||
is Genre -> GenreViewHolder.VIEW_TYPE
|
||||
is Playlist -> PlaylistViewHolder.VIEW_TYPE
|
||||
is Divider -> DividerViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
@ -55,6 +55,7 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent)
|
||||
GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent)
|
||||
PlaylistViewHolder.VIEW_TYPE -> PlaylistViewHolder.from(parent)
|
||||
DividerViewHolder.VIEW_TYPE -> DividerViewHolder.from(parent)
|
||||
BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
|
||||
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 {
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
|
@ -87,6 +86,10 @@ class SearchAdapter(private val listener: SelectableListListener<Music>) :
|
|||
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Genre && newItem is Genre ->
|
||||
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 ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> false
|
||||
|
|
|
@ -29,10 +29,13 @@ import androidx.core.widget.addTextChangedListener
|
|||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
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.ListFragment
|
||||
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 ---
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.*
|
||||
|
@ -138,23 +139,44 @@ constructor(
|
|||
|
||||
return buildList {
|
||||
results.artists?.let {
|
||||
add(BasicHeader(R.string.lbl_artists))
|
||||
val header = BasicHeader(R.string.lbl_artists)
|
||||
add(header)
|
||||
addAll(SORT.artists(it))
|
||||
}
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ import androidx.core.graphics.Insets
|
|||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import java.lang.IllegalArgumentException
|
||||
|
@ -105,6 +106,20 @@ val ViewBinding.context: Context
|
|||
*/
|
||||
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
|
||||
* or higher.
|
||||
|
|
4
app/src/main/res/layout/item_divider.xml
Normal file
4
app/src/main/res/layout/item_divider.xml
Normal 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" />
|
Loading…
Reference in a new issue