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