Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/src/main/java/com/papi/nova/grid/GenericGridAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -95,13 +96,46 @@ abstract class GenericGridAdapter<T>(

val focusRing = holder.itemView.findViewById<View?>(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)
}

holder.itemView.setOnClickListener {
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()
}
}
19 changes: 14 additions & 5 deletions app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
135 changes: 121 additions & 14 deletions app/src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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<Float>(durationMillis = NovaFocusMotionSpec.DurationMillis)

private fun novaFocusDpTween() = tween<Dp>(durationMillis = NovaFocusMotionSpec.DurationMillis)

private fun novaFocusColorTween() = tween<Color>(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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -155,6 +260,8 @@ fun NovaActionButton(
.onFocusChanged { focused = it.isFocused || it.hasFocus }
.clickable(
enabled = enabled,
interactionSource = interactionSource,
indication = null,
role = Role.Button,
onClick = onClick
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/src/test/java/com/papi/nova/ui/NovaFocusDrawableTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading