Unify fast-scroller

Completely unify the fast scroll indicators and the fast scroll thumb into a single view.
This commit is contained in:
OxygenCobalt 2021-03-23 14:34:23 -06:00
parent cc3d4fb9c6
commit 0a18108419
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 112 additions and 205 deletions

View file

@ -68,9 +68,10 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:1.3.1"
// UI
implementation "androidx.recyclerview:recyclerview:1.1.0"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
// Lifecycle
def lifecycle_version = "2.3.0"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"

View file

@ -78,6 +78,7 @@ class MusicStore private constructor() {
* Find a song from this instance in a safe manner.
* Using a normal search of the songs list runs the risk of getting the *wrong* song with
* the same name, so the album name is also used to fix the above problem.
* FIXME: Artist names are more unique than album names, use those
* @param name The name of the song
* @param albumName The name of the song's album.
* @return The song requested, null if there isnt one.

View file

@ -1,78 +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.View
import androidx.constraintlayout.widget.ConstraintLayout
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 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, albeit specialized
* towards Auxio. The original library is 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 = ViewScrollThumbBinding.inflate(context.inflater, 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. Do not use it.
*/
fun jumpTo(indicator: FastScrollView.Indicator, centerY: Int) {
binding.thumbText.text = indicator.char.toString()
thumbAnim.animateToFinalPosition(
centerY.toFloat() - (binding.thumbLayout.measuredHeight / 2)
)
}
}

View file

@ -4,22 +4,26 @@ 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.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ViewFastScrollBinding
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.ui.Accent
import org.oxycblt.auxio.ui.canScroll
import org.oxycblt.auxio.ui.inflater
import org.oxycblt.auxio.ui.resolveAttr
import org.oxycblt.auxio.ui.toColor
import kotlin.math.ceil
import kotlin.math.min
import kotlin.math.roundToInt
/**
* A view that allows for quick scrolling through a [RecyclerView] with many items. Unlike other
@ -32,22 +36,23 @@ class FastScrollView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = -1
) : LinearLayout(context, attrs, defStyleAttr) {
) : ConstraintLayout(context, attrs, defStyleAttr) {
// --- BASIC SETUP ---
// --- UI ---
private val binding = ViewFastScrollBinding.inflate(context.inflater, this, true)
private val thumbAnim: SpringAnimation
// --- RECYCLER ---
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 data class Indicator(val char: Char, val pos: Int)
private var indicators = listOf<Indicator>()
private val indicatorText: TextView
private val activeColor = Accent.get().color.toColor(context)
private val inactiveColor = android.R.attr.textColorSecondary.resolveAttr(context)
@ -59,34 +64,21 @@ class FastScrollView @JvmOverloads constructor(
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)
thumbAnim = SpringAnimation(binding.scrollThumb, DynamicAnimation.TRANSLATION_Y).apply {
spring = SpringForce().also {
it.dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
}
}
addView(indicatorText)
}
/**
* Set up this view with a [RecyclerView] and a corresponding [FastScrollThumb].
*/
fun setup(recycler: RecyclerView, thumb: FastScrollThumb, getItem: (Int) -> Char) {
fun setup(recycler: RecyclerView, getItem: (Int) -> Char) {
check(mRecycler == null) { "Only set up this view once." }
mRecycler = recycler
mThumb = thumb
mGetItem = getItem
postIndicatorUpdate()
@ -105,6 +97,9 @@ class FastScrollView @JvmOverloads constructor(
updateIndicators()
}
// Hide this view if there is nothing to scroll
isVisible = recycler.canScroll()
hasPostedItemUpdate = false
}
}
@ -140,9 +135,9 @@ class FastScrollView @JvmOverloads constructor(
}
}
indicatorText.apply {
tag = indicators
text = indicators.joinToString("\n") { it.char.toString() }
// Then set it as the unified TextView text, for efficiency purposes.
binding.scrollIndicatorText.text = indicators.joinToString("\n") { indicator ->
indicator.char.toString()
}
}
@ -152,37 +147,38 @@ class FastScrollView @JvmOverloads constructor(
override fun onTouchEvent(event: MotionEvent): Boolean {
performClick()
val success = handleTouch(event.action, event.y.toInt())
val success = handleTouch(event.action, event.y.roundToInt())
// Depending on the results, update the visibility of the thumb and the pressed state of
// this view.
isPressed = success
mThumb?.isActivated = success
binding.scrollThumb.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)
binding.scrollIndicatorText.setTextColor(inactiveColor)
lastPos = -1
return false
}
if (touchY in (indicatorText.top until indicatorText.bottom)) {
// Try to roughly caculate which indicator the user is currently touching [Since the
val textHeight = indicatorText.height / indicators.size
val indicatorIndex = min(
(touchY - indicatorText.top) / textHeight, indicators.lastIndex
)
// Try to figure out which indicator the pointer has landed on
if (touchY in (binding.scrollIndicatorText.top until binding.scrollIndicatorText.bottom)) {
// Get the touch position in regards to the TextView and the rough text height
val indicatorTouchY = touchY - binding.scrollIndicatorText.top
val textHeight = binding.scrollIndicatorText.height / indicators.size
val centerY = y.toInt() + (textHeight / 2) + (indicatorIndex * textHeight)
// Use that to calculate the indicator index, if the calculation is
// invalid just ignore it.
val index = min(indicatorTouchY / textHeight, indicators.lastIndex)
val touchedIndicator = indicators[indicatorIndex]
// Also calculate the rough center position of the indicator for the scroll thumb
val centerY = binding.scrollIndicatorText.y + (textHeight / 2) + (index * textHeight)
selectIndicator(touchedIndicator, centerY)
selectIndicator(indicators[index], centerY)
return true
}
@ -190,20 +186,20 @@ class FastScrollView @JvmOverloads constructor(
return false
}
private fun selectIndicator(indicator: Indicator, indicatorCenterY: Int) {
private fun selectIndicator(indicator: Indicator, centerY: Float) {
if (indicator.pos != lastPos) {
lastPos = indicator.pos
indicatorText.setTextColor(activeColor)
binding.scrollIndicatorText.setTextColor(activeColor)
// Stop any scroll momentum and snap-scroll to the position
mRecycler?.apply {
stopScroll()
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(
indicator.pos, 0
)
(layoutManager as LinearLayoutManager).scrollToPositionWithOffset(indicator.pos, 0)
}
mThumb?.jumpTo(indicator, indicatorCenterY)
// Update the thumb position/text
binding.scrollThumbText.text = indicator.char.toString()
thumbAnim.animateToFinalPosition(centerY - (binding.scrollThumb.measuredHeight / 2))
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {

View file

@ -12,7 +12,6 @@ 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.canScroll
import org.oxycblt.auxio.ui.fixAnimInfoLeak
import org.oxycblt.auxio.ui.getSpans
import org.oxycblt.auxio.ui.newMenu
@ -55,17 +54,9 @@ class SongsFragment : Fragment() {
if (spans != 1) {
layoutManager = GridLayoutManager(requireContext(), spans)
}
post {
if (!canScroll()) {
// Disable fast scrolling if there is nothing to scroll
binding.songFastScroll.visibility = View.GONE
binding.songFastScrollThumb.visibility = View.GONE
}
}
}
binding.songFastScroll.setup(binding.songRecycler, binding.songFastScrollThumb) { pos ->
binding.songFastScroll.setup(binding.songRecycler) { pos ->
val char = musicStore.songs[pos].name.first
if (char.isDigit()) '#' else char

View file

@ -84,7 +84,7 @@ fun Context.getPlural(@PluralsRes pluralsRes: Int, value: Int): String {
* @param T The system service in question.
* @param serviceClass The service's kotlin class [Java class will be used in function call]
* @return The system service
* @throws IllegalStateException If the system service cannot be retrieved.
* @throws IllegalArgumentException If the system service cannot be retrieved.
*/
fun <T : Any> Context.getSystemServiceSafe(serviceClass: KClass<T>): T {
return requireNotNull(ContextCompat.getSystemService(this, serviceClass.java)) {
@ -218,7 +218,6 @@ fun Activity.isIrregularLandscape(): Boolean {
* Check if the system bars are on the bottom.
* @return If the system bars are on the bottom, false if no.
*/
@Suppress("DEPRECATION")
private fun isSystemBarOnBottom(activity: Activity): Boolean {
val realPoint = Point()
val metrics = DisplayMetrics()
@ -236,6 +235,7 @@ private fun isSystemBarOnBottom(activity: Activity): Boolean {
}
}
} else {
@Suppress("DEPRECATION")
(activity.getSystemServiceSafe(WindowManager::class)).apply {
defaultDisplay.getRealSize(realPoint)
defaultDisplay.getMetrics(metrics)

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/white" />
</shape>

View file

@ -39,13 +39,5 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/song_toolbar" />
<org.oxycblt.auxio.songs.FastScrollThumb
android:id="@+id/song_fast_scroll_thumb"
android:layout_width="@dimen/width_thumb_view"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_fast_scroll"
app:layout_constraintTop_toBottomOf="@+id/song_toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<merge
tools:layout_height="match_parent"
tools:layout_width="match_parent"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<FrameLayout
android:id="@+id/scroll_thumb"
android:layout_width="@dimen/size_scroll_thumb"
android:layout_height="@dimen/size_scroll_thumb"
android:background="@drawable/ui_circle"
android:elevation="@dimen/elevation_small"
android:backgroundTint="?attr/colorPrimary"
android:stateListAnimator="@animator/animator_thumb"
app:layout_constraintEnd_toStartOf="@+id/scroll_indicator_text">
<TextView
android:id="@+id/scroll_thumb_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:textSize="@dimen/text_size_thumb"
android:textColor="?android:attr/windowBackground"
android:fontFamily="@font/inter_semibold"
tools:text="A" />
</FrameLayout>
<TextView
android:id="@+id/scroll_indicator_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_semibold"
android:gravity="center"
android:includeFontPadding="false"
android:lineSpacingExtra="@dimen/padding_tiny"
android:paddingTop="@dimen/padding_tiny"
android:paddingBottom="@dimen/padding_tiny"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="A\nB\n\C\nD\nE" />
</merge>
</layout>

View file

@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Animator from IndicatorFastScroll (https://github.com/reddit/IndicatorFastScroll) -->
<layout>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:layout_height="match_parent"
tools:layout_width="@dimen/size_scroll_thumb"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<FrameLayout
android:id="@+id/thumb_layout"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/ui_circular_button"
android:elevation="@dimen/elevation_small"
android:stateListAnimator="@animator/animator_thumb"
app:layout_constraintDimensionRatio="W,1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<TextView
android:id="@+id/thumb_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
tools:text="A" />
</FrameLayout>
</merge>
</layout>

View file

@ -24,11 +24,6 @@
android:name="org.oxycblt.auxio.MainFragment"
android:label="MainFragment"
tools:layout="@layout/fragment_main">
<action
android:id="@+id/action_return_to_loading"
app:destination="@id/loading_fragment"
app:popUpTo="@id/main_fragment"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_go_to_playback"
app:destination="@id/playback_fragment"

View file

@ -40,7 +40,7 @@
<dimen name="size_play_pause">70dp</dimen>
<dimen name="size_play_pause_compact">36dp</dimen>
<dimen name="size_scroll_thumb">32dp</dimen>
<dimen name="size_scroll_thumb">48dp</dimen>
<dimen name="size_clear">32dp</dimen>
<dimen name="size_app_icon">60dp</dimen>

View file

@ -98,18 +98,6 @@
<item name="android:fontFamily">@font/inter_exbold</item>
</style>
<!-- Fast scroll text appearance -->
<style name="TextAppearance.FastScroll" parent="TextAppearance.AppCompat.Body2">
<item name="android:fontFamily">@font/inter_semibold</item>
<item name="android:lineSpacingExtra">@dimen/padding_tiny</item>
</style>
<!-- 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 -->
<style name="ItemSurroundings">
<item name="android:layout_width">match_parent</item>

View file

@ -1,23 +1,21 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.4.31'
ext.kotlin_version = "1.4.31"
ext.navigation_version = "2.3.4"
repositories {
google()
mavenCentral()
// TODO: Eliminate Exoplayer when it migrates to GMaven
jcenter {
content {
includeGroup("org.jetbrains.trove4j")
includeGroup("com.google.android.exoplayer")
}
}
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
classpath "com.android.tools.build:gradle:4.1.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"
@ -31,9 +29,9 @@ allprojects {
google()
mavenCentral()
// TODO: Eliminate Exoplayer when it migrates to GMaven
jcenter {
content {
includeGroup("org.jetbrains.trove4j")
includeGroup("com.google.android.exoplayer")
}
}