home: clean up fast scroller

Clean up the fast scroller implementation to remove dead code and
be more coherent in general.
This commit is contained in:
OxygenCobalt 2022-03-19 10:59:13 -06:00
parent f1bd6b1c8c
commit 90f10f2a84
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
13 changed files with 377 additions and 431 deletions

View file

@ -106,7 +106,6 @@ dependencies {
spotless { spotless {
kotlin { kotlin {
target "src/**/*.kt" target "src/**/*.kt"
ktfmt('0.30').dropboxStyle() ktfmt('0.30').dropboxStyle()
licenseHeaderFile("NOTICE") licenseHeaderFile("NOTICE")
} }

View file

@ -40,8 +40,11 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* The single [AppCompatActivity] for Auxio. * The single [AppCompatActivity] for Auxio.
* *
* TODO: Add a new view for crashes with a stack trace TODO: Custom language support TODO: Rework * TODO: Add a new view for crashes with a stack trace
* menus [perhaps add multi-select] *
* TODO: Custom language support
*
* TODO: Rework menus [perhaps add multi-select]
*/ */
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by viewModels() private val playbackModel: PlaybackViewModel by viewModels()

View file

@ -55,7 +55,9 @@ import org.oxycblt.auxio.util.logTraceOrThrow
* respective item. * respective item.
* @author OxygenCobalt * @author OxygenCobalt
* *
* TODO: Make tabs invisible when there is only one TODO: Add duration and song count sorts * TODO: Make tabs invisible when there is only one
*
* TODO: Add duration and song count sorts
*/ */
class HomeFragment : Fragment() { class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()

View file

@ -1,168 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.view.View
import androidx.core.graphics.drawable.DrawableCompat
import kotlin.math.sqrt
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenOffsetSafe
/**
* The custom drawable used as FastScrollRecyclerView's popup background. This is an adaptation from
* AndroidFastScroll's MD2 theme.
*
* Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang
* [https://github.com/zhanghai] PROJECT: Android Fast Scroll
* [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/]
*
* !!! MODIFICATIONS !!!:
* - Use modified Auxio resources instead of AFS resources
* - Variable names are no longer prefixed with m
* - Made path management compat-friendly
* - Converted to kotlin
*
* @author Hai Zhang, OxygenCobalt
*/
class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint =
Paint().apply {
isAntiAlias = true
color = context.getAttrColorSafe(R.attr.colorSecondary)
style = Paint.Style.FILL
}
private val path = Path()
private val matrix = Matrix()
private val paddingStart = context.getDimenOffsetSafe(R.dimen.spacing_medium)
private val paddingEnd = context.getDimenOffsetSafe(R.dimen.popup_padding_end)
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath()
return true
}
@Suppress("DEPRECATION")
override fun getOutline(outline: Outline) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
// Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating point
// errors caused by calculations involving sqrt(2) or OEM implementation
// differences,
// so in this case we just omit the shadow instead of crashing.
super.getOutline(outline)
}
}
}
override fun getPadding(padding: Rect): Boolean {
if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0
} else {
padding[paddingStart, 0, paddingEnd] = 0
}
return true
}
override fun isAutoMirrored(): Boolean = true
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
private fun updatePath() {
path.reset()
var width = bounds.width().toFloat()
val height = bounds.height().toFloat()
val r = height / 2
val sqrt2 = sqrt(2.0).toFloat()
// Ensure we are convex
width = (r + sqrt2 * r).coerceAtLeast(width)
pathArcTo(path, r, r, r, 90f, 180f)
val o1X = width - sqrt2 * r
pathArcTo(path, o1X, r, r, -90f, 45f)
val r2 = r / 5
val o2X = width - sqrt2 * r2
pathArcTo(path, o2X, r, r2, -45f, 90f)
pathArcTo(path, o1X, r, r, 45f, 45f)
path.close()
if (isRtl) {
matrix.setScale(-1f, 1f, width / 2, 0f)
} else {
matrix.reset()
}
matrix.postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
path.transform(matrix)
}
private fun pathArcTo(
path: Path,
centerX: Float,
centerY: Float,
radius: Float,
startAngle: Float,
sweepAngle: Float
) {
path.arcTo(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
}
private val isRtl: Boolean
get() = DrawableCompat.getLayoutDirection(this) == View.LAYOUT_DIRECTION_RTL
}

View file

@ -0,0 +1,173 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import androidx.core.widget.TextViewCompat
import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenOffsetSafe
import org.oxycblt.auxio.util.getDimenSizeSafe
import org.oxycblt.auxio.util.isRtl
class FastScrollPopupView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
MaterialTextView(context, attrs, defStyleRes) {
init {
minimumWidth = context.getDimenSizeSafe(R.dimen.fast_scroll_popup_min_width)
minimumHeight = context.getDimenSizeSafe(R.dimen.fast_scroll_popup_min_height)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorSafe(R.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
alpha = 0f
elevation = context.getDimenSizeSafe(R.dimen.elevation_normal).toFloat()
background = FastScrollPopupDrawable(context)
}
private class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint =
Paint().apply {
isAntiAlias = true
color = context.getAttrColorSafe(R.attr.colorSecondary)
style = Paint.Style.FILL
}
private val path = Path()
private val matrix = Matrix()
private val paddingStart =
context.getDimenOffsetSafe(R.dimen.fast_scroll_popup_padding_start)
private val paddingEnd = context.getDimenOffsetSafe(R.dimen.fast_scroll_popup_padding_end)
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath()
return true
}
@Suppress("DEPRECATION")
override fun getOutline(outline: Outline) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
// Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating
// point errors caused by calculations involving sqrt(2) or OEM differences,
// so in this case we just omit the shadow instead of crashing.
super.getOutline(outline)
}
}
}
override fun getPadding(padding: Rect): Boolean {
if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0
} else {
padding[paddingStart, 0, paddingEnd] = 0
}
return true
}
override fun isAutoMirrored(): Boolean = true
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
private fun updatePath() {
val r = bounds.height().toFloat() / 2
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
path.apply {
reset()
// Draw the left pill shape
val o1X = w - SQRT2 * r
arcToSafe(r, r, r, 90f, 180f)
arcToSafe(o1X, r, r, -90f, 45f)
// Draw the right arrow shape
val point = r / 5
val o2X = w - SQRT2 * point
arcToSafe(o2X, r, point, -45f, 90f)
arcToSafe(o1X, r, r, 45f, 45f)
close()
}
matrix.apply {
reset()
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
}
path.transform(matrix)
}
private fun Path.arcToSafe(
centerX: Float,
centerY: Float,
radius: Float,
startAngle: Float,
sweepAngle: Float
) {
path.arcTo(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
}
}
companion object {
private const val SQRT2 = 1.4142135623730950488f
}
}

View file

@ -17,11 +17,9 @@
package org.oxycblt.auxio.home.fastscroll package org.oxycblt.auxio.home.fastscroll
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
@ -30,12 +28,9 @@ import android.view.ViewConfiguration
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.math.MathUtils import androidx.core.math.MathUtils
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@ -43,19 +38,21 @@ import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.EdgeRecyclerView import org.oxycblt.auxio.ui.EdgeRecyclerView
import org.oxycblt.auxio.util.canScroll import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenOffsetSafe import org.oxycblt.auxio.util.getDimenOffsetSafe
import org.oxycblt.auxio.util.getDimenSizeSafe import org.oxycblt.auxio.util.getDimenSizeSafe
import org.oxycblt.auxio.util.getDrawableSafe import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of * A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
* Hai Zhang's AndroidFastScroll but slimmed down for Auxio and with a couple of enhancements. * Hai Zhang's AndroidFastScroll but slimmed down for Auxio and with a couple of enhancements.
* *
* Attributions as per the Apache 2.0 license: ORIGINAL AUTHOR: Hai Zhang * Attributions as per the Apache 2.0 license:
* [https://github.com/zhanghai] PROJECT: Android Fast Scroll * - ORIGINAL AUTHOR: Hai Zhang [https://github.com/zhanghai]
* [https://github.com/zhanghai/AndroidFastScroll] MODIFIER: OxygenCobalt [https://github.com/] * - PROJECT: Android Fast Scroll [https://github.com/zhanghai/AndroidFastScroll]
* - MODIFIER: OxygenCobalt [https://github.com/oxygencobalt]
* *
* !!! MODIFICATIONS !!!: * !!! MODIFICATIONS !!!:
* - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with * - Scroller will no longer show itself on startup or relayouts, which looked unpleasant with
@ -72,11 +69,90 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added documentation * - Added documentation
* *
* @author Hai Zhang, OxygenCobalt * @author Hai Zhang, OxygenCobalt
*
* TODO: Fix strange touch behavior when the pointer is slightly outside of the view.
*
* TODO: Really try to make this view less insane.
*/ */
class FastScrollRecyclerView class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
EdgeRecyclerView(context, attrs, defStyleAttr) { EdgeRecyclerView(context, attrs, defStyleAttr) {
private val minTouchTargetSize =
context.getDimenSizeSafe(R.dimen.fast_scroll_thumb_touch_target_size)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
// Thumb
private val thumbView =
View(context).apply {
alpha = 0f
background = context.getDrawableSafe(R.drawable.ui_scroll_thumb)
this@FastScrollRecyclerView.overlay.add(this)
}
private val thumbWidth = thumbView.background.intrinsicWidth
private val thumbHeight = thumbView.background.intrinsicHeight
private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0
private var showingThumb = false
private val hideThumbRunnable = Runnable {
if (!dragging) {
hideScrollbar()
}
}
// Popup
private val popupView =
FastScrollPopupView(context).apply {
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenOffsetSafe(R.dimen.spacing_small)
}
this@FastScrollRecyclerView.overlay.add(this)
}
private var showingPopup = false
// Touch events
private var downX = 0f
private var downY = 0f
private var lastY = 0f
private var dragStartY = 0f
private var dragStartThumbOffset = 0
private var dragging = false
set(value) {
if (field == value) {
return
}
field = value
if (value) {
parent.requestDisallowInterceptTouchEvent(true)
}
thumbView.isPressed = value
if (field) {
removeCallbacks(hideThumbRunnable)
showScrollbar()
showPopup()
} else {
postAutoHideScrollbar()
hidePopup()
}
onDragListener?.invoke(value)
}
private val childRect = Rect()
/** Callback to provide a string to be shown on the popup when an item is passed */ /** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: ((Int) -> String)? = null var popupProvider: ((Int) -> String)? = null
@ -86,83 +162,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
var onDragListener: ((Boolean) -> Unit)? = null var onDragListener: ((Boolean) -> Unit)? = null
private val minTouchTargetSize: Int = context.getDimenSizeSafe(R.dimen.size_btn_small)
private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
// Views for the track, thumb, and popup. Note that the track view is mostly vestigial
// and is only for bounds checking.
private val trackView: View
private val thumbView: View
private val popupView: TextView
// Touch values
private val thumbWidth: Int
private val thumbHeight: Int
private var thumbOffset = 0
private var downX = 0f
private var downY = 0f
private var lastY = 0f
private var dragStartY = 0f
private var dragStartThumbOffset = 0
// State
private var dragging = false
private var showingScrollbar = false
private var showingPopup = false
private val childRect = Rect()
private val hideScrollbarRunnable = Runnable {
if (!dragging) {
hideScrollbar()
}
}
private val scrollerPadding = Rect(0, 0, 0, 0)
init { init {
val thumbDrawable = context.getDrawableSafe(R.drawable.ui_scroll_thumb)
trackView = View(context)
thumbView =
View(context).apply {
alpha = 0f
background = thumbDrawable
}
popupView =
AppCompatTextView(context).apply {
alpha = 0f
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
minimumWidth = context.getDimenSizeSafe(R.dimen.popup_min_width)
minimumHeight = context.getDimenSizeSafe(R.dimen.size_btn_large)
(layoutParams as FrameLayout.LayoutParams).apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenOffsetSafe(R.dimen.spacing_small)
}
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorSafe(R.attr.colorOnSecondary))
background = FastScrollPopupDrawable(context)
elevation = context.getDimenSizeSafe(R.dimen.elevation_normal).toFloat()
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
isSingleLine = true
}
thumbWidth = thumbDrawable.intrinsicWidth
thumbHeight = thumbDrawable.intrinsicHeight
check(thumbWidth >= 0)
check(thumbHeight >= 0)
overlay.add(trackView)
overlay.add(thumbView) overlay.add(thumbView)
overlay.add(popupView) overlay.add(popupView)
@ -195,42 +195,33 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun onPreDraw() { private fun onPreDraw() {
updateScrollbarState() updateScrollbarState()
trackView.layoutDirection = layoutDirection
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
popupView.layoutDirection = layoutDirection popupView.layoutDirection = layoutDirection
val trackLeft =
if (isRtl) {
scrollerPadding.left
} else {
width - scrollerPadding.right - thumbWidth
}
trackView.layout(
trackLeft, scrollerPadding.top, trackLeft + thumbWidth, height - scrollerPadding.bottom)
val thumbLeft = val thumbLeft =
if (isRtl) { if (isRtl) {
scrollerPadding.left thumbPadding.left
} else { } else {
width - scrollerPadding.right - thumbWidth width - thumbPadding.right - thumbWidth
} }
val thumbTop = scrollerPadding.top + thumbOffset val thumbTop = thumbPadding.top + thumbOffset
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
val firstPos = firstAdapterPos val firstPos = firstAdapterPos
val popupText = val popupText =
if (firstPos != NO_POSITION) { if (firstPos != NO_POSITION) {
popupProvider?.invoke(firstPos) ?: "" popupProvider?.invoke(firstPos)?.ifEmpty { null }
} else { } else {
"" null
} }
popupView.isInvisible = popupText.isEmpty() // Lay out the popup view
if (popupText.isNotEmpty()) { popupView.isInvisible = popupText == null
if (popupText != null) {
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) { if (popupView.text != popupText) {
@ -239,8 +230,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val widthMeasureSpec = val widthMeasureSpec =
ViewGroup.getChildMeasureSpec( ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
scrollerPadding.left + thumbPadding.left +
scrollerPadding.right + thumbPadding.right +
thumbWidth + thumbWidth +
popupLayoutParams.leftMargin + popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin, popupLayoutParams.rightMargin,
@ -249,8 +240,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val heightMeasureSpec = val heightMeasureSpec =
ViewGroup.getChildMeasureSpec( ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
scrollerPadding.top + thumbPadding.top +
scrollerPadding.bottom + thumbPadding.bottom +
popupLayoutParams.topMargin + popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin, popupLayoutParams.bottomMargin,
popupLayoutParams.height) popupLayoutParams.height)
@ -262,39 +253,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val popupHeight = popupView.measuredHeight val popupHeight = popupView.measuredHeight
val popupLeft = val popupLeft =
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) { if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
scrollerPadding.left + thumbWidth + popupLayoutParams.leftMargin thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else { } else {
width - width -
scrollerPadding.right - thumbPadding.right -
thumbWidth - thumbWidth -
popupLayoutParams.rightMargin - popupLayoutParams.rightMargin -
popupWidth popupWidth
} }
// We handle RTL separately, so it's okay if Gravity.RIGHT is used here val popupAnchorY = popupHeight / 2
@SuppressLint("RtlHardcoded") val thumbAnchorY = thumbView.paddingTop
val popupAnchorY =
when (popupLayoutParams.gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.CENTER_HORIZONTAL -> popupHeight / 2
Gravity.RIGHT -> popupHeight
else -> 0
}
val thumbAnchorY =
when (popupLayoutParams.gravity and Gravity.VERTICAL_GRAVITY_MASK) {
Gravity.CENTER_VERTICAL -> {
thumbView.paddingTop +
(thumbHeight - thumbView.paddingTop - thumbView.paddingBottom) / 2
}
Gravity.BOTTOM -> thumbHeight - thumbView.paddingBottom
else -> thumbView.paddingTop
}
val popupTop = val popupTop =
MathUtils.clamp( MathUtils.clamp(
thumbTop + thumbAnchorY - popupAnchorY, thumbTop + thumbAnchorY - popupAnchorY,
scrollerPadding.top + popupLayoutParams.topMargin, thumbPadding.top + popupLayoutParams.topMargin,
height - scrollerPadding.bottom - popupLayoutParams.bottomMargin - popupHeight) height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight) popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
} }
@ -317,7 +292,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
super.onApplyWindowInsets(insets) super.onApplyWindowInsets(insets)
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
scrollerPadding.bottom = bars.bottom thumbPadding.bottom = bars.bottom
return insets return insets
} }
@ -345,38 +320,36 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
downX = eventX downX = eventX
downY = eventY downY = eventY
val scrollX = trackView.scrollX if (eventX >= thumbView.left && eventX < thumbView.right) {
val isInScrollbar =
eventX >= thumbView.left - scrollX && eventX < thumbView.right - scrollX
if (trackView.alpha > 0 && isInScrollbar) {
dragStartY = eventY dragStartY = eventY
if (isInViewTouchTarget(thumbView, eventX, eventY)) { if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartThumbOffset = dragStartThumbOffset =
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt() (eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
setDragging(true) dragging = true
} }
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
if (!dragging && if (!dragging &&
isInViewTouchTarget(trackView, downX, downY) && thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) &&
abs(eventY - downY) > touchSlop) { abs(eventY - downY) > touchSlop) {
if (isInViewTouchTarget(thumbView, downX, downY)) {
if (thumbView.isUnder(downX, downY, minTouchTargetSize)) {
dragStartY = lastY dragStartY = lastY
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartY = eventY dragStartY = eventY
dragStartThumbOffset = dragStartThumbOffset =
(eventY - scrollerPadding.top - thumbHeight / 2f).toInt() (eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
setDragging(true)
dragging = true
} }
if (dragging) { if (dragging) {
@ -384,49 +357,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
scrollToThumbOffset(thumbOffset) scrollToThumbOffset(thumbOffset)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> setDragging(false) MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> dragging = false
} }
lastY = eventY lastY = eventY
return dragging return dragging
} }
private fun isInViewTouchTarget(view: View, x: Float, y: Float): Boolean {
return isInTouchTarget(x, view.left - scrollX, view.right - scrollX, width) &&
isInTouchTarget(y, view.top - scrollY, view.bottom - scrollY, height)
}
private fun isInTouchTarget(
position: Float,
viewStart: Int,
viewEnd: Int,
parentEnd: Int
): Boolean {
val viewSize = viewEnd - viewStart
if (viewSize >= minTouchTargetSize) {
return position >= viewStart && position < viewEnd
}
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
if (touchTargetStart < 0) {
touchTargetStart = 0
}
var touchTargetEnd = touchTargetStart + minTouchTargetSize
if (touchTargetEnd > parentEnd) {
touchTargetEnd = parentEnd
touchTargetStart = touchTargetEnd - minTouchTargetSize
if (touchTargetStart < 0) {
touchTargetStart = 0
}
}
return position >= touchTargetStart && position < touchTargetEnd
}
private fun scrollToThumbOffset(thumbOffset: Int) { private fun scrollToThumbOffset(thumbOffset: Int) {
val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange) val clampedThumbOffset = MathUtils.clamp(thumbOffset, 0, thumbOffsetRange)
@ -464,54 +401,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
} }
private fun setDragging(isDragging: Boolean) {
if (dragging == isDragging) {
return
}
dragging = isDragging
if (dragging) {
parent.requestDisallowInterceptTouchEvent(true)
}
trackView.isPressed = dragging
thumbView.isPressed = dragging
if (dragging) {
removeCallbacks(hideScrollbarRunnable)
showScrollbar()
showPopup()
} else {
postAutoHideScrollbar()
hidePopup()
}
onDragListener?.invoke(isDragging)
}
// --- SCROLLBAR APPEARANCE --- // --- SCROLLBAR APPEARANCE ---
private fun postAutoHideScrollbar() { private fun postAutoHideScrollbar() {
removeCallbacks(hideScrollbarRunnable) removeCallbacks(hideThumbRunnable)
postDelayed(hideScrollbarRunnable, AUTO_HIDE_SCROLLBAR_DELAY_MILLIS.toLong()) postDelayed(hideThumbRunnable, AUTO_HIDE_SCROLLBAR_DELAY_MILLIS.toLong())
} }
private fun showScrollbar() { private fun showScrollbar() {
if (showingScrollbar) { if (showingThumb) {
return return
} }
showingScrollbar = true showingThumb = true
animateView(thumbView, 1f) animateView(thumbView, 1f)
} }
private fun hideScrollbar() { private fun hideScrollbar() {
if (!showingScrollbar) { if (!showingThumb) {
return return
} }
showingScrollbar = false showingThumb = false
animateView(thumbView, 0f) animateView(thumbView, 0f)
} }
@ -539,12 +450,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
private val isRtl: Boolean
get() = layoutDirection == LAYOUT_DIRECTION_RTL
private val thumbOffsetRange: Int private val thumbOffsetRange: Int
get() { get() {
return height - scrollerPadding.top - scrollerPadding.bottom - thumbHeight return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
} }
private val scrollRange: Int private val scrollRange: Int

View file

@ -44,7 +44,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
class PlaybackFragment : Fragment() { class PlaybackFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private var mLastBinding: FragmentPlaybackBinding? = null private var lastBinding: FragmentPlaybackBinding? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -55,7 +55,7 @@ class PlaybackFragment : Fragment() {
val queueItem: MenuItem val queueItem: MenuItem
// See onDestroyView for why we do this // See onDestroyView for why we do this
mLastBinding = binding lastBinding = binding
// --- UI SETUP --- // --- UI SETUP ---
@ -157,8 +157,8 @@ class PlaybackFragment : Fragment() {
// playbackSong will leak if we don't disable marquee, keep the binding around // playbackSong will leak if we don't disable marquee, keep the binding around
// so that we can turn it off when we destroy the view. // so that we can turn it off when we destroy the view.
mLastBinding?.playbackSong?.isSelected = false lastBinding?.playbackSong?.isSelected = false
mLastBinding = null lastBinding = null
} }
private fun navigateUp() { private fun navigateUp() {

View file

@ -47,6 +47,7 @@ import org.oxycblt.auxio.util.disableDropShadowCompat
import org.oxycblt.auxio.util.getAttrColorSafe import org.oxycblt.auxio.util.getAttrColorSafe
import org.oxycblt.auxio.util.getDimenSafe import org.oxycblt.auxio.util.getDimenSafe
import org.oxycblt.auxio.util.getDrawableSafe import org.oxycblt.auxio.util.getDrawableSafe
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.pxOfDp import org.oxycblt.auxio.util.pxOfDp
import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
@ -458,27 +459,25 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
return super.onInterceptTouchEvent(ev) return super.onInterceptTouchEvent(ev)
} }
val adx = abs(ev.x - initMotionX)
val ady = abs(ev.y - initMotionY)
val dragSlop = dragHelper.touchSlop
when (ev.actionMasked) { when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
initMotionX = ev.x initMotionX = ev.x
initMotionY = ev.y initMotionY = ev.y
if (!playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt())) { if (!playbackContainerView.isUnder(ev.x, ev.y)) {
// Pointer is not on our view, do not intercept this event // Pointer is not on our view, do not intercept this event
dragHelper.cancel() dragHelper.cancel()
return false return false
} }
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
val pointerUnder = playbackContainerView.isUnder(ev.x.toInt(), ev.y.toInt()) val adx = abs(ev.x - initMotionX)
val motionUnder = val ady = abs(ev.y - initMotionY)
playbackContainerView.isUnder(initMotionX.toInt(), initMotionY.toInt())
if (!(pointerUnder || motionUnder) || ady > dragSlop && adx > ady) { val pointerUnder = playbackContainerView.isUnder(ev.x, ev.y)
val motionUnder = playbackContainerView.isUnder(initMotionX, initMotionY)
if (!(pointerUnder || motionUnder) || ady > dragHelper.touchSlop && adx > ady) {
// Pointer has moved beyond our control, do not intercept this event // Pointer has moved beyond our control, do not intercept this event
dragHelper.cancel() dragHelper.cancel()
return false return false
@ -502,22 +501,6 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
} }
} }
private fun View.isUnder(x: Int, y: Int): Boolean {
val viewLocation = IntArray(2)
getLocationOnScreen(viewLocation)
val parentLocation = IntArray(2)
(parent as View).getLocationOnScreen(parentLocation)
val screenX = parentLocation[0] + x
val screenY = parentLocation[1] + y
val inX = screenX >= viewLocation[0] && screenX < viewLocation[0] + width
val inY = screenY >= viewLocation[1] && screenY < viewLocation[1] + height
return inX && inY
}
private val ViewDragHelper.isDragging: Boolean private val ViewDragHelper.isDragging: Boolean
get() { get() {
// We can't grab the drag state outside of a callback, but that's stupid and I don't // We can't grab the drag state outside of a callback, but that's stupid and I don't

View file

@ -441,6 +441,7 @@ class PlaybackService :
// Technically the MediaSession seems to handle bluetooth events on their // Technically the MediaSession seems to handle bluetooth events on their
// own, but keep this around as a fallback in the case that the former fails // own, but keep this around as a fallback in the case that the former fails
// for whatever reason. // for whatever reason.
// TODO: Remove this since the headset hook KeyEvent should be fine enough.
AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> { AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED -> {
when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) { when (intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)) {
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug() AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> pauseFromPlug()
@ -459,7 +460,7 @@ class PlaybackService :
initialHeadsetPlugEventHandled = true initialHeadsetPlugEventHandled = true
} }
// I have never seen this ever happen but it might be useful // I have never seen this happen but it might be useful
AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug() AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pauseFromPlug()
// --- AUXIO EVENTS --- // --- AUXIO EVENTS ---
@ -484,9 +485,7 @@ class PlaybackService :
* that friendly * that friendly
* 2. There is a bug where playback will always start when this service starts, mostly due * 2. There is a bug where playback will always start when this service starts, mostly due
* to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear * to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but I fear
* that it may not work on OEM skins that for whatever reason don't make this action fire. * that it may not work on OEM skins that for whatever reason don't make this action fire.\
* TODO: Figure out how players like Retro are able to get autoplay working with bluetooth
* headsets
*/ */
private fun maybeResumeFromPlug() { private fun maybeResumeFromPlug() {
if (playbackManager.song != null && if (playbackManager.song != null &&
@ -497,12 +496,7 @@ class PlaybackService :
} }
} }
/** /** Pause from a headset plug. */
* Pause from a headset plug.
*
* TODO: Find a way to centralize this stuff into a single BroadcastReceiver instead of the
* weird disjointed arrangement between MediaSession and this.
*/
private fun pauseFromPlug() { private fun pauseFromPlug() {
if (playbackManager.song != null) { if (playbackManager.song != null) {
logD("Device disconnected, pausing") logD("Device disconnected, pausing")

View file

@ -56,8 +56,11 @@ fun Fragment.newMenu(anchor: View, data: Item, flag: Int = ActionMenu.FLAG_NONE)
* @throws IllegalStateException When there is no menu for this specific datatype/flag * @throws IllegalStateException When there is no menu for this specific datatype/flag
* @author OxygenCobalt * @author OxygenCobalt
* *
* TODO: Stop scrolling when a menu is open TODO: Prevent duplicate menus from showing up TODO: * TODO: Stop scrolling when a menu is open
* Maybe replace this with a bottom sheet? *
* TODO: Prevent duplicate menus from showing up
*
* TODO: Maybe replace this with a bottom sheet?
*/ */
class ActionMenu( class ActionMenu(
activity: AppCompatActivity, activity: AppCompatActivity,

View file

@ -20,7 +20,9 @@ package org.oxycblt.auxio.util
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Insets import android.graphics.Insets
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.util.Log
import android.view.View import android.view.View
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
@ -73,6 +75,50 @@ fun View.disableDropShadowCompat() {
} }
} }
fun View.isUnder(x: Float, y: Float, minTouchTargetSize: Int = 0): Boolean {
return isUnderImpl(x, left, right, (parent as View).width, minTouchTargetSize) &&
isUnderImpl(y, top, bottom, (parent as View).height, minTouchTargetSize)
}
private fun isUnderImpl(
position: Float,
viewStart: Int,
viewEnd: Int,
parentEnd: Int,
minTouchTargetSize: Int
): Boolean {
val viewSize = viewEnd - viewStart
if (viewSize >= minTouchTargetSize) {
return position >= viewStart && position < viewEnd
}
Log.d("Auxio.ViewUtil", "isInTouchTarget: $minTouchTargetSize")
var touchTargetStart = viewStart - (minTouchTargetSize - viewSize) / 2
if (touchTargetStart < 0) {
touchTargetStart = 0
}
var touchTargetEnd = touchTargetStart + minTouchTargetSize
if (touchTargetEnd > parentEnd) {
touchTargetEnd = parentEnd
touchTargetStart = touchTargetEnd - minTouchTargetSize
if (touchTargetStart < 0) {
touchTargetStart = 0
}
}
return position >= touchTargetStart && position < touchTargetEnd
}
val View.isRtl: Boolean
get() = layoutDirection == View.LAYOUT_DIRECTION_RTL
val Drawable.isRtl: Boolean
get() = layoutDirection == View.LAYOUT_DIRECTION_RTL
/** /**
* Resolve system bar insets in a version-aware manner. This can be used to apply padding to a view * Resolve system bar insets in a version-aware manner. This can be used to apply padding to a view
* that properly follows all the frustrating changes that were made between 8-11. * that properly follows all the frustrating changes that were made between 8-11.

View file

@ -30,8 +30,11 @@
<dimen name="elevation_small">2dp</dimen> <dimen name="elevation_small">2dp</dimen>
<dimen name="elevation_normal">4dp</dimen> <dimen name="elevation_normal">4dp</dimen>
<dimen name="popup_min_width">78dp</dimen> <dimen name="fast_scroll_popup_min_width">80dp</dimen>
<dimen name="popup_padding_end">28dp</dimen> <dimen name="fast_scroll_popup_min_height">64dp</dimen>
<dimen name="fast_scroll_popup_padding_start">@dimen/spacing_medium</dimen>
<dimen name="fast_scroll_popup_padding_end">28dp</dimen>
<dimen name="fast_scroll_thumb_touch_target_size">16dp</dimen>
<dimen name="slider_thumb_radius">6dp</dimen> <dimen name="slider_thumb_radius">6dp</dimen>
<dimen name="slider_halo_radius">12dp</dimen> <dimen name="slider_halo_radius">12dp</dimen>

View file

@ -19,7 +19,7 @@ import re
# WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION AND # WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION AND
# THE GRADLE DEPENDENCY. IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE. # THE GRADLE DEPENDENCY. IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
EXO_VERSION = "2.17.0" EXO_VERSION = "2.17.1"
FLAC_VERSION = "1.3.2" FLAC_VERSION = "1.3.2"
FATAL="\033[1;31m" FATAL="\033[1;31m"