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:
parent
94d486be84
commit
0918f9ed29
8 changed files with 110 additions and 66 deletions
1
changelog.d/+improve-accessibility-in-timeline.bugfix
Normal file
1
changelog.d/+improve-accessibility-in-timeline.bugfix
Normal 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.
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue