diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fd1f7d1..609c11d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 3e9a639c3..93b8b239a 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -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 */ diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 42d6bb341..b168f1afe 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 4578f664d..20b055183 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index d2e87f6ed..127c84468 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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() - 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() // 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() 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) } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 2028c3610..2729267f7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index d1214f6b2..d1b7da0c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt index b3d01e970..b7217a681 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/AlbumDetailListAdapter.kt @@ -69,15 +69,6 @@ class AlbumDetailListAdapter(private val listener: Listener) : } } - 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 = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt index f27a11100..e281d9982 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/ArtistDetailListAdapter.kt @@ -65,14 +65,6 @@ class ArtistDetailListAdapter(private val listener: Listener) : } } - 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 = diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt index 7959ec47d..cd23751be 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/DetailListAdapter.kt @@ -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 -) : - SelectionIndicatorAdapter(diffCallback), - AuxioRecyclerView.SpanSizeLookup { +) : SelectionIndicatorAdapter(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 : SelectableListListener { /** @@ -94,6 +89,8 @@ abstract class DetailListAdapter( object : SimpleDiffCallback() { 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 -> diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt index e4a33c80b..5f2c704f2 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/GenreDetailListAdapter.kt @@ -60,14 +60,6 @@ class GenreDetailListAdapter(private val listener: Listener) : } } - 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() { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt index a6c695ac2..5a33e511f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/list/PlaylistDetailListAdapter.kt @@ -56,14 +56,6 @@ class PlaylistDetailListAdapter(private val listener: Listener) : } } - 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() { diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt index 5fed1627d..27f059602 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index b535feba2..eb7c820a4 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -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 - } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt deleted file mode 100644 index 1ffc6c1fc..000000000 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/HeaderItemDecoration.kt +++ /dev/null @@ -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 . - */ - -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 - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt index 7119a163c..1b575978f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -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() { - 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() { - 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() { - 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() { + override fun areContentsTheSame(oldItem: Divider, newItem: Divider) = + oldItem.anchor == newItem.anchor } } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 191529cca..4c1b2c2a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -34,8 +34,7 @@ import org.oxycblt.auxio.util.logD * @author Alexander Capehart (OxygenCobalt) */ class SearchAdapter(private val listener: SelectableListListener) : - SelectionIndicatorAdapter(DIFF_CALLBACK), - AuxioRecyclerView.SpanSizeLookup { + SelectionIndicatorAdapter(DIFF_CALLBACK) { override fun getItemViewType(position: Int) = when (getItem(position)) { @@ -44,6 +43,7 @@ class SearchAdapter(private val listener: SelectableListListener) : 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) : 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) : } } - 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) : 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 diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 7846710b9..b0a0feb06 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -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() { } } - 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 --- diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index e34dcf800..ec42ca3cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -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)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index 14cd2a20e..71fc880d6 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -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. diff --git a/app/src/main/res/layout/item_divider.xml b/app/src/main/res/layout/item_divider.xml new file mode 100644 index 000000000..4767eae41 --- /dev/null +++ b/app/src/main/res/layout/item_divider.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file