mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Improve accessibility of timeline selection mode
This commit is contained in:
+32
-7
@@ -182,9 +182,11 @@ fun TimelineItemEventRow(
|
||||
onSwipeToReply: () -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
// Horizontal offset applied to this row's content by selection mode. The read receipts are
|
||||
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount.
|
||||
// Horizontal offset by which selection mode slides this row's content aside, to make room for the tick. It is applied here, to the message and the
|
||||
// thread summary, rather than to the row as a whole: that keeps the row's accessibility node spanning the whole row, tick included, so that a
|
||||
// screen reader can focus it from anywhere on the row. The read receipts are anchored to the end edge and stay pinned, so they are not slid.
|
||||
selectionContentOffset: Dp = 0.dp,
|
||||
isSelectionToggle: Boolean = false,
|
||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
|
||||
// Only pass down a custom clickable lambda if the content can be clicked separately
|
||||
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
||||
@@ -226,6 +228,7 @@ fun TimelineItemEventRow(
|
||||
|
||||
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
|
||||
val accessibilityActions = rememberTimelineItemAccessibilityActions(
|
||||
isEnabled = !isSelectionToggle,
|
||||
canReply = canReply,
|
||||
onLongClick = onLongClick,
|
||||
onSwipeToReply = onSwipeToReply,
|
||||
@@ -247,7 +250,11 @@ fun TimelineItemEventRow(
|
||||
val swipeThresholdPx = 40.dp.toPx()
|
||||
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
||||
SwipeSensitivity(3f) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(x = selectionContentOffset)
|
||||
) {
|
||||
Row(modifier = Modifier.matchParentSize()) {
|
||||
ReplySwipeIndicator({ offset / 120 })
|
||||
}
|
||||
@@ -268,7 +275,7 @@ fun TimelineItemEventRow(
|
||||
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
||||
.draggable(
|
||||
orientation = Orientation.Horizontal,
|
||||
enabled = !state.isResettingOnRelease,
|
||||
enabled = !state.isResettingOnRelease && !isSelectionToggle,
|
||||
onDragStopped = {
|
||||
coroutineScope.launch {
|
||||
if (thresholdCrossed) {
|
||||
@@ -286,6 +293,7 @@ fun TimelineItemEventRow(
|
||||
}
|
||||
} else {
|
||||
TimelineItemEventRowContent(
|
||||
modifier = Modifier.offset(x = selectionContentOffset),
|
||||
event = event,
|
||||
timelineMode = timelineMode,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
@@ -311,7 +319,10 @@ fun TimelineItemEventRow(
|
||||
.padding(end = 16.dp)
|
||||
} else {
|
||||
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
||||
}.padding(top = 2.dp),
|
||||
}
|
||||
.padding(top = 2.dp)
|
||||
.offset(x = selectionContentOffset)
|
||||
.hiddenFromAccessibilityWhile(isSelectionToggle),
|
||||
threadSummary = event.threadInfo.summary,
|
||||
latestEventText = event.threadInfo.latestEventText,
|
||||
isOutgoing = event.isMine,
|
||||
@@ -335,17 +346,30 @@ fun TimelineItemEventRow(
|
||||
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.offset(x = -selectionContentOffset)
|
||||
.hiddenFromAccessibilityWhile(isSelectionToggle)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides this element and its descendants from screen readers while [hidden] is true.
|
||||
*
|
||||
* A clickable descendant stays an accessibility node of its own even though the row merges its descendants, so while the row acts as a selection toggle
|
||||
* it would otherwise still be reachable and activatable by a screen reader. Reactions already hide themselves this way at all times.
|
||||
*/
|
||||
private fun Modifier.hiddenFromAccessibilityWhile(hidden: Boolean): Modifier = if (hidden) {
|
||||
semantics { hideFromAccessibility() }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the gestures of a timeline item — long press for the action list, swipe for a reply — as TalkBack actions, since neither gesture is reachable
|
||||
* with a screen reader enabled.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberTimelineItemAccessibilityActions(
|
||||
isEnabled: Boolean,
|
||||
canReply: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onSwipeToReply: () -> Unit,
|
||||
@@ -354,8 +378,9 @@ private fun rememberTimelineItemAccessibilityActions(
|
||||
val replyLabel = stringResource(CommonStrings.action_reply)
|
||||
val latestOnLongClick by rememberUpdatedState(onLongClick)
|
||||
val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply)
|
||||
return remember(canReply, messageActionsLabel, replyLabel) {
|
||||
return remember(isEnabled, canReply, messageActionsLabel, replyLabel) {
|
||||
buildList {
|
||||
if (!isEnabled) return@buildList
|
||||
add(
|
||||
CustomAccessibilityAction(messageActionsLabel) {
|
||||
latestOnLongClick()
|
||||
|
||||
+30
-33
@@ -13,7 +13,6 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -29,8 +28,11 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.toggleableState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -122,22 +124,14 @@ internal fun TimelineItemRow(
|
||||
}
|
||||
}
|
||||
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected }
|
||||
// Only start-aligned message bubbles slide to make room for the selection indicator:
|
||||
// - Outgoing (own) messages are end-aligned, opposite the indicator, so they already have room.
|
||||
// - Centered tiles (state events, call tiles, day dividers, grouped events) must stay put.
|
||||
val isOutgoing = (timelineItem as? TimelineItem.Event)?.isMine == true
|
||||
val shouldSlideContent = timelineItem.rendersAsBubble() && !isOutgoing
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
|
||||
Layout(
|
||||
modifier = modifier.then(backgroundModifier),
|
||||
contents = listOf(
|
||||
{
|
||||
TimelineItemRowContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.offset(x = contentSlide),
|
||||
selectionContentOffset = contentSlide,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectionData = selectionData,
|
||||
timelineItem = timelineItem,
|
||||
timelineMode = timelineMode,
|
||||
timelineRoomInfo = timelineRoomInfo,
|
||||
@@ -177,11 +171,15 @@ internal fun TimelineItemRow(
|
||||
{
|
||||
if (isSelectionModeActive) {
|
||||
// In selection mode the whole row toggles selection.
|
||||
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
|
||||
if (selectableEvent != null) {
|
||||
onContentClick(selectableEvent)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
|
||||
if (selectableEvent != null) {
|
||||
onContentClick(selectableEvent)
|
||||
}
|
||||
}
|
||||
.semantics { hideFromAccessibility() }
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -206,17 +204,6 @@ internal fun TimelineItemRow(
|
||||
/** Width reserved for the selection indicator : the row content slides by this amount. */
|
||||
private val SELECTION_COLUMN_WIDTH = 36.dp
|
||||
|
||||
/**
|
||||
* Whether this item is rendered as a start/end-aligned message bubble, as opposed to a centered
|
||||
* tile (state event, legacy call invite, call/RTC notification, day divider, grouped events).
|
||||
* Mirrors the dispatch in [TimelineItemRowContent]: only bubbles shift to reveal the selection
|
||||
* indicator; centered tiles stay put.
|
||||
*/
|
||||
private fun TimelineItem.rendersAsBubble(): Boolean = this is TimelineItem.Event &&
|
||||
content !is TimelineItemStateContent &&
|
||||
content !is TimelineItemLegacyCallInviteContent &&
|
||||
content !is TimelineItemRtcNotificationContent
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun TimelineItemRowContent(
|
||||
@@ -242,7 +229,7 @@ private fun TimelineItemRowContent(
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
selectionContentOffset: Dp = 0.dp,
|
||||
selectionData: TimelineItemSelectionData = TimelineItemSelectionData(),
|
||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
) {
|
||||
Box(modifier) {
|
||||
@@ -292,6 +279,10 @@ private fun TimelineItemRowContent(
|
||||
}
|
||||
else -> {
|
||||
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
|
||||
val isSelectionToggle = selectionData.isSelectionModeActive && selectionData.canBeSelected
|
||||
// Only start-aligned message bubbles slide to make room for the selection indicator. Outgoing (own) messages are end-aligned,
|
||||
// opposite the indicator, so they already have room, and the centered tiles handled by the branches above never slide.
|
||||
val contentSlide = if (timelineItem.isMine) 0.dp else SELECTION_COLUMN_WIDTH * selectionData.progress
|
||||
TimelineItemEventRow(
|
||||
modifier = Modifier
|
||||
.semantics(mergeDescendants = true) {
|
||||
@@ -301,6 +292,9 @@ private fun TimelineItemRowContent(
|
||||
} else {
|
||||
timelineItem.safeSenderName
|
||||
}
|
||||
if (isSelectionToggle) {
|
||||
toggleableState = ToggleableState(selectionData.isSelected)
|
||||
}
|
||||
// For Polls, allow the answers to be traversed by Talkback
|
||||
isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
|
||||
timelineItem.failedToSend ||
|
||||
@@ -309,16 +303,18 @@ private fun TimelineItemRowContent(
|
||||
}
|
||||
// Custom clickable that applies over the whole item for accessibility
|
||||
.then(
|
||||
if (isTalkbackActive()) {
|
||||
Modifier
|
||||
when {
|
||||
!isTalkbackActive() -> Modifier
|
||||
// While selecting, the row only toggles the selection. The context menu cannot be opened, so the long press
|
||||
// that opens it is neither offered nor advertised.
|
||||
isSelectionToggle -> Modifier.clickable { onContentClick(timelineItem) }
|
||||
else -> Modifier
|
||||
.combinedClickable(
|
||||
onClick = { onContentClick(timelineItem) },
|
||||
onLongClick = { onLongClick(timelineItem) },
|
||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||
)
|
||||
.onKeyboardContextMenuAction { onLongClick(timelineItem) }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
event = timelineItem,
|
||||
@@ -340,7 +336,8 @@ private fun TimelineItemRowContent(
|
||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
|
||||
eventSink = eventSink,
|
||||
selectionContentOffset = selectionContentOffset,
|
||||
selectionContentOffset = contentSlide,
|
||||
isSelectionToggle = isSelectionToggle,
|
||||
eventContentView = { contentModifier, onContentLayoutChange ->
|
||||
eventContentView(timelineItem, contentModifier, onContentLayoutChange)
|
||||
},
|
||||
|
||||
+14
-1
@@ -12,6 +12,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
@@ -34,10 +39,18 @@ fun SelectionModeTopBar(
|
||||
onConfirmClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val a11ySelectedCount = pluralStringResource(CommonPlurals.screen_room_selection_count, selectedCount, selectedCount)
|
||||
TopAppBar(
|
||||
modifier = modifier,
|
||||
title = {
|
||||
Text(text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount))
|
||||
Text(
|
||||
text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount),
|
||||
modifier = Modifier.semantics {
|
||||
heading()
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
contentDescription = a11ySelectedCount
|
||||
},
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
|
||||
Reference in New Issue
Block a user