Improve typing notification animations (#2386)

Only modify the layout for typing notifications when the first one is displayed: after that, just show/hide them using a fade animation, but keep the empty space there ready to be reused.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-02-14 09:05:55 +01:00
committed by GitHub
parent 76beb6bec4
commit 00dd0cbcd1
16 changed files with 218 additions and 63 deletions

View File

@@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.typing
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.MessagesView
import io.element.android.features.messages.impl.aMessagesState
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -24,16 +25,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun MessagesViewWithTypingPreview() = ElementPreview {
internal fun MessagesViewWithTypingPreview(
@PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState
) = ElementPreview {
MessagesView(
state = aMessagesState().copy(
typingNotificationState = aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
),
state = aMessagesState().copy(typingNotificationState = typingState),
onBackPressed = {},
onRoomDetailsClicked = {},
onEventClicked = { false },

View File

@@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
@@ -46,6 +47,7 @@ class TypingNotificationPresenter @Inject constructor(
override fun present(): TypingNotificationState {
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
LaunchedEffect(renderTypingNotifications) {
if (renderTypingNotifications) {
observeRoomTypingMembers(typingMembersState)
@@ -54,9 +56,18 @@ class TypingNotificationPresenter @Inject constructor(
}
}
// This will keep the space reserved for the typing notifications after the first one is displayed
var reserveSpace by remember { mutableStateOf(false) }
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
reserveSpace = true
}
}
return TypingNotificationState(
renderTypingNotifications = renderTypingNotifications,
typingMembers = typingMembersState.value.toImmutableList(),
reserveSpace = reserveSpace,
)
}

View File

@@ -19,7 +19,14 @@ package io.element.android.features.messages.impl.typing
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
/**
* State for the typing notification view.
*/
data class TypingNotificationState(
/** Whether to render the typing notifications based on the user's preferences. */
val renderTypingNotifications: Boolean,
/** The room members currently typing. */
val typingMembers: ImmutableList<RoomMember>,
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
val reserveSpace: Boolean,
)

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.typing
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
class TypingNotificationStateForMessagesProvider : PreviewParameterProvider<TypingNotificationState> {
override val values: Sequence<TypingNotificationState>
get() = sequenceOf(
aTypingNotificationState(
typingMembers = listOf(
aTypingRoomMember(displayName = "Alice"),
aTypingRoomMember(displayName = "Bob"),
),
),
aTypingNotificationState(
typingMembers = listOf(aTypingRoomMember()),
reserveSpace = true
),
aTypingNotificationState(reserveSpace = true),
)
}

View File

@@ -68,14 +68,20 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
),
),
aTypingNotificationState(
typingMembers = emptyList(),
reserveSpace = true,
),
)
}
internal fun aTypingNotificationState(
typingMembers: List<RoomMember> = emptyList(),
reserveSpace: Boolean = false,
) = TypingNotificationState(
renderTypingNotifications = true,
typingMembers = typingMembers.toImmutableList(),
reserveSpace = reserveSpace,
)
internal fun aTypingRoomMember(

View File

@@ -16,12 +16,27 @@
package io.element.android.features.messages.impl.typing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -43,54 +58,89 @@ fun TypingNotificationView(
state: TypingNotificationState,
modifier: Modifier = Modifier,
) {
if (state.typingMembers.isEmpty() || !state.renderTypingNotifications) return
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Text(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp),
text = typingNotificationText,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
@Suppress("ModifierNaming")
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
Text(
modifier = textModifier,
text = text,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
AnimatedVisibility(
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
visible = displayNotifications || state.reserveSpace,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
Box(contentAlignment = Alignment.BottomStart) {
// Reserve the space for the typing notification by adding an invisible text
TypingText(
text = typingNotificationText,
textModifier = Modifier
.alpha(0f)
// Remove the semantics of the text to avoid screen readers to read it
.clearAndSetSemantics { }
)
// Display the actual notification
AnimatedVisibility(
visible = displayNotifications,
enter = fadeIn(),
exit = fadeOut(),
) {
TypingText(text = typingNotificationText, textModifier = Modifier.padding(horizontal = 24.dp))
}
}
}
}
@Composable
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
val names = when (typingMembers.size) {
0 -> "" // Cannot happen
1 -> typingMembers[0].disambiguatedDisplayName
2 -> stringResource(
id = R.string.screen_room_typing_two_members,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
)
else -> pluralStringResource(
id = R.plurals.screen_room_typing_many_members,
count = typingMembers.size - 2,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
typingMembers.size - 2,
)
}
// Get the translated string with a fake pattern
val tmpString = pluralStringResource(
id = R.plurals.screen_room_typing_notification,
count = typingMembers.size,
"<>",
)
// Split the string in 3 parts
val parts = tmpString.split("<>")
// And rebuild the string with the names
return buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
// Remember the last value to avoid empty typing messages while animating
var result by remember { mutableStateOf(AnnotatedString("")) }
if (typingMembers.isNotEmpty()) {
val names = when (typingMembers.size) {
0 -> "" // Cannot happen
1 -> typingMembers[0].disambiguatedDisplayName
2 -> stringResource(
id = R.string.screen_room_typing_two_members,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
)
else -> pluralStringResource(
id = R.plurals.screen_room_typing_many_members,
count = typingMembers.size - 2,
typingMembers[0].disambiguatedDisplayName,
typingMembers[1].disambiguatedDisplayName,
typingMembers.size - 2,
)
}
// Get the translated string with a fake pattern
val tmpString = pluralStringResource(
id = R.plurals.screen_room_typing_notification,
count = typingMembers.size,
"<>",
)
// Split the string in 3 parts
val parts = tmpString.split("<>")
// And rebuild the string with the names
result = buildAnnotatedString {
append(parts[0])
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(names)
}
append(parts[1])
}
append(parts[1])
}
return result
}
@PreviewsDayNight
@@ -99,6 +149,7 @@ internal fun TypingNotificationViewPreview(
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
) = ElementPreview {
TypingNotificationView(
modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier,
state = state,
)
}

View File

@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.typing
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore
@@ -53,6 +54,7 @@ class TypingNotificationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.renderTypingNotifications).isTrue()
assertThat(initialState.typingMembers).isEmpty()
assertThat(initialState.reserveSpace).isFalse()
}
}
@@ -85,7 +87,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// Preferences changes again
sessionPreferencesStore.setRenderTypingNotifications(false)
skipItems(1)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.renderTypingNotifications).isFalse()
assertThat(finalState.typingMembers).isEmpty()
@@ -108,6 +110,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
@@ -140,6 +143,7 @@ class TypingNotificationPresenterTest {
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
// User stops typing
room.givenRoomTypingMembers(emptyList())
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers).isEmpty()
}
@@ -166,11 +170,38 @@ class TypingNotificationPresenterTest {
listOf(aKnownRoomMember).toImmutableList()
)
)
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
}
}
@Test
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.typingMembers).isEmpty()
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
skipItems(1)
val updatedTypingState = awaitItem()
assertThat(updatedTypingState.reserveSpace).isTrue()
// User stops typing
room.givenRoomTypingMembers(emptyList())
// Is still true for all future events
val futureEvents = cancelAndConsumeRemainingEvents()
for (event in futureEvents) {
if (event is Event.Item) {
assertThat(event.value.reserveSpace).isTrue()
}
}
}
}
private fun createPresenter(
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))

View File

@@ -16,12 +16,11 @@
package io.element.android.features.roomlist.impl.migration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
@@ -33,14 +32,14 @@ fun MigrationScreenView(
isMigrating: Boolean,
modifier: Modifier = Modifier,
) {
val displayMigrationStatusFadeProgress by animateFloatAsState(
targetValue = if (isMigrating) 1f else 0f,
animationSpec = tween(durationMillis = 200),
label = "Migration view fade"
)
if (displayMigrationStatusFadeProgress > 0f) {
AnimatedVisibility(
visible = isMigrating,
enter = fadeIn(),
exit = fadeOut(),
label = "Migration view fade",
) {
SunsetPage(
modifier = modifier.alpha(displayMigrationStatusFadeProgress),
modifier = modifier,
isLoading = true,
title = stringResource(id = R.string.screen_migration_title),
subtitle = stringResource(id = R.string.screen_migration_message),