diff --git a/app/build.gradle b/app/build.gradle index 938264b13..5a2e74aa4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -97,9 +97,6 @@ dependencies { // Material implementation 'com.google.android.material:material:1.3.0' - // Fast-Scroll - implementation 'com.reddit:indicator-fast-scroll:1.3.0' - // Dialogs implementation 'com.afollestad.material-dialogs:core:3.3.0' implementation 'com.afollestad.material-dialogs:files:3.3.0' diff --git a/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt b/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt deleted file mode 100644 index ffd2da454..000000000 --- a/app/src/main/java/org/oxycblt/auxio/songs/CobaltScrollThumb.kt +++ /dev/null @@ -1,119 +0,0 @@ -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.View -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(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 - } - } - - visibility = View.INVISIBLE - isActivated = false - - post { - visibility = View.VISIBLE - } - } - - /** - * 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 { _, event -> - scrollView.onTouchEvent(event) - scrollView.performClick() - - val action = event.actionMasked - val actionValid = action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_CANCEL - - isActivated = if (actionValid) { - 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 - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/songs/FastScrollThumb.kt b/app/src/main/java/org/oxycblt/auxio/songs/FastScrollThumb.kt new file mode 100644 index 000000000..4c1145aaf --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/songs/FastScrollThumb.kt @@ -0,0 +1,81 @@ +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.View +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.widget.TextViewCompat +import androidx.databinding.DataBindingUtil +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.ViewScrollThumbBinding +import org.oxycblt.auxio.ui.Accent +import org.oxycblt.auxio.ui.inflater + +/** + * The companion thumb for [FastScrollView]. This does not need any setup, instead pass it as an + * argument to [FastScrollView.setup]. + * This code is fundamentally an adaptation of Reddit's IndicatorFastScroll, targeted towards + * Auxio specifically. Check them out here: https://github.com/reddit/IndicatorFastScroll/ + * @author OxygenCobalt + */ +class FastScrollThumb @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = -1 +) : ConstraintLayout(context, attrs, defStyleAttr) { + private val thumbAnim: SpringAnimation + private val binding = DataBindingUtil.inflate( + context.inflater, R.layout.view_scroll_thumb, this, true + ) + + init { + val accent = Accent.get().getStateList(context) + + binding.thumbLayout.apply { + backgroundTintList = accent + + // Workaround for API 21 tint bug + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.LOLLIPOP) { + (background as GradientDrawable).apply { + mutate() + color = accent + } + } + } + + binding.thumbText.apply { + isVisible = true + TextViewCompat.setTextAppearance(this, R.style.TextAppearance_ThumbIndicator) + } + + thumbAnim = SpringAnimation(binding.thumbLayout, DynamicAnimation.TRANSLATION_Y).apply { + spring = SpringForce().also { + it.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY + } + } + + visibility = View.INVISIBLE + isActivated = false + + post { + visibility = View.VISIBLE + } + } + + /** + * Make the thumb jump to a new position and update its text to the given [indicator]. + * This is not meant for use outside of the main [FastScrollView] code, don't use it. + */ + fun jumpTo(indicator: FastScrollView.Indicator, centerY: Int) { + binding.thumbText.text = indicator.char.toString() + thumbAnim.animateToFinalPosition( + centerY.toFloat() - (binding.thumbLayout.measuredHeight / 2) + ) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/songs/FastScrollView.kt b/app/src/main/java/org/oxycblt/auxio/songs/FastScrollView.kt new file mode 100644 index 000000000..0f0ca24ca --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/songs/FastScrollView.kt @@ -0,0 +1,217 @@ +package org.oxycblt.auxio.songs + +import android.content.Context +import android.os.Build +import android.util.AttributeSet +import android.util.TypedValue +import android.view.Gravity +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.oxycblt.auxio.R +import org.oxycblt.auxio.logD +import org.oxycblt.auxio.ui.Accent +import org.oxycblt.auxio.ui.resolveAttr +import org.oxycblt.auxio.ui.toColor +import kotlin.math.ceil +import kotlin.math.min + +/** + * A view that allows for quick scrolling through a [RecyclerView] with many items. Unlike other + * fast-scrollers, this one displays indicators instead of simply a scroll bar. + * This code is fundamentally an adaptation of Reddit's IndicatorFastScroll, targeted towards + * Auxio specifically. Check them out here: https://github.com/reddit/IndicatorFastScroll/ + * @author OxygenCobalt + */ +class FastScrollView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = -1 +) : LinearLayout(context, attrs, defStyleAttr) { + + // --- BASIC SETUP --- + + private var mRecycler: RecyclerView? = null + private var mThumb: FastScrollThumb? = null + private var mGetItem: ((Int) -> Char)? = null + + // --- INDICATORS --- + + /** Representation of a single Indicator character in the view */ + data class Indicator(val char: Char, val pos: Int) + + private var indicators = listOf() + + private val indicatorText: TextView + private val activeColor = Accent.get().color.toColor(context) + private val inactiveColor = android.R.attr.textColorSecondary.resolveAttr(context) + + // --- STATE --- + + private var hasPostedItemUpdate = false + private var lastPos = -1 + + init { + isFocusableInTouchMode = true + isClickable = true + gravity = Gravity.CENTER + + val textPadding = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 4F, resources.displayMetrics + ) + + // Making this entire view a TextView will cause distortions due to the touch calculations + // using a height that is not wrapped to the text. + indicatorText = AppCompatTextView(context).apply { + gravity = Gravity.CENTER + includeFontPadding = false + + TextViewCompat.setTextAppearance(this, R.style.TextAppearance_FastScroll) + setLineSpacing(textPadding, lineSpacingMultiplier) + setTextColor(inactiveColor) + } + + addView(indicatorText) + } + + /** + * Set up this view with a [RecyclerView] and a corresponding [FastScrollThumb]. + */ + fun setup(recycler: RecyclerView, thumb: FastScrollThumb, getItem: (Int) -> Char) { + check(mRecycler == null) { "Only set up this view once." } + + mRecycler = recycler + mThumb = thumb + mGetItem = getItem + + postIndicatorUpdate() + } + + // --- INDICATOR UPDATES --- + + private fun postIndicatorUpdate() { + if (!hasPostedItemUpdate) { + hasPostedItemUpdate = true + + post { + val recycler = requireNotNull(mRecycler) + + if (recycler.isAttachedToWindow && recycler.adapter != null) { + updateIndicators() + } + + hasPostedItemUpdate = false + } + } + } + + private fun updateIndicators() { + val recycler = requireNotNull(mRecycler) + val getItem = requireNotNull(mGetItem) + + indicators = 0.until(recycler.adapter!!.itemCount).mapNotNull { pos -> + Indicator(getItem(pos), pos) + }.distinctBy { it.char } + + val textHeight = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, 14F, resources.displayMetrics + ) + + // If the scroller size is too small to contain all the entries, truncate entries + // so that the fast scroller entries fit. + val maxEntries = height / textHeight + + if (indicators.size > maxEntries.toInt()) { + val truncateInterval = ceil(indicators.size / maxEntries).toInt() + + check(truncateInterval > 1) { + "Needed to truncate, but truncateInterval was 1 or lower anyway" + } + + logD("More entries than screen space, truncating by $truncateInterval.") + + indicators = indicators.filterIndexed { index, _ -> + index % truncateInterval == 0 + } + } + + indicatorText.apply { + tag = indicators + text = indicators.joinToString("\n") { it.char.toString() } + } + } + + // --- TOUCH --- + + @Suppress("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + performClick() + + val success = handleTouch(event.action, event.y.toInt()) + + // Depending on the results, update the visibility of the thumb and the pressed state of + // this view. + isPressed = success + mThumb?.isActivated = success + + return success + } + + @Suppress("UNCHECKED_CAST") + private fun handleTouch(action: Int, touchY: Int): Boolean { + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + indicatorText.setTextColor(inactiveColor) + lastPos = -1 + + return false + } + + if (touchY in (indicatorText.top until indicatorText.bottom)) { + val textHeight = indicatorText.height / indicators.size + val indicatorIndex = min( + (touchY - indicatorText.top) / textHeight, indicators.lastIndex + ) + + val centerY = y.toInt() + (textHeight / 2) + (indicatorIndex * textHeight) + + val touchedIndicator = indicators[indicatorIndex] + + selectIndicator(touchedIndicator, centerY) + + return true + } + + return false + } + + private fun selectIndicator(indicator: Indicator, indicatorCenterY: Int) { + if (indicator.pos != lastPos) { + lastPos = indicator.pos + indicatorText.setTextColor(activeColor) + + // Stop any scroll momentum and snap-scroll to the position + mRecycler?.apply { + stopScroll() + (layoutManager as LinearLayoutManager).scrollToPositionWithOffset( + indicator.pos, 0 + ) + } + + mThumb?.jumpTo(indicator, indicatorCenterY) + + performHapticFeedback( + if (Build.VERSION.SDK_INT >= 27) { + // Dragging across a scroller is closer to moving a text handle + HapticFeedbackConstants.TEXT_HANDLE_MOVE + } else { + HapticFeedbackConstants.KEYBOARD_TAP + } + ) + } + } +} 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 f72f3a4b8..3351383cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/songs/SongsFragment.kt @@ -1,29 +1,21 @@ package org.oxycblt.auxio.songs -import android.os.Build import android.os.Bundle -import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup 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 import org.oxycblt.auxio.databinding.FragmentSongsBinding 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.fixAnimInfoLeak import org.oxycblt.auxio.ui.getSpans import org.oxycblt.auxio.ui.newMenu -import kotlin.math.ceil /** * A [Fragment] that shows a list of all songs on the device. @@ -73,75 +65,21 @@ class SongsFragment : Fragment() { } } - binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb) + binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb) { pos -> + val char = musicStore.songs[pos].name.first + + if (char.isDigit()) '#' else char + } logD("Fragment created.") return binding.root } - /** - * Perform the (Frustratingly Long and Complicated) FastScrollerView setup. - * TODO: Roll FastScrollerView yourself and eliminate its dependency, you're already customizing it enough as it is. - */ - private fun FastScrollerView.setup(recycler: RecyclerView, thumb: CobaltScrollThumb) { - var truncateInterval: Int = -1 - val indicatorTextSize = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, 14F, - resources.displayMetrics - ) + override fun onDestroyView() { + super.onDestroyView() - // 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 - - FastScrollItemIndicator.Text( - // Use "#" if the character is a digit, also has the nice side-effect of - // truncating extra numbers. - if (char.isDigit()) "#" else char.toString() - ) - }, - null, false - ) - - showIndicator = { _, i, total -> - if (truncateInterval == -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()) { - truncateInterval = ceil(total / maxEntries).toInt() - - check(truncateInterval > 1) { - "Needed to truncate, but truncateInterval was 1 or lower anyway" - } - - logD("More entries than screen space, truncating by $truncateInterval.") - } else { - truncateInterval = 1 - } - } - - // Any items that need to be truncated will be hidden - (i % truncateInterval) == 0 - } - - addIndicatorCallback { _, _, pos -> - recycler.apply { - (layoutManager as LinearLayoutManager).scrollToPositionWithOffset(pos, 0) - - stopScroll() - } - } - - thumb.setup(this) + fixAnimInfoLeak() } /** diff --git a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt index c5e16fa14..fd1ee2ca3 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/InterfaceUtils.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.AnimatedVectorDrawable import android.graphics.drawable.Drawable import android.os.Build import android.util.DisplayMetrics +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.WindowManager @@ -23,8 +24,6 @@ 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 import kotlin.reflect.KClass @@ -129,6 +128,24 @@ fun Int.toDrawable(context: Context) = ContextCompat.getDrawable(context, this) */ fun Int.toAnimDrawable(context: Context) = toDrawable(context) as AnimatedVectorDrawable +/** + * Resolve this int into a color as if it was an attribute + */ +fun Int.resolveAttr(context: Context): Int { + // Convert the attribute into its color + val resolvedAttr = TypedValue() + context.theme.resolveAttribute(this, resolvedAttr, true) + + // Then convert it to a proper color + val color = if (resolvedAttr.resourceId != 0) { + resolvedAttr.resourceId + } else { + resolvedAttr.data + } + + return color.toColor(context) +} + /** * Create a [Toast] from a [String] * @param context [Context] required to create the toast @@ -137,22 +154,6 @@ 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 --- /** @@ -253,8 +254,8 @@ private fun isSystemBarOnBottom(activity: Activity): Boolean { // --- HACKY NIGHTMARES --- /** - * Use reflection to fix a memory leak in the [Fragment] source code where the focused view will - * never be cleared. I can't believe I have to do this. + * Use ***REFLECTION*** to fix a memory leak in the [Fragment] source code where the focused view + * will never be cleared. I can't believe I have to do this. */ fun Fragment.fixAnimInfoLeak() { try { diff --git a/app/src/main/res/color/color_scroll_tints.xml b/app/src/main/res/color/color_scroll_tints.xml deleted file mode 100644 index 86937ee12..000000000 --- a/app/src/main/res/color/color_scroll_tints.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ 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 e47c39153..8e15c4355 100644 --- a/app/src/main/res/layout/fragment_songs.xml +++ b/app/src/main/res/layout/fragment_songs.xml @@ -31,7 +31,7 @@ app:layout_constraintTop_toBottomOf="@+id/song_toolbar" tools:listitem="@layout/item_song" /> - - + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9f4638bc2..341a2055b 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -16,7 +16,6 @@ @style/Widget.CustomPopup @color/control_color @style/Theme.CustomDialog - @style/FastScrollTheme @color/selection_color ?attr/colorPrimary 0dp @@ -99,15 +98,10 @@ @font/inter_exbold - - - diff --git a/build.gradle b/build.gradle index 211ba9da5..8d5f3a4ae 100644 --- a/build.gradle +++ b/build.gradle @@ -5,8 +5,15 @@ buildscript { repositories { google() - jcenter() // TODO: Remove JCenter when Exoplayer migrates mavenCentral() + + // TODO: Eliminate Exoplayer when it migrates to GMaven + jcenter { + content { + includeGroup("org.jetbrains.trove4j") + includeGroup("com.google.android.exoplayer") + } + } } dependencies { @@ -22,8 +29,14 @@ buildscript { allprojects { repositories { google() - jcenter() mavenCentral() + + jcenter { + content { + includeGroup("org.jetbrains.trove4j") + includeGroup("com.google.android.exoplayer") + } + } } }