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

BIN
Satsamp Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

View file

@ -1,5 +1,6 @@
package com.rada.ui.library package com.rada.ui.library
import androidx.compose.animation.Crossfade
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -284,14 +285,18 @@ fun LibraryScreen(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 2.dp), .padding(top = 4.dp, bottom = 2.dp),
) { ) {
// 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(
text = headerTitle, text = t,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = LocalTokens.current.text, color = LocalTokens.current.text,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}
Text( Text(
text = "${uiState.songs.size} songs", text = "${uiState.songs.size} songs",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -477,8 +482,12 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(uiState.songs, key = { it.id }) { song -> 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( SongListItem(
song = song, song = song,
modifier = rowMod,
isPlaying = song.id == uiState.currentPlayingId, isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) }, onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) }, onToggleFavorite = { viewModel.toggleFavorite(song.id) },
@ -768,10 +777,11 @@ private fun SongListItem(
onToggleFavorite: () -> Unit, onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit, onAddToPlaylist: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
Row( Row(
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current) .pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick) .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.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,16 +50,28 @@ fun TabsScreen(
) { ) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 } val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val reduceMotion = LocalReduceMotion.current
// Deep-link target for Library (playlist picker → open that playlist). // Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Tab taps land immediately. Swiping remains animated, but animating a // Adjacent tabs glide (neighbors stay composed, so it's smooth); far
// tap composes and disposes heavy pages while the gesture is in flight. // 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) { fun goToPage(page: Int) {
if (pagerState.currentPage == page) return if (pagerState.currentPage == page) return
scope.launch { scope.launch {
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) pagerState.scrollToPage(page)
} }
} }
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,

View file

@ -1,6 +1,7 @@
package com.rada.ui.theme package com.rada.ui.theme
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.runtime.Composable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState 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 * 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. * 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( fun Modifier.pressDip(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
reduce: Boolean, reduce: Boolean,
): Modifier = composed { ): Modifier {
if (reduce) return@composed this if (reduce) return this
val pressed by interactionSource.collectIsPressedAsState() val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f, targetValue = if (pressed) 0.97f else 1f,
@ -145,7 +149,7 @@ fun Modifier.pressDip(
), ),
label = "pressDip", label = "pressDip",
) )
this.graphicsLayer { scaleX = scale; scaleY = scale } return this.graphicsLayer { scaleX = scale; scaleY = scale }
} }
// ── Dismiss-by-drag ─────────────────────────────────────────────────────── // ── 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