diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt
index 766feb236..29b07df05 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ListUtil.kt
@@ -24,6 +24,6 @@ import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
- tokens.firstOrNull()?.let { if (it.value.isDigitsOnly()) "#" else it.value }
+ tokens.firstOrNull()?.let { if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase() }
is Name.Unknown -> "?"
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt
index fbfe9f9ca..4fc4d8a6f 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/FastScrollRecyclerView.kt
@@ -15,29 +15,43 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
+
package org.oxycblt.auxio.list.recycler
import android.animation.Animator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
+import android.text.TextUtils
import android.util.AttributeSet
+import android.view.Gravity
import android.view.MotionEvent
+import android.view.View
import android.view.ViewConfiguration
+import android.view.ViewGroup
import android.view.WindowInsets
+import android.widget.FrameLayout
+import android.widget.TextView
import androidx.annotation.AttrRes
+import androidx.core.view.isInvisible
+import androidx.core.view.updatePaddingRelative
+import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider
+import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
+import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
+import timber.log.Timber
/**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
@@ -74,11 +88,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Thumb
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
- private val slider = MaterialSlider(context, thumbWidth)
+ private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private var thumbAnimator: Animator? = null
private val thumbView =
- context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
+ context.inflater.inflate(R.layout.view_scroll_thumb, null)
+ .apply { thumbSlider.jumpOut(this) }
private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0
@@ -89,6 +104,36 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
+ private val popupView = MaterialTextView(context).apply {
+ minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
+ minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
+
+ TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
+ setTextColor(context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
+ ellipsize = TextUtils.TruncateAt.MIDDLE
+ gravity = Gravity.CENTER
+ includeFontPadding = false
+
+ elevation =
+ context.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
+ .toFloat()
+ background = context.getDrawableCompat(R.drawable.ui_popup)
+ updatePaddingRelative(end = context.getDimenPixels(R.dimen.spacing_tiny) / 2)
+ layoutParams =
+ FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ .apply {
+ gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
+ }
+ }
+ private val popupSlider =
+ MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
+ jumpOut(popupView)
+ }
+ private var popupAnimator: Animator? = null
+ private var showingPopup = false
+
// Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
@@ -109,6 +154,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (!value) {
removeCallbacks(hideThumbRunnable)
hideScrollbar()
+ hidePopup()
}
listener?.onFastScrollingChanged(field)
@@ -131,7 +177,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (field) {
removeCallbacks(hideThumbRunnable)
showScrollbar()
+ showPopup()
} else {
+ hidePopup()
postAutoHideScrollbar()
}
@@ -143,6 +191,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
init {
overlay.add(thumbView)
+ overlay.add(popupView)
addItemDecoration(
object : ItemDecoration() {
@@ -176,7 +225,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
thumbView.layoutDirection = layoutDirection
thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
- MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
+ MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY)
+ )
val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft =
if (isRtl) {
@@ -185,6 +235,77 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
width - thumbPadding.right - thumbWidth
}
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
+
+ popupView.layoutDirection = layoutDirection
+ val child = getChildAt(0)
+ val firstAdapterPos =
+ if (child != null) {
+ layoutManager?.getPosition(child) ?: NO_POSITION
+ } else {
+ NO_POSITION
+ }
+
+ val popupText: String
+ val provider = popupProvider
+ if (firstAdapterPos != NO_POSITION && provider != null) {
+ popupView.isInvisible = false
+ // Get the popup text. If there is none, we default to "?".
+ popupText = provider.getPopup(firstAdapterPos) ?: "?"
+ } else {
+ // No valid position or provider, do not show the popup.
+ popupView.isInvisible = false
+ popupText = ""
+ }
+ val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
+
+ if (popupView.text != popupText) {
+ popupView.text = popupText
+
+ val widthMeasureSpec =
+ ViewGroup.getChildMeasureSpec(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ thumbPadding.left +
+ thumbPadding.right +
+ thumbWidth +
+ popupLayoutParams.leftMargin +
+ popupLayoutParams.rightMargin,
+ popupLayoutParams.width
+ )
+
+ val heightMeasureSpec =
+ ViewGroup.getChildMeasureSpec(
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
+ thumbPadding.top +
+ thumbPadding.bottom +
+ popupLayoutParams.topMargin +
+ popupLayoutParams.bottomMargin,
+ popupLayoutParams.height
+ )
+
+ popupView.measure(widthMeasureSpec, heightMeasureSpec)
+ Timber.d("Updating popup text to ${popupView.measuredHeight} ${popupView.measuredWidth}")
+ }
+
+ val popupWidth = popupView.measuredWidth
+ val popupHeight = popupView.measuredHeight
+ val popupLeft =
+ if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
+ thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin + popupWidth / 2
+ } else {
+ width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth - popupWidth / 2
+ }
+
+ val popupAnchorY = popupHeight / 2
+ val thumbAnchorY = thumbView.height / 2
+
+ val popupTop =
+ (thumbTop + thumbAnchorY - popupAnchorY)
+ .coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
+ .coerceAtMost(
+ height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight
+ )
+
+ popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
}
override fun onScrolled(dx: Int, dy: Int) {
@@ -249,10 +370,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
dragging = true
}
}
+
MotionEvent.ACTION_MOVE -> {
if (!dragging &&
thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) &&
- abs(eventY - downY) > touchSlop) {
+ abs(eventY - downY) > touchSlop
+ ) {
if (thumbView.isUnder(downX, downY, minTouchTargetSize)) {
dragStartY = lastY
dragStartThumbOffset = thumbOffset
@@ -271,6 +394,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
scrollToThumbOffset(thumbOffset)
}
}
+
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> dragging = false
}
@@ -312,7 +436,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showingThumb = true
thumbAnimator?.cancel()
- thumbAnimator = slider.slideIn(thumbView).also { it.start() }
+ thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
}
private fun hideScrollbar() {
@@ -322,7 +446,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showingThumb = false
thumbAnimator?.cancel()
- thumbAnimator = slider.slideOut(thumbView).also { it.start() }
+ thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
+ }
+
+ private fun showPopup() {
+ if (!thumbEnabled) {
+ return
+ }
+ if (showingPopup) {
+ return
+ }
+
+ showingPopup = true
+ popupAnimator?.cancel()
+ popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
+ }
+
+ private fun hidePopup() {
+ if (!showingPopup) {
+ return
+ }
+
+ showingPopup = false
+ popupAnimator?.cancel()
+ popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
}
// --- LAYOUT STATE ---
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/Animations.kt b/app/src/main/java/org/oxycblt/auxio/ui/Animations.kt
index 43155aa8d..4329f09ea 100644
--- a/app/src/main/java/org/oxycblt/auxio/ui/Animations.kt
+++ b/app/src/main/java/org/oxycblt/auxio/ui/Animations.kt
@@ -201,19 +201,24 @@ class MaterialFlipper(context: Context) {
}
}
-class MaterialSlider(context: Context, private val x: Int) {
- private val outConfig =
- AnimConfig.of(context, AnimConfig.EMPHASIZED_ACCELERATE, AnimConfig.SHORT3)
- private val inConfig =
- AnimConfig.of(context, AnimConfig.EMPHASIZED_DECELERATE, AnimConfig.MEDIUM1)
+class MaterialSlider private constructor(context: Context, private val x: Int?, inDuration: Pair, outDuration: Pair) {
+ private val outConfig = AnimConfig.of(context, AnimConfig.EMPHASIZED_ACCELERATE, outDuration)
+ private val inConfig = AnimConfig.of(context, AnimConfig.EMPHASIZED_DECELERATE, inDuration)
fun jumpOut(view: View) {
- view.translationX = x.toFloat()
+ if (x == null) {
+ view.translationX = 100000f
+ }
+ view.translationX = (x ?: view.width).toFloat()
}
fun slideOut(view: View): Animator {
+ val target = (x ?: view.width).toFloat()
+ if (view.translationX > target) {
+ view.translationX = target
+ }
val animator =
- outConfig.genericFloat(view.translationX, x.toFloat()) { view.translationX = it }
+ outConfig.genericFloat(view.translationX, target) { view.translationX = it }
return animator
}
@@ -221,4 +226,36 @@ class MaterialSlider(context: Context, private val x: Int) {
val animator = inConfig.genericFloat(view.translationX, 0f) { view.translationX = it }
return animator
}
+
+ companion object {
+ fun small(context: Context, x: Int?) = MaterialSlider(context, x, AnimConfig.SHORT3, AnimConfig.MEDIUM1)
+
+ fun large(context: Context, x: Int?) = MaterialSlider(context, x, AnimConfig.MEDIUM3, AnimConfig.SHORT3)
+
+ }
}
+
+class MaterialFadingSlider(private val slider: MaterialSlider) {
+ fun jumpOut(view: View) {
+ slider.jumpOut(view)
+ view.alpha = 0f
+ }
+
+ fun slideOut(view: View): Animator {
+ val slideOut = slider.slideOut(view)
+ val alphaOut = ValueAnimator.ofFloat(1f, 0f).apply {
+ duration = slideOut.duration
+ addUpdateListener { view.alpha = it.animatedValue as Float }
+ }
+ return AnimatorSet().apply { playTogether(slideOut, alphaOut) }
+ }
+
+ fun slideIn(view: View): Animator {
+ val slideIn = slider.slideIn(view)
+ val alphaIn = ValueAnimator.ofFloat(0f, 1f).apply {
+ duration = slideIn.duration
+ addUpdateListener { view.alpha = it.animatedValue as Float }
+ }
+ return AnimatorSet().apply { playTogether(slideIn, alphaIn) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ui_popup.xml b/app/src/main/res/drawable/ui_popup.xml
index af0c3a354..14e0b82ad 100644
--- a/app/src/main/res/drawable/ui_popup.xml
+++ b/app/src/main/res/drawable/ui_popup.xml
@@ -3,9 +3,9 @@
android:shape="rectangle"
android:tint="?attr/colorSecondary">
-
+
+ android:width="48dp"
+ android:height="48dp" />
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index f611b6fde..cc1da0f4c 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -29,6 +29,12 @@
48dp
+ 72dp
+ 52dp
+ @dimen/spacing_medium
+ 30dp
+ 48dp
+
16dp
128dp