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 2028604134
commit 8d543e19b1
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 package io.element.android.features.messages.impl.timeline
import android.view.accessibility.AccessibilityManager
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -45,8 +47,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.TypingNotificationState
import io.element.android.features.messages.impl.typing.TypingNotificationView import io.element.android.features.messages.impl.typing.TypingNotificationView
import io.element.android.features.messages.impl.typing.aTypingNotificationState 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -99,7 +100,13 @@ fun TimelineView(
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
} }
val context = LocalContext.current
val lazyListState = rememberLazyListState() 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") @Suppress("UNUSED_PARAMETER")
fun inReplyToClicked(eventId: EventId) { 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 // Animate alpha when timeline is first displayed, to avoid flashes or glitching when viewing rooms
val alpha by alphaAnimation(label = "alpha for timeline") AnimatedVisibility(visible = true, enter = fadeIn()) {
Box(modifier) {
Box(modifier = modifier.alpha(alpha)) { LazyColumn(
LazyColumn( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), state = lazyListState,
state = lazyListState, reverseLayout = useReverseLayout,
reverseLayout = true, contentPadding = PaddingValues(vertical = 8.dp),
contentPadding = PaddingValues(vertical = 8.dp), ) {
) { item {
item { TypingNotificationView(state = typingNotificationState)
TypingNotificationView(state = typingNotificationState) }
} items(
items( items = state.timelineItems,
items = state.timelineItems, contentType = { timelineItem -> timelineItem.contentType() },
contentType = { timelineItem -> timelineItem.contentType() }, key = { timelineItem -> timelineItem.identifier() },
key = { timelineItem -> timelineItem.identifier() }, ) { timelineItem ->
) { timelineItem -> TimelineItemRow(
TimelineItemRow( timelineItem = timelineItem,
timelineItem = timelineItem, timelineRoomInfo = state.timelineRoomInfo,
timelineRoomInfo = state.timelineRoomInfo, renderReadReceipts = state.renderReadReceipts,
renderReadReceipts = state.renderReadReceipts, isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true &&
isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && state.timelineItems.first().identifier() == timelineItem.identifier(),
state.timelineItems.first().identifier() == timelineItem.identifier(), highlightedItem = state.highlightedEventId?.value,
highlightedItem = state.highlightedEventId?.value, onClick = onMessageClicked,
onClick = onMessageClicked, onLongClick = onMessageLongClicked,
onLongClick = onMessageLongClicked, onUserDataClick = onUserDataClicked,
onUserDataClick = onUserDataClicked, inReplyToClick = ::inReplyToClicked,
inReplyToClick = ::inReplyToClicked, onReactionClick = onReactionClicked,
onReactionClick = onReactionClicked, onReactionLongClick = onReactionLongClicked,
onReactionLongClick = onReactionLongClicked, onMoreReactionsClick = onMoreReactionsClicked,
onMoreReactionsClick = onMoreReactionsClicked, onReadReceiptClick = onReadReceiptClick,
onReadReceiptClick = onReadReceiptClick, onTimestampClicked = onTimestampClicked,
onTimestampClicked = onTimestampClicked, sessionState = state.sessionState,
sessionState = state.sessionState, eventSink = state.eventSink,
eventSink = state.eventSink, onSwipeToReply = onSwipeToReply,
onSwipeToReply = onSwipeToReply, )
) }
} if (state.paginationState.hasMoreToLoadBackwards) {
if (state.paginationState.hasMoreToLoadBackwards) { // Do not use key parameter to avoid wrong positioning
// Do not use key parameter to avoid wrong positioning item(contentType = "TimelineLoadingMoreIndicator") {
item(contentType = "TimelineLoadingMoreIndicator") { TimelineLoadingMoreIndicator()
TimelineLoadingMoreIndicator() LaunchedEffect(Unit) {
LaunchedEffect(Unit) { onReachedLoadMore()
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( TimelineScrollHelper(
isTimelineEmpty = state.timelineItems.isEmpty(), isTimelineEmpty = state.timelineItems.isEmpty(),
lazyListState = lazyListState, lazyListState = lazyListState,
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
newEventState = state.newEventState, newEventState = state.newEventState,
onScrollFinishedAt = ::onScrollFinishedAt onScrollFinishedAt = ::onScrollFinishedAt
) )
}
} }
} }

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds 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.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource 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.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.Mention import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs import kotlin.math.abs
@ -256,6 +263,7 @@ private fun SwipeSensitivity(
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun TimelineItemEventRowContent( private fun TimelineItemEventRowContent(
event: TimelineItem.Event, event: TimelineItem.Event,
@ -305,6 +313,11 @@ private fun TimelineItemEventRowContent(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.zIndex(1f) .zIndex(1f)
.clickable(onClick = onUserDataClicked) .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( private fun MessageEventBubbleContent(
event: TimelineItem.Event, event: TimelineItem.Event,
onMessageLongClick: () -> Unit, onMessageLongClick: () -> Unit,
@Suppress("UNUSED_PARAMETER")
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
onMentionClicked: (Mention) -> Unit, onMentionClicked: (Mention) -> Unit,
@ -445,6 +459,7 @@ private fun MessageEventBubbleContent(
text = stringResource(CommonStrings.common_thread), text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyXsRegular, style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
modifier = Modifier.clearAndSetSemantics { }
) )
} }
} }
@ -580,7 +595,8 @@ private fun MessageEventBubbleContent(
modifier = Modifier modifier = Modifier
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(RoundedCornerShape(6.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) { if (inReplyToDetails != null) {
@ -611,7 +627,9 @@ private fun MessageEventBubbleContent(
timestampPosition = timestampPosition, timestampPosition = timestampPosition,
inReplyToDetails = event.inReplyTo, inReplyToDetails = event.inReplyTo,
canShrinkContent = event.content is TimelineItemVoiceContent, 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)) Spacer(modifier = Modifier.width(8.dp))
} }
val a11InReplyToText = stringResource(CommonStrings.common_in_reply_to, senderName)
Column(verticalArrangement = Arrangement.SpaceBetween) { Column(verticalArrangement = Arrangement.SpaceBetween) {
Text( Text(
modifier = Modifier.semantics {
contentDescription = a11InReplyToText
},
text = senderName, text = senderName,
style = ElementTheme.typography.fontBodySmMedium, style = ElementTheme.typography.fontBodySmMedium,
textAlign = TextAlign.Start, 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.runtime.Composable
import androidx.compose.ui.Modifier 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 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.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContentProvider 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun TimelineItemImageView( fun TimelineItemImageView(
content: TimelineItemImageContent, content: TimelineItemImageContent,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val description = stringResource(CommonStrings.common_image)
TimelineItemAspectRatioBox( TimelineItemAspectRatioBox(
aspectRatio = content.aspectRatio, aspectRatio = content.aspectRatio,
modifier = modifier, modifier = modifier.semantics { contentDescription = description },
) { ) {
BlurHashAsyncImage( BlurHashAsyncImage(
model = MediaRequestData(content.preferredMediaSource, MediaRequestData.Kind.File(content.body, content.mimeType)), 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier 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 androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
@ -48,7 +50,7 @@ fun TimelineItemTextView(
val formattedBody = content.formattedBody val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body) val body = SpannableString(formattedBody ?: content.body)
Box(modifier) { Box(modifier.semantics { contentDescription = body.toString() }) {
EditorStyledText( EditorStyledText(
text = body, text = body,
onLinkClickedListener = onLinkClicked, onLinkClickedListener = onLinkClicked,

View file

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

View file

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

View file

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