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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.Spring
Expand Down Expand Up @@ -468,6 +469,16 @@ fun DynamicIsland(
)
}

// The normal cutout's icon pops in whenever a new event takes the pill over. What counts as
// "new" is deliberately not the event id: a live tile re-resolves on every refresh (a media
// progress tick, a timer second) and gets a fresh id each time, which would re-pop constantly.
// A notification's key survives its own updates, and a tile's label survives its lifetime.
val iconPop = remember { Animatable(1f) }
val iconPopKey = event?.let { it.notificationKey ?: it.label }
LaunchedEffect(iconPopKey) {
if (iconPopKey != null) motion.popIn(iconPop)
}

LaunchedEffect(emptyPill) {
if (emptyPill) {
tapExpanded = false
Expand Down Expand Up @@ -810,6 +821,7 @@ fun DynamicIsland(
heightDp = collapsed.heightDp,
isStickToCamera = isStickToCamera,
trailingInsetDp = collapsedTrailingInsetDp,
iconPop = iconPop,
)
}
}
Expand Down Expand Up @@ -1008,13 +1020,18 @@ private fun albumArtStrokeFor(event: IslandEvent): Color? =
* @param trailingInsetDp extra room to leave on the trailing edge, so whatever the caller draws
* there — today the permission dots — isn't overlapped by the timer's remaining time or the
* progress ring.
* @param iconPop scale for the badge's arrival pop, driven by the caller. Hoisted rather than owned
* here because the collapsed content re-enters composition every time the island collapses back
* from expanded, which would otherwise re-fire the pop on a collapse. Null (the settings preview)
* leaves the badge at rest.
*/
@Composable
private fun CollapsedContent(
event: IslandEvent,
heightDp: Int,
isStickToCamera: Boolean = false,
trailingInsetDp: Int = 0,
iconPop: Animatable<Float, AnimationVector1D>? = null,
) {
// The music tile shows album art, the phone tile the caller's photo, on the normal cutout.
val nowPlaying by NowPlayingBus.state.collectAsStateWithLifecycle()
Expand All @@ -1024,12 +1041,25 @@ private fun CollapsedContent(
val badgeSize = (heightDp * 0.72f).dp

Box(modifier = Modifier.fillMaxSize()) {
// Scaled after the padding so the pop grows the badge about its own centre instead of
// dragging it in from the pill's edge, and read inside the layer block so each frame redraws
// without recomposing the pill.
val placement = Modifier
.align(if (isStickToCamera) Alignment.BottomCenter else Alignment.CenterStart)
.padding(
start = if (isStickToCamera) 0.dp else (heightDp * 0.16f).dp,
bottom = if (isStickToCamera) (heightDp * 0.14f).dp else 0.dp,
)
.then(
if (iconPop != null) {
Modifier.graphicsLayer {
scaleX = iconPop.value
scaleY = iconPop.value
}
} else {
Modifier
}
)
when {
albumArt != null -> AlbumArt(
bitmap = albumArt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ internal class IslandMotion(
}
}

/**
* Pops a freshly arrived icon into the normal cutout: it starts at [POP_IN_START_SCALE] and
* springs up to rest, overshooting slightly. When an event lands on a cutout that is already on
* screen — the resting "shows when empty" pill, or one notification replacing another — neither
* the reveal nor the size transition runs, so this is the only thing marking the arrival.
*
* Snaps to the start scale itself, so a second arrival mid-animation restarts the pop instead of
* springing on from wherever the previous one had got to.
*/
suspend fun popIn(scale: Animatable<Float, AnimationVector1D>) {
scale.snapTo(POP_IN_START_SCALE)
scale.animateTo(
targetValue = REST_SCALE,
animationSpec = if (expressive) spatialSpec(speed, bounce, visibilityThreshold = 0.0005f)
else tween(durationMillis = scaled(POP_IN_MS), easing = EaseInOutEasing),
)
}

/** Alpha / colour motion: critically damped (no overshoot), so fades never over-brighten. */
fun fade(): AnimationSpec<Float> =
if (expressive) effectsSpec(speed)
Expand Down Expand Up @@ -137,6 +155,16 @@ internal class IslandMotion(
*/
private const val POP_PEAK_RATIO = 0.5216f

/**
* Where [popIn] starts from. The badge is small enough on the pill that the growth has to be
* a good half of its resting size to register at all; the spring's overshoot scales with that
* travel too, so a lower start buys a taller pop at both ends of the arc.
*/
private const val POP_IN_START_SCALE = 0.55f

/** [popIn]'s length under [AnimationStyle.EASE_IN_OUT], which has no spring to settle. */
private const val POP_IN_MS = 200

/**
* A spatial spring based on the Material 3 expressive MotionScheme tokens: [speed] sets the
* stiffness (the MotionScheme slow / default / fast values) and [bounce] the damping.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package com.ekoehler.expressivecutout.ui.components

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp

/**
* One entry of an [OptionSelectionCard]. [value] is what gets handed back on selection, so callers
* can pass an enum entry, an id, or any other key and stay type-safe.
*/
data class SelectableOption<T>(
val value: T,
val title: String,
val description: String? = null,
val enabled: Boolean = true,
)

/**
* A titled card holding a single-choice list of options, each with its own title and optional
* description. The selected row fills with the theme's secondary container so it reads as chosen in
* both light and dark dynamic-colour schemes; [onSelectionChange] fires with the tapped option's
* value. Rows already tapped don't re-emit, and the whole group is exposed as one radio group to
* accessibility services. Clear [isFirst] / [isLast] to tighten the matching outer corners so the
* card stacks into a grouped settings list, and pass [header] to sit extra content — a preview, say
* — between the title and the rows.
*/
@Composable
fun <T> OptionSelectionCard(
title: String,
options: List<SelectableOption<T>>,
selectedValue: T?,
onSelectionChange: (T) -> Unit,
modifier: Modifier = Modifier,
isFirst: Boolean = true,
isLast: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface,
enabled: Boolean = true,
header: (@Composable () -> Unit)? = null,
) {
val haptics = LocalHapticFeedback.current
Surface(
modifier = modifier.fillMaxWidth(),
shape = cardShape(isFirst = isFirst, isLast = isLast),
color = containerColor,
) {
Column(
modifier = Modifier.padding(
start = 12.dp,
end = 12.dp,
top = 16.dp,
bottom = 12.dp,
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
header?.invoke()
Column(
modifier = Modifier.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
options.forEachIndexed { index, option ->
OptionRow(
option = option,
shape = optionShape(
isFirst = index == 0,
isLast = index == options.lastIndex,
),
selected = option.value == selectedValue,
enabled = enabled && option.enabled,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onSelectionChange(option.value)
},
)
}
}
}
}
}

/** Grouped-list card shape: the outer corners round only where the card ends the group. */
private fun cardShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape(
topStart = if (isFirst) 32.dp else 4.dp,
topEnd = if (isFirst) 32.dp else 4.dp,
bottomStart = if (isLast) 32.dp else 4.dp,
bottomEnd = if (isLast) 32.dp else 4.dp,
)

/** Grouped-list row shape: the group's outer corners round, the ones between rows stay tight. */
private fun optionShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape(
topStart = if (isFirst) 24.dp else 4.dp,
topEnd = if (isFirst) 24.dp else 4.dp,
bottomStart = if (isLast) 24.dp else 4.dp,
bottomEnd = if (isLast) 24.dp else 4.dp,
)

/**
* A single selectable row of [OptionSelectionCard]. Instead of the flat bounded ripple, pressing a
* row squashes it on a bouncy spring, then it settles into the selected container colour — the
* motion feedback Material 3 Expressive uses in place of a plain ripple wash. A soft
* secondary-tinted ripple rides along underneath so the touch point still reads.
*/
@Composable
private fun <T> OptionRow(
option: SelectableOption<T>,
shape: Shape,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val contentAlpha = if (enabled) 1f else 0.38f
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val bouncy = spring<Float>(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
)

val containerColor by animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "optionContainerColor",
)
val titleColor by animateColorAsState(
targetValue = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "optionTitleColor",
)
val scale by animateFloatAsState(
targetValue = if (pressed) 0.96f else 1f,
animationSpec = bouncy,
label = "optionScale",
)

Surface(
modifier = Modifier
.scale(scale)
.fillMaxWidth()
.clip(shape)
.selectable(
selected = selected,
interactionSource = interactionSource,
indication = ripple(color = MaterialTheme.colorScheme.secondary),
enabled = enabled && !selected,
role = Role.RadioButton,
onClick = onClick,
),
shape = shape,
color = containerColor,
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = option.title,
style = MaterialTheme.typography.titleSmall,
color = titleColor.copy(alpha = contentAlpha),
)
option.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
)
}
}
Spacer(Modifier.width(16.dp))
RadioButton(
selected = selected,
onClick = null,
enabled = enabled,
colors = RadioButtonDefaults.colors(
selectedColor = MaterialTheme.colorScheme.primary,
unselectedColor = MaterialTheme.colorScheme.outline,
),
)
}
}
}
Loading
Loading