Improve fast-scroll

Fix bugs and improve the UI of the fast-scroller on SongsFragment.
This commit is contained in:
OxygenCobalt 2020-11-27 15:23:19 -07:00
parent 1980dafcff
commit 6a5084beb1
13 changed files with 327 additions and 34 deletions

View file

@ -88,8 +88,9 @@ dependencies {
// Material
implementation 'com.google.android.material:material:1.3.0-alpha03'
// Fast-Scroll [Too lazy to make it myself]
// Fast-Scroll
implementation 'com.reddit:indicator-fast-scroll:1.3.0'
// --- DEV ---
// Lint

View file

@ -0,0 +1,165 @@
package org.oxycblt.auxio.recycler
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
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.toColor
/**
* A source code copy of [com.reddit.indicatorfastscroll.FastScrollerThumbView] that fixes a
* memory leak that occurs from having nested fragments. All credit goes to the authors of
* the fast scroll library.
* <a href="https://github.com/reddit/IndicatorFastScroll"> Link to repo </a>
* @author Reddit, OxygenCobalt
*/
class NoLeakThumbView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.indicatorFastScrollerThumbStyle
) : ConstraintLayout(
context,
attrs,
defStyleAttr
),
FastScrollerView.ItemIndicatorSelectedCallback {
private var thumbColor = ColorStateList.valueOf(accent.first.toColor(context))
var iconColor = R.color.background.toColor(context)
var textAppearanceRes = R.style.TextAppearance_ThumbIndicator
var textColor = R.color.background.toColor(context)
private val thumbView: ViewGroup
private val textView: TextView
private val iconView: ImageView
private val isSetup: Boolean get() = (fastScrollerView != null)
private var fastScrollerView: FastScrollerView? = null
private val thumbAnimation: SpringAnimation
init {
LayoutInflater.from(context).inflate(R.layout.fast_scroller_thumb_view, this, true)
thumbView = findViewById(R.id.fast_scroller_thumb)
textView = thumbView.findViewById(R.id.fast_scroller_thumb_text)
iconView = thumbView.findViewById(R.id.fast_scroller_thumb_icon)
applyStyle()
thumbAnimation = SpringAnimation(thumbView, DynamicAnimation.TRANSLATION_Y).apply {
spring = SpringForce().apply {
dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
}
}
}
@SuppressLint("ClickableViewAccessibility")
fun setupWithFastScroller(fastScrollerView: FastScrollerView) {
check(!isSetup) { "Only set this view's FastScrollerView once!" }
this.fastScrollerView = fastScrollerView
fastScrollerView.itemIndicatorSelectedCallbacks += this
// FastScrollerView's "onItemIndicatorTouched" [Which I would've used here] is internal,
// so instead I just use a setOnTouchListener to get the same-ish effect.
fastScrollerView.setOnTouchListener { v, event ->
fastScrollerView.onTouchEvent(event)
fastScrollerView.performClick()
if (event.actionMasked in intArrayOf(MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL)) {
isActivated = false
return@setOnTouchListener true
}
isActivated = isPointerOnItem(fastScrollerView, event.y.toInt())
true
}
}
/**
* Hack so that I can detect when the pointer is off the FastScrollerView's items
* without using onItemIndicatorTouched [Which is internal]
* @author OxygenCobalt
*/
private fun isPointerOnItem(fastScrollerView: FastScrollerView, touchY: Int): Boolean {
fun View.containsY(y: Int) = y in (top until bottom)
var consumed = false
fastScrollerView.apply {
children.forEach { view ->
if (view.containsY(touchY)) {
when (view) {
is ImageView -> {
consumed = true
}
is TextView -> {
consumed = true
}
}
}
}
}
return consumed
}
private fun applyStyle() {
thumbView.backgroundTintList = thumbColor
if (Build.VERSION.SDK_INT == 21) {
// Workaround for 21 background tint bug
(thumbView.background as GradientDrawable).apply {
mutate()
color = thumbColor
}
}
TextViewCompat.setTextAppearance(textView, textAppearanceRes)
textView.setTextColor(textColor)
iconView.imageTintList = ColorStateList.valueOf(iconColor)
}
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) {
val thumbTargetY = indicatorCenterY.toFloat() - (thumbView.measuredHeight / 2)
thumbAnimation.animateToFinalPosition(thumbTargetY)
when (indicator) {
is FastScrollItemIndicator.Text -> {
textView.isVisible = true
iconView.isVisible = false
textView.text = indicator.text
}
is FastScrollItemIndicator.Icon -> {
textView.isVisible = false
iconView.isVisible = true
iconView.setImageResource(indicator.iconRes)
}
}
}
}

View file

@ -5,7 +5,13 @@ import androidx.databinding.ViewDataBinding
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.BaseModel
// ViewHolder abstraction that automates some of the things that are common for all ViewHolders.
/**
* A [RecyclerView.ViewHolder] that streamlines a lot of the common things across all viewholders.
* @property baseBinding Basic [ViewDataBinding] required to set up click listeners & sizing.
* @property doOnClick Function that specifies what to do when an item is clicked. Specify null if you want no action to occur.
* @property doOnLongClick Function that specifies what to do when an item is long clicked. Specify null if you want no action to occur.
* @author OxygenCobalt
*/
abstract class BaseViewHolder<T : BaseModel>(
private val baseBinding: ViewDataBinding,
private val doOnClick: ((data: T) -> Unit)?,
@ -18,6 +24,11 @@ abstract class BaseViewHolder<T : BaseModel>(
)
}
/**
* Bind the viewholder with whatever [BaseModel] instance that has been specified.
* Will call [onBind] on the inheriting ViewHolder.
* @param data Data that the viewholder should be binded with
*/
fun bind(data: T) {
doOnClick?.let { onClick ->
baseBinding.root.setOnClickListener {
@ -38,5 +49,9 @@ abstract class BaseViewHolder<T : BaseModel>(
baseBinding.executePendingBindings()
}
/**
* Function that performs binding operations unique to the inheriting viewholder.
* Add any specialized code to an override of this instead of [BaseViewHolder] itself.
*/
protected abstract fun onBind(data: T)
}

View file

@ -1,24 +1,38 @@
package org.oxycblt.auxio.songs
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemBasicSongBinding
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.recycler.viewholders.SongViewHolder
import org.oxycblt.auxio.recycler.viewholders.BaseViewHolder
class SongsAdapter(
private val data: List<Song>,
private val doOnClick: (data: Song) -> Unit,
private val doOnLongClick: (data: Song, view: View) -> Unit
) : RecyclerView.Adapter<SongViewHolder>() {
) : RecyclerView.Adapter<SongsAdapter.ViewHolder>() {
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
return SongViewHolder.from(parent.context, doOnClick, doOnLongClick)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ItemBasicSongBinding.inflate(LayoutInflater.from(parent.context)))
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(data[position])
}
inner class ViewHolder(
private val binding: ItemBasicSongBinding
) : BaseViewHolder<Song>(binding, doOnClick, doOnLongClick) {
override fun onBind(data: Song) {
binding.song = data
binding.songName.requestLayout()
binding.songInfo.requestLayout()
}
}
}

View file

@ -1,12 +1,12 @@
package org.oxycblt.auxio.songs
import android.content.res.ColorStateList
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
@ -17,16 +17,14 @@ import org.oxycblt.auxio.databinding.FragmentSongsBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.accent
import org.oxycblt.auxio.ui.setupSongActions
import org.oxycblt.auxio.ui.toColor
/**
* A [Fragment] that shows a list of all songs on the device. Contains options to search/shuffle
* them.
* @author OxygenCobalt
*/
class SongsFragment : Fragment() {
class SongsFragment : Fragment(), SearchView.OnQueryTextListener {
private val playbackModel: PlaybackViewModel by activityViewModels()
override fun onCreateView(
@ -50,11 +48,13 @@ class SongsFragment : Fragment() {
// --- UI SETUP ---
binding.songToolbar.setOnMenuItemClickListener {
if (it.itemId == R.id.action_shuffle) {
playbackModel.shuffleAll()
binding.songToolbar.apply {
setOnMenuItemClickListener {
if (it.itemId == R.id.action_shuffle) {
playbackModel.shuffleAll()
}
true
}
true
}
binding.songRecycler.apply {
@ -69,6 +69,14 @@ class SongsFragment : Fragment() {
return binding.root
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
private fun setupFastScroller(binding: FragmentSongsBinding) {
val musicStore = MusicStore.getInstance()
@ -105,6 +113,8 @@ class SongsFragment : Fragment() {
if (char.isDigit()) {
if (!hasAddedNumber) {
hasAddedNumber = true
return@setupWithRecyclerView FastScrollItemIndicator.Text("#")
} else {
return@setupWithRecyclerView null
}
@ -117,23 +127,20 @@ class SongsFragment : Fragment() {
}
)
textAppearanceRes = R.style.TextAppearance_FastScroll
textColor = ColorStateList.valueOf(accent.first.toColor(requireContext()))
useDefaultScroller = false
itemIndicatorSelectedCallbacks.add(
object : FastScrollerView.ItemIndicatorSelectedCallback {
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) {
val layoutManager = binding.songRecycler.layoutManager
as LinearLayoutManager
itemIndicatorSelectedCallbacks.add(object : FastScrollerView.ItemIndicatorSelectedCallback {
override fun onItemIndicatorSelected(
indicator: FastScrollItemIndicator,
indicatorCenterY: Int,
itemPosition: Int
) {
val layoutManager = binding.songRecycler.layoutManager
as LinearLayoutManager
layoutManager.scrollToPositionWithOffset(itemPosition, 0)
}
layoutManager.scrollToPositionWithOffset(itemPosition, 0)
}
}
)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_pressed="true" />
<item android:color="?android:attr/textColorSecondary" />
</selector>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View file

@ -34,7 +34,7 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintTop_toBottomOf="@+id/queue_header"
tools:layout_editor_absoluteX="0dp"
tools:listitem="@layout/item_song" />
tools:listitem="@layout/item_basic_song" />
</LinearLayout>
</layout>

View file

@ -28,10 +28,10 @@
android:layout_height="0dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_fast_scroll"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_toolbar"
tools:listitem="@layout/item_song" />
tools:listitem="@layout/item_basic_song" />
<com.reddit.indicatorfastscroll.FastScrollerView
android:id="@+id/song_fast_scroll"
@ -41,7 +41,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_toolbar" />
<com.reddit.indicatorfastscroll.FastScrollerThumbView
<org.oxycblt.auxio.recycler.NoLeakThumbView
android:id="@+id/song_fast_scroll_thumb"
android:layout_width="50dp"
android:layout_height="0dp"

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".recycler.viewholders.SongViewHolder">
<data>
<variable
name="song"
type="org.oxycblt.auxio.music.Song" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/ui_ripple"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_medium">
<ImageView
android:id="@+id/album_cover"
android:layout_width="@dimen/size_cover_compact"
android:layout_height="@dimen/size_cover_compact"
android:contentDescription="@{@string/description_album_cover(song.name)}"
app:coverArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_medium"
android:ellipsize="end"
android:maxLines="1"
android:text="@{song.name}"
android:textAppearance="?android:attr/textAppearanceListItem"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_medium"
android:layout_marginEnd="@dimen/margin_medium"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?android:attr/textAppearanceListItemSecondary"
android:textColor="?android:attr/textColorSecondary"
android:text="@{@string/format_info(song.album.artist.name, song.album.name)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Artist / Album" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -27,6 +27,7 @@
<string name="label_next_user_queue">Next in Queue</string>
<string name="label_channel">Music Playback</string>
<string name="label_service_playback">The music playback service for Auxio.</string>
<string name="label_settings">Settings</string>
<!-- Debug Namespace | Debug labels -->
<string name="debug_state_saved">State saved</string>

View file

@ -5,6 +5,7 @@
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:fontFamily">@font/inter</item>
<item name="indicatorFastScrollerStyle">@style/FastScrollTheme</item>
<item name="android:textCursorDrawable">@drawable/ui_cursor</item>
<item name="android:fitsSystemWindows">true</item>
@ -47,9 +48,13 @@
<item name="android:popupBackground">@color/background</item>
</style>
<style name="FastScrollTheme" parent="Widget.IndicatorFastScroll.FastScroller">
<item name="android:textColor">@color/ui_state_color</item>
<item name="android:textAppearance">@style/TextAppearance.FastScroll</item>
</style>
<style name="TextAppearance.FastScroll" parent="TextAppearance.AppCompat.Body2">
<item name="android:fontFamily">@font/inter_semibold</item>
<item name="android:verticalSpacing">1dp</item>
</style>
<style name="TextAppearance.ThumbIndicator" parent="TextAppearance.FastScroll">

View file

@ -20,7 +20,6 @@ allprojects {
repositories {
google()
jcenter()
maven { url "https://jitpack.io" }
}
}