From 86e2fd7a89af7215143de060d43febd1118df065 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 Jul 2024 11:19:18 -0600 Subject: [PATCH] detail: make album view use collapsing toolbar --- .../auxio/detail/AlbumDetailFragment.kt | 120 +++++++---- .../oxycblt/auxio/detail/DetailViewModel.kt | 1 - .../detail/header/AlbumDetailHeaderAdapter.kt | 114 ---------- .../res/layout-w600dp/fragment_detail2.xml | 203 ++++++++++++++++++ app/src/main/res/layout/fragment_detail2.xml | 187 ++++++++++++++++ 5 files changed, 474 insertions(+), 151 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt create mode 100644 app/src/main/res/layout-w600dp/fragment_detail2.xml create mode 100644 app/src/main/res/layout/fragment_detail2.xml 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 b49b206ec..56b271310 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -23,14 +23,16 @@ import android.view.LayoutInflater 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.appbar.AppBarLayout import com.google.android.material.transition.MaterialSharedAxis import dagger.hilt.android.AndroidEntryPoint +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentDetailBinding -import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter +import org.oxycblt.auxio.databinding.FragmentDetail2Binding import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter import org.oxycblt.auxio.detail.list.DetailListAdapter import org.oxycblt.auxio.list.Divider @@ -46,12 +48,14 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.info.Disc +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.canScroll +import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately +import org.oxycblt.auxio.util.getDimenPixels +import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.overrideOnOverflowMenuClick @@ -66,9 +70,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class AlbumDetailFragment : - ListFragment(), - AlbumDetailHeaderAdapter.Listener, - DetailListAdapter.Listener { + ListFragment(), + DetailListAdapter.Listener, + AppBarLayout.OnOffsetChangedListener { private val detailModel: DetailViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() @@ -77,9 +81,10 @@ class AlbumDetailFragment : // Information about what album to display is initially within the navigation arguments // as a UID, as that is the only safe way to parcel an album. private val args: AlbumDetailFragmentArgs by navArgs() - private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this) private val albumListAdapter = AlbumDetailListAdapter(this) + private var spacingSmall = 0 + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Detail transitions are always on the X axis. Shared element transitions are more @@ -90,15 +95,18 @@ class AlbumDetailFragment : reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) } - override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) + override fun onCreateBinding(inflater: LayoutInflater) = + FragmentDetail2Binding.inflate(inflater) - override fun getSelectionToolbar(binding: FragmentDetailBinding) = + override fun getSelectionToolbar(binding: FragmentDetail2Binding) = binding.detailSelectionToolbar - override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { + override fun onBindingCreated(binding: FragmentDetail2Binding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) // --- UI SETUP -- + binding.detailAppbar.addOnOffsetChangedListener(this) + binding.detailNormalToolbar.apply { setNavigationOnClickListener { findNavController().navigateUp() } overrideOnOverflowMenuClick { @@ -108,17 +116,23 @@ class AlbumDetailFragment : } binding.detailRecycler.apply { - adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter) + adapter = albumListAdapter + (layoutManager as GridLayoutManager).setFullWidthLookup { if (it != 0) { - val item = detailModel.albumSongList.value[it - 1] - item is Divider || item is Header || item is Disc + val item = + detailModel.genreSongList.value.getOrElse(it - 1) { + return@setFullWidthLookup false + } + item is Divider || item is Header } else { true } } } + spacingSmall = requireContext().getDimenPixels(R.dimen.spacing_small) + // -- VIEWMODEL SETUP --- // DetailViewModel handles most initialization from the navigation argument. detailModel.setAlbum(args.albumUid) @@ -134,15 +148,31 @@ class AlbumDetailFragment : collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } - override fun onDestroyBinding(binding: FragmentDetailBinding) { + override fun onDestroyBinding(binding: FragmentDetail2Binding) { super.onDestroyBinding(binding) - binding.detailNormalToolbar.setOnMenuItemClickListener(null) binding.detailRecycler.adapter = null // Avoid possible race conditions that could cause a bad replace instruction to be consumed // during list initialization and crash the app. Could happen if the user is fast enough. detailModel.albumSongInstructions.consume() } + override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { + val binding = requireBinding() + val range = appBarLayout.totalScrollRange + val ratio = abs(verticalOffset.toFloat()) / range.toFloat() + + val outRatio = min(ratio * 2, 1f) + val detailHeader = binding.detailHeader + detailHeader.scaleX = 1 - 0.05f * outRatio + detailHeader.scaleY = 1 - 0.05f * outRatio + detailHeader.alpha = 1 - outRatio + + val inRatio = max(ratio - 0.5f, 0f) * 2 + val detailContent = binding.detailToolbarContent + detailContent.alpha = inRatio + detailContent.translationY = spacingSmall * (1 - inRatio) + } + override fun onRealClick(item: Song) { playbackModel.play(item, detailModel.playInAlbumWith) } @@ -151,30 +181,50 @@ class AlbumDetailFragment : listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith) } - override fun onPlay() { - playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value)) - } - - override fun onShuffle() { - playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) - } - override fun onOpenSortMenu() { findNavController().navigateSafe(AlbumDetailFragmentDirections.sort()) } - override fun onNavigateToParentArtist() { - detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value)) - } - private fun updateAlbum(album: Album?) { if (album == null) { logD("No album to show, navigating away") findNavController().navigateUp() return } - requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext()) - albumHeaderAdapter.setParent(album) + + val binding = requireBinding() + + binding.detailToolbarTitle.text = album.name.resolve(requireContext()) + binding.detailCover.bind(album) + // The type text depends on the release type (Album, EP, Single, etc.) + binding.detailType.text = getString(album.releaseType.stringRes) + binding.detailName.text = album.name.resolve(requireContext()) + // Artist name maps to the subhead text + binding.detailSubhead.apply { + text = album.artists.resolveNames(context) + + // Add a QoL behavior where navigation to the artist will occur if the artist + // name is pressed. + setOnClickListener { + detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value)) + } + } + + // Date, song count, and duration map to the info text + binding.detailInfo.apply { + // Fall back to a friendlier "No date" text if the album doesn't have date information + val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date) + val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size) + val duration = album.durationMs.formatDurationMs(true) + text = context.getString(R.string.fmt_three, date, songCount, duration) + } + + binding.detailPlayButton.setOnClickListener { + playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value)) + } + binding.detailShuffleButton.setOnClickListener { + playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) + } } private fun updateList(list: List) { @@ -318,6 +368,9 @@ class AlbumDetailFragment : if (pos != -1) { // Only scroll if the song is within this album. val binding = requireBinding() + // RecyclerView will scroll assuming it has the total height of the screen (i.e a + // collapsed appbar), so we need to collapse the appbar if that's the case. + binding.detailAppbar.setExpanded(false) binding.detailRecycler.post { // Use a custom smooth scroller that will settle the item in the middle of // the screen rather than the end. @@ -340,11 +393,6 @@ class AlbumDetailFragment : // Make sure to increment the position to make up for the detail header binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller) - - // If the recyclerview can scroll, its certain that it will have to scroll to - // correctly center the playing item, so make sure that the Toolbar is lifted in - // that case. - binding.detailAppbar.isLifted = binding.detailRecycler.canScroll() } } } 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 3613d96c6..a9695df55 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -556,7 +556,6 @@ constructor( logD("Refreshing album list") val list = mutableListOf() val header = SortHeader(R.string.lbl_songs) - list.add(Divider(header)) list.add(header) val instructions = if (replace) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt deleted file mode 100644 index 41c12d9d6..000000000 --- a/app/src/main/java/org/oxycblt/auxio/detail/header/AlbumDetailHeaderAdapter.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * AlbumDetailHeaderAdapter.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.detail.header - -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.resolveNames -import org.oxycblt.auxio.playback.formatDurationMs -import org.oxycblt.auxio.util.context -import org.oxycblt.auxio.util.getPlural -import org.oxycblt.auxio.util.inflater - -/** - * A [DetailHeaderAdapter] that shows [Album] information. - * - * @param listener [DetailHeaderAdapter.Listener] to bind interactions to. - * @author Alexander Capehart (OxygenCobalt) - */ -class AlbumDetailHeaderAdapter(private val listener: Listener) : - DetailHeaderAdapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - AlbumDetailHeaderViewHolder.from(parent) - - override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) = - holder.bind(parent, listener) - - /** An extended listener for [DetailHeaderAdapter] implementations. */ - interface Listener : DetailHeaderAdapter.Listener { - - /** - * Called when the artist name in the [Album] header was clicked, requesting navigation to - * it's parent artist. - */ - fun onNavigateToParentArtist() - } -} - -/** - * A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to - * create an instance. - * - * @author Alexander Capehart (OxygenCobalt) - */ -class AlbumDetailHeaderViewHolder -private constructor(private val binding: ItemDetailHeaderBinding) : - RecyclerView.ViewHolder(binding.root) { - - /** - * Bind new data to this instance. - * - * @param album The new [Album] to bind. - * @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to. - */ - fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) { - binding.detailCover.bind(album) - - // The type text depends on the release type (Album, EP, Single, etc.) - binding.detailType.text = binding.context.getString(album.releaseType.stringRes) - - binding.detailName.text = album.name.resolve(binding.context) - - // Artist name maps to the subhead text - binding.detailSubhead.apply { - text = album.artists.resolveNames(context) - - // Add a QoL behavior where navigation to the artist will occur if the artist - // name is pressed. - setOnClickListener { listener.onNavigateToParentArtist() } - } - - // Date, song count, and duration map to the info text - binding.detailInfo.apply { - // Fall back to a friendlier "No date" text if the album doesn't have date information - val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date) - val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size) - val duration = album.durationMs.formatDurationMs(true) - text = context.getString(R.string.fmt_three, date, songCount, duration) - } - - binding.detailPlayButton.setOnClickListener { listener.onPlay() } - binding.detailShuffleButton.setOnClickListener { listener.onShuffle() } - } - - companion object { - /** - * Create a new instance. - * - * @param parent The parent to inflate this instance from. - * @return A new instance. - */ - fun from(parent: View) = - AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater)) - } -} diff --git a/app/src/main/res/layout-w600dp/fragment_detail2.xml b/app/src/main/res/layout-w600dp/fragment_detail2.xml new file mode 100644 index 000000000..1ffac5630 --- /dev/null +++ b/app/src/main/res/layout-w600dp/fragment_detail2.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_detail2.xml b/app/src/main/res/layout/fragment_detail2.xml new file mode 100644 index 000000000..4bab91c03 --- /dev/null +++ b/app/src/main/res/layout/fragment_detail2.xml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file