From b93a512bf7fe04cfbfcac0f17a940292973bab13 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sun, 28 May 2023 19:51:06 -0600 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../java/org/oxycblt/auxio/image/CoverView.kt | 131 +++++++++++++----- .../auxio/image/PlaybackIndicatorView.kt | 100 ------------- app/src/main/res/values/attrs.xml | 5 + app/src/main/res/values/dimens.xml | 3 +- app/src/main/res/values/styles_ui.xml | 16 +-- 6 files changed, 116 insertions(+), 140 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e24e73339..bee626c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 86707f22e..b08ca6951 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -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) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt deleted file mode 100644 index 50e032bd0..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt +++ /dev/null @@ -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 . - */ - -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) - } - } - } -} diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 018d1c219..a320f9ea5 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -12,6 +12,11 @@ + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 0600e089c..7dffd1917 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -20,13 +20,14 @@ 16dp 24dp + 48dp 56dp 64dp 72dp 24dp - 32dp + 32dp 56dp diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 3478f99b0..97611ce8d 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -34,38 +34,38 @@