image: introduce sizing

Unify corner radius and icon size configurations under a new sizing
property. This is largely driven by a need to remove the typical
half-width icon sizing, as it results in blurry playing indicators
in some cases. This also co-incides with a change of parent image
icon sizes to 32dp over 28dp.

Resolves #415.
This commit is contained in:
Alexander Capehart 2023-05-28 19:51:06 -06:00
parent 47b791b95f
commit b93a512bf7
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 116 additions and 140 deletions

View file

@ -9,6 +9,7 @@
- Tracks with no disc number now default to "No Disc" instead of "Disc 1"
- Albums implicitly linked only via "artist" tags are now placed in a special
"appears on" section in the artist view
- Album covers that are not 1:1 aspect ratio are no longer cropped
#### What's Fixed
- Prevented options such as "Add to queue" from being selected on empty artists and playlists

View file

@ -23,17 +23,23 @@ import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.AnimationDrawable
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.Gravity
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.core.content.res.getIntOrThrow
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat
import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils
@ -50,6 +56,7 @@ import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.getInteger
@ -72,29 +79,34 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Inject lateinit var uiSettings: UISettings
private val image: ImageView
private val playbackIndicator: PlaybackIndicatorView?
data class PlaybackIndicator(
val view: ImageView,
val playingDrawable: AnimationDrawable,
val pausedDrawable: Drawable
)
private val playbackIndicator: PlaybackIndicator?
private val selectionBadge: ImageView?
private val cornerRadius: Float
@DimenRes private val iconSizeRes: Int?
@DimenRes private val cornerRadiusRes: Int
private var fadeAnimator: ValueAnimator? = null
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
init {
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
// Keep track of our corner radius so that we can apply the same attributes to the custom
// view.
cornerRadius =
if (uiSettings.roundMode) {
styledAttrs.getDimension(R.styleable.CoverView_cornerRadius, 0f)
} else {
0f
}
val sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
iconSizeRes = SIZING_ICON_SIZE[sizing]
cornerRadiusRes = SIZING_CORNER_RADII[sizing]
val playbackIndicatorEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
val selectionBadgeEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enableSelectionBadge, true)
@ -105,7 +117,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Initialize the playback indicator if enabled.
playbackIndicator =
if (playbackIndicatorEnabled) {
PlaybackIndicatorView(context)
PlaybackIndicator(
ImageView(context).apply {
scaleType = ImageView.ScaleType.MATRIX
ImageViewCompat.setImageTintList(
this, context.getColorCompat(R.color.sel_on_cover_bg))
},
context.getDrawableCompat(R.drawable.ic_playing_indicator_24)
as AnimationDrawable,
context.getDrawableCompat(R.drawable.ic_paused_indicator_24))
} else {
null
}
@ -131,10 +151,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
addView(image)
}
playbackIndicator?.let(::addView)
playbackIndicator?.run { addView(view) }
// Add backgrounds to each children. This creates visual consistency between each view,
// and also enables views to be hidden without clunky visibility changes.
// Add backgrounds to each child for visual consistency
for (child in children) {
child.apply {
// If there are rounded corners, we want to make sure view content will be cropped
@ -143,7 +162,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius)
setCornerSize(context.getDimen(cornerRadiusRes))
}
}
}
@ -164,6 +183,36 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
// behavior with a matrix.
val playbackIndicator = (playbackIndicator ?: return).view
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
playbackIndicator.apply {
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
0f,
drawable.intrinsicWidth.toFloat(),
drawable.intrinsicHeight.toFloat())
indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
invalidateRootAlpha()
@ -185,17 +234,25 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/**
* Set if the playback indicator should be indicated ongoing or paused playback.
*
* @param playing Whether playback is ongoing or paused.
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPlaying(playing: Boolean) {
playbackIndicator?.setPlaying(playing)
fun setPlaying(isPlaying: Boolean) {
playbackIndicator?.run {
if (isPlaying) {
playingDrawable.start()
view.setImageDrawable(playingDrawable)
} else {
playingDrawable.stop()
view.setImageDrawable(pausedDrawable)
}
}
}
private fun invalidateRootAlpha() {
alpha = if (isSelected || isEnabled) 1f else 0.5f
}
private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: ImageView) {
private fun invalidatePlaybackIndicatorAlpha(playbackIndicator: PlaybackIndicator) {
// Occasionally content can bleed through the rounded corners and result in a seam
// on the playing indicator, prevent that from occurring by disabling the visibility of
// all views below the playback indicator.
@ -204,7 +261,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
when (child) {
// Selection badge is above the playback indicator, do nothing
selectionBadge -> child.alpha
playbackIndicator -> if (isSelected) 1f else 0f
playbackIndicator.view -> if (isSelected) 1f else 0f
else -> if (isSelected) 0f else 1f
}
}
@ -312,8 +369,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
val request =
ImageRequest.Builder(context)
.data(songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
.transformations(RoundedCornersTransformation(cornerRadius))
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.transformations(RoundedCornersTransformation(context.getDimen(cornerRadiusRes)))
.target(image)
.build()
// Dispose of any previous image request and load a new image.
@ -322,23 +379,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
contentDescription = desc
}
private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() {
/**
* Since the error drawable must also share a view with an image, any kind of transform or tint
* must occur within a custom dialog, which is implemented here.
*/
private class StyledDrawable(
context: Context,
private val inner: Drawable,
@DimenRes iconSizeRes: Int?
) : Drawable() {
init {
// Re-tint the drawable to use the analogous "on surface" color for
// StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}
private val dimen = iconSizeRes?.let(context::getDimenPixels)
override fun draw(canvas: Canvas) {
// Resize the drawable such that it's always 1/4 the size of the image and
// centered in the middle of the canvas.
val adjustWidth = inner.bounds.width() / 4
val adjustHeight = inner.bounds.height() / 4
inner.bounds.set(
adjustWidth,
adjustHeight,
bounds.width() - adjustWidth,
bounds.height() - adjustHeight)
val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
inner.draw(canvas)
}
@ -354,4 +416,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
companion object {
val SIZING_CORNER_RADII =
arrayOf(
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
}
}

View file

@ -1,100 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* PlaybackIndicatorView.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.image
import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.graphics.drawable.AnimationDrawable
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* A view that displays an activation (i.e playback) indicator, with an accented styling and an
* animated equalizer icon.
*
* This is only meant for use with [CoverView]. Due to limitations with [AnimationDrawable]
* instances within custom views, this cannot be merged with [CoverView].
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaybackIndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
private val playingIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
private val pausedIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
fun setPlaying(isPlaying: Boolean) {
if (isPlaying) {
playingIndicatorDrawable.start()
setImageDrawable(playingIndicatorDrawable)
} else {
playingIndicatorDrawable.stop()
setImageDrawable(pausedIndicatorDrawable)
}
}
init {
// We will need to manually re-scale the playing/paused drawables to align with
// StyledDrawable, so use the matrix scale type.
scaleType = ScaleType.MATRIX
// Tint the playing/paused drawables so they are harmonious with the background.
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Emulate StyledDrawable scaling with matrix scaling.
val iconSize = max(measuredWidth, measuredHeight) / 2
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
0f,
drawable.intrinsicWidth.toFloat(),
drawable.intrinsicHeight.toFloat())
indicatorMatrixDst.set(0f, 0f, iconSize.toFloat(), iconSize.toFloat())
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
}

View file

@ -12,6 +12,11 @@
<declare-styleable name="CoverView">
<attr name="cornerRadius" format="dimension" />
<attr name="sizing" format="enum">
<enum name="small" value="0" />
<enum name="medium" value="1" />
<enum name="large" value="2" />
</attr>
<attr name="enablePlaybackIndicator" format="boolean" />
<attr name="enableSelectionBadge" format="boolean" />
</declare-styleable>

View file

@ -20,13 +20,14 @@
<dimen name="size_corners_medium">16dp</dimen>
<dimen name="size_corners_mid_large">24dp</dimen>
<dimen name="size_btn">48dp</dimen>
<dimen name="size_accent_item">56dp</dimen>
<dimen name="size_bottom_sheet_bar">64dp</dimen>
<dimen name="size_play_pause_button">72dp</dimen>
<dimen name="size_icon_small">24dp</dimen>
<dimen name="size_icon_large">32dp</dimen>
<dimen name="size_icon_medium">32dp</dimen>
<dimen name="size_pre_amp_ticker">56dp</dimen>

View file

@ -34,38 +34,38 @@
<style name="Widget.Auxio.Image.Small" parent="">
<item name="android:layout_width">@dimen/size_cover_compact</item>
<item name="android:layout_height">@dimen/size_cover_compact</item>
<item name="cornerRadius">@dimen/size_corners_small</item>
<item name="sizing">small</item>
</style>
<style name="Widget.Auxio.Image.Medium" parent="">
<item name="android:layout_width">@dimen/size_cover_normal</item>
<item name="android:layout_height">@dimen/size_cover_normal</item>
<item name="cornerRadius">@dimen/size_corners_small</item>
<item name="sizing">medium</item>
</style>
<style name="Widget.Auxio.Image.Large" parent="">
<item name="android:layout_width">@dimen/size_cover_large</item>
<item name="android:layout_height">@dimen/size_cover_large</item>
<item name="cornerRadius">@dimen/size_corners_medium</item>
<item name="sizing">large</item>
</style>
<style name="Widget.Auxio.Image.MidHuge" parent="">
<item name="android:layout_width">@dimen/size_cover_mid_huge</item>
<item name="android:layout_height">@dimen/size_cover_mid_huge</item>
<item name="cornerRadius">@dimen/size_corners_medium</item>
<item name="sizing">large</item>
</style>
<style name="Widget.Auxio.Image.Huge" parent="">
<item name="android:layout_width">@dimen/size_cover_huge</item>
<item name="android:layout_height">@dimen/size_cover_huge</item>
<item name="cornerRadius">@dimen/size_corners_medium</item>
<item name="sizing">large</item>
</style>
<style name="Widget.Auxio.Image.Full" parent="">
<item name="android:layout_width">0dp</item>
<item name="android:layout_height">0dp</item>
<item name="layout_constraintDimensionRatio">1</item>
<item name="cornerRadius">@dimen/size_corners_medium</item>
<item name="sizing">large</item>
</style>
<style name="Widget.Auxio.RecyclerView.Linear" parent="">
@ -221,7 +221,7 @@
</style>
<style name="Widget.Auxio.Button.Icon.Large" parent="Widget.Auxio.Button.Icon.Base">
<item name="iconSize">@dimen/size_icon_large</item>
<item name="iconSize">@dimen/size_icon_medium</item>
<item name="android:minWidth">@dimen/size_btn</item>
<item name="android:minHeight">@dimen/size_btn</item>
<item name="android:insetTop">0dp</item>
@ -238,7 +238,7 @@
<style name="Widget.Auxio.Button.PlayPause" parent="Widget.Material3.Button.IconButton.Filled.Tonal">
<item name="android:minWidth">@dimen/size_play_pause_button</item>
<item name="android:minHeight">@dimen/size_play_pause_button</item>
<item name="iconSize">@dimen/size_icon_large</item>
<item name="iconSize">@dimen/size_icon_medium</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
<item name="android:insetLeft">0dp</item>