Skip to content

Commit 5d1abc6

Browse files
committed
own sticky header with reverseLayout = true
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
1 parent 82c5eff commit 5d1abc6

2 files changed

Lines changed: 144 additions & 44 deletions

File tree

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,7 @@ class ChatViewModel @AssistedInject constructor(
13281328

13291329
sealed interface ChatItem {
13301330
fun messageOrNull(): ChatMessage? = (this as? MessageItem)?.message
1331+
fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date
13311332

13321333
fun stableKey(): Any =
13331334
when (this) {

app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt

Lines changed: 143 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
package com.nextcloud.talk.ui.chat
22

33
import android.util.Log
4+
import androidx.compose.animation.AnimatedVisibility
5+
import androidx.compose.animation.core.animateFloatAsState
6+
import androidx.compose.animation.core.tween
7+
import androidx.compose.animation.fadeIn
8+
import androidx.compose.animation.fadeOut
9+
import androidx.compose.animation.scaleIn
10+
import androidx.compose.animation.scaleOut
411
import androidx.compose.foundation.ExperimentalFoundationApi
512
import androidx.compose.foundation.background
613
import androidx.compose.foundation.layout.Arrangement
@@ -11,21 +18,32 @@ import androidx.compose.foundation.layout.Spacer
1118
import androidx.compose.foundation.layout.fillMaxSize
1219
import androidx.compose.foundation.layout.fillMaxWidth
1320
import androidx.compose.foundation.layout.padding
21+
import androidx.compose.foundation.layout.size
1422
import androidx.compose.foundation.lazy.LazyColumn
1523
import androidx.compose.foundation.lazy.items
1624
import androidx.compose.foundation.lazy.rememberLazyListState
25+
import androidx.compose.foundation.shape.CircleShape
1726
import androidx.compose.foundation.shape.RoundedCornerShape
27+
import androidx.compose.material.icons.Icons
28+
import androidx.compose.material.icons.filled.KeyboardArrowDown
29+
import androidx.compose.material3.Icon
30+
import androidx.compose.material3.MaterialTheme
1831
import androidx.compose.material3.MaterialTheme.colorScheme
32+
import androidx.compose.material3.Surface
1933
import androidx.compose.material3.Text
2034
import androidx.compose.runtime.Composable
2135
import androidx.compose.runtime.LaunchedEffect
2236
import androidx.compose.runtime.MutableState
37+
import androidx.compose.runtime.derivedStateOf
38+
import androidx.compose.runtime.getValue
2339
import androidx.compose.runtime.mutableStateOf
2440
import androidx.compose.runtime.remember
2541
import androidx.compose.runtime.rememberCoroutineScope
42+
import androidx.compose.runtime.setValue
2643
import androidx.compose.runtime.snapshotFlow
2744
import androidx.compose.ui.Alignment
2845
import androidx.compose.ui.Modifier
46+
import androidx.compose.ui.draw.alpha
2947
import androidx.compose.ui.draw.shadow
3048
import androidx.compose.ui.graphics.Color
3149
import androidx.compose.ui.platform.LocalResources
@@ -36,7 +54,6 @@ import com.nextcloud.talk.chat.UnreadMessagesPopup
3654
import com.nextcloud.talk.chat.data.model.ChatMessage
3755
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
3856
import com.nextcloud.talk.data.user.model.User
39-
import kotlinx.coroutines.Dispatchers
4057
import kotlinx.coroutines.delay
4158
import kotlinx.coroutines.flow.distinctUntilChanged
4259
import kotlinx.coroutines.flow.map
@@ -47,8 +64,6 @@ import java.time.ZoneId
4764
import java.time.format.DateTimeFormatter
4865

4966
private const val LONG_1000 = 1000L
50-
private const val SCROLL_DELAY = 20L
51-
private const val ANIMATION_DURATION = 2500L
5267
private val AUTHOR_TEXT_SIZE = 12.sp
5368

5469
@OptIn(ExperimentalFoundationApi::class)
@@ -59,7 +74,6 @@ fun GetNewChatView(
5974
onLoadMore: (() -> Unit?)?
6075
) {
6176
val listState = rememberLazyListState()
62-
val displayedChatItems = remember(chatItems) { chatItems }
6377
val showUnreadPopup = remember { mutableStateOf(false) }
6478
val coroutineScope = rememberCoroutineScope()
6579

@@ -69,15 +83,28 @@ fun GetNewChatView(
6983
}
7084
}
7185

86+
val isAtNewest by remember(listState) {
87+
derivedStateOf {
88+
listState.firstVisibleItemIndex == 0 &&
89+
listState.firstVisibleItemScrollOffset == 0
90+
}
91+
}
92+
93+
val showScrollToNewest by remember {
94+
derivedStateOf { !isAtNewest }
95+
}
96+
7297
LaunchedEffect(chatItems) {
7398
if (chatItems.isEmpty()) return@LaunchedEffect
7499

75-
val newestId = chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id }
100+
val newestId =
101+
chatItems.firstNotNullOfOrNull { it.messageOrNull()?.id }
76102

77103
val previousNewestId = lastNewestIdRef.value
78104

79105
val isNearBottom = listState.firstVisibleItemIndex <= 2
80-
val hasNewMessage = previousNewestId != null && newestId != previousNewestId
106+
val hasNewMessage =
107+
previousNewestId != null && newestId != previousNewestId
81108

82109
if (hasNewMessage) {
83110
if (isNearBottom) {
@@ -94,54 +121,92 @@ fun GetNewChatView(
94121
snapshotFlow { listState.firstVisibleItemIndex }
95122
.map { it <= 2 }
96123
.distinctUntilChanged()
97-
.collect { isNearBottom ->
98-
if (isNearBottom) {
99-
showUnreadPopup.value = false
100-
}
124+
.collect { nearBottom ->
125+
if (nearBottom) showUnreadPopup.value = false
101126
}
102127
}
103128

104129
LaunchedEffect(listState, chatItems.size) {
105130
snapshotFlow {
106131
val layoutInfo = listState.layoutInfo
107132
val total = layoutInfo.totalItemsCount
108-
val lastVisible =
109-
layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
110-
133+
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111134
lastVisible to total
112135
}
113136
.distinctUntilChanged()
114137
.collect { (lastVisible, total) ->
115138
if (total == 0) return@collect
116139

117140
val buffer = 5
118-
val shouldLoadMore =
119-
lastVisible >= (total - 1 - buffer)
141+
val shouldLoadMore = lastVisible >= (total - 1 - buffer)
120142

121143
if (shouldLoadMore) {
122144
onLoadMore?.invoke()
123145
}
124146
}
125147
}
126148

149+
val stickyDateHeaderText by remember(listState, chatItems) {
150+
derivedStateOf {
151+
chatItems.getOrNull(
152+
listState.layoutInfo.visibleItemsInfo
153+
.lastOrNull()
154+
?.index ?: 0
155+
)?.let { item ->
156+
when (item) {
157+
is ChatViewModel.ChatItem.MessageItem ->
158+
formatTime(item.message.timestamp * LONG_1000)
159+
160+
is ChatViewModel.ChatItem.DateHeaderItem ->
161+
formatTime(item.date)
162+
}
163+
} ?: ""
164+
}
165+
}
166+
167+
var stickyDateHeader by remember { mutableStateOf(false) }
168+
169+
LaunchedEffect(listState) {
170+
snapshotFlow { listState.isScrollInProgress }
171+
.collect { scrolling ->
172+
if (scrolling) {
173+
stickyDateHeader = true
174+
} else {
175+
delay(1200)
176+
stickyDateHeader = false
177+
}
178+
}
179+
}
180+
181+
val stickyDateHeaderAlpha by animateFloatAsState(
182+
targetValue = if (stickyDateHeader && stickyDateHeaderText.isNotEmpty()) 1f else 0f,
183+
animationSpec = tween(
184+
durationMillis = if (stickyDateHeader) 500 else 1000
185+
),
186+
label = ""
187+
)
188+
127189
Box(modifier = Modifier.fillMaxSize()) {
128190
LazyColumn(
129191
state = listState,
130-
verticalArrangement = Arrangement.spacedBy(8.dp),
131192
reverseLayout = true,
193+
verticalArrangement = Arrangement.spacedBy(8.dp),
132194
modifier = Modifier
133-
.padding(16.dp)
195+
.padding(
196+
start = 12.dp,
197+
end = 12.dp
198+
)
134199
.fillMaxSize()
135200
) {
136201
items(
137-
displayedChatItems,
138-
key = {
139-
it.stableKey()
140-
}
202+
chatItems,
203+
key = { it.stableKey() }
141204
) { chatItem ->
205+
142206
when (chatItem) {
143207
is ChatViewModel.ChatItem.MessageItem -> {
144-
val isBlinkingState = remember { mutableStateOf(false) }
208+
val isBlinkingState =
209+
remember { mutableStateOf(false) }
145210

146211
GetComposableForMessage(
147212
message = chatItem.message,
@@ -157,11 +222,28 @@ fun GetNewChatView(
157222
}
158223
}
159224

225+
Surface(
226+
modifier = Modifier
227+
.align(Alignment.TopCenter)
228+
.padding(top = 12.dp)
229+
.alpha(stickyDateHeaderAlpha),
230+
shape = RoundedCornerShape(16.dp),
231+
tonalElevation = 2.dp
232+
) {
233+
Text(
234+
stickyDateHeaderText,
235+
modifier = Modifier.padding(
236+
horizontal = 12.dp,
237+
vertical = 6.dp
238+
)
239+
)
240+
}
241+
160242
if (showUnreadPopup.value) {
161243
UnreadMessagesPopup(
162244
onClick = {
163245
coroutineScope.launch {
164-
listState.animateScrollToItem(0)
246+
listState.scrollToItem(0)
165247
}
166248
showUnreadPopup.value = false
167249
},
@@ -170,6 +252,35 @@ fun GetNewChatView(
170252
.padding(bottom = 20.dp)
171253
)
172254
}
255+
256+
AnimatedVisibility(
257+
visible = showScrollToNewest,
258+
modifier = Modifier
259+
.align(Alignment.BottomEnd)
260+
.padding(end = 16.dp, bottom = 24.dp),
261+
enter = fadeIn() + scaleIn(),
262+
exit = fadeOut() + scaleOut()
263+
) {
264+
Surface(
265+
onClick = {
266+
coroutineScope.launch {
267+
listState.scrollToItem(0)
268+
}
269+
},
270+
shape = CircleShape,
271+
color = colorScheme.surface.copy(alpha = 0.9f),
272+
tonalElevation = 2.dp
273+
) {
274+
Icon(
275+
imageVector = Icons.Default.KeyboardArrowDown,
276+
contentDescription = "Scroll to newest",
277+
modifier = Modifier
278+
.size(36.dp)
279+
.padding(8.dp),
280+
tint = colorScheme.onSurface.copy(alpha = 0.9f)
281+
)
282+
}
283+
}
173284
}
174285
}
175286

@@ -199,6 +310,7 @@ fun DateHeader(date: LocalDate) {
199310
}
200311
}
201312

313+
@Deprecated("do not use Compose Chat Adapter")
202314
@OptIn(ExperimentalFoundationApi::class)
203315
@Composable
204316
fun GetView(messages: List<ChatMessage>, messageIdToBlink: String, user: User?) {
@@ -258,21 +370,6 @@ fun GetView(messages: List<ChatMessage>, messageIdToBlink: String, user: User?)
258370
)
259371
}
260372
}
261-
262-
if (messages.isNotEmpty()) {
263-
LaunchedEffect(Dispatchers.Main) {
264-
delay(SCROLL_DELAY)
265-
val pos = searchMessages(
266-
messages,
267-
messageIdToBlink
268-
)
269-
if (pos > 0) {
270-
listState.scrollToItem(pos)
271-
}
272-
delay(ANIMATION_DURATION)
273-
isBlinkingState.value = false
274-
}
275-
}
276373
}
277374

278375
@Composable
@@ -364,13 +461,15 @@ private fun ChatMessage.shouldFilter(): Boolean =
364461
fun formatTime(timestampMillis: Long): String {
365462
val instant = Instant.ofEpochMilli(timestampMillis)
366463
val dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDate()
367-
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
368-
return dateTime.format(formatter)
464+
return formatTime(dateTime)
369465
}
370466

371-
fun searchMessages(messages: List<ChatMessage>, searchId: String): Int {
372-
messages.forEachIndexed { index, message ->
373-
if (message.id == searchId) return index
467+
fun formatTime(localDate: LocalDate): String {
468+
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
469+
val text = when (localDate) {
470+
LocalDate.now() -> "Today"
471+
LocalDate.now().minusDays(1) -> "Yesterday"
472+
else -> localDate.format(formatter)
374473
}
375-
return -1
474+
return text
376475
}

0 commit comments

Comments
 (0)