detail: fix highlighting issues

Fix two major highlighting bugs based around the janky and stupid way
I would handle highlighting previously.

Previously, I would index the views of a RecyclerView in order to
highlight viewholders. In retrospect this was a pretty bad idea,
as viewholders could be in a weird limbo state where they are bound,
but not accessible. I mean, it's in the name. It's a Recycling View.

Fortunately, google actually knew what they were doing and provided
a way to mutate viewholders at runtime using notifyItemChanged.
And the API actually makes sense! Wow! Migrate all detail adapters
to a system that uses notifyItemChanged instead of the terrible
pre-existing system.
This commit is contained in:
OxygenCobalt 2022-06-02 10:13:27 -06:00
parent 182b08ab65
commit 10afae0bfc
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 86 additions and 123 deletions

View file

@ -56,6 +56,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2"
// --- SUPPORT ---

View file

@ -44,8 +44,6 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* TODO: Custom language support
*
* TODO: Fix how selection works in the RecyclerViews (doing it poorly right now)
*
* TODO: Rework padding ethos
*
* @author OxygenCobalt

View file

@ -25,7 +25,6 @@ import androidx.core.view.children
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
@ -33,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.Header
@ -40,6 +40,7 @@ import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -70,7 +71,7 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
launch { detailModel.albumData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
}
override fun onMenuItemClick(item: MenuItem): Boolean {
@ -188,22 +189,24 @@ class AlbumDetailFragment : DetailFragment(), AlbumDetailAdapter.Listener {
}
}
/** Updates the queue actions when a song is present or not */
private fun updateSong(song: Song?) {
private fun updatePlayback(song: Song?, parent: MusicParent?) {
val binding = requireBinding()
for (item in binding.detailToolbar.menu.children) {
// If there is no playback going in, any queue additions will be wiped as soon as
// something is played. Disable these actions when playback is going on so that
// it isn't possible to add anything during that time.
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
item.isEnabled = song != null
}
}
if (playbackModel.parent.value is Album &&
playbackModel.parent.value?.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
logD("update $song")
detailAdapter.highlightSong(song)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
detailAdapter.highlightSong(null)
}
}

View file

@ -38,6 +38,7 @@ import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -68,8 +69,7 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
launch { detailModel.artistData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.parent.collect(::updateParent) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
}
override fun onMenuItemClick(item: MenuItem): Boolean = false
@ -135,23 +135,22 @@ class ArtistDetailFragment : DetailFragment(), DetailAdapter.Listener {
}
}
private fun updateSong(song: Song?) {
private fun updatePlayback(song: Song?, parent: MusicParent?) {
val binding = requireBinding()
if (playbackModel.parent.value is Artist &&
playbackModel.parent.value?.id == detailModel.currentArtist.value?.id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
detailAdapter.highlightSong(song)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
}
// Clear the ViewHolders if the given song is not part of this artist.
detailAdapter.highlightSong(null)
}
private fun updateParent(parent: MusicParent?) {
val binding = requireBinding()
if (parent is Album?) {
detailAdapter.highlightAlbum(parent, binding.detailRecycler)
if (parent is Album &&
parent.artist.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
detailAdapter.highlightAlbum(parent)
} else {
detailAdapter.highlightAlbum(null, binding.detailRecycler)
// Clear out the album viewholder if the parent is not an album.
detailAdapter.highlightAlbum(null)
}
}
}

View file

@ -85,13 +85,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun findRecyclerView(): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
@ -124,10 +122,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
this.titleAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView?.alpha = it.animatedValue as Float }
duration =
resources.getInteger(R.integer.detail_app_bar_title_anim_duration).toLong()
start()
}
}

View file

@ -123,7 +123,7 @@ class DetailViewModel : ViewModel() {
// To create a good user experience regarding disc numbers, we intersperse
// items that show the disc number throughout the album's songs. In the case
// that the album does not have disc numbers, we omit the header.
// that the album does not have distinct disc numbers, we omit the header.
val songs = albumSort.songs(album.songs)
val byDisc = songs.groupBy { it.disc ?: 1 }
if (byDisc.size > 1) {

View file

@ -22,7 +22,6 @@ import android.view.MenuItem
import android.view.View
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter
@ -32,12 +31,14 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.Header
import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.ui.newMenu
import org.oxycblt.auxio.util.applySpans
import org.oxycblt.auxio.util.collectWith
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
@ -66,7 +67,7 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
launch { detailModel.genreData.collect(detailAdapter.data::submitList) }
launch { navModel.exploreNavigationItem.collect(::handleNavigation) }
launch { playbackModel.song.collect(::updateSong) }
launch { playbackModel.song.collectWith(playbackModel.parent, ::updatePlayback) }
}
override fun onMenuItemClick(item: MenuItem): Boolean = false
@ -124,14 +125,13 @@ class GenreDetailFragment : DetailFragment(), DetailAdapter.Listener {
}
}
private fun updateSong(song: Song?) {
private fun updatePlayback(song: Song?, parent: MusicParent?) {
val binding = requireBinding()
if (playbackModel.parent.value is Genre &&
playbackModel.parent.value?.id == unlikelyToBeNull(detailModel.currentGenre.value).id) {
detailAdapter.highlightSong(song, binding.detailRecycler)
if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) {
detailAdapter.highlightSong(song)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.highlightSong(null, binding.detailRecycler)
detailAdapter.highlightSong(null)
}
}
}

View file

@ -44,8 +44,8 @@ import org.oxycblt.auxio.util.textSafe
*/
class AlbumDetailAdapter(listener: Listener) :
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) {
private var highlightedSong: Song? = null
private var highlightedViewHolder: Highlightable? = null
private var currentSong: Song? = null
private var currentHighlightedSongPos: Int? = null
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
@ -76,25 +76,15 @@ class AlbumDetailAdapter(listener: Listener) :
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
if (item is Song && item.id == highlightedSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
highlightedViewHolder?.setHighlighted(false)
highlightedViewHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
viewHolder.setHighlighted(item.id == currentSong?.id)
}
/**
* Update the [song] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
if (song == highlightedSong) return
highlightedSong = song
highlightedViewHolder?.setHighlighted(false)
highlightedViewHolder = highlightItem(song, recycler)
/** Update the [song] that this adapter should highlight */
fun highlightSong(song: Song?) {
if (song == currentSong) return
currentSong = song
currentHighlightedSongPos?.let { notifyItemChanged(it, PAYLOAD_HIGHLIGHT_CHANGED) }
currentHighlightedSongPos = highlightItem(song)
}
companion object {

View file

@ -45,10 +45,10 @@ import org.oxycblt.auxio.util.textSafe
class ArtistDetailAdapter(listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentAlbum: Album? = null
private var currentAlbumHolder: Highlightable? = null
private var currentHighlightedAlbumPos: Int? = null
private var currentSong: Song? = null
private var currentSongHolder: Highlightable? = null
private var currentHighlightedSongPos: Int? = null
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
@ -79,40 +79,26 @@ class ArtistDetailAdapter(listener: Listener) :
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
// If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentAlbum?.id && item is Album) {
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = viewHolder
viewHolder.setHighlighted(true)
} else if (item.id == currentSong?.id && item is Song) {
currentSongHolder?.setHighlighted(false)
currentSongHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
viewHolder.setHighlighted(
(item is Album && item.id == currentAlbum?.id) ||
(item is Song && item.id == currentSong?.id)
)
}
/**
* Update the current [album] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightAlbum(album: Album?, recycler: RecyclerView) {
/** Update the current [album] that this adapter should highlight */
fun highlightAlbum(album: Album?) {
if (album == currentAlbum) return
currentAlbum = album
currentAlbumHolder?.setHighlighted(false)
currentAlbumHolder = highlightItem(album, recycler)
currentHighlightedAlbumPos?.let { notifyItemChanged(it, PAYLOAD_HIGHLIGHT_CHANGED) }
currentHighlightedAlbumPos = highlightItem(album)
}
/**
* Update the [song] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
/** Update the [song] that this adapter should highlight */
fun highlightSong(song: Song?) {
if (song == currentSong) return
currentSong = song
currentSongHolder?.setHighlighted(false)
currentSongHolder = highlightItem(song, recycler)
currentHighlightedSongPos?.let { notifyItemChanged(it, PAYLOAD_HIGHLIGHT_CHANGED) }
currentHighlightedSongPos = highlightItem(song)
}
companion object {

View file

@ -35,7 +35,6 @@ import org.oxycblt.auxio.ui.NewHeaderViewHolder
import org.oxycblt.auxio.ui.SimpleItemCallback
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.textSafe
abstract class DetailAdapter<L : DetailAdapter.Listener>(
@ -44,10 +43,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
) : MultiAdapter<L>(listener) {
abstract fun onHighlightViewHolder(viewHolder: Highlightable, item: Item)
protected inline fun <reified T : Item> highlightItem(
newItem: T?,
recycler: RecyclerView
): Highlightable? {
protected inline fun <reified T : Item> highlightItem(newItem: T?): Int? {
if (newItem == null) {
return null
}
@ -55,19 +51,8 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
// Use existing data instead of having to re-sort it.
// We also have to account for the album count when searching for the ViewHolder.
val pos = data.currentList.indexOfFirst { item -> item.id == newItem.id && item is T }
// Check if the ViewHolder for this song is visible, if it is then highlight it.
// If the ViewHolder is not visible, then the adapter should take care of it if
// it does become visible.
val viewHolder = recycler.findViewHolderForAdapterPosition(pos)
return if (viewHolder is Highlightable) {
viewHolder.setHighlighted(true)
viewHolder
} else {
logW("ViewHolder intended to highlight was not Highlightable")
null
}
notifyItemChanged(pos, PAYLOAD_HIGHLIGHT_CHANGED)
return pos
}
@Suppress("LeakingThis") override val data = AsyncBackingData(this, diffCallback)
@ -98,6 +83,13 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
}
companion object {
// This payload value serves two purposes:
// 1. It disables animations from notifyItemChanged, which looks bad when highlighting
// values.
// 2. It instructs adapters to avoid re-binding information, and instead simply
// change the highlight state.
val PAYLOAD_HIGHLIGHT_CHANGED = Any()
val DIFFER =
object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {

View file

@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.textSafe
class GenreDetailAdapter(listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
private var currentHolder: Highlightable? = null
private var currentHighlightedSongPos: Int? = null
override fun getCreatorFromItem(item: Item) =
super.getCreatorFromItem(item)
@ -71,26 +71,15 @@ class GenreDetailAdapter(listener: Listener) :
}
override fun onHighlightViewHolder(viewHolder: Highlightable, item: Item) {
// If the item corresponds to a currently playing song/album then highlight it
if (item.id == currentSong?.id) {
// Reset the last ViewHolder before assigning the new, correct one to be highlighted
currentHolder?.setHighlighted(false)
currentHolder = viewHolder
viewHolder.setHighlighted(true)
} else {
viewHolder.setHighlighted(false)
}
viewHolder.setHighlighted(item.id == currentSong?.id)
}
/**
* Update the [song] that this adapter should highlight
* @param recycler The recyclerview the highlighting should act on.
*/
fun highlightSong(song: Song?, recycler: RecyclerView) {
/** Update the [song] that this adapter should highlight */
fun highlightSong(song: Song?) {
if (song == currentSong) return
currentSong = song
currentHolder?.setHighlighted(false)
currentHolder = highlightItem(song, recycler)
currentHighlightedSongPos?.let { notifyItemChanged(it, PAYLOAD_HIGHLIGHT_CHANGED) }
currentHighlightedSongPos = highlightItem(song)
}
companion object {

View file

@ -58,8 +58,7 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore
get() = _song
private val _parent = MutableStateFlow<MusicParent?>(null)
/** The current model that is being played from, such as an [Album] or [Artist] */
val parent: StateFlow<MusicParent?>
get() = _parent
val parent: StateFlow<MusicParent?> = _parent
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean>
get() = _isPlaying
@ -298,8 +297,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback, MusicStore
}
override fun onNewPlayback(index: Int, queue: List<Song>, parent: MusicParent?) {
_parent.value = playbackManager.parent
_song.value = playbackManager.song
_parent.value = playbackManager.parent
_nextUp.value = queue.slice(index + 1 until queue.size)
}

View file

@ -53,6 +53,8 @@ private typealias AnyCreator = BindingViewHolder.Creator<out RecyclerView.ViewHo
* An adapter for many viewholders tied to many types of data. Deriving this is more complicated
* than [MonoAdapter], as less overrides can be provided "for free".
* @author OxygenCobalt
*
* TODO: Force impls to handle payload situations.
*/
abstract class MultiAdapter<L>(private val listener: L) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {

View file

@ -38,6 +38,9 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
@ -166,6 +169,11 @@ fun Fragment.launch(
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) }
}
/** Combines the called flow with the given flow and then collects them both into [block]. */
suspend fun <T1, T2> Flow<T1>.collectWith(other: Flow<T2>, block: suspend (T1, T2) -> Unit) {
combine(this, other) { a, b -> a to b }.collect { block(it.first, it.second) }
}
/**
* Shortcut for querying all items in a database and running [block] with the cursor returned. Will
* not run if the cursor is null.