ui: Eased card-row scrolling, dark text on focused white cards
Focused-card rows now animate the newly focused card to the left edge (animateScrollToItem) instead of the abrupt bring-into-view jump - SmartTube-style easing. Text inside a focused (white) card flips to a dark gray-on-white palette instead of staying light gray. LEFT on the first card of a row/chain opens the menu drawer.
This commit is contained in:
parent
9635e31589
commit
60ef94c52e
5 changed files with 113 additions and 38 deletions
|
|
@ -66,6 +66,7 @@ class ChannelsViewModel(private val store: TokenStore) : PagedListViewModel<Vide
|
||||||
fun ChannelsScreen(
|
fun ChannelsScreen(
|
||||||
onOpenChannel: (handle: String) -> Unit,
|
onOpenChannel: (handle: String) -> Unit,
|
||||||
epoch: Int = 0,
|
epoch: Int = 0,
|
||||||
|
onOpenDrawer: () -> Unit = {},
|
||||||
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
viewModel: ChannelsViewModel = viewModel(key = "channels$epoch", factory = ChannelsViewModel.factory),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) { viewModel.loadMore(force = true) }
|
LaunchedEffect(Unit) { viewModel.loadMore(force = true) }
|
||||||
|
|
@ -95,7 +96,7 @@ fun ChannelsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> ChannelsList(items, viewModel, onOpenChannel)
|
else -> ChannelsList(items, viewModel, onOpenChannel, onOpenDrawer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -105,12 +106,14 @@ private fun ChannelsList(
|
||||||
items: List<VideoChannelDto>,
|
items: List<VideoChannelDto>,
|
||||||
viewModel: ChannelsViewModel,
|
viewModel: ChannelsViewModel,
|
||||||
onOpenChannel: (String) -> Unit,
|
onOpenChannel: (String) -> Unit,
|
||||||
|
onOpenDrawer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) {
|
||||||
itemsIndexed(items) { index, channel ->
|
itemsIndexed(items) { index, channel ->
|
||||||
ChannelCard(
|
ChannelCard(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
onClick = { onOpenChannel("${channel.name}@${channel.host}") },
|
||||||
|
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||||
)
|
)
|
||||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.loadMore() }
|
LaunchedEffect(index) { viewModel.loadMore() }
|
||||||
|
|
@ -127,7 +130,7 @@ private fun ChannelsList(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit) {
|
private fun ChannelCard(channel: VideoChannelDto, onClick: () -> Unit, onLeftAtStart: (() -> Unit)? = null) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -130,6 +133,7 @@ fun HomeRowsScreen(
|
||||||
onOpenVideo: (VideoDto) -> Unit,
|
onOpenVideo: (VideoDto) -> Unit,
|
||||||
restoreToken: Int = 0,
|
restoreToken: Int = 0,
|
||||||
epoch: Int = 0,
|
epoch: Int = 0,
|
||||||
|
onOpenDrawer: () -> Unit = {},
|
||||||
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
|
viewModel: HomeRowsViewModel = viewModel(key = "home$epoch", factory = HomeRowsViewModel.factory),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) { viewModel.load() }
|
LaunchedEffect(Unit) { viewModel.load() }
|
||||||
|
|
@ -154,21 +158,14 @@ fun HomeRowsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyRow(
|
FocusScrollRow(
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
videos = viewModel.continueItems.map { it.video },
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
progressOf = { shortUUID ->
|
||||||
) {
|
viewModel.continueItems.firstOrNull { it.video.shortUUID == shortUUID }?.progress
|
||||||
itemsIndexed(viewModel.continueItems) { _, item ->
|
},
|
||||||
VideoCard(
|
onOpenVideo = onOpenVideo,
|
||||||
video = item.video,
|
onLeftAtStart = onOpenDrawer,
|
||||||
onClick = { onOpenVideo(item.video) },
|
)
|
||||||
modifier = Modifier.width(rowCardWidth()),
|
|
||||||
showPrivacy = false,
|
|
||||||
progress = item.progress,
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -190,24 +187,63 @@ fun HomeRowsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyRow(
|
FocusScrollRow(
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
videos = recentItems,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
progressOf = { null },
|
||||||
) {
|
onOpenVideo = onOpenVideo,
|
||||||
itemsIndexed(recentItems) { index, video ->
|
onLeftAtStart = onOpenDrawer,
|
||||||
VideoCard(
|
) { index, video ->
|
||||||
video = video,
|
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
onClick = { onOpenVideo(video) },
|
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
||||||
modifier = Modifier.width(rowCardWidth()),
|
|
||||||
showPrivacy = false,
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
if (index >= recentItems.size - PagedListViewModel.LOOKAHEAD) {
|
|
||||||
LaunchedEffect(index) { viewModel.recent.loadMore() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Horizontal card row with eased scrolling: when focus moves to a card outside the
|
||||||
|
* viewport, the row animates it to the left edge (SmartTube behaviour) instead of
|
||||||
|
* the abrupt jump the built-in lazy scroll produces.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FocusScrollRow(
|
||||||
|
videos: List<VideoDto>,
|
||||||
|
progressOf: (String) -> Float?,
|
||||||
|
onOpenVideo: (VideoDto) -> Unit,
|
||||||
|
onLeftAtStart: () -> Unit,
|
||||||
|
afterItem: @Composable (index: Int, video: VideoDto) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
|
val rowState = rememberLazyListState()
|
||||||
|
var focusedIndex by remember { androidx.compose.runtime.mutableStateOf(-1) }
|
||||||
|
LaunchedEffect(focusedIndex) {
|
||||||
|
if (focusedIndex > 0) {
|
||||||
|
val visible = rowState.layoutInfo.visibleItemsInfo
|
||||||
|
val target = visible.firstOrNull { it.index == focusedIndex }
|
||||||
|
// animate only when the focused card is not (fully) on screen
|
||||||
|
val notFullyVisible = target == null ||
|
||||||
|
target.offset < 0 ||
|
||||||
|
target.offset + (target.size / 2) > rowState.layoutInfo.viewportEndOffset
|
||||||
|
if (notFullyVisible) rowState.animateScrollToItem(focusedIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyRow(
|
||||||
|
state = rowState,
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(videos) { index, video ->
|
||||||
|
VideoCard(
|
||||||
|
video = video,
|
||||||
|
onClick = { onOpenVideo(video) },
|
||||||
|
modifier = Modifier.width(rowCardWidth()),
|
||||||
|
showPrivacy = false,
|
||||||
|
compact = true,
|
||||||
|
progress = progressOf(video.shortUUID),
|
||||||
|
onLeftAtStart = if (index == 0) onLeftAtStart else null,
|
||||||
|
onFocused = { focusedIndex = index },
|
||||||
|
)
|
||||||
|
afterItem(index, video)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.MyVideos ->
|
Destination.MyVideos ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -123,10 +124,12 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.Channels -> ChannelsScreen(
|
Destination.Channels -> ChannelsScreen(
|
||||||
onOpenChannel = { handle -> channelDetails = handle },
|
onOpenChannel = { handle -> channelDetails = handle },
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.Search ->
|
Destination.Search ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -134,6 +137,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.Subscriptions ->
|
Destination.Subscriptions ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -141,6 +145,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
Destination.History ->
|
Destination.History ->
|
||||||
VideoListScreen(
|
VideoListScreen(
|
||||||
|
|
@ -148,6 +153,7 @@ fun HomeScreen(viewModel: AppViewModel = androidx.lifecycle.viewmodel.compose.vi
|
||||||
onOpenVideo = { playingVideo = it.shortUUID },
|
onOpenVideo = { playingVideo = it.shortUUID },
|
||||||
restoreToken = restoreToken,
|
restoreToken = restoreToken,
|
||||||
epoch = epoch,
|
epoch = epoch,
|
||||||
|
onOpenDrawer = { drawerOpen = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -48,10 +57,29 @@ fun VideoCard(
|
||||||
progress: Float? = null,
|
progress: Float? = null,
|
||||||
/** Compact = row in the SmartTube home: single-line title, smaller. */
|
/** Compact = row in the SmartTube home: single-line title, smaller. */
|
||||||
compact: Boolean = false,
|
compact: Boolean = false,
|
||||||
|
/** LEFT (only on the row's first card) can open the menu drawer. */
|
||||||
|
onLeftAtStart: (() -> Unit)? = null,
|
||||||
|
/** Notifies the row wrapper when this card gains focus (for eased scrolling). */
|
||||||
|
onFocused: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
var cardFocused by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.padding(6.dp),
|
modifier = modifier
|
||||||
|
.padding(6.dp)
|
||||||
|
.onFocusChanged {
|
||||||
|
cardFocused = it.isFocused
|
||||||
|
if (it.isFocused) onFocused?.invoke()
|
||||||
|
}
|
||||||
|
.onPreviewKeyEvent { ke ->
|
||||||
|
if (ke.type == androidx.compose.ui.input.key.KeyEventType.KeyDown &&
|
||||||
|
ke.key == androidx.compose.ui.input.key.Key.DirectionLeft &&
|
||||||
|
onLeftAtStart != null
|
||||||
|
) {
|
||||||
|
onLeftAtStart()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
},
|
||||||
colors = CardDefaults.colors(
|
colors = CardDefaults.colors(
|
||||||
containerColor = UnfocusedCardBg,
|
containerColor = UnfocusedCardBg,
|
||||||
contentColor = Color.White,
|
contentColor = Color.White,
|
||||||
|
|
@ -127,6 +155,7 @@ fun VideoCard(
|
||||||
maxLines = if (compact) 1 else 2,
|
maxLines = if (compact) 1 else 2,
|
||||||
minLines = if (compact) 1 else 2,
|
minLines = if (compact) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (cardFocused) Color(0xFF101010) else Color.White,
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -136,7 +165,7 @@ fun VideoCard(
|
||||||
Text(
|
Text(
|
||||||
text = video.channel.displayName.ifBlank { video.channel.name },
|
text = video.channel.displayName.ifBlank { video.channel.name },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color(0xFFB8B8B8),
|
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFFB8B8B8),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
|
@ -165,7 +194,7 @@ fun VideoCard(
|
||||||
text = formatAge(video.publishedAt) +
|
text = formatAge(video.publishedAt) +
|
||||||
if (video.views > 0) " · " + formatViews(video.views) else "",
|
if (video.views > 0) " · " + formatViews(video.views) else "",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color(0xFF8A8A8A),
|
color = if (cardFocused) Color(0xFF6A6A6A) else Color(0xFF8A8A8A),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ fun VideoListScreen(
|
||||||
onOpenVideo: (VideoDto) -> Unit,
|
onOpenVideo: (VideoDto) -> Unit,
|
||||||
restoreToken: Int = 0,
|
restoreToken: Int = 0,
|
||||||
epoch: Int = 0,
|
epoch: Int = 0,
|
||||||
|
onOpenDrawer: () -> Unit = {},
|
||||||
viewModel: VideoListViewModel = viewModel(
|
viewModel: VideoListViewModel = viewModel(
|
||||||
key = "v$epoch:" + sourceKey(source),
|
key = "v$epoch:" + sourceKey(source),
|
||||||
factory = VideoListViewModel.factory(source),
|
factory = VideoListViewModel.factory(source),
|
||||||
|
|
@ -135,9 +136,9 @@ fun VideoListScreen(
|
||||||
video = video,
|
video = video,
|
||||||
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
onClick = { lastOpenedIndex = index; onOpenVideo(video) },
|
||||||
showPrivacy = source is ListSource.MyVideos,
|
showPrivacy = source is ListSource.MyVideos,
|
||||||
|
onLeftAtStart = if (index == 0) onOpenDrawer else null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(fr)
|
.focusRequester(fr),
|
||||||
.also { if (index == 0) Modifier.focusRequester(initialFocus) },
|
|
||||||
)
|
)
|
||||||
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
if (index >= items.size - PagedListViewModel.LOOKAHEAD) {
|
||||||
LaunchedEffect(index) { viewModel.loadMore() }
|
LaunchedEffect(index) { viewModel.loadMore() }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue