mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-09 12:07:27 +00:00
android, desktop: keep sender avatar visible while their messages scroll
When the top of a sender's consecutive messages scrolls under the top bar, the avatar stays below the bar and moves with the messages until the end of the last one pushes it up. Groups only, channels are unchanged.
This commit is contained in:
1 parent
149cfe609b
commit
9a58e4db2b
1 file changed
+97
-2
+97
-2
@@ -16,8 +16,15 @@ import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.PointerInputModifierNode
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -1908,6 +1915,28 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
)
|
||||
|
||||
val listCoordinates = remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
|
||||
fun Density.pinnedAvatarTop(index: Int, avatarHeight: Int): Float {
|
||||
val belowTopBar = (topPaddingToContentPx.value + 4.dp.roundToPx()).toFloat()
|
||||
val runBottom = senderRunBottom(listState.value.layoutInfo, mergedItems.value.items, index) ?: return belowTopBar
|
||||
return min(belowTopBar, runBottom - avatarHeight)
|
||||
}
|
||||
|
||||
// reading coordinates during placement re-runs it on every scroll frame
|
||||
fun Modifier.stickyInSenderRun(itemId: Long): Modifier = layout { measurable, constraints ->
|
||||
val avatar = measurable.measure(constraints)
|
||||
layout(avatar.width, avatar.height) {
|
||||
val list = listCoordinates.value
|
||||
val self = coordinates
|
||||
val index = mergedItems.value.indexInParentItems[itemId]?.takeIf { i -> listState.value.layoutInfo.visibleItemsInfo.any { it.index == i } }
|
||||
val shift = if (list != null && self != null && index != null) {
|
||||
max(0f, pinnedAvatarTop(index, avatar.height) - list.localPositionOf(self).y)
|
||||
} else 0f
|
||||
avatar.place(0, shift.roundToInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatViewListItem(
|
||||
itemAtZeroIndexInWholeList: Boolean,
|
||||
@@ -2073,7 +2102,7 @@ fun BoxScope.ChatItemsList(
|
||||
}
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) {
|
||||
val member = cItem.chatDir.groupMember
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
Box((if (chatInfo.isChannel) Modifier else Modifier.stickyInSenderRun(cItem.id)).clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) {
|
||||
@@ -2373,7 +2402,7 @@ fun BoxScope.ChatItemsList(
|
||||
val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, revealedItems, linkMode) else Modifier
|
||||
|
||||
LazyColumnWithScrollBar(
|
||||
modifier.align(Alignment.BottomCenter),
|
||||
modifier.align(Alignment.BottomCenter).onPlaced { listCoordinates.value = it },
|
||||
state = listState.value,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent,
|
||||
@@ -2467,6 +2496,40 @@ fun BoxScope.ChatItemsList(
|
||||
loadMessages
|
||||
)
|
||||
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState)
|
||||
// a lambda has its own restart scope, so the reads below don't recompose ChatItemsList
|
||||
val PinnedSenderAvatar: @Composable (GroupInfo) -> Unit = pinned@{ groupInfo ->
|
||||
fun topItem(): Pair<Int, MergedItem>? {
|
||||
val index = listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: return null
|
||||
return mergedItems.value.items.getOrNull(index)?.let { index to it }
|
||||
}
|
||||
val memberAtTop = remember { derivedStateOf(sameMemberPolicy) { topItem()?.let { (it.second.newest().item.chatDir as? CIDirection.GroupRcv)?.groupMember } } }
|
||||
val selectionOffset by animateDpAsState(if (selectedChatItems.value != null) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
|
||||
val member = memberAtTop.value ?: return@pinned
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.layout { measurable, constraints ->
|
||||
val avatar = measurable.measure(constraints)
|
||||
layout(avatar.width, avatar.height) {
|
||||
val (index, top) = topItem() ?: return@layout
|
||||
if (continuesSenderRun(top) && (top.newest().item.chatDir as CIDirection.GroupRcv).groupMember.memberId == member.memberId) {
|
||||
val y = pinnedAvatarTop(index, avatar.height).roundToInt()
|
||||
if (y + avatar.height > topPaddingToContentPx.value) {
|
||||
avatar.placeRelativeWithLayer(8.dp.roundToPx(), y) { translationX = selectionOffset.toPx() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clickable { showMemberInfo(groupInfo, member) }
|
||||
.then(ShareTouchesWithList)
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
}
|
||||
|
||||
if (chatInfo is ChatInfo.Group && !chatInfo.isChannel) {
|
||||
PinnedSenderAvatar(chatInfo.groupInfo)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.value.isScrollInProgress }
|
||||
@@ -3788,6 +3851,38 @@ private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// messages of one sender can carry different snapshots of the member
|
||||
private val sameMemberPolicy = object : SnapshotMutationPolicy<GroupMember?> {
|
||||
override fun equivalent(a: GroupMember?, b: GroupMember?): Boolean = a?.memberId == b?.memberId
|
||||
}
|
||||
|
||||
// the list under the stand-in avatar still gets the drags and wheel events that scroll it
|
||||
private data object ShareTouchesWithList : ModifierNodeElement<Modifier.Node>() {
|
||||
override fun create(): Modifier.Node = object : Modifier.Node(), PointerInputModifierNode {
|
||||
override fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {}
|
||||
override fun onCancelPointerInput() {}
|
||||
override fun sharePointerInputWithSiblings(): Boolean = true
|
||||
}
|
||||
override fun update(node: Modifier.Node) {}
|
||||
}
|
||||
|
||||
private fun continuesSenderRun(merged: MergedItem): Boolean {
|
||||
val item = merged.newest().item
|
||||
return item.chatDir is CIDirection.GroupRcv && !shouldShowAvatar(item, merged.oldest().nextItem)
|
||||
}
|
||||
|
||||
// in list coordinates, null when the run continues below the visible items
|
||||
private fun senderRunBottom(layoutInfo: LazyListLayoutInfo, items: List<MergedItem>, index: Int): Float? {
|
||||
val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: return null
|
||||
var last = index
|
||||
while (items.getOrNull(last - 1)?.let(::continuesSenderRun) == true) {
|
||||
last--
|
||||
if (last < firstVisibleIndex) return null
|
||||
}
|
||||
val lastItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == last } ?: return null
|
||||
return (layoutInfo.viewportEndOffset - lastItem.offset).toFloat()
|
||||
}
|
||||
|
||||
|
||||
@Preview/*(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
|
||||
Reference in new issue
Block a user