diff --git a/app/src/main/java/com/papi/nova/grid/GenericGridAdapter.kt b/app/src/main/java/com/papi/nova/grid/GenericGridAdapter.kt index c1f3dd87..cc27311f 100644 --- a/app/src/main/java/com/papi/nova/grid/GenericGridAdapter.kt +++ b/app/src/main/java/com/papi/nova/grid/GenericGridAdapter.kt @@ -4,6 +4,7 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator import android.widget.ImageView import android.widget.ProgressBar import android.widget.RelativeLayout @@ -95,8 +96,10 @@ abstract class GenericGridAdapter( val focusRing = holder.itemView.findViewById(R.id.nova_focus_ring) focusRing?.visibility = if (holder.itemView.hasFocus()) View.VISIBLE else View.GONE + applyFocusMotionState(holder.itemView, holder.itemView.hasFocus(), animate = false) holder.itemView.setOnFocusChangeListener { _, hasFocus -> focusRing?.visibility = if (hasFocus) View.VISIBLE else View.GONE + applyFocusMotionState(holder.itemView, hasFocus, animate = true) onItemFocusChanged(holder.itemView, hasFocus) } @@ -104,4 +107,35 @@ abstract class GenericGridAdapter( clickListener?.onItemClick(item) } } + + private fun applyFocusMotionState(view: View, hasFocus: Boolean, animate: Boolean) { + val scale = if (hasFocus) FOCUSED_SCALE else 1f + val elevation = if (hasFocus) focusedTranslationZPx(view) else 0f + view.animate().cancel() + if (!animate) { + view.scaleX = scale + view.scaleY = scale + view.translationZ = elevation + return + } + + view.animate() + .scaleX(scale) + .scaleY(scale) + .translationZ(elevation) + .setDuration(FOCUS_MOTION_DURATION_MS) + .setInterpolator(FOCUS_INTERPOLATOR) + .withLayer() + .start() + } + + private fun focusedTranslationZPx(view: View): Float = + FOCUSED_TRANSLATION_Z_DP * view.resources.displayMetrics.density + + private companion object { + private const val FOCUS_MOTION_DURATION_MS = 140L + private const val FOCUSED_SCALE = 1.025f + private const val FOCUSED_TRANSLATION_Z_DP = 8f + private val FOCUS_INTERPOLATOR = DecelerateInterpolator() + } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt index c7e47016..372ca0de 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt @@ -69,7 +69,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -108,6 +107,8 @@ import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces import com.papi.nova.ui.compose.NovaActionButton import com.papi.nova.ui.compose.NovaComposeTheme +import com.papi.nova.ui.compose.NovaFocusMotionSpec +import com.papi.nova.ui.compose.novaFocusMotion import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -1439,10 +1440,12 @@ class NovaLibraryActivity : AppCompatActivity() { modifier = modifier .fillMaxWidth() .height(cardHeight) - .graphicsLayer { - scaleX = if (focused) 1.035f else 1f - scaleY = if (focused) 1.035f else 1f - } + .novaFocusMotion( + focused = focused, + focusedScale = NovaFocusMotionSpec.CardFocusedScale, + haloAlpha = NovaFocusMotionSpec.CardFocusedHaloAlpha, + cornerRadius = 14.dp + ) .clip(RoundedCornerShape(14.dp)) .background(if (focused) surfaces.tile.copy(alpha = 1f) else surfaces.tile) .border( @@ -1833,6 +1836,12 @@ class NovaLibraryActivity : AppCompatActivity() { Row( modifier = modifier .height(42.dp) + .novaFocusMotion( + focused = focused, + focusedScale = NovaFocusMotionSpec.CardFocusedScale, + haloAlpha = NovaFocusMotionSpec.ButtonFocusedHaloAlpha, + cornerRadius = 14.dp + ) .clip(RoundedCornerShape(14.dp)) .background( when { diff --git a/app/src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt b/app/src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt index 732347a5..d89c4698 100644 --- a/app/src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt +++ b/app/src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt @@ -1,9 +1,15 @@ package com.papi.nova.ui.compose +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues @@ -19,9 +25,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role @@ -34,6 +44,62 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +internal object NovaFocusMotionSpec { + const val DurationMillis = 150 + const val CardFocusedScale = 1.025f + const val ButtonFocusedScale = 1.03f + const val ButtonPressedScale = 0.98f + const val CardFocusedHaloAlpha = 0.34f + const val ButtonFocusedHaloAlpha = 0.28f + const val ButtonPressedAlpha = 0.86f +} + +private fun novaFocusFloatTween() = tween(durationMillis = NovaFocusMotionSpec.DurationMillis) + +private fun novaFocusDpTween() = tween(durationMillis = NovaFocusMotionSpec.DurationMillis) + +private fun novaFocusColorTween() = tween(durationMillis = NovaFocusMotionSpec.DurationMillis) + +internal fun Modifier.novaFocusMotion( + focused: Boolean, + enabled: Boolean = true, + pressed: Boolean = false, + focusedScale: Float = NovaFocusMotionSpec.CardFocusedScale, + pressedScale: Float = NovaFocusMotionSpec.ButtonPressedScale, + haloAlpha: Float = NovaFocusMotionSpec.CardFocusedHaloAlpha, + cornerRadius: Dp = 14.dp +): Modifier = composed { + val surfaces = LocalNovaLibrarySurfaces.current + val targetScale = when { + pressed && enabled -> pressedScale + focused && enabled -> focusedScale + else -> 1f + } + val scale by animateFloatAsState( + targetValue = targetScale, + animationSpec = novaFocusFloatTween(), + label = "NovaFocusMotionScale" + ) + val animatedHaloAlpha by animateFloatAsState( + targetValue = if (focused && enabled) haloAlpha else 0f, + animationSpec = novaFocusFloatTween(), + label = "NovaFocusMotionHalo" + ) + + graphicsLayer { + scaleX = scale + scaleY = scale + }.drawWithContent { + if (animatedHaloAlpha > 0f) { + drawRoundRect( + color = surfaces.focusHalo.copy(alpha = surfaces.focusHalo.alpha * animatedHaloAlpha), + cornerRadius = CornerRadius((cornerRadius + 4.dp).toPx(), (cornerRadius + 4.dp).toPx()) + ) + } + drawContent() + } +} + @Composable fun NovaBadge( text: String, @@ -66,8 +132,16 @@ fun NovaFocusableCard( var focused by remember { mutableStateOf(false) } val surfaces = LocalNovaLibrarySurfaces.current val shape = RoundedCornerShape(14.dp) - val borderWidth = if (focused) 2.dp else 1.dp - val borderColor = if (focused) surfaces.focusRing else surfaces.tileBorder + val borderWidth by animateDpAsState( + targetValue = if (focused && enabled) 2.dp else 1.dp, + animationSpec = novaFocusDpTween(), + label = "NovaFocusableCardBorderWidth" + ) + val borderColor by animateColorAsState( + targetValue = if (focused && enabled) surfaces.focusRing else surfaces.tileBorder, + animationSpec = novaFocusColorTween(), + label = "NovaFocusableCardBorderColor" + ) val clickableModifier = if (onClick != null) { Modifier.clickable( enabled = enabled, @@ -90,6 +164,12 @@ fun NovaFocusableCard( Box( modifier = modifier + .novaFocusMotion( + focused = focused, + enabled = enabled, + haloAlpha = NovaFocusMotionSpec.CardFocusedHaloAlpha, + cornerRadius = 14.dp + ) .clip(shape) .background(surfaces.tile) .border(borderWidth, borderColor, shape) @@ -116,35 +196,60 @@ fun NovaActionButton( contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 9.dp) ) { var focused by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current val shape = RoundedCornerShape(cornerRadius) - val container = when { + val targetContainer = when { + pressed && enabled && primary -> colors.accent.copy(alpha = colors.accent.alpha * NovaFocusMotionSpec.ButtonPressedAlpha) + pressed && enabled -> surfaces.selectedControl.copy(alpha = surfaces.selectedControl.alpha * NovaFocusMotionSpec.ButtonPressedAlpha) primary && enabled -> colors.accent focused -> surfaces.selectedControl else -> surfaces.control } + val container by animateColorAsState( + targetValue = targetContainer, + animationSpec = novaFocusColorTween(), + label = "NovaActionButtonContainer" + ) val contentColor = when { primary && enabled -> colors.onAccent enabled -> colors.textPrimary else -> colors.textMuted } - val borderColor = when { - focused && primary -> colors.onAccent - focused -> surfaces.focusRing - !primary -> surfaces.tileBorder - else -> surfaces.tileBorder - } - val borderWidth = when { - focused -> 3.dp - !primary -> 1.dp - else -> 0.dp - } + val borderColor by animateColorAsState( + targetValue = when { + focused && primary -> colors.onAccent + focused -> surfaces.focusRing + !primary -> surfaces.tileBorder + else -> surfaces.tileBorder + }, + animationSpec = novaFocusColorTween(), + label = "NovaActionButtonBorderColor" + ) + val borderWidth by animateDpAsState( + targetValue = when { + focused -> 3.dp + !primary -> 1.dp + else -> 0.dp + }, + animationSpec = novaFocusDpTween(), + label = "NovaActionButtonBorderWidth" + ) val alpha = if (enabled) 1f else 0.45f Box( modifier = modifier .defaultMinSize(minHeight = minHeight) + .novaFocusMotion( + focused = focused, + enabled = enabled, + pressed = pressed, + focusedScale = NovaFocusMotionSpec.ButtonFocusedScale, + haloAlpha = NovaFocusMotionSpec.ButtonFocusedHaloAlpha, + cornerRadius = cornerRadius + ) .clip(shape) .background(container.copy(alpha = container.alpha * alpha)) .border(borderWidth, borderColor, shape) @@ -155,6 +260,8 @@ fun NovaActionButton( .onFocusChanged { focused = it.isFocused || it.hasFocus } .clickable( enabled = enabled, + interactionSource = interactionSource, + indication = null, role = Role.Button, onClick = onClick ) diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index b4c70868..73629846 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -408,6 +408,13 @@ class NovaComposeSourceGuardTest { selectableChip.indexOf(".onFocusChanged {") in 0 until selectableChip.indexOf(".combinedClickable(") ) + assertTrue( + "shared Compose focus controls should use the Nova focus motion modifier", + focusComponents.contains("internal fun Modifier.novaFocusMotion(") && + focusComponents.contains("animateFloatAsState(") && + focusComponents.contains("NovaFocusMotionSpec.ButtonPressedScale") && + actionButton.contains(".novaFocusMotion(") + ) } @Test diff --git a/app/src/test/java/com/papi/nova/ui/NovaFocusDrawableTest.kt b/app/src/test/java/com/papi/nova/ui/NovaFocusDrawableTest.kt index bff11b1d..654f6489 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaFocusDrawableTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaFocusDrawableTest.kt @@ -275,6 +275,12 @@ class NovaFocusDrawableTest { source.contains("setOnFocusChangeListener") && source.contains("focusRing?.visibility = if (hasFocus) View.VISIBLE else View.GONE") ) + assertTrue( + "game grid focus should use the shared motion treatment instead of only toggling a static ring", + source.contains("applyFocusMotionState(holder.itemView, hasFocus, animate = true)") && + source.contains("FOCUSED_SCALE") && + source.contains("translationZ(elevation)") + ) } @Test