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.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.BaseModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
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
* 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,
@DrawableRes error: Int,
fetcher: Fetcher<T>,
@ -63,7 +68,6 @@ inline fun <reified T : Any> ImageView.load(
if (!settingsManager.showCovers) {
setImageResource(error)
return
}

View file

@ -40,8 +40,8 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
private val mPosition = MutableLiveData(0L)
// Queue
private val mQueue = MutableLiveData(mutableListOf<Song>())
private val mUserQueue = MutableLiveData(mutableListOf<Song>())
private val mQueue = MutableLiveData(listOf<Song>())
private val mUserQueue = MutableLiveData(listOf<Song>())
private val mIndex = MutableLiveData(0)
private val mMode = MutableLiveData(PlaybackMode.ALL_SONGS)
@ -64,9 +64,9 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
val position: LiveData<Long> get() = mPosition
/** 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. */
val userQueue: LiveData<MutableList<Song>> get() = mUserQueue
val userQueue: LiveData<List<Song>> get() = mUserQueue
/** The current [PlaybackMode] that also determines the queue */
val mode: LiveData<PlaybackMode> get() = mMode
/** 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
}
override fun onUserQueueUpdate(userQueue: MutableList<Song>) {
override fun onUserQueueUpdate(userQueue: List<Song>) {
mUserQueue.value = userQueue
}

View file

@ -101,9 +101,9 @@ class PlaybackStateManager private constructor() {
/** The current playback progress */
val position: Long get() = mPosition
/** 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. */
val userQueue: MutableList<Song> get() = mUserQueue
val userQueue: List<Song> get() = mUserQueue
/** The current index of the queue */
val index: Int get() = mIndex
/** The current [PlaybackMode] */
@ -116,7 +116,7 @@ class PlaybackStateManager private constructor() {
val loopMode: LoopMode get() = mLoopMode
/** Whether this instance has already been restored */
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
private val settingsManager = SettingsManager.getInstance()
@ -788,8 +788,8 @@ class PlaybackStateManager private constructor() {
fun onSongUpdate(song: Song?) {}
fun onParentUpdate(parent: Parent?) {}
fun onPositionUpdate(position: Long) {}
fun onQueueUpdate(queue: MutableList<Song>) {}
fun onUserQueueUpdate(userQueue: MutableList<Song>) {}
fun onQueueUpdate(queue: List<Song>) {}
fun onUserQueueUpdate(userQueue: List<Song>) {}
fun onModeUpdate(mode: PlaybackMode) {}
fun onIndexUpdate(index: Int) {}
fun onPlayingUpdate(isPlaying: Boolean) {}

View file

@ -252,7 +252,11 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
}
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) {
notification.setLoop(this, loopMode)
@ -344,27 +348,6 @@ class PlaybackService : Service(), Player.EventListener, PlaybackStateManager.Ca
onLoopUpdate(playbackManager.loopMode)
onSongUpdate(playbackManager.song)
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.
*/

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.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.reddit.indicatorfastscroll.FastScrollItemIndicator
import com.reddit.indicatorfastscroll.FastScrollerView
import org.oxycblt.auxio.R
@ -18,6 +19,7 @@ import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.addIndicatorCallback
import org.oxycblt.auxio.ui.canScroll
import org.oxycblt.auxio.ui.getSpans
import org.oxycblt.auxio.ui.newMenu
@ -57,9 +59,7 @@ class SongsFragment : Fragment() {
if (it.itemId == R.id.action_shuffle) {
playbackModel.shuffleAll()
true
}
false
} else false
}
}
@ -82,114 +82,89 @@ class SongsFragment : Fragment() {
}
}
setupFastScroller(binding)
binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb)
logD("Fragment created.")
return binding.root
}
override fun onDestroyView() {
requireView().rootView.clearFocus()
super.onDestroyView()
}
/**
* Go through the fast scroller setup process.
* @param binding Binding required
* Perform the (Frustratingly Long and Complicated) FastScrollerView setup.
*/
private fun setupFastScroller(binding: FragmentSongsBinding) {
binding.songFastScroll.apply {
var concatInterval = -1
private fun FastScrollerView.setup(recycler: RecyclerView, thumb: CobaltScrollThumb) {
var concatInterval: Int = -1
// API 22 and below don't support the state color, so just use the accent.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
textColor = Accent.get().getStateList(requireContext())
// API 22 and below don't support the state color, so just use the accent.
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
textColor = Accent.get().getStateList(requireContext())
}
setupWithRecyclerView(
recycler,
{ pos ->
val char = musicStore.songs[pos].name.first
// Use "#" if the character is a digit, also has the nice side-effect of
// truncating extra numbers.
if (char.isDigit()) {
FastScrollItemIndicator.Text("#")
} else {
FastScrollItemIndicator.Text(char.toString())
}
},
null, false
)
showIndicator = { _, i, total ->
if (concatInterval == -1) {
// If the scroller size is too small to contain all the entries, truncate entries
// so that the fast scroller entries fit.
val maxEntries = (height / (indicatorTextSize + textPadding))
if (total > maxEntries.toInt()) {
concatInterval = ceil(total / maxEntries).toInt()
check(concatInterval > 1) {
"Needed to truncate, but concatInterval was 1 or lower anyway"
}
logD("More entries than screen space, truncating by $concatInterval.")
} else {
concatInterval = 1
}
}
setupWithRecyclerView(
binding.songRecycler,
{ pos ->
val item = musicStore.songs[pos]
// Any items that need to be truncated will be hidden
(i % concatInterval) == 0
}
// 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()
}
addIndicatorCallback { _, _, pos ->
recycler.apply {
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0)
// Use "#" if the character is a digit, also has the nice side-effect of
// truncating extra numbers.
if (char.isDigit()) {
FastScrollItemIndicator.Text("#")
} else {
FastScrollItemIndicator.Text(char.toString())
}
}
)
showIndicator = { _, i, total ->
var isGood = true
if (concatInterval == -1) {
// If the scroller size is too small to contain all the entries, truncate entries
// so that the fast scroller entries fit.
val maxEntries = (height / (indicatorTextSize + textPadding))
if (total > maxEntries.toInt()) {
concatInterval = ceil(total / maxEntries).toInt()
logD("More entries than screen space, truncating by $concatInterval.")
check(concatInterval > 1) {
"ConcatInterval was one despite truncation being needed"
}
} else {
concatInterval = 1
}
}
if ((i % concatInterval) != 0) {
isGood = false
}
isGood
}
useDefaultScroller = false
addIndicatorCallback { pos ->
binding.songRecycler.apply {
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0)
stopScroll()
}
stopScroll()
}
}
binding.songFastScrollThumb.setupWithFastScroller(binding.songFastScroll)
thumb.setup(this)
}
private fun FastScrollerView.addIndicatorCallback(callback: (pos: Int) -> Unit) {
itemIndicatorSelectedCallbacks.add(
object : FastScrollerView.ItemIndicatorSelectedCallback {
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) = callback(itemPosition)
}
)
/**
* 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()
}
if (length > 3 && startsWith("a ", true)) {
return get(2).toUpperCase()
}
return get(0).toUpperCase()
}
}

View file

@ -23,6 +23,8 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
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.logE
@ -121,6 +123,22 @@ fun String.createToast(context: Context) {
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 ---
/**

View file

@ -39,7 +39,7 @@
app:layout_constraintEnd_toEndOf="parent"
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:layout_width="@dimen/width_thumb_view"
android:layout_height="0dp"

View file

@ -100,8 +100,8 @@
<!-- Fast scroll theme -->
<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:textColor">@color/color_scroll_tints</item>
</style>
<!-- Fast scroll text appearance -->
@ -112,6 +112,7 @@
<!-- Fast scroll thumb appearance -->
<style name="TextAppearance.ThumbIndicator" parent="TextAppearance.FastScroll">
<item name="android:textSize">@dimen/text_size_thumb</item>
<item name="android:textColor">?android:attr/windowBackground</item>
</style>
<!-- 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.
buildscript {
ext.kotlin_version = '1.4.30'
ext.kotlin_version = '1.4.31'
repositories {
google()