Skip to content

Commit 1b690c1

Browse files
jmartinespElementBot
andauthored
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 <[email protected]>
1 parent 58a4cc2 commit 1b690c1

File tree

16 files changed

+217
-62
lines changed

16 files changed

+217
-62
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,19 @@
1717
package io.element.android.features.messages.impl.typing
1818

1919
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.tooling.preview.PreviewParameter
2021
import io.element.android.features.messages.impl.MessagesView
2122
import io.element.android.features.messages.impl.aMessagesState
2223
import io.element.android.libraries.designsystem.preview.ElementPreview
2324
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
2425

2526
@PreviewsDayNight
2627
@Composable
27-
internal fun MessagesViewWithTypingPreview() = ElementPreview {
28+
internal fun MessagesViewWithTypingPreview(
29+
@PreviewParameter(TypingNotificationStateForMessagesProvider::class) typingState: TypingNotificationState
30+
) = ElementPreview {
2831
MessagesView(
29-
state = aMessagesState().copy(
30-
typingNotificationState = aTypingNotificationState(
31-
typingMembers = listOf(
32-
aTypingRoomMember(displayName = "Alice"),
33-
aTypingRoomMember(displayName = "Bob"),
34-
),
35-
),
36-
),
32+
state = aMessagesState().copy(typingNotificationState = typingState),
3733
onBackPressed = {},
3834
onRoomDetailsClicked = {},
3935
onEventClicked = { false },

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenter.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
2323
import androidx.compose.runtime.getValue
2424
import androidx.compose.runtime.mutableStateOf
2525
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.setValue
2627
import io.element.android.features.preferences.api.store.SessionPreferencesStore
2728
import io.element.android.libraries.architecture.Presenter
2829
import io.element.android.libraries.matrix.api.core.UserId
@@ -46,6 +47,7 @@ class TypingNotificationPresenter @Inject constructor(
4647
override fun present(): TypingNotificationState {
4748
val typingMembersState = remember { mutableStateOf(emptyList<RoomMember>()) }
4849
val renderTypingNotifications by sessionPreferencesStore.isRenderTypingNotificationsEnabled().collectAsState(initial = true)
50+
4951
LaunchedEffect(renderTypingNotifications) {
5052
if (renderTypingNotifications) {
5153
observeRoomTypingMembers(typingMembersState)
@@ -54,9 +56,18 @@ class TypingNotificationPresenter @Inject constructor(
5456
}
5557
}
5658

59+
// This will keep the space reserved for the typing notifications after the first one is displayed
60+
var reserveSpace by remember { mutableStateOf(false) }
61+
LaunchedEffect(renderTypingNotifications, typingMembersState.value) {
62+
if (renderTypingNotifications && typingMembersState.value.isNotEmpty()) {
63+
reserveSpace = true
64+
}
65+
}
66+
5767
return TypingNotificationState(
5868
renderTypingNotifications = renderTypingNotifications,
5969
typingMembers = typingMembersState.value.toImmutableList(),
70+
reserveSpace = reserveSpace,
6071
)
6172
}
6273

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationState.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@ package io.element.android.features.messages.impl.typing
1919
import io.element.android.libraries.matrix.api.room.RoomMember
2020
import kotlinx.collections.immutable.ImmutableList
2121

22+
/**
23+
* State for the typing notification view.
24+
*/
2225
data class TypingNotificationState(
26+
/** Whether to render the typing notifications based on the user's preferences. */
2327
val renderTypingNotifications: Boolean,
28+
/** The room members currently typing. */
2429
val typingMembers: ImmutableList<RoomMember>,
30+
/** Whether to reserve space for the typing notifications at the bottom of the timeline. */
31+
val reserveSpace: Boolean,
2532
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2024 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.messages.impl.typing
18+
19+
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
20+
21+
class TypingNotificationStateForMessagesProvider : PreviewParameterProvider<TypingNotificationState> {
22+
override val values: Sequence<TypingNotificationState>
23+
get() = sequenceOf(
24+
aTypingNotificationState(
25+
typingMembers = listOf(
26+
aTypingRoomMember(displayName = "Alice"),
27+
aTypingRoomMember(displayName = "Bob"),
28+
),
29+
),
30+
aTypingNotificationState(
31+
typingMembers = listOf(aTypingRoomMember()),
32+
reserveSpace = true
33+
),
34+
aTypingNotificationState(reserveSpace = true),
35+
)
36+
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationStateProvider.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,20 @@ class TypingNotificationStateProvider : PreviewParameterProvider<TypingNotificat
6868
aTypingRoomMember(displayName = "Alice with a very long display name which means that it will be truncated"),
6969
),
7070
),
71+
aTypingNotificationState(
72+
typingMembers = emptyList(),
73+
reserveSpace = true,
74+
),
7175
)
7276
}
7377

7478
internal fun aTypingNotificationState(
7579
typingMembers: List<RoomMember> = emptyList(),
80+
reserveSpace: Boolean = false,
7681
) = TypingNotificationState(
7782
renderTypingNotifications = true,
7883
typingMembers = typingMembers.toImmutableList(),
84+
reserveSpace = reserveSpace,
7985
)
8086

8187
internal fun aTypingRoomMember(

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationView.kt

Lines changed: 92 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,27 @@
1616

1717
package io.element.android.features.messages.impl.typing
1818

19+
import androidx.compose.animation.AnimatedVisibility
20+
import androidx.compose.animation.expandVertically
21+
import androidx.compose.animation.fadeIn
22+
import androidx.compose.animation.fadeOut
23+
import androidx.compose.animation.shrinkVertically
24+
import androidx.compose.foundation.border
25+
import androidx.compose.foundation.layout.Box
1926
import androidx.compose.foundation.layout.fillMaxWidth
2027
import androidx.compose.foundation.layout.padding
2128
import androidx.compose.runtime.Composable
29+
import androidx.compose.runtime.getValue
30+
import androidx.compose.runtime.mutableStateOf
31+
import androidx.compose.runtime.remember
32+
import androidx.compose.runtime.setValue
33+
import androidx.compose.ui.Alignment
2234
import androidx.compose.ui.Modifier
35+
import androidx.compose.ui.draw.alpha
36+
import androidx.compose.ui.graphics.Color
2337
import androidx.compose.ui.res.pluralStringResource
2438
import androidx.compose.ui.res.stringResource
39+
import androidx.compose.ui.semantics.clearAndSetSemantics
2540
import androidx.compose.ui.text.AnnotatedString
2641
import androidx.compose.ui.text.SpanStyle
2742
import androidx.compose.ui.text.buildAnnotatedString
@@ -43,54 +58,89 @@ fun TypingNotificationView(
4358
state: TypingNotificationState,
4459
modifier: Modifier = Modifier,
4560
) {
46-
if (state.typingMembers.isEmpty() || !state.renderTypingNotifications) return
47-
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
48-
Text(
49-
modifier = modifier
50-
.fillMaxWidth()
51-
.padding(horizontal = 24.dp, vertical = 2.dp),
52-
text = typingNotificationText,
53-
overflow = TextOverflow.Ellipsis,
54-
maxLines = 1,
55-
style = ElementTheme.typography.fontBodySmRegular,
56-
color = ElementTheme.colors.textSecondary,
57-
)
61+
val displayNotifications = state.typingMembers.isNotEmpty() && state.renderTypingNotifications
62+
63+
@Suppress("ModifierNaming")
64+
@Composable fun TypingText(text: AnnotatedString, textModifier: Modifier = Modifier) {
65+
Text(
66+
modifier = textModifier,
67+
text = text,
68+
overflow = TextOverflow.Ellipsis,
69+
maxLines = 1,
70+
style = ElementTheme.typography.fontBodySmRegular,
71+
color = ElementTheme.colors.textSecondary,
72+
)
73+
}
74+
75+
// Display the typing notification space when either a typing notification needs to be displayed or a previous one already was
76+
AnimatedVisibility(
77+
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
78+
visible = displayNotifications || state.reserveSpace,
79+
enter = fadeIn() + expandVertically(),
80+
exit = fadeOut() + shrinkVertically(),
81+
) {
82+
val typingNotificationText = computeTypingNotificationText(state.typingMembers)
83+
Box(contentAlignment = Alignment.BottomStart) {
84+
// Reserve the space for the typing notification by adding an invisible text
85+
TypingText(
86+
text = typingNotificationText,
87+
textModifier = Modifier
88+
.alpha(0f)
89+
// Remove the semantics of the text to avoid screen readers to read it
90+
.clearAndSetSemantics { }
91+
)
92+
93+
// Display the actual notification
94+
AnimatedVisibility(
95+
visible = displayNotifications,
96+
enter = fadeIn(),
97+
exit = fadeOut(),
98+
) {
99+
TypingText(text = typingNotificationText, textModifier = Modifier.padding(horizontal = 24.dp))
100+
}
101+
}
102+
}
58103
}
59104

60105
@Composable
61106
private fun computeTypingNotificationText(typingMembers: ImmutableList<RoomMember>): AnnotatedString {
62-
val names = when (typingMembers.size) {
63-
0 -> "" // Cannot happen
64-
1 -> typingMembers[0].disambiguatedDisplayName
65-
2 -> stringResource(
66-
id = R.string.screen_room_typing_two_members,
67-
typingMembers[0].disambiguatedDisplayName,
68-
typingMembers[1].disambiguatedDisplayName,
69-
)
70-
else -> pluralStringResource(
71-
id = R.plurals.screen_room_typing_many_members,
72-
count = typingMembers.size - 2,
73-
typingMembers[0].disambiguatedDisplayName,
74-
typingMembers[1].disambiguatedDisplayName,
75-
typingMembers.size - 2,
107+
// Remember the last value to avoid empty typing messages while animating
108+
var result by remember { mutableStateOf(AnnotatedString("")) }
109+
if (typingMembers.isNotEmpty()) {
110+
val names = when (typingMembers.size) {
111+
0 -> "" // Cannot happen
112+
1 -> typingMembers[0].disambiguatedDisplayName
113+
2 -> stringResource(
114+
id = R.string.screen_room_typing_two_members,
115+
typingMembers[0].disambiguatedDisplayName,
116+
typingMembers[1].disambiguatedDisplayName,
117+
)
118+
else -> pluralStringResource(
119+
id = R.plurals.screen_room_typing_many_members,
120+
count = typingMembers.size - 2,
121+
typingMembers[0].disambiguatedDisplayName,
122+
typingMembers[1].disambiguatedDisplayName,
123+
typingMembers.size - 2,
124+
)
125+
}
126+
// Get the translated string with a fake pattern
127+
val tmpString = pluralStringResource(
128+
id = R.plurals.screen_room_typing_notification,
129+
count = typingMembers.size,
130+
"<>",
76131
)
77-
}
78-
// Get the translated string with a fake pattern
79-
val tmpString = pluralStringResource(
80-
id = R.plurals.screen_room_typing_notification,
81-
count = typingMembers.size,
82-
"<>",
83-
)
84-
// Split the string in 3 parts
85-
val parts = tmpString.split("<>")
86-
// And rebuild the string with the names
87-
return buildAnnotatedString {
88-
append(parts[0])
89-
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
90-
append(names)
132+
// Split the string in 3 parts
133+
val parts = tmpString.split("<>")
134+
// And rebuild the string with the names
135+
result = buildAnnotatedString {
136+
append(parts[0])
137+
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
138+
append(names)
139+
}
140+
append(parts[1])
91141
}
92-
append(parts[1])
93142
}
143+
return result
94144
}
95145

96146
@PreviewsDayNight
@@ -99,6 +149,7 @@ internal fun TypingNotificationViewPreview(
99149
@PreviewParameter(TypingNotificationStateProvider::class) state: TypingNotificationState,
100150
) = ElementPreview {
101151
TypingNotificationView(
152+
modifier = if (state.reserveSpace) Modifier.border(1.dp, Color.Blue) else Modifier,
102153
state = state,
103154
)
104155
}

features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/typing/TypingNotificationPresenterTest.kt

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.typing
1818

1919
import app.cash.molecule.RecompositionMode
2020
import app.cash.molecule.moleculeFlow
21+
import app.cash.turbine.Event
2122
import app.cash.turbine.test
2223
import com.google.common.truth.Truth.assertThat
2324
import io.element.android.features.preferences.api.store.SessionPreferencesStore
@@ -53,6 +54,7 @@ class TypingNotificationPresenterTest {
5354
val initialState = awaitItem()
5455
assertThat(initialState.renderTypingNotifications).isTrue()
5556
assertThat(initialState.typingMembers).isEmpty()
57+
assertThat(initialState.reserveSpace).isFalse()
5658
}
5759
}
5860

@@ -85,7 +87,7 @@ class TypingNotificationPresenterTest {
8587
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
8688
// Preferences changes again
8789
sessionPreferencesStore.setRenderTypingNotifications(false)
88-
skipItems(1)
90+
skipItems(2)
8991
val finalState = awaitItem()
9092
assertThat(finalState.renderTypingNotifications).isFalse()
9193
assertThat(finalState.typingMembers).isEmpty()
@@ -108,6 +110,7 @@ class TypingNotificationPresenterTest {
108110
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aDefaultRoomMember)
109111
// User stops typing
110112
room.givenRoomTypingMembers(emptyList())
113+
skipItems(1)
111114
val finalState = awaitItem()
112115
assertThat(finalState.typingMembers).isEmpty()
113116
}
@@ -140,6 +143,7 @@ class TypingNotificationPresenterTest {
140143
assertThat(oneMemberTypingState.typingMembers.first()).isEqualTo(aKnownRoomMember)
141144
// User stops typing
142145
room.givenRoomTypingMembers(emptyList())
146+
skipItems(1)
143147
val finalState = awaitItem()
144148
assertThat(finalState.typingMembers).isEmpty()
145149
}
@@ -166,11 +170,38 @@ class TypingNotificationPresenterTest {
166170
listOf(aKnownRoomMember).toImmutableList()
167171
)
168172
)
173+
skipItems(1)
169174
val finalState = awaitItem()
170175
assertThat(finalState.typingMembers.first()).isEqualTo(aKnownRoomMember)
171176
}
172177
}
173178

179+
@Test
180+
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
181+
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
182+
val room = FakeMatrixRoom()
183+
val presenter = createPresenter(matrixRoom = room)
184+
moleculeFlow(RecompositionMode.Immediate) {
185+
presenter.present()
186+
}.test {
187+
val initialState = awaitItem()
188+
assertThat(initialState.typingMembers).isEmpty()
189+
room.givenRoomTypingMembers(listOf(A_USER_ID_2))
190+
skipItems(1)
191+
val updatedTypingState = awaitItem()
192+
assertThat(updatedTypingState.reserveSpace).isTrue()
193+
// User stops typing
194+
room.givenRoomTypingMembers(emptyList())
195+
// Is still true for all future events
196+
val futureEvents = cancelAndConsumeRemainingEvents()
197+
for (event in futureEvents) {
198+
if (event is Event.Item) {
199+
assertThat(event.value.reserveSpace).isTrue()
200+
}
201+
}
202+
}
203+
}
204+
174205
private fun createPresenter(
175206
matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
176207
givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))

0 commit comments

Comments
 (0)