Refactor fast scroll

Completely write my own fast scroller thumb and also redo how the fast scroller is configured in SongsFragment.
This commit is contained in:
OxygenCobalt 2021-02-27 13:00:46 -07:00
parent a9765d2ad6
commit 917540e626
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 225 additions and 143 deletions

View file

@ -12,6 +12,7 @@ import coil.request.ImageRequest
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
@ -53,8 +54,12 @@ fun ImageView.bindGenreImage(genre: Genre) {
/** /**
* Custom extension function similar to the stock coil load extensions, but handles whether * Custom extension function similar to the stock coil load extensions, but handles whether
* to show images and custom fetchers. * to show images and custom fetchers.
* @param T Any datatype that inherits [BaseModel]
* @param data The data itself
* @param error Drawable resource to use when loading failed/should not occur.
* @param fetcher Required fetcher that uses [T] as its datatype
*/ */
inline fun <reified T : Any> ImageView.load( inline fun <reified T : BaseModel> ImageView.load(
data: T, data: T,
@DrawableRes error: Int, @DrawableRes error: Int,
fetcher: Fetcher<T>, fetcher: Fetcher<T>,
@ -63,7 +68,6 @@ inline fun <reified T : Any> ImageView.load(
if (!settingsManager.showCovers) { if (!settingsManager.showCovers) {
setImageResource(error) setImageResource(error)
return return
} }

View file

@ -40,8 +40,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private val mPosition = MutableLiveData(0L) private val mPosition = MutableLiveData(0L)
// Queue // Queue
private val mQueue = MutableLiveData(mutableListOf<Song>()) private val mQueue = MutableLiveData(listOf<Song>())
private val mUserQueue = MutableLiveData(mutableListOf<Song>()) private val mUserQueue = MutableLiveData(listOf<Song>())
private val mIndex = MutableLiveData(0) private val mIndex = MutableLiveData(0)
private val mMode = MutableLiveData(PlaybackMode.ALL_SONGS) private val mMode = MutableLiveData(PlaybackMode.ALL_SONGS)
@ -64,9 +64,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
val position: LiveData<Long> get() = mPosition val position: LiveData<Long> get() = mPosition
/** The current queue determined by [mode] and [parent] */ /** The current queue determined by [mode] and [parent] */
val queue: LiveData<MutableList<Song>> get() = mQueue val queue: LiveData<List<Song>> get() = mQueue
/** The queue created by the user. */ /** The queue created by the user. */
val userQueue: LiveData<MutableList<Song>> get() = mUserQueue val userQueue: LiveData<List<Song>> get() = mUserQueue
/** The current [PlaybackMode] that also determines the queue */ /** The current [PlaybackMode] that also determines the queue */
val mode: LiveData<PlaybackMode> get() = mMode val mode: LiveData<PlaybackMode> get() = mMode
/** Whether playback is originating from the user-generated queue or not */ /** Whether playback is originating from the user-generated queue or not */
@ -451,11 +451,11 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
} }
} }
override fun onQueueUpdate(queue: MutableList<Song>) { override fun onQueueUpdate(queue: List<Song>) {
mQueue.value = queue mQueue.value = queue
} }
override fun onUserQueueUpdate(userQueue: MutableList<Song>) { override fun onUserQueueUpdate(userQueue: List<Song>) {
mUserQueue.value = userQueue mUserQueue.value = userQueue
} }

View file

@ -101,9 +101,9 @@ class PlaybackStateManager private constructor() {
/** The current playback progress */ /** The current playback progress */
val position: Long get() = mPosition val position: Long get() = mPosition
/** The current queue determined by [parent] and [mode] */ /** The current queue determined by [parent] and [mode] */
val queue: MutableList<Song> get() = mQueue val queue: List<Song> get() = mQueue
/** The queue created by the user. */ /** The queue created by the user. */
val userQueue: MutableList<Song> get() = mUserQueue val userQueue: List<Song> get() = mUserQueue
/** The current index of the queue */ /** The current index of the queue */
val index: Int get() = mIndex val index: Int get() = mIndex
/** The current [PlaybackMode] */ /** The current [PlaybackMode] */
@ -116,7 +116,7 @@ class PlaybackStateManager private constructor() {
val loopMode: LoopMode get() = mLoopMode val loopMode: LoopMode get() = mLoopMode
/** Whether this instance has already been restored */ /** Whether this instance has already been restored */
val isRestored: Boolean get() = mIsRestored val isRestored: Boolean get() = mIsRestored
/** Whether this instance has started playing or not */ /** Whether playback has begun in this instance during **PlaybackService's Lifecycle.** */
val hasPlayed: Boolean get() = mHasPlayed val hasPlayed: Boolean get() = mHasPlayed
private val settingsManager = SettingsManager.getInstance() private val settingsManager = SettingsManager.getInstance()
@ -788,8 +788,8 @@ class PlaybackStateManager private constructor() {
fun onSongUpdate(song: Song?) {} fun onSongUpdate(song: Song?) {}
fun onParentUpdate(parent: Parent?) {} fun onParentUpdate(parent: Parent?) {}
fun onPositionUpdate(position: Long) {} fun onPositionUpdate(position: Long) {}
fun onQueueUpdate(queue: MutableList<Song>) {} fun onQueueUpdate(queue: List<Song>) {}
fun onUserQueueUpdate(userQueue: MutableList<Song>) {} fun onUserQueueUpdate(userQueue: List<Song>) {}
fun onModeUpdate(mode: PlaybackMode) {} fun onModeUpdate(mode: PlaybackMode) {}
fun onIndexUpdate(index: Int) {} fun onIndexUpdate(index: Int) {}
fun onPlayingUpdate(isPlaying: Boolean) {} fun onPlayingUpdate(isPlaying: Boolean) {}

View file

@ -252,7 +252,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
override fun onLoopUpdate(loopMode: LoopMode) { override fun onLoopUpdate(loopMode: LoopMode) {
player.setLoopMode(loopMode) player.repeatMode = if (loopMode == LoopMode.NONE) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ALL
}
if (!settingsManager.useAltNotifAction) { if (!settingsManager.useAltNotifAction) {
notification.setLoop(this, loopMode) notification.setLoop(this, loopMode)
@ -344,27 +348,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
onLoopUpdate(playbackManager.loopMode) onLoopUpdate(playbackManager.loopMode)
onSongUpdate(playbackManager.song) onSongUpdate(playbackManager.song)
onSeek(playbackManager.position) onSeek(playbackManager.position)
/*
Old Manual restore code, restore this if the above causes bugs
notification.setParent(this, playbackManager.parent)
notification.setPlaying(this, playbackManager.isPlaying)
if (settingsManager.useAltNotifAction) {
notification.setShuffle(this, playbackManager.isShuffling)
} else {
notification.setLoop(this, playbackManager.loopMode)
}
player.setLoopMode(playbackManager.loopMode)
playbackManager.song?.let { song ->
notification.setMetadata(this, song, settingsManager.colorizeNotif) {}
player.setMediaItem(MediaItem.fromUri(song.id.toURI()))
player.seekTo(playbackManager.position)
player.prepare()
}*/
} }
/** /**
@ -406,17 +389,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
} }
} }
/**
* Shortcut to transform a [LoopMode] into a player repeat mode
*/
private fun Player.setLoopMode(mode: LoopMode) {
repeatMode = if (mode == LoopMode.NONE) {
Player.REPEAT_MODE_OFF
} else {
Player.REPEAT_MODE_ALL
}
}
/** /**
* Bring the service into the foreground and show the notification, or refresh the notification. * Bring the service into the foreground and show the notification, or refresh the notification.
*/ */

View file

@ -0,0 +1,112 @@
package org.oxycblt.auxio.songs
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.children
import androidx.core.view.isVisible
import androidx.core.widget.TextViewCompat
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.addIndicatorCallback
import org.oxycblt.auxio.ui.inflater
/**
* A slimmed-down variant of [com.reddit.indicatorfastscroll.FastScrollerThumbView] designed
* specifically for Auxio. Also fixes a memory leak that occurs from a bug fix they
* added.
* @author OxygenCobalt
*/
class CobaltScrollThumb @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = -1
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val thumbView: ViewGroup
private val textView: TextView
private val thumbAnim: SpringAnimation
init {
context.inflater.inflate(R.layout.fast_scroller_thumb_view, this, true)
val accent = Accent.get().getStateList(context)
thumbView = findViewById<ViewGroup>(R.id.fast_scroller_thumb).apply {
textView = findViewById(R.id.fast_scroller_thumb_text)
backgroundTintList = accent
// Workaround for API 21 tint bug
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) {
(background as GradientDrawable).apply {
mutate()
color = accent
}
}
}
textView.apply {
isVisible = true
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_ThumbIndicator)
}
thumbAnim = SpringAnimation(thumbView, DynamicAnimation.TRANSLATION_Y).apply {
spring = SpringForce().also {
it.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
}
}
isActivated = false
}
/**
* Set up this view with a [FastScrollerView]. Should only be called once.
*/
fun setup(scrollView: FastScrollerView) {
scrollView.addIndicatorCallback { indicator, centerY, _ ->
thumbAnim.animateToFinalPosition(centerY.toFloat() - (thumbView.measuredHeight / 2))
if (indicator is FastScrollItemIndicator.Text) {
textView.text = indicator.text
}
}
@Suppress("ClickableViewAccessibility")
scrollView.setOnTouchListener { v, event ->
scrollView.onTouchEvent(event)
scrollView.performClick()
val action = event.actionMasked
// If we arent deselecting the scroll view, determine if we are selecting an item.
isActivated = if (
action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_CANCEL
) isPointerOnItem(scrollView, event.y.toInt()) else false
true
}
}
/**
* Hack that determines whether the pointer is currently on the [scrollView] or not.
*/
private fun isPointerOnItem(scrollView: FastScrollerView, touchY: Int): Boolean {
scrollView.children.forEach { child ->
if (touchY in (child.top until child.bottom)) {
return true
}
}
return false
}
}

View file

@ -10,6 +10,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.reddit.indicatorfastscroll.FastScrollItemIndicator import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -18,6 +19,7 @@ import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Accent import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.addIndicatorCallback
import org.oxycblt.auxio.ui.canScroll import org.oxycblt.auxio.ui.canScroll
import org.oxycblt.auxio.ui.getSpans import org.oxycblt.auxio.ui.getSpans
import org.oxycblt.auxio.ui.newMenu import org.oxycblt.auxio.ui.newMenu
@ -57,9 +59,7 @@ class SongsFragment : Fragment() {
if (it.itemId == R.id.action_shuffle) { if (it.itemId == R.id.action_shuffle) {
playbackModel.shuffleAll() playbackModel.shuffleAll()
true true
} } else false
false
} }
} }
@ -82,26 +82,18 @@ class SongsFragment : Fragment() {
} }
} }
setupFastScroller(binding) binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb)
logD("Fragment created.") logD("Fragment created.")
return binding.root return binding.root
} }
override fun onDestroyView() {
requireView().rootView.clearFocus()
super.onDestroyView()
}
/** /**
* Go through the fast scroller setup process. * Perform the (Frustratingly Long and Complicated) FastScrollerView setup.
* @param binding Binding required
*/ */
private fun setupFastScroller(binding: FragmentSongsBinding) { private fun FastScrollerView.setup(recycler: RecyclerView, thumb: CobaltScrollThumb) {
binding.songFastScroll.apply { var concatInterval: Int = -1
var concatInterval = -1
// API 22 and below don't support the state color, so just use the accent. // API 22 and below don't support the state color, so just use the accent.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
@ -109,25 +101,9 @@ class SongsFragment : Fragment() {
} }
setupWithRecyclerView( setupWithRecyclerView(
binding.songRecycler, recycler,
{ pos -> { pos ->
val item = musicStore.songs[pos] val char = musicStore.songs[pos].name.first
// If the item starts with "the"/"a", then actually use the character after that
// as its initial. Yes, this is stupidly western-centric but the code [hopefully]
// shouldn't run with other languages.
val char: Char = if (item.name.length > 5 &&
item.name.startsWith("the ", ignoreCase = true)
) {
item.name[4].toUpperCase()
} else if (item.name.length > 3 &&
item.name.startsWith("a ", ignoreCase = true)
) {
item.name[2].toUpperCase()
} else {
// If it doesn't begin with that word, then just use the first character.
item.name[0].toUpperCase()
}
// Use "#" if the character is a digit, also has the nice side-effect of // Use "#" if the character is a digit, also has the nice side-effect of
// truncating extra numbers. // truncating extra numbers.
@ -136,12 +112,11 @@ class SongsFragment : Fragment() {
} else { } else {
FastScrollItemIndicator.Text(char.toString()) FastScrollItemIndicator.Text(char.toString())
} }
} },
null, false
) )
showIndicator = { _, i, total -> showIndicator = { _, i, total ->
var isGood = true
if (concatInterval == -1) { if (concatInterval == -1) {
// If the scroller size is too small to contain all the entries, truncate entries // If the scroller size is too small to contain all the entries, truncate entries
// so that the fast scroller entries fit. // so that the fast scroller entries fit.
@ -150,46 +125,46 @@ class SongsFragment : Fragment() {
if (total > maxEntries.toInt()) { if (total > maxEntries.toInt()) {
concatInterval = ceil(total / maxEntries).toInt() concatInterval = ceil(total / maxEntries).toInt()
logD("More entries than screen space, truncating by $concatInterval.")
check(concatInterval > 1) { check(concatInterval > 1) {
"ConcatInterval was one despite truncation being needed" "Needed to truncate, but concatInterval was 1 or lower anyway"
} }
logD("More entries than screen space, truncating by $concatInterval.")
} else { } else {
concatInterval = 1 concatInterval = 1
} }
} }
if ((i % concatInterval) != 0) { // Any items that need to be truncated will be hidden
isGood = false (i % concatInterval) == 0
} }
isGood addIndicatorCallback { _, _, pos ->
} recycler.apply {
useDefaultScroller = false
addIndicatorCallback { pos ->
binding.songRecycler.apply {
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0)
stopScroll() stopScroll()
} }
} }
thumb.setup(this)
} }
binding.songFastScrollThumb.setupWithFastScroller(binding.songFastScroll) /**
* Dumb shortcut for getting the first letter in a string, while regarding certain
* semantics when it comes to articles.
*/
private val String.first: Char get() {
// If the name actually starts with "The" or "A", get the character *after* that word.
// Yes, this is stupidly english centric but it wont run with other languages.
if (length > 5 && startsWith("the ", true)) {
return get(4).toUpperCase()
} }
private fun FastScrollerView.addIndicatorCallback(callback: (pos: Int) -> Unit) { if (length > 3 && startsWith("a ", true)) {
itemIndicatorSelectedCallbacks.add( return get(2).toUpperCase()
object : FastScrollerView.ItemIndicatorSelectedCallback {
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) = callback(itemPosition)
} }
)
return get(0).toUpperCase()
} }
} }

View file

@ -23,6 +23,8 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.logE import org.oxycblt.auxio.logE
@ -121,6 +123,22 @@ fun String.createToast(context: Context) {
Toast.makeText(context.applicationContext, this, Toast.LENGTH_SHORT).show() Toast.makeText(context.applicationContext, this, Toast.LENGTH_SHORT).show()
} }
/**
* Shortcut that allows me to add a indicator callback to [FastScrollerView] without
* the nightmarish boilerplate that entails.
*/
fun FastScrollerView.addIndicatorCallback(
callback: (indicator: FastScrollItemIndicator, centerY: Int, pos: Int) -> Unit
) {
itemIndicatorSelectedCallbacks += object : FastScrollerView.ItemIndicatorSelectedCallback {
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) = callback(indicator, indicatorCenterY, itemPosition)
}
}
// --- CONFIGURATION --- // --- CONFIGURATION ---
/** /**

View file

@ -39,7 +39,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_toolbar" /> app:layout_constraintTop_toBottomOf="@+id/song_toolbar" />
<org.oxycblt.auxio.recycler.NoLeakThumbView <org.oxycblt.auxio.songs.CobaltScrollThumb
android:id="@+id/song_fast_scroll_thumb" android:id="@+id/song_fast_scroll_thumb"
android:layout_width="@dimen/width_thumb_view" android:layout_width="@dimen/width_thumb_view"
android:layout_height="0dp" android:layout_height="0dp"

View file

@ -100,8 +100,8 @@
<!-- Fast scroll theme --> <!-- Fast scroll theme -->
<style name="FastScrollTheme" parent="Widget.IndicatorFastScroll.FastScroller"> <style name="FastScrollTheme" parent="Widget.IndicatorFastScroll.FastScroller">
<item name="android:textColor">@color/color_scroll_tints</item>
<item name="android:textAppearance">@style/TextAppearance.FastScroll</item> <item name="android:textAppearance">@style/TextAppearance.FastScroll</item>
<item name="android:textColor">@color/color_scroll_tints</item>
</style> </style>
<!-- Fast scroll text appearance --> <!-- Fast scroll text appearance -->
@ -112,6 +112,7 @@
<!-- Fast scroll thumb appearance --> <!-- Fast scroll thumb appearance -->
<style name="TextAppearance.ThumbIndicator" parent="TextAppearance.FastScroll"> <style name="TextAppearance.ThumbIndicator" parent="TextAppearance.FastScroll">
<item name="android:textSize">@dimen/text_size_thumb</item> <item name="android:textSize">@dimen/text_size_thumb</item>
<item name="android:textColor">?android:attr/windowBackground</item>
</style> </style>
<!-- Style for the general item background --> <!-- Style for the general item background -->

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.4.30' ext.kotlin_version = '1.4.31'
repositories { repositories {
google() google()