Skip to content

Commit 8bd19e9

Browse files
committed
Fix chat scroll UI: smart auto-scroll, stable keys, and scroll-to-bottom FAB
The conversation scroll had several issues: auto-scroll fired on every token during generation (forcing users back to bottom even when reading history), LazyColumn keys included list index (defeating item reuse), and the clickable modifier for keyboard dismiss interfered with scroll gestures. - Add unique id field to Message for stable LazyColumn keys - Replace per-token scrollTrigger with throttled polling (300ms) that only scrolls when user is near bottom (derivedStateOf detection) - Use instant scrollToItem during generation, animateScrollToItem on completion - Add SmallFloatingActionButton to jump back to bottom when scrolled up - Replace clickable with pointerInput/detectTapGestures for keyboard dismiss to avoid scroll gesture interference
1 parent 01463a3 commit 8bd19e9

3 files changed

Lines changed: 83 additions & 27 deletions

File tree

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/Message.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ package com.example.executorchllamademo
1111
import java.text.SimpleDateFormat
1212
import java.util.Date
1313
import java.util.Locale
14+
import java.util.UUID
1415

1516
/**
1617
* Represents a chat message in the conversation.
@@ -26,7 +27,8 @@ class Message(
2627
isSent: Boolean,
2728
val messageType: MessageType,
2829
val promptID: Int,
29-
existingTimestamp: Long? = null
30+
existingTimestamp: Long? = null,
31+
val id: String = UUID.randomUUID().toString()
3032
) {
3133
// Use @JvmName to maintain Java compatibility - Java expects getIsSent()
3234
@get:JvmName("getIsSent")
@@ -56,7 +58,7 @@ class Message(
5658
*/
5759
fun copy(): Message {
5860
val sourceText = if (messageType == MessageType.IMAGE) (imagePath ?: "") else text
59-
return Message(sourceText, isSent, messageType, promptID, timestamp).also {
61+
return Message(sourceText, isSent, messageType, promptID, timestamp, id).also {
6062
it.tokensPerSecond = tokensPerSecond
6163
it.totalGenerationTime = totalGenerationTime
6264
}

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/screens/ChatScreen.kt

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,38 +10,46 @@ package com.example.executorchllamademo.ui.screens
1010

1111
import androidx.compose.foundation.background
1212
import androidx.compose.foundation.clickable
13-
import androidx.compose.foundation.interaction.MutableInteractionSource
13+
import androidx.compose.foundation.gestures.detectTapGestures
14+
import androidx.compose.foundation.layout.Box
1415
import androidx.compose.foundation.layout.Column
1516
import androidx.compose.foundation.layout.Row
1617
import androidx.compose.foundation.layout.fillMaxSize
1718
import androidx.compose.foundation.layout.fillMaxWidth
1819
import androidx.compose.foundation.layout.imePadding
1920
import androidx.compose.foundation.layout.padding
21+
import androidx.compose.foundation.layout.size
2022
import androidx.compose.foundation.lazy.LazyColumn
21-
import androidx.compose.foundation.lazy.itemsIndexed
23+
import androidx.compose.foundation.lazy.items
2224
import androidx.compose.foundation.lazy.rememberLazyListState
2325
import androidx.compose.material.icons.Icons
2426
import androidx.compose.material.icons.filled.ArrowBack
2527
import androidx.compose.material.icons.filled.Article
28+
import androidx.compose.material.icons.filled.KeyboardArrowDown
2629
import androidx.compose.material.icons.filled.SwapHoriz
2730
import androidx.compose.material3.AlertDialog
2831
import androidx.compose.material3.ExperimentalMaterial3Api
2932
import androidx.compose.material3.Icon
3033
import androidx.compose.material3.IconButton
3134
import androidx.compose.material3.RadioButton
3235
import androidx.compose.material3.Scaffold
36+
import androidx.compose.material3.SmallFloatingActionButton
3337
import androidx.compose.material3.Text
3438
import androidx.compose.material3.TextButton
3539
import androidx.compose.material3.TopAppBar
3640
import androidx.compose.material3.TopAppBarDefaults
3741
import androidx.compose.runtime.Composable
3842
import androidx.compose.runtime.DisposableEffect
3943
import androidx.compose.runtime.LaunchedEffect
44+
import androidx.compose.runtime.derivedStateOf
4045
import androidx.compose.runtime.getValue
4146
import androidx.compose.runtime.mutableStateOf
4247
import androidx.compose.runtime.remember
48+
import androidx.compose.runtime.rememberCoroutineScope
4349
import androidx.compose.runtime.setValue
50+
import androidx.compose.ui.Alignment
4451
import androidx.compose.ui.Modifier
52+
import androidx.compose.ui.input.pointer.pointerInput
4553
import androidx.compose.ui.platform.LocalFocusManager
4654
import androidx.compose.ui.platform.LocalLifecycleOwner
4755
import androidx.compose.ui.res.stringResource
@@ -53,6 +61,7 @@ import com.example.executorchllamademo.ui.components.MessageItem
5361
import com.example.executorchllamademo.ui.theme.LocalAppColors
5462
import com.example.executorchllamademo.ui.viewmodel.ChatViewModel
5563
import kotlinx.coroutines.delay
64+
import kotlinx.coroutines.launch
5665
import androidx.lifecycle.Lifecycle
5766
import androidx.lifecycle.LifecycleEventObserver
5867

@@ -72,14 +81,40 @@ fun ChatScreen(
7281
val listState = rememberLazyListState()
7382
val appColors = LocalAppColors.current
7483
val focusManager = LocalFocusManager.current
84+
val coroutineScope = rememberCoroutineScope()
7585

76-
// Auto-scroll to bottom when new messages are added or content changes during generation
77-
LaunchedEffect(viewModel.messages.size, viewModel.scrollTrigger) {
86+
// Detect whether the user is near the bottom of the list
87+
val isNearBottom by remember {
88+
derivedStateOf {
89+
val layoutInfo = listState.layoutInfo
90+
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
91+
lastVisible >= layoutInfo.totalItemsCount - 2
92+
}
93+
}
94+
95+
// Scroll to bottom when a new message is added (user sent or new response placeholder)
96+
LaunchedEffect(viewModel.messages.size) {
7897
if (viewModel.messages.isNotEmpty()) {
7998
listState.animateScrollToItem(viewModel.messages.size - 1)
8099
}
81100
}
82101

102+
// During generation: poll and scroll only if user is near bottom (throttled)
103+
LaunchedEffect(viewModel.isGenerating) {
104+
if (viewModel.isGenerating) {
105+
while (viewModel.isGenerating) {
106+
delay(300)
107+
if (isNearBottom && viewModel.messages.isNotEmpty()) {
108+
listState.scrollToItem(viewModel.messages.size - 1)
109+
}
110+
}
111+
// Final animated scroll when generation completes
112+
if (isNearBottom && viewModel.messages.isNotEmpty()) {
113+
listState.animateScrollToItem(viewModel.messages.size - 1)
114+
}
115+
}
116+
}
117+
83118
// Periodically update memory usage
84119
LaunchedEffect(Unit) {
85120
while (true) {
@@ -165,27 +200,52 @@ fun ChatScreen(
165200
.fillMaxSize()
166201
.padding(paddingValues)
167202
.imePadding()
168-
.clickable(
169-
indication = null,
170-
interactionSource = remember { MutableInteractionSource() }
171-
) {
172-
focusManager.clearFocus()
203+
.pointerInput(Unit) {
204+
detectTapGestures(onTap = { focusManager.clearFocus() })
173205
}
174206
) {
175-
// Messages list
176-
LazyColumn(
177-
state = listState,
207+
// Messages list with scroll-to-bottom FAB overlay
208+
Box(
178209
modifier = Modifier
179210
.weight(1f)
180211
.fillMaxWidth()
181-
.background(appColors.chatBackground)
182-
.padding(horizontal = 8.dp)
183212
) {
184-
itemsIndexed(
185-
items = viewModel.messages,
186-
key = { index, message -> "${index}_${message.timestamp}_${message.promptID}" }
187-
) { _, message ->
188-
MessageItem(message = message)
213+
LazyColumn(
214+
state = listState,
215+
modifier = Modifier
216+
.fillMaxSize()
217+
.background(appColors.chatBackground)
218+
.padding(horizontal = 8.dp)
219+
) {
220+
items(
221+
items = viewModel.messages,
222+
key = { message -> message.id }
223+
) { message ->
224+
MessageItem(message = message)
225+
}
226+
}
227+
228+
// Scroll-to-bottom FAB: shown when user scrolls up
229+
if (!isNearBottom && viewModel.messages.isNotEmpty()) {
230+
SmallFloatingActionButton(
231+
onClick = {
232+
coroutineScope.launch {
233+
listState.animateScrollToItem(viewModel.messages.size - 1)
234+
}
235+
},
236+
modifier = Modifier
237+
.align(Alignment.BottomEnd)
238+
.padding(12.dp)
239+
.size(36.dp),
240+
containerColor = appColors.navBar
241+
) {
242+
Icon(
243+
imageVector = Icons.Filled.KeyboardArrowDown,
244+
contentDescription = "Scroll to bottom",
245+
tint = appColors.textOnNavBar,
246+
modifier = Modifier.size(20.dp)
247+
)
248+
}
189249
}
190250
}
191251

llm/android/LlamaDemo/app/src/main/java/com/example/executorchllamademo/ui/viewmodel/ChatViewModel.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
6060
var supportsImageInput by mutableStateOf(false)
6161
var supportsAudioInput by mutableStateOf(false)
6262

63-
// Counter that increments on each token to trigger auto-scroll during generation
64-
var scrollTrigger by mutableStateOf(0)
65-
private set
66-
6763
private val _selectedImages = mutableStateListOf<Uri>()
6864
val selectedImages: List<Uri> = _selectedImages
6965

@@ -785,8 +781,6 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), L
785781
_messages[index] = updated
786782
resultMessage = updated
787783
}
788-
// Increment scroll trigger to auto-scroll during generation
789-
scrollTrigger++
790784
}
791785
}
792786

0 commit comments

Comments
 (0)