Improve how Talkback works with the timeline (#2404)

* Improve how Talkback works with the timeline

* Fix interaction test by adding test tag

* Disable reverse layout when a screen reader is enabled

This messes up with the ordering, but fixes the scrolling when a screen reader is used.
This commit is contained in:
Jorge Martin Espinosa 2024-02-19 11:36:15 +01:00 committed by GitHub
parent 94d486be84
commit 0918f9ed29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 110 additions and 66 deletions

View file

@ -0,0 +1 @@
Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix.

View file

@ -18,9 +18,11 @@
package io.element.android.features.messages.impl.timeline
import android.view.accessibility.AccessibilityManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -64,7 +66,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.typing.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState
import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -99,7 +100,13 @@ fun TimelineView(
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
}
val context = LocalContext.current
val lazyListState = rememberLazyListState()
// Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version
val useReverseLayout = remember {
val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
accessibilityManager.isTouchExplorationEnabled.not()
}
@Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) {
@ -107,67 +114,67 @@ fun TimelineView(
}
// Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
val alpha by alphaAnimation(label = "alpha for timeline")
Box(modifier = modifier.alpha(alpha)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
reverseLayout = true,
contentPadding = PaddingValues(vertical = 8.dp),
) {
item {
TypingNotificationView(state = typingNotificationState)
}
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.identifier() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply,
)
}
if (state.paginationState.hasMoreToLoadBackwards) {
// Do not use key parameter to avoid wrong positioning
item(contentType = "TimelineLoadingMoreIndicator") {
TimelineLoadingMoreIndicator()
LaunchedEffect(Unit) {
onReachedLoadMore()
AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
reverseLayout = useReverseLayout,
contentPadding = PaddingValues(vertical = 8.dp),
) {
item {
TypingNotificationView(state = typingNotificationState)
}
items(
items = state.timelineItems,
contentType = { timelineItem -> timelineItem.contentType() },
key = { timelineItem -> timelineItem.identifier() },
) { timelineItem ->
TimelineItemRow(
timelineItem = timelineItem,
timelineRoomInfo = state.timelineRoomInfo,
renderReadReceipts = state.renderReadReceipts,
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
state.timelineItems.first().identifier() == timelineItem.identifier(),
highlightedItem = state.highlightedEventId?.value,
onClick = onMessageClicked,
onLongClick = onMessageLongClicked,
onUserDataClick = onUserDataClicked,
inReplyToClick = ::inReplyToClicked,
onReactionClick = onReactionClicked,
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onReadReceiptClick = onReadReceiptClick,
onTimestampClicked = onTimestampClicked,
sessionState = state.sessionState,
eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply,
)
}
if (state.paginationState.hasMoreToLoadBackwards) {
// Do not use key parameter to avoid wrong positioning
item(contentType = "TimelineLoadingMoreIndicator") {
TimelineLoadingMoreIndicator()
LaunchedEffect(Unit) {
onReachedLoadMore()
}
}
}
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
}
if (state.paginationState.beginningOfRoomReached && !state.timelineRoomInfo.isDirect) {
item(contentType = "BeginningOfRoomReached") {
TimelineItemRoomBeginningView(roomName = roomName)
}
}
}
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt
)
TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt
)
}
}
}

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
@ -53,6 +54,11 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -107,6 +113,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import kotlin.math.abs
@ -256,6 +263,7 @@ private fun SwipeSensitivity(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent(
.padding(horizontal = 16.dp)
.zIndex(1f)
.clickable(onClick = onUserDataClicked)
// This is redundant when using talkback
.clearAndSetSemantics {
invisibleToUser()
testTag = TestTags.timelineItemSenderInfo.value
}
)
}
@ -413,6 +426,7 @@ private fun MessageSenderInformation(
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
onMessageLongClick: () -> Unit,
@Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit,
@ -445,6 +459,7 @@ private fun MessageEventBubbleContent(
text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.clearAndSetSemantics { }
)
}
}
@ -580,7 +595,8 @@ private fun MessageEventBubbleContent(
modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(enabled = true, onClick = inReplyToClick),
// FIXME when a node is clickable, its contents won't be added to the semantics tree of its parent
// .clickable(enabled = true, onClick = inReplyToClick)
)
}
if (inReplyToDetails != null) {
@ -611,7 +627,9 @@ private fun MessageEventBubbleContent(
timestampPosition = timestampPosition,
inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent,
modifier = bubbleModifier
modifier = bubbleModifier.semantics(mergeDescendants = true) {
contentDescription = event.safeSenderName
}
)
}
@ -641,8 +659,12 @@ private fun ReplyToContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
Column(verticalArrangement = Arrangement.SpaceBetween) {
Text(
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
text = senderName,
style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start,

View file

@ -18,6 +18,9 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider
@ -25,15 +28,17 @@ import io.element.android.libraries.designsystem.components.BlurHashAsyncImage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,
modifier: Modifier = Modifier,
) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio,
modifier = modifier,
modifier = modifier.semantics { contentDescription = description },
) {
BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)),

View file

@ -23,6 +23,8 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
@ -48,7 +50,7 @@ fun TimelineItemTextView(
val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body)
Box(modifier) {
Box(modifier.semantics { contentDescription = body.toString() }) {
EditorStyledText(
text = body,
onLinkClickedListener = onLinkClicked,

View file

@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
@ -42,9 +44,10 @@ fun TimelineItemVideoView(
content: TimelineItemVideoContent,
modifier: Modifier = Modifier,
) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio,
modifier = modifier,
modifier = modifier.semantics { contentDescription = description },
contentAlignment = Alignment.Center,
) {
BlurHashAsyncImage(

View file

@ -326,8 +326,7 @@ class MessagesViewTest {
state = state,
onUserDataClicked = callback,
)
val senderName = (timelineItem as? TimelineItem.Event)?.senderDisplayName.orEmpty()
rule.onNodeWithText(senderName).performClick()
rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick()
}
}

View file

@ -74,4 +74,9 @@ object TestTags {
val dialogPositive = TestTag("dialog-positive")
val dialogNegative = TestTag("dialog-negative")
val dialogNeutral = TestTag("dialog-neutral")
/**
* Timeline item.
*/
val timelineItemSenderInfo = TestTag("timeline_item-sender_info")
}