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:
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)
|
||||||
|
|||||||
Reference in new issue
Block a user