Chat: scroll to bottom of newest message on open and on new messages
scrollToItem(last) top-aligns the last row, so a final message taller than the viewport stayed cut off at the bottom. New scrollToBottom() brings the last row into view, then aligns its bottom edge with the viewport bottom. The isAtBottom check now also treats 'can't scroll forward' as at-bottom, so auto-scroll and the back-to-bottom button behave correctly when the newest message is taller than the viewport.
This commit is contained in:
1 parent
6591d7cec0
commit
dd43033888
1 file changed
+31
-15
@@ -167,10 +167,14 @@ 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
|
// "At the bottom" when the list can't scroll further forward, or the last
|
||||||
// tolerance of its rest position (16dp above the viewport bottom, from the
|
// row's bottom edge is within a small tolerance of the viewport bottom
|
||||||
// list's content padding). Scrolling up by more than that reveals the
|
// (the tolerance avoids flicker when the newest message is shorter than
|
||||||
// back-to-bottom button and stops new messages from auto-scrolling the view.
|
// the viewport). The canScrollForward check is what makes this work when
|
||||||
|
// the newest message is TALLER than the viewport: the gap is then
|
||||||
|
// negative even at the true bottom. Scrolling up by more than the
|
||||||
|
// tolerance reveals the back-to-bottom button and stops new messages from
|
||||||
|
// auto-scrolling the view.
|
||||||
val bottomThresholdPx = 8.dp.value * LocalDensity.current.density
|
val bottomThresholdPx = 8.dp.value * LocalDensity.current.density
|
||||||
var isAtBottom by remember { mutableStateOf(true) }
|
var isAtBottom by remember { mutableStateOf(true) }
|
||||||
var isAutoScrolling by remember { mutableStateOf(false) }
|
var isAutoScrolling by remember { mutableStateOf(false) }
|
||||||
@@ -182,10 +186,28 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat()
|
val gap = (info.viewportEndOffset - (last.offset + last.size)).toFloat()
|
||||||
last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx
|
!listState.canScrollForward ||
|
||||||
|
(last.index == info.totalItemsCount - 1 && gap >= bottomThresholdPx)
|
||||||
}
|
}
|
||||||
}.collect { isAtBottom = it }
|
}.collect { isAtBottom = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll so the newest row's BOTTOM edge is visible: first bring the last
|
||||||
|
// row into view (top-aligned), then, if it is taller than the viewport,
|
||||||
|
// align its bottom with the viewport bottom. A plain scrollToItem(last)
|
||||||
|
// would leave a long final message cut off at the bottom.
|
||||||
|
suspend fun scrollToBottom(animate: Boolean) {
|
||||||
|
val total = listState.layoutInfo.totalItemsCount
|
||||||
|
if (total == 0) return
|
||||||
|
val last = total - 1
|
||||||
|
if (animate) listState.animateScrollToItem(last) else listState.scrollToItem(last)
|
||||||
|
val info = listState.layoutInfo
|
||||||
|
val item = info.visibleItemsInfo.firstOrNull { it.index == last } ?: return
|
||||||
|
val offset = (item.offset + item.size - info.viewportSize.height).coerceAtLeast(0)
|
||||||
|
if (offset > 0) {
|
||||||
|
if (animate) listState.animateScrollToItem(last, offset) else listState.scrollToItem(last, offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
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) }
|
||||||
@@ -235,11 +257,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Re)entering a lane always lands on the latest message.
|
// (Re)entering a lane always lands on the latest message (bottom-aligned).
|
||||||
LaunchedEffect(currentLane) {
|
LaunchedEffect(currentLane) { scrollToBottom(animate = false) }
|
||||||
val last = items.size - 1
|
|
||||||
if (last >= 0) listState.scrollToItem(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Follow the bottom only while the user is already there; if they've
|
// 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
|
// scrolled up to read history, new messages must not yank the view back
|
||||||
@@ -250,7 +269,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
if (last >= 0) {
|
if (last >= 0) {
|
||||||
isAutoScrolling = true
|
isAutoScrolling = true
|
||||||
try {
|
try {
|
||||||
listState.animateScrollToItem(last)
|
scrollToBottom(animate = true)
|
||||||
} finally {
|
} finally {
|
||||||
isAutoScrolling = false
|
isAutoScrolling = false
|
||||||
}
|
}
|
||||||
@@ -551,10 +570,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
.background(IrisColors.surface)
|
.background(IrisColors.surface)
|
||||||
.border(1.dp, IrisColors.divider, CircleShape)
|
.border(1.dp, IrisColors.divider, CircleShape)
|
||||||
.clickable {
|
.clickable {
|
||||||
val last = listState.layoutInfo.totalItemsCount - 1
|
drawerScope.launch { scrollToBottom(animate = true) }
|
||||||
if (last >= 0) {
|
|
||||||
drawerScope.launch { listState.animateScrollToItem(last) }
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in new issue
Block a user