From 6a5084beb1ef4738797f67925aed54bb293adc26 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Fri, 27 Nov 2020 15:23:19 -0700 Subject: [PATCH] Improve fast-scroll Fix bugs and improve the UI of the fast-scroller on SongsFragment. --- app/build.gradle | 3 +- .../oxycblt/auxio/recycler/NoLeakThumbView.kt | 165 ++++++++++++++++++ .../recycler/viewholders/BaseViewHolder.kt | 17 +- .../org/oxycblt/auxio/songs/SongsAdapter.kt | 24 ++- .../org/oxycblt/auxio/songs/SongsFragment.kt | 49 +++--- app/src/main/res/color/ui_state_color.xml | 5 + app/src/main/res/drawable/ic_settings.xml | 11 ++ app/src/main/res/layout/fragment_queue.xml | 2 +- app/src/main/res/layout/fragment_songs.xml | 6 +- app/src/main/res/layout/item_basic_song.xml | 70 ++++++++ app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 7 +- build.gradle | 1 - 13 files changed, 327 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/recycler/NoLeakThumbView.kt create mode 100644 app/src/main/res/color/ui_state_color.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/layout/item_basic_song.xml diff --git a/app/build.gradle b/app/build.gradle index 9bac620b2..d634d7a01 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/recycler/NoLeakThumbView.kt b/app/src/main/java/org/oxycblt/auxio/recycler/NoLeakThumbView.kt new file mode 100644 index 000000000..d14e7fdff --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/recycler/NoLeakThumbView.kt @@ -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. + * Link to repo + * @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) + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/recycler/viewholders/BaseViewHolder.kt b/app/src/main/java/org/oxycblt/auxio/recycler/viewholders/BaseViewHolder.kt index b468d65bd..520bddbaf 100644 --- a/app/src/main/java/org/oxycblt/auxio/recycler/viewholders/BaseViewHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/recycler/viewholders/BaseViewHolder.kt @@ -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( private val baseBinding: ViewDataBinding, private val doOnClick: ((data: T) -> Unit)?, @@ -18,6 +24,11 @@ abstract class BaseViewHolder( ) } + /** + * 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( 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) } diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongsAdapter.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongsAdapter.kt index cf49471b0..6c0addb18 100644 --- a/app/src/main/java/org/oxycblt/auxio/songs/SongsAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsAdapter.kt @@ -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, private val doOnClick: (data: Song) -> Unit, private val doOnLongClick: (data: Song, view: View) -> Unit -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { 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(binding, doOnClick, doOnLongClick) { + + override fun onBind(data: Song) { + binding.song = data + + binding.songName.requestLayout() + binding.songInfo.requestLayout() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt index 560dbbf26..5e49f8401 100644 --- a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt @@ -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) } + } ) } diff --git a/app/src/main/res/color/ui_state_color.xml b/app/src/main/res/color/ui_state_color.xml new file mode 100644 index 000000000..86937ee12 --- /dev/null +++ b/app/src/main/res/color/ui_state_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 000000000..162b11342 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_queue.xml b/app/src/main/res/layout/fragment_queue.xml index d3f0b9021..7da6ff940 100644 --- a/app/src/main/res/layout/fragment_queue.xml +++ b/app/src/main/res/layout/fragment_queue.xml @@ -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" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_songs.xml b/app/src/main/res/layout/fragment_songs.xml index b8d3a257e..b6fb9a2b5 100644 --- a/app/src/main/res/layout/fragment_songs.xml +++ b/app/src/main/res/layout/fragment_songs.xml @@ -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" /> - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca35f7d2b..fcf2a312d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Next in Queue Music Playback The music playback service for Auxio. + Settings State saved diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index da2870ef3..b3df616b4 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -5,6 +5,7 @@ @color/background @android:color/black @font/inter + @style/FastScrollTheme @drawable/ui_cursor true @@ -47,9 +48,13 @@ @color/background + +