From 3185bb6031cd798713f7666b7fc4a87486836877 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Thu, 4 Dec 2025 11:03:30 -0500 Subject: [PATCH] Add animation to pinned messages. --- .../conversation/v2/ConversationBannerView.kt | 51 +++-- .../v2/PinnedMessagesComponent.kt | 179 +++++++++++------- .../res/layout/v2_conversation_fragment.xml | 15 +- 3 files changed, 159 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index 3038404623..1f3dac79c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -16,6 +16,7 @@ import android.view.View import androidx.appcompat.widget.LinearLayoutCompat import androidx.compose.ui.platform.ComposeView import androidx.core.transition.addListener +import androidx.interpolator.view.animation.FastOutSlowInInterpolator import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.banner.Banner import org.thoughtcrime.securesms.banner.BannerManager @@ -49,6 +50,11 @@ class ConversationBannerView @JvmOverloads constructor( attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : LinearLayoutCompat(context, attrs, defStyleAttr) { + + companion object { + private const val ANIMATION_DURATION = 500L + } + private val unverifiedBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.unverified_banner_stub) } private val bannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.banner_stub) } private val reviewBannerStub: Stub by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) } @@ -134,24 +140,39 @@ class ConversationBannerView @JvmOverloads constructor( } fun showPinnedMessageStub(messages: List, canUnpin: Boolean, hasWallpaper: Boolean) { - show( - stub = pinnedMessageStub - ) { - this.apply { - setContent { - SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) { - PinnedMessagesBanner( - messages = messages, - canUnpin = canUnpin, - hasWallpaper = hasWallpaper, - onUnpinMessage = { messageId -> listener?.onUnpinMessage(messageId) }, - onGoToMessage = { messageId -> listener?.onGoToMessage(messageId) }, - onViewAllMessages = { listener?.onViewAllMessages() } - ) - } + val firstRender = !pinnedMessageStub.isVisible + + val view = pinnedMessageStub.get() + view.apply { + setContent { + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) { + PinnedMessagesBanner( + messages = messages, + canUnpin = canUnpin, + hasWallpaper = hasWallpaper, + onUnpinMessage = { messageId -> listener?.onUnpinMessage(messageId) }, + onGoToMessage = { messageId -> listener?.onGoToMessage(messageId) }, + onViewAllMessages = { listener?.onViewAllMessages() } + ) } } } + + if (firstRender) { + view.visibility = INVISIBLE + view.post { + view.visible = true + view.translationY = -view.height.toFloat() + view.alpha = 0f + + view.animate() + .translationY(0f) + .setInterpolator(FastOutSlowInInterpolator()) + .alpha(1f) + .setDuration(ANIMATION_DURATION) + .start() + } + } } fun hidePinnedMessageStub() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt index b88e8ae554..89ee336aed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/PinnedMessagesComponent.kt @@ -3,12 +3,21 @@ package org.thoughtcrime.securesms.conversation.v2 import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.TextUtils +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -36,6 +45,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.doOnPreDraw import org.signal.core.ui.compose.DropdownMenus @@ -55,6 +65,7 @@ import org.thoughtcrime.securesms.mms.StickerSlide import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.util.DynamicTheme import org.thoughtcrime.securesms.util.hasSharedContact +import org.thoughtcrime.securesms.util.hasSticker import org.thoughtcrime.securesms.util.isPoll import org.thoughtcrime.securesms.util.isViewOnceMessage import org.whispersystems.signalservice.api.payments.FormatterOptions @@ -78,7 +89,9 @@ fun PinnedMessagesBanner( val message = conversationMessage.messageRecord as MmsMessageRecord val (glyph, body, showThumbnail) = getMessageMetadata(conversationMessage) - Column { + Column( + modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 54.dp) + ) { HorizontalDivider( thickness = 1.dp, color = if (DynamicTheme.isDarkTheme(LocalContext.current)) Color(0XFF4A4C52) else Color(0XFFCCCFD5) @@ -92,61 +105,91 @@ fun PinnedMessagesBanner( index = (index + 1) % messages.size onGoToMessage(message.id) } - .padding(8.dp) .height(IntrinsicSize.Min) ) { if (messages.size > 1) { Heading(index, messages.size) + } else { + Spacer(modifier = Modifier.size(16.dp)) } - if (showThumbnail && message.slideDeck.firstSlide?.uri != null) { - GlideImage( - model = DecryptableUri(message.slideDeck.firstSlide!!.uri!!), - modifier = Modifier - .padding(start = 8.dp) - .size(32.dp) - .clip(RoundedCornerShape(12.dp)) - ) - } - - Column( - modifier = Modifier - .weight(1f) - .padding(start = 8.dp) - ) { - Text( - text = if (message.fromRecipient.isSelf) { - stringResource(R.string.Recipient_you) - } else { - message.fromRecipient.getDisplayName(LocalContext.current) - }, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - val displayBody = if (glyph != null) { - SpannableStringBuilder() - .append(getSpannedString(LocalContext.current, SignalSymbols.Weight.REGULAR, glyph, -1)) - .append(" ") - .append(body) - } else { - body + AnimatedContent( + modifier = Modifier.weight(1f), + targetState = message, + transitionSpec = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Up, animationSpec = tween(150)) togetherWith + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Up, animationSpec = tween(150)) } + ) { message -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + onClick = { + index = (index + 1) % messages.size + onGoToMessage(message.id) + }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label), + enabled = true + ) + .padding(vertical = 8.dp) + ) { + if (showThumbnail && + !message.hasSticker() && + message.slideDeck.firstSlide?.uri != null && + !message.slideDeck.firstSlide!!.isVideoGif + ) { + GlideImage( + model = DecryptableUri(message.slideDeck.firstSlide!!.uri!!), + modifier = Modifier + .padding(end = 8.dp) + .size(32.dp) + .clip(RoundedCornerShape(10.dp)) + ) + } - AndroidView( - factory = ::EmojiTextView - ) { view -> - view.enableRenderSpoilers() - view.text = displayBody - view.ellipsize = TextUtils.TruncateAt.END - view.maxLines = 1 - view.doOnPreDraw { - (it as EmojiTextView).ellipsizeEmojiTextForMaxLines() + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = if (message.fromRecipient.isSelf) { + stringResource(R.string.Recipient_you) + } else { + message.fromRecipient.getDisplayName(LocalContext.current) + }, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 13.sp + ) + + val displayBody = if (glyph != null) { + SpannableStringBuilder() + .append(getSpannedString(LocalContext.current, SignalSymbols.Weight.REGULAR, glyph, -1)) + .append(" ") + .append(body) + } else { + body + } + + AndroidView( + factory = ::EmojiTextView + ) { view -> + view.enableRenderSpoilers() + view.text = displayBody + view.ellipsize = TextUtils.TruncateAt.END + view.maxLines = 1 + view.doOnPreDraw { + (it as EmojiTextView).ellipsizeEmojiTextForMaxLines() + } + } } } } - Box(modifier = Modifier.padding(horizontal = 8.dp)) { + Box(modifier = Modifier.padding(8.dp).padding(end = 8.dp)) { Icon( imageVector = ImageVector.vectorResource(R.drawable.symbol_pin_24), contentDescription = stringResource(R.string.PinnedMessage__pinned), @@ -175,26 +218,34 @@ fun PinnedMessagesBanner( */ @Composable fun Heading(selectedIndex: Int, size: Int) { - Column( - modifier = Modifier.fillMaxHeight() - ) { - for (i in 0 until size) { - Box( - modifier = Modifier - .padding(vertical = 2.dp) - .width(2.dp) - .weight(1f) - .background( - color = if (i == selectedIndex) { - MaterialTheme.colorScheme.onSurface - } else if (DynamicTheme.isDarkTheme(LocalContext.current)) { - MaterialTheme.colorScheme.secondaryContainer - } else { - SignalTheme.colors.colorTransparentInverse2 - }, - shape = RoundedCornerShape(16.dp) - ) - ) + AnimatedContent( + targetState = selectedIndex, + transitionSpec = { + fadeIn(tween(durationMillis = 150)).togetherWith(fadeOut(tween(durationMillis = 150))) + } + ) { selectedIndex -> + Column( + modifier = Modifier.fillMaxHeight().padding(vertical = 8.dp) + ) { + for (i in 0 until size) { + Box( + modifier = Modifier + .padding(vertical = 2.dp) + .padding(horizontal = 7.dp) + .width(2.dp) + .weight(1f) + .background( + color = if (i == selectedIndex) { + MaterialTheme.colorScheme.onSurface + } else if (DynamicTheme.isDarkTheme(LocalContext.current)) { + MaterialTheme.colorScheme.secondaryContainer + } else { + SignalTheme.colors.colorTransparentInverse2 + }, + shape = RoundedCornerShape(16.dp) + ) + ) + } } } } diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index f7e02338ce..3dfb98b6d1 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -162,6 +162,14 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + + - -