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

This commit is contained in:
ARIA committed 2026-08-20 20:15:12 +02:00
1 parent 1ec705727a
commit 079ef664a2
1 file changed
+75 -2
@@ -41,12 +41,15 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
@@ -64,9 +67,11 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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 threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
val listState = rememberLazyListState() 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 input by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) } var showSearch by remember { mutableStateOf(false) }
var showNewChannel by remember { mutableStateOf(false) } var showNewChannel by remember { mutableStateOf(false) }
@@ -187,9 +211,26 @@ fun ChatScreen(controller: IrisController) {
focusManager.clearFocus(force = true) 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 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. // 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) // Pending attachments (M4)