detail: continue scrolling even after toolbar collapses

This commit is contained in:
Alexander Capehart 2024-10-17 09:44:51 -06:00
parent 6c9f170afc
commit 1ee5645780
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 121 additions and 169 deletions

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2022 Auxio Project
* ContinuousAppBarLayoutBehavior.kt is part of Auxio.
*
* 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.detail
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
class ContinuousAppBarLayoutBehavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
private var recycler: RecyclerView? = null
private var pointerId = -1
private var velocityTracker: VelocityTracker? = null
override fun onInterceptTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onInterceptTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
findRecyclerView(child).stopScroll()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
return consumed
}
override fun onTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_UP -> {
findRecyclerView(child).fling(0, getYVelocity(ev))
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
velocityTracker?.addMovement(ev)
return consumed
}
private fun ensureVelocityTracker() {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
}
private fun getYVelocity(event: MotionEvent): Int {
velocityTracker?.let {
it.addMovement(event)
it.computeCurrentVelocity(FLING_UNITS)
return -it.getYVelocity(pointerId).toInt()
}
return 0
}
private fun findRecyclerView(child: AppBarLayout): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler =
(child.parent as ViewGroup).findViewById<RecyclerView>(child.liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
companion object {
private const val FLING_UNITS = 1000 // copied from base class
}
}

View file

@ -1,169 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailAppBarLayout.kt is part of Auxio.
*
* 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.detail
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
/**
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
private var titleShown: Boolean? = null
private var titleAnimator: ValueAnimator? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isInEditMode) {
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
}
}
private fun findTitleView(): TextView {
val titleView = titleView
if (titleView != null) {
return titleView
}
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.
val newTitleView =
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
// We can never properly initialize the title view's state before draw time,
// so we just set it's alpha to 0f to produce a less jarring initialization
// animation.
alpha = 0f
}
this.titleView = newTitleView
return newTitleView
}
private fun findRecyclerView(): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
private fun setTitleVisibility(visible: Boolean) {
if (titleShown == visible) return
titleShown = visible
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
// the title view's alpha instead of the AppBarLayout's elevation.
val titleView = findTitleView()
val from: Float
val to: Float
if (visible) {
from = 0f
to = 1f
} else {
from = 1f
to = 0f
}
if (titleView.alpha == to) {
// Nothing to do
return
}
logD("Changing title visibility [from: $from to: $to]")
titleAnimator?.cancel()
titleAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float }
duration =
if (titleShown == true) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
start()
}
}
class Behavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
val appBarLayout = child as DetailAppBarLayout
val recycler = appBarLayout.findRecyclerView()
// Title should be visible if we are no longer showing the top item
// (i.e the header)
appBarLayout.setTitleVisibility(
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
}
}
private companion object {
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
}
}

View file

@ -11,6 +11,7 @@
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/detail_appbar"
style="@style/Widget.Auxio.AppBarLayout"
app:layout_behavior="org.oxycblt.auxio.detail.ContinuousAppBarLayoutBehavior"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">

View file

@ -11,6 +11,7 @@
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/detail_appbar"
style="@style/Widget.Auxio.AppBarLayout"
app:layout_behavior="org.oxycblt.auxio.detail.ContinuousAppBarLayoutBehavior"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">

View file

@ -11,6 +11,7 @@
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/detail_appbar"
style="@style/Widget.Auxio.AppBarLayout"
app:layout_behavior="org.oxycblt.auxio.detail.ContinuousAppBarLayoutBehavior"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">

View file

@ -11,6 +11,7 @@
<org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/detail_appbar"
style="@style/Widget.Auxio.AppBarLayout"
app:layout_behavior="org.oxycblt.auxio.detail.ContinuousAppBarLayoutBehavior"
app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/detail_recycler">

View file

@ -12,6 +12,7 @@
android:id="@+id/detail_appbar"
style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true"
app:layout_behavior="org.oxycblt.auxio.detail.ContinuousAppBarLayoutBehavior"
app:liftOnScrollTargetViewId="@id/detail_recycler">
<com.google.android.material.appbar.CollapsingToolbarLayout