playback: improve queue ui

Improve the queue UI some more:
- Fixed an issue where clearing the user queue by clearing all items would
result in it bugging out
- Queue items now show a Material-y background when they are swiped
away. This was way harder than you might think it was.
This commit is contained in:
OxygenCobalt 2021-10-11 20:32:23 -06:00
parent 9fc56c1c3d
commit 2d5c438c58
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 137 additions and 81 deletions

View file

@ -60,7 +60,6 @@ import org.oxycblt.auxio.util.makeScrollingViewFade
* - Edge-to-edge is borked still, unsure how to really fix this aside from making some
* magic layout like Material Files, but even then it might not work since the scrolling
* views are not laid side-by-side to the layout itself.
* So excited to have enough time to get to these in like...november.
* @author OxygenCobalt
*/
class HomeFragment : Fragment() {

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.ItemTouchHelper
@ -35,6 +36,7 @@ import org.oxycblt.auxio.ui.DiffCallback
import org.oxycblt.auxio.ui.HeaderViewHolder
import org.oxycblt.auxio.util.applyMaterialDrawable
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
/**
@ -113,6 +115,8 @@ class QueueAdapter(
fun removeItem(adapterIndex: Int) {
data.removeAt(adapterIndex)
logD(data)
/*
* If the data from the next queue is now entirely empty [Signified by a header at the
* end, remove the next queue header as notify as such.
@ -123,14 +127,15 @@ class QueueAdapter(
* Otherwise just remove the item as usual.
*/
if (data[data.lastIndex] is Header) {
logD("Queue is empty, removing header")
val lastIndex = data.lastIndex
data.removeAt(lastIndex)
notifyItemRangeRemoved(lastIndex, 2)
} else if (data.lastIndex >= 1 && data[0] is Header && data[1] is Header) {
data.removeAt(0)
} else if (data.lastIndex >= 1 && data[0] is ActionHeader && data[1] is Header) {
logD("User queue is empty, removing header")
data.removeAt(0)
notifyItemRangeRemoved(0, 2)
} else {
notifyItemRemoved(adapterIndex)
@ -143,9 +148,11 @@ class QueueAdapter(
inner class QueueSongViewHolder(
private val binding: ItemQueueSongBinding,
) : BaseViewHolder<Song>(binding) {
val bodyView: View get() = binding.body
val backgroundView: View get() = binding.background
init {
binding.root.applyMaterialDrawable()
binding.body.applyMaterialDrawable()
}
@SuppressLint("ClickableViewAccessibility")

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.playback.queue
import android.graphics.Canvas
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.shape.MaterialShapeDrawable
@ -31,8 +32,9 @@ import kotlin.math.min
import kotlin.math.sign
/**
* A highly customized [ItemTouchHelper.Callback] that handles queue item moving, removal, and some
* of the UI magic that makes up the queue UI.
* A highly customized [ItemTouchHelper.Callback] that handles the queue system while basically
* rebuilding most the "Material-y" aspects of an editable list because Google's implementations
* are hot garbage. This shouldn't have *too many* UI bugs. I hope.
* @author OxygenCobalt
*/
class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouchHelper.Callback() {
@ -88,48 +90,63 @@ class QueueDragCallback(private val playbackModel: PlaybackViewModel) : ItemTouc
// themselves when being dragged. Too bad google's implementation of this doesn't even
// work! To emulate it on my own, I check if this child is in a drag state and then animate
// an elevation change.
// TODO: Maybe restrict the item from being drawn over the recycler bounds?
// Seems like its possible with enough UI magic
// TODO: Add an accented BG to the removal action
// TODO: Some other enhancements I could make maybe
// - Maybe stopping dragged items from extending beyond their specific part of the queue?
val view = viewHolder.itemView
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
val bg = view.background as MaterialShapeDrawable
val bg = holder.bodyView.background as MaterialShapeDrawable
val elevation = recyclerView.resources.getDimension(R.dimen.elevation_small)
view.animate()
.translationZ(view.resources.getDimension(R.dimen.elevation_small))
holder.itemView.animate()
.translationZ(elevation)
.setDuration(100)
.setUpdateListener { bg.elevation = view.translationZ }
.setUpdateListener {
bg.elevation = holder.itemView.translationZ
}
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
shouldLift = false
}
view.translationX = dX
view.translationY = dY
// We show a background with a clear icon behind the queue song each time one is swiped
// away. To avoid any canvas shenanigans, we just place a custom background view behind the
// main "body" layout of the queue item and then translate that.
//
// That comes with a couple of problems, however. For one, the background view will always
// lag behind the body view, resulting in a noticeable pixel offset when dragging. To fix
// this, we make this a separate view and make this view invisible whenever the item is
// not being swiped. We cannot merge this view with the FrameLayout, as that will cause
// another weird pixel desync issue that is less visible but still incredibly annoying.
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
holder.backgroundView.isInvisible = dX == 0f
}
holder.bodyView.translationX = dX
holder.itemView.translationY = dY
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
// When an elevated item is cleared, we reset the elevation using another animation.
val view = viewHolder.itemView
val holder = viewHolder as QueueAdapter.QueueSongViewHolder
if (view.translationZ != 0.0f) {
val bg = view.background as MaterialShapeDrawable
if (holder.itemView.translationZ != 0.0f) {
val bg = holder.bodyView.background as MaterialShapeDrawable
view.animate()
holder.itemView.animate()
.translationZ(0.0f)
.setDuration(100)
.setUpdateListener { bg.elevation = view.translationZ }
.setUpdateListener { bg.elevation = holder.itemView.translationZ }
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}
shouldLift = true
view.translationX = 0f
view.translationY = 0f
holder.bodyView.translationX = 0f
holder.itemView.translationY = 0f
}
override fun onMove(

View file

@ -34,8 +34,6 @@ import org.oxycblt.auxio.util.logE
* will not properly respond to RecyclerView events.
* **Note:** This layout relies on [AppBarLayout.liftOnScrollTargetViewId] to figure out what
* scrolling view to use. Failure to specify this will result in the layout not working.
* FIXME: Fix issue where elevation change will always animate
* FIXME: Fix issue where expanded state does not work correctly when switching orientations
*/
class LiftAppBarLayout @JvmOverloads constructor(
context: Context,

View file

@ -30,6 +30,7 @@ import android.view.WindowInsets
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -111,6 +112,15 @@ fun @receiver:ColorRes Int.resolveColor(context: Context): Int {
fun @receiver:ColorRes Int.resolveStateList(context: Context) =
ContextCompat.getColorStateList(context, this)
/*
* Resolve a color and turn it into a [ColorStateList]
* @param context [Context] required
* @return The resolved color as a [ColorStateList]
* @see resolveColor
*/
fun @receiver:DrawableRes Int.resolveDrawable(context: Context) =
requireNotNull(ContextCompat.getDrawable(context, this))
/**
* Resolve this int into a color as if it was an attribute
*/

View file

@ -6,5 +6,6 @@
android:viewportHeight="108">
<path
android:fillColor="#111111"
android:strokeColor="#00000000"
android:pathData="M0 0h108v108H0z" />
</vector>

View file

@ -6,7 +6,7 @@
android:viewportHeight="108">
<path
android:pathData="M54 29.83v28.333c-1.585-0.913-3.41-1.477-5.371-1.477-5.935 0-10.743 4.807-10.743 10.743 0 5.935 4.807 10.742 10.743 10.742 5.935 0 10.743-4.807 10.743-10.742V40.573h10.742V29.831z"
android:strokeColor="#ffffff"
android:strokeColor="#00000000"
android:fillColor="@color/fill_icon_fg">
</path>
</vector>

View file

@ -11,65 +11,88 @@
type="org.oxycblt.auxio.music.Song" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface">
<ImageView
android:id="@+id/album_cover"
style="@style/Widget.Auxio.Image.Compact"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@{@string/desc_album_cover(song.name)}"
app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
style="@style/Widget.Auxio.TextView.Item.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{song.name}"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
style="@style/Widget.Auxio.TextView.Item.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Artist / Album" />
<View
android:id="@+id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorPrimary"
android:visibility="invisible"/>
<ImageView
android:id="@+id/song_drag_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium"
android:clickable="true"
android:contentDescription="@string/desc_queue_handle"
android:focusable="true"
android:scaleType="center"
android:src="@drawable/ic_handle"
app:layout_constraintBottom_toBottomOf="@+id/album_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_name" />
android:src="@drawable/ic_clear"
android:layout_gravity="end|center_vertical"
android:padding="@dimen/spacing_medium"
app:tint="?attr/colorSurface"
android:contentDescription="@string/desc_clear_queue_item"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface">
<ImageView
android:id="@+id/album_cover"
style="@style/Widget.Auxio.Image.Compact"
android:layout_margin="@dimen/spacing_medium"
android:contentDescription="@{@string/desc_album_cover(song.name)}"
app:albumArt="@{song}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_song" />
<TextView
android:id="@+id/song_name"
style="@style/Widget.Auxio.TextView.Item.Primary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{song.name}"
app:layout_constraintBottom_toTopOf="@+id/song_info"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Song Name" />
<TextView
android:id="@+id/song_info"
style="@style/Widget.Auxio.TextView.Item.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_medium"
android:text="@{@string/fmt_two(song.album.artist.name, song.album.name)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/song_drag_handle"
app:layout_constraintStart_toEndOf="@+id/album_cover"
app:layout_constraintTop_toBottomOf="@+id/song_name"
tools:text="Artist / Album" />
<ImageView
android:id="@+id/song_drag_handle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="true"
android:contentDescription="@string/desc_queue_handle"
android:focusable="true"
android:minWidth="@dimen/size_btn_small"
android:minHeight="@dimen/size_btn_small"
android:paddingStart="@dimen/spacing_medium"
android:paddingEnd="@dimen/spacing_medium"
android:scaleType="center"
android:src="@drawable/ic_handle"
app:layout_constraintBottom_toBottomOf="@+id/album_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/song_name" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</layout>

View file

@ -119,6 +119,7 @@
<string name="desc_shuffle">Turn shuffle on or off</string>
<string name="desc_clear_user_queue">Clear queue</string>
<string name="desc_clear_queue_item">Remove this queue item</string>
<string name="desc_queue_handle">Move queue song</string>
<string name="desc_clear_search">Clear search query</string>
<string name="desc_blacklist_delete">Remove excluded directory</string>

View file

@ -9,7 +9,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.0.2'
classpath 'com.android.tools.build:gradle:7.0.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version"