From 804db8b0d31facf65060eccd79c18a43e7f5be65 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Mon, 28 Dec 2020 10:48:50 -0700 Subject: [PATCH] Update genre detail layout Make GenreDetailFragment a full RecyclerView in order to fix issues with how NestedScrollView has to generate all ViewHolders in order on creation. --- .../auxio/detail/GenreDetailFragment.kt | 40 +++-- .../auxio/detail/adapters/GenreSongAdapter.kt | 60 +++++-- .../org/oxycblt/auxio/music/MusicUtils.kt | 12 ++ .../auxio/recycler/LinearCenterScroller.kt | 166 ++++++++++++++++++ .../org/oxycblt/auxio/ui/InterfaceUtils.kt | 7 +- .../res/layout-land/fragment_genre_detail.xml | 135 -------------- .../res/layout-land/item_genre_header.xml | 97 ++++++++++ app/src/main/res/layout/fragment_detail.xml | 32 ++++ .../main/res/layout/fragment_genre_detail.xml | 133 -------------- app/src/main/res/layout/item_genre_header.xml | 94 ++++++++++ app/src/main/res/menu/menu_genre_actions.xml | 9 + app/src/main/res/navigation/nav_explore.xml | 2 +- 12 files changed, 485 insertions(+), 302 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/recycler/LinearCenterScroller.kt delete mode 100644 app/src/main/res/layout-land/fragment_genre_detail.xml create mode 100644 app/src/main/res/layout-land/item_genre_header.xml create mode 100644 app/src/main/res/layout/fragment_detail.xml delete mode 100644 app/src/main/res/layout/fragment_genre_detail.xml create mode 100644 app/src/main/res/layout/item_genre_header.xml create mode 100644 app/src/main/res/menu/menu_genre_actions.xml 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 b2be70cac..7918127b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -8,14 +8,16 @@ import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager import org.oxycblt.auxio.R -import org.oxycblt.auxio.databinding.FragmentGenreDetailBinding +import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.adapters.GenreSongAdapter import org.oxycblt.auxio.logD +import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.state.PlaybackMode -import org.oxycblt.auxio.ui.disable +import org.oxycblt.auxio.ui.isLandscape import org.oxycblt.auxio.ui.setupGenreSongActions /** @@ -32,7 +34,7 @@ class GenreDetailFragment : DetailFragment() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - val binding = FragmentGenreDetailBinding.inflate(inflater) + val binding = FragmentDetailBinding.inflate(inflater) // If DetailViewModel isn't already storing the genre, get it from MusicStore // using the ID given by the navigation arguments @@ -47,6 +49,7 @@ class GenreDetailFragment : DetailFragment() { } val songAdapter = GenreSongAdapter( + viewLifecycleOwner, detailModel, doOnClick = { playbackModel.playSong(it, PlaybackMode.IN_GENRE) }, @@ -60,10 +63,9 @@ class GenreDetailFragment : DetailFragment() { // --- UI SETUP --- binding.lifecycleOwner = this - binding.detailModel = detailModel - binding.genre = detailModel.currentGenre.value - binding.genreToolbar.apply { + binding.detailToolbar.apply { + inflateMenu(R.menu.menu_songs) setNavigationOnClickListener { findNavController().navigateUp() } @@ -84,13 +86,19 @@ class GenreDetailFragment : DetailFragment() { } } - if (detailModel.currentGenre.value!!.songs.size < 2) { - binding.genreSortButton.disable(requireContext()) - } - - binding.genreSongRecycler.apply { + binding.detailRecycler.apply { adapter = songAdapter setHasFixedSize(true) + + if (isLandscape(resources)) { + layoutManager = GridLayoutManager(requireContext(), 2).also { + it.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (position == 0) 2 else 1 + } + } + } + } } // --- VIEWMODEL SETUP --- @@ -98,13 +106,11 @@ class GenreDetailFragment : DetailFragment() { detailModel.genreSortMode.observe(viewLifecycleOwner) { mode -> logD("Updating sort mode to $mode") - // Update the current sort icon - binding.genreSortButton.setImageResource(mode.iconRes) + val data = mutableListOf(detailModel.currentGenre.value!!).also { + it.addAll(mode.getSortedSongList(detailModel.currentGenre.value!!.songs)) + } - // Then update the sort mode of the artist adapter. - songAdapter.submitList( - mode.getSortedSongList(detailModel.currentGenre.value!!.songs) - ) + songAdapter.submitList(data) } logD("Fragment created.") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/adapters/GenreSongAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/adapters/GenreSongAdapter.kt index 3557d21ee..dc9cfdbbd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/adapters/GenreSongAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/adapters/GenreSongAdapter.kt @@ -3,8 +3,14 @@ package org.oxycblt.auxio.detail.adapters import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.databinding.ItemGenreHeaderBinding import org.oxycblt.auxio.databinding.ItemGenreSongBinding +import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.music.BaseModel +import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.recycler.DiffCallback import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder @@ -13,25 +19,54 @@ import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder * An adapter for displaying the [Song]s of a genre. */ class GenreSongAdapter( + private val lifecycleOwner: LifecycleOwner, + private val detailModel: DetailViewModel, private val doOnClick: (data: Song) -> Unit, private val doOnLongClick: (data: Song, view: View) -> Unit -) : ListAdapter(DiffCallback()) { +) : ListAdapter(DiffCallback()) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GenreSongViewHolder { - return GenreSongViewHolder( - ItemGenreSongBinding.inflate(LayoutInflater.from(parent.context)), - doOnClick, doOnLongClick - ) + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is Genre -> GENRE_HEADER_ITEM_TYPE + is Song -> GENRE_SONG_ITEM_TYPE + + else -> -1 + } } - override fun onBindViewHolder(holder: GenreSongViewHolder, position: Int) { - holder.bind(getItem(position)) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + GENRE_HEADER_ITEM_TYPE -> GenreHeaderViewHolder( + ItemGenreHeaderBinding.inflate(LayoutInflater.from(parent.context)) + ) + + GENRE_SONG_ITEM_TYPE -> GenreSongViewHolder( + ItemGenreSongBinding.inflate(LayoutInflater.from(parent.context)), + ) + + else -> error("Bad viewholder item type $viewType") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = getItem(position)) { + is Genre -> (holder as GenreHeaderViewHolder).bind(item) + is Song -> (holder as GenreSongViewHolder).bind(item) + } + } + + inner class GenreHeaderViewHolder( + private val binding: ItemGenreHeaderBinding + ) : BaseViewHolder(binding, null, null) { + override fun onBind(data: Genre) { + binding.genre = data + binding.detailModel = detailModel + binding.lifecycleOwner = lifecycleOwner + } } inner class GenreSongViewHolder( private val binding: ItemGenreSongBinding, - doOnClick: (data: Song) -> Unit, - doOnLongClick: (data: Song, view: View) -> Unit ) : BaseViewHolder(binding, doOnClick, doOnLongClick) { override fun onBind(data: Song) { @@ -41,4 +76,9 @@ class GenreSongAdapter( binding.songInfo.requestLayout() } } + + companion object { + const val GENRE_HEADER_ITEM_TYPE = 0xA020 + const val GENRE_SONG_ITEM_TYPE = 0xA021 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt index 11c72ed03..ebeb85d3a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtils.kt @@ -5,9 +5,12 @@ import android.content.Context import android.net.Uri import android.provider.MediaStore import android.text.format.DateUtils +import android.widget.ImageButton import android.widget.TextView import androidx.databinding.BindingAdapter import org.oxycblt.auxio.R +import org.oxycblt.auxio.logD +import org.oxycblt.auxio.recycler.SortMode /** * List of ID3 genres + Winamp extensions, each index corresponds to their int value. @@ -166,3 +169,12 @@ fun TextView.bindAlbumInfo(album: Album) { fun TextView.bindAlbumYear(album: Album) { text = album.year.toYear(context) } + +/** + * Bind the [SortMode] icon for an ImageButton. + */ +@BindingAdapter("sortIcon") +fun ImageButton.bindSortIcon(data: SortMode) { + logD("YOU STUPID FUCKING RETARD JUST FUNCITON") + setImageResource(data.iconRes) +} diff --git a/app/src/main/java/org/oxycblt/auxio/recycler/LinearCenterScroller.kt b/app/src/main/java/org/oxycblt/auxio/recycler/LinearCenterScroller.kt new file mode 100644 index 000000000..6ca0a01fe --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/recycler/LinearCenterScroller.kt @@ -0,0 +1,166 @@ +package org.oxycblt.auxio.recycler + +import android.graphics.PointF +import android.view.View +import android.view.animation.Interpolator +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.BuildConfig +import kotlin.math.exp + +/** + * A custom [RecyclerView.SmoothScroller] partially copied from [androidx.recyclerview.widget.LinearSmoothScroller] that has a scroll effect similar + * to [androidx.core.widget.NestedScrollView]. + * + * I don't know what half of this code does but it works and looks better than the default scroller so I use it + */ +class LinearCenterScroller(target: Int) : RecyclerView.SmoothScroller() { + private val viscousInterpolator = ViscousFluidInterpolator() + private var targetVec: PointF? = null + + // Temporary variables to keep track of the interim scroll target. These values do not + // point to a real item position, rather point to an estimated location pixels. + private var interimTargetDx = 0 + private var interimTargetDy = 0 + + init { + targetPosition = target + } + + // Not used + override fun onStart() {} + + override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) { + val dx = calcDxToMakeVisible(targetView) + val dy = calcDyToMakeVisible(targetView) + + action.update(-dx, -dy, DEFAULT_TIME, viscousInterpolator) + } + + override fun onSeekTargetStep(dx: Int, dy: Int, state: RecyclerView.State, action: Action) { + if (childCount == 0) { + stop() + return + } + + if (BuildConfig.DEBUG && targetVec != null && ((targetVec!!.x * dx < 0 || targetVec!!.y * dy < 0))) { + error("Scroll happened in the opposite direction of the target. Some calculations are wrong") + } + + interimTargetDx = clampApplyScroll(interimTargetDx, dx) + interimTargetDy = clampApplyScroll(interimTargetDy, dy) + + if (interimTargetDx == 0 && interimTargetDy == 0) { + updateActionForInterimTarget(action) + } + } + + override fun onStop() { + interimTargetDx = 0 + interimTargetDy = 0 + targetVec = null + } + + private fun calcDxToMakeVisible(view: View): Int { + val manager = layoutManager ?: return 0 + + if (!manager.canScrollHorizontally()) return 0 + + val params = view.layoutParams as RecyclerView.LayoutParams + val top = manager.getDecoratedTop(view) - params.topMargin + val bottom = manager.getDecoratedBottom(view) + params.bottomMargin + val start = manager.paddingTop + val end = manager.height - manager.paddingBottom + + return calculateDeltaToFit(top, bottom, start, end) + } + + private fun calcDyToMakeVisible(view: View): Int { + val manager = layoutManager ?: return 0 + + if (!manager.canScrollVertically()) return 0 + + val params = view.layoutParams as RecyclerView.LayoutParams + val top = manager.getDecoratedTop(view) - params.topMargin + val bottom = manager.getDecoratedBottom(view) + params.bottomMargin + val start = manager.paddingTop + val end = manager.height - manager.paddingBottom + + return calculateDeltaToFit(top, bottom, start, end) + } + + private fun calculateDeltaToFit(viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int): Int { + // Center the view instead of making it sit at the top or bottom. + return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2) + } + + private fun clampApplyScroll(argTmpDt: Int, dt: Int): Int { + var tmpDt = argTmpDt + tmpDt -= dt + if (argTmpDt * tmpDt <= 0) { // changed sign, reached 0 or was 0, reset + return 0 + } + return tmpDt + } + + private fun updateActionForInterimTarget(action: Action) { + val scrollVector = computeScrollVectorForPosition(targetPosition) + if (scrollVector == null || (scrollVector.x == 0.0f && scrollVector.y == 0.0f)) { + val target = targetPosition + action.jumpTo(target) + stop() + return + } + normalize(scrollVector) + + targetVec = scrollVector + + interimTargetDx = (TARGET_SEEK_SCROLL_DIST * scrollVector.x).toInt() + interimTargetDy = (TARGET_SEEK_SCROLL_DIST * scrollVector.y).toInt() + // To avoid UI hiccups, trigger a smooth scroll to a distance little further than the + // interim target. Since we track the distance travelled in onSeekTargetStep callback, it + // won't actually scroll more than what we need. + action.update( + (interimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO).toInt(), + (interimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO).toInt(), + DEFAULT_TIME, viscousInterpolator + ) + } + + /** + * A nice-looking interpolator that is similar to the [androidx.core.widget.NestedScrollView] interpolator. + */ + private inner class ViscousFluidInterpolator : Interpolator { + private val viscousNormalize = 1.0f / viscousFluid(1.0f) + private val viscousOffset = 1.0f - viscousNormalize * viscousFluid(1.0f) + + fun viscousFluid(argX: Float): Float { + var x = argX + + x *= VISCOUS_FLUID_SCALE + + if (x < 1.0f) { + x -= (1.0f - exp(-x)) + } else { + val start = 0.36787944117f; // 1/e == exp(-1) + x = 1.0f - exp(1.0f - x) + x = start + x * (1.0f - start) + } + return x + } + + override fun getInterpolation(input: Float): Float { + val interpolated = viscousNormalize * viscousFluid(input) + if (interpolated > 0) { + return interpolated + viscousOffset + } + return interpolated + } + } + + companion object { + private const val VISCOUS_FLUID_SCALE = 12.0f + private const val TARGET_SEEK_SCROLL_DIST = 10000 + private const val TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f + private const val DEFAULT_TIME = 500 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt index 9c61e142c..0a674d3fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt @@ -240,7 +240,7 @@ fun PopupMenu.setupGenreActions(genre: Genre, playbackModel: PlaybackViewModel) else -> false } } - inflateAndShow(R.menu.menu_artist_detail) + inflateAndShow(R.menu.menu_genre_actions) } /** @@ -268,11 +268,6 @@ fun PopupMenu.setupGenreSongActions(context: Context, song: Song, playbackModel: true } - R.id.action_play_all_songs -> { - playbackModel.playSong(song, PlaybackMode.ALL_SONGS) - true - } - else -> false } } diff --git a/app/src/main/res/layout-land/fragment_genre_detail.xml b/app/src/main/res/layout-land/fragment_genre_detail.xml deleted file mode 100644 index c1546e34e..000000000 --- a/app/src/main/res/layout-land/fragment_genre_detail.xml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout-land/item_genre_header.xml b/app/src/main/res/layout-land/item_genre_header.xml new file mode 100644 index 000000000..4af287b0f --- /dev/null +++ b/app/src/main/res/layout-land/item_genre_header.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_detail.xml b/app/src/main/res/layout/fragment_detail.xml new file mode 100644 index 000000000..a5680c967 --- /dev/null +++ b/app/src/main/res/layout/fragment_detail.xml @@ -0,0 +1,32 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_genre_detail.xml b/app/src/main/res/layout/fragment_genre_detail.xml deleted file mode 100644 index e02007d52..000000000 --- a/app/src/main/res/layout/fragment_genre_detail.xml +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_genre_header.xml b/app/src/main/res/layout/item_genre_header.xml new file mode 100644 index 000000000..a01681e63 --- /dev/null +++ b/app/src/main/res/layout/item_genre_header.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_genre_actions.xml b/app/src/main/res/menu/menu_genre_actions.xml new file mode 100644 index 000000000..f3859f101 --- /dev/null +++ b/app/src/main/res/menu/menu_genre_actions.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_explore.xml b/app/src/main/res/navigation/nav_explore.xml index 6697d873a..d733bfebd 100644 --- a/app/src/main/res/navigation/nav_explore.xml +++ b/app/src/main/res/navigation/nav_explore.xml @@ -71,7 +71,7 @@ android:id="@+id/genre_detail_fragment" android:name="org.oxycblt.auxio.detail.GenreDetailFragment" android:label="GenreDetailFragment" - tools:layout="@layout/fragment_genre_detail"> + tools:layout="@layout/fragment_detail">