feat: new launcher icon + motion polish (adjacent-tab glide, animateItem rows, header crossfade, cheaper pressDip)

This commit is contained in:
avi 2026-09-28 19:25:29 -05:00
commit e5a64e1892
14 changed files with 42 additions and 15 deletions

View file

@ -1,5 +1,6 @@
package com.rada.ui.library
import androidx.compose.animation.Crossfade
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@ -284,14 +285,18 @@ fun LibraryScreen(
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 2.dp),
) {
Text(
text = headerTitle,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Crossfade the header when the selected folder changes — a
// hard text swap reads as a glitch when tabs are tapped.
Crossfade(targetState = headerTitle, label = "libraryHeader") { t ->
Text(
text = t,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = "${uiState.songs.size} songs",
style = MaterialTheme.typography.bodySmall,
@ -477,8 +482,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(),
) {
items(uiState.songs, key = { it.id }) { song ->
// Keyed rows animate in/out when the folder tab or
// search filter changes instead of popping.
val rowMod = if (LocalReduceMotion.current) Modifier else Modifier.animateItem()
SongListItem(
song = song,
modifier = rowMod,
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
@ -768,10 +777,11 @@ private fun SongListItem(
onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)

View file

@ -30,6 +30,7 @@ import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
@ -49,14 +50,26 @@ fun TabsScreen(
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope()
val reduceMotion = LocalReduceMotion.current
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Tab taps land immediately. Swiping remains animated, but animating a
// tap composes and disposes heavy pages while the gesture is in flight.
// Adjacent tabs glide (neighbors stay composed, so it's smooth); far
// taps jump instantly — animating a multi-page hop composes heavy pages
// mid-flight and reintroduces the jank the jump was there to avoid.
fun goToPage(page: Int) {
if (pagerState.currentPage == page) return
scope.launch {
pagerState.scrollToPage(page)
if (kotlin.math.abs(pagerState.currentPage - page) == 1 && !reduceMotion) {
pagerState.animateScrollToPage(
page,
animationSpec = androidx.compose.animation.core.tween(
durationMillis = 260,
easing = com.rada.ui.theme.MotionArrival,
),
)
} else {
pagerState.scrollToPage(page)
}
}
}

View file

@ -1,6 +1,7 @@
package com.rada.ui.theme
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
@ -130,12 +131,15 @@ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
/**
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
* already have a clickable/combinedClickable modifier earlier in the chain.
* (A plain @Composable extension — the old composed{} form spun up an extra
* subcomposition per row, wasted recomposition churn in long lists.)
*/
@Composable
fun Modifier.pressDip(
interactionSource: MutableInteractionSource,
reduce: Boolean,
): Modifier = composed {
if (reduce) return@composed this
): Modifier {
if (reduce) return this
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
@ -145,7 +149,7 @@ fun Modifier.pressDip(
),
label = "pressDip",
)
this.graphicsLayer { scaleX = scale; scaleY = scale }
return this.graphicsLayer { scaleX = scale; scaleY = scale }
}
// ── Dismiss-by-drag ───────────────────────────────────────────────────────

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB