From 079ef664a2f74e9fb1f2d01aa272cc2a9f4117dc Mon Sep 17 00:00:00 2001 From: ARIA Date: Thu, 20 Aug 2026 20:15:12 +0200 Subject: [PATCH] Back-to-bottom button: chevron FAB appears when scrolled up, tap glides to latest; new messages no longer yank the view while reading history; lane switch always lands on latest --- .../kotlin/iris/ui/screens/ChatScreen.kt | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 968eaa0..d91d833 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -41,12 +41,15 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet @@ -64,9 +67,11 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -145,6 +150,25 @@ fun ChatScreen(controller: IrisController) { val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId } val listState = rememberLazyListState() + // "At the bottom" while the last row's bottom edge is within a small + // tolerance of its rest position (16dp above the viewport bottom, from the + // list's content padding). Scrolling up by more than that reveals the + // back-to-bottom button and stops new messages from auto-scrolling the view. + val bottomThresholdPx = 8.dp.value * LocalDensity.current.density + var isAtBottom by remember { mutableStateOf(true) } + var isAutoScrolling by remember { mutableStateOf(false) } + LaunchedEffect(listState) { + snapshotFlow { + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + if (last == null) { + true + } else { + val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat() + last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx + } + }.collect { isAtBottom = it } + } var input by remember { mutableStateOf("") } var showSearch by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) } @@ -187,9 +211,26 @@ fun ChatScreen(controller: IrisController) { focusManager.clearFocus(force = true) } - LaunchedEffect(items.size, typing) { + // (Re)entering a lane always lands on the latest message. + LaunchedEffect(currentLane) { val last = items.size - 1 - if (last >= 0) listState.animateScrollToItem(last) + if (last >= 0) listState.scrollToItem(last) + } + + // Follow the bottom only while the user is already there; if they've + // scrolled up to read history, new messages must not yank the view back + // (the back-to-bottom button gets them back). + LaunchedEffect(items.size, typing) { + if (!isAtBottom) return@LaunchedEffect + val last = items.size - 1 + if (last >= 0) { + isAutoScrolling = true + try { + listState.animateScrollToItem(last) + } finally { + isAutoScrolling = false + } + } } // M6: keep the rail selection in sync with the current channel. @@ -379,6 +420,38 @@ fun ChatScreen(controller: IrisController) { } } } + + // Back-to-bottom button: appears once the user has scrolled up + // to read history; a tap glides the view back to the latest + // message (smooth but quick). + if (!isAtBottom && !isAutoScrolling) { + val alpha = remember { Animatable(0f) } + LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) } + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 12.dp) + .alpha(alpha.value) + .size(40.dp) + .clip(CircleShape) + .background(IrisColors.surface) + .border(1.dp, IrisColors.divider, CircleShape) + .clickable { + val last = listState.layoutInfo.totalItemsCount - 1 + if (last >= 0) { + drawerScope.launch { listState.animateScrollToItem(last) } + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = "Scroll to latest", + tint = IrisColors.textSecondary, + modifier = Modifier.size(22.dp), + ) + } + } } // Pending attachments (M4)