Add animation to pinned messages.

This commit is contained in:
Michelle Tang
2025-12-04 11:03:30 -05:00
committed by jeffrey-signal
parent 630c21b07a
commit 3185bb6031
3 changed files with 159 additions and 86 deletions

View File

@@ -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<UnverifiedBannerView> by lazy { ViewUtil.findStubById(this, R.id.unverified_banner_stub) }
private val bannerStub: Stub<ComposeView> by lazy { ViewUtil.findStubById(this, R.id.banner_stub) }
private val reviewBannerStub: Stub<ReviewBannerView> by lazy { ViewUtil.findStubById(this, R.id.review_banner_stub) }
@@ -134,24 +140,39 @@ class ConversationBannerView @JvmOverloads constructor(
}
fun showPinnedMessageStub(messages: List<ConversationMessage>, 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() {

View File

@@ -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)
)
)
}
}
}
}

View File

@@ -162,6 +162,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ViewStub
android:id="@+id/pinned_message_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:inflatedId="@+id/pinned_message_compose_view"
android:layout="@layout/pinned_message_stub" />
<ViewStub
android:id="@+id/voice_note_player_stub"
android:layout_width="match_parent"
@@ -190,13 +198,6 @@
android:inflatedId="@+id/banner_compose_view"
android:layout="@layout/conversation_list_banner_view" />
<ViewStub
android:id="@+id/pinned_message_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/pinned_message_compose_view"
android:layout="@layout/pinned_message_stub" />
</org.thoughtcrime.securesms.conversation.v2.ConversationBannerView>
</FrameLayout>