mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Improve accessibility of timeline selection mode
This commit is contained in:
+32
-7
@@ -182,9 +182,11 @@ fun TimelineItemEventRow(
|
|||||||
onSwipeToReply: () -> Unit,
|
onSwipeToReply: () -> Unit,
|
||||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
// Horizontal offset applied to this row's content by selection mode. The read receipts are
|
// 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
|
||||||
// anchored to the end edge and should stay pinned, so they are counter-offset by this amount.
|
// 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,
|
selectionContentOffset: Dp = 0.dp,
|
||||||
|
isSelectionToggle: Boolean = false,
|
||||||
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
|
eventContentView: @Composable (Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit = { contentModifier, onContentLayoutChange ->
|
||||||
// Only pass down a custom clickable lambda if the content can be clicked separately
|
// Only pass down a custom clickable lambda if the content can be clicked separately
|
||||||
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
val onContentClick = onEventClick.takeUnless { event.isWholeContentClickable }
|
||||||
@@ -226,6 +228,7 @@ fun TimelineItemEventRow(
|
|||||||
|
|
||||||
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
|
val canReply = timelineRoomInfo.userHasPermissionToSendMessage && event.canBeRepliedTo
|
||||||
val accessibilityActions = rememberTimelineItemAccessibilityActions(
|
val accessibilityActions = rememberTimelineItemAccessibilityActions(
|
||||||
|
isEnabled = !isSelectionToggle,
|
||||||
canReply = canReply,
|
canReply = canReply,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
onSwipeToReply = onSwipeToReply,
|
onSwipeToReply = onSwipeToReply,
|
||||||
@@ -247,7 +250,11 @@ fun TimelineItemEventRow(
|
|||||||
val swipeThresholdPx = 40.dp.toPx()
|
val swipeThresholdPx = 40.dp.toPx()
|
||||||
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
val thresholdCrossed = abs(offset) > swipeThresholdPx
|
||||||
SwipeSensitivity(3f) {
|
SwipeSensitivity(3f) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.offset(x = selectionContentOffset)
|
||||||
|
) {
|
||||||
Row(modifier = Modifier.matchParentSize()) {
|
Row(modifier = Modifier.matchParentSize()) {
|
||||||
ReplySwipeIndicator({ offset / 120 })
|
ReplySwipeIndicator({ offset / 120 })
|
||||||
}
|
}
|
||||||
@@ -268,7 +275,7 @@ fun TimelineItemEventRow(
|
|||||||
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
|
||||||
.draggable(
|
.draggable(
|
||||||
orientation = Orientation.Horizontal,
|
orientation = Orientation.Horizontal,
|
||||||
enabled = !state.isResettingOnRelease,
|
enabled = !state.isResettingOnRelease && !isSelectionToggle,
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (thresholdCrossed) {
|
if (thresholdCrossed) {
|
||||||
@@ -286,6 +293,7 @@ fun TimelineItemEventRow(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TimelineItemEventRowContent(
|
TimelineItemEventRowContent(
|
||||||
|
modifier = Modifier.offset(x = selectionContentOffset),
|
||||||
event = event,
|
event = event,
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
timelineProtectionState = timelineProtectionState,
|
timelineProtectionState = timelineProtectionState,
|
||||||
@@ -311,7 +319,10 @@ fun TimelineItemEventRow(
|
|||||||
.padding(end = 16.dp)
|
.padding(end = 16.dp)
|
||||||
} else {
|
} else {
|
||||||
if (timelineRoomInfo.isDm) Modifier else Modifier.padding(start = 16.dp)
|
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,
|
threadSummary = event.threadInfo.summary,
|
||||||
latestEventText = event.threadInfo.latestEventText,
|
latestEventText = event.threadInfo.latestEventText,
|
||||||
isOutgoing = event.isMine,
|
isOutgoing = event.isMine,
|
||||||
@@ -335,17 +346,30 @@ fun TimelineItemEventRow(
|
|||||||
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
|
// Counter the selection-mode content slide so the end-anchored read receipts stay pinned.
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 4.dp)
|
.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
|
* 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.
|
* with a screen reader enabled.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberTimelineItemAccessibilityActions(
|
private fun rememberTimelineItemAccessibilityActions(
|
||||||
|
isEnabled: Boolean,
|
||||||
canReply: Boolean,
|
canReply: Boolean,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
onSwipeToReply: () -> Unit,
|
onSwipeToReply: () -> Unit,
|
||||||
@@ -354,8 +378,9 @@ private fun rememberTimelineItemAccessibilityActions(
|
|||||||
val replyLabel = stringResource(CommonStrings.action_reply)
|
val replyLabel = stringResource(CommonStrings.action_reply)
|
||||||
val latestOnLongClick by rememberUpdatedState(onLongClick)
|
val latestOnLongClick by rememberUpdatedState(onLongClick)
|
||||||
val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply)
|
val latestOnSwipeToReply by rememberUpdatedState(onSwipeToReply)
|
||||||
return remember(canReply, messageActionsLabel, replyLabel) {
|
return remember(isEnabled, canReply, messageActionsLabel, replyLabel) {
|
||||||
buildList {
|
buildList {
|
||||||
|
if (!isEnabled) return@buildList
|
||||||
add(
|
add(
|
||||||
CustomAccessibilityAction(messageActionsLabel) {
|
CustomAccessibilityAction(messageActionsLabel) {
|
||||||
latestOnLongClick()
|
latestOnLongClick()
|
||||||
|
|||||||
+30
-33
@@ -13,7 +13,6 @@ import androidx.compose.foundation.combinedClickable
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
import androidx.compose.ui.semantics.isTraversalGroup
|
import androidx.compose.ui.semantics.isTraversalGroup
|
||||||
import androidx.compose.ui.semantics.semantics
|
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.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
@@ -122,22 +124,14 @@ internal fun TimelineItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val selectableEvent = (timelineItem as? TimelineItem.Event)?.takeIf { selectionData.canBeSelected }
|
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 layoutDirection = LocalLayoutDirection.current
|
||||||
val contentSlide = if (shouldSlideContent) SELECTION_COLUMN_WIDTH * selectionProgress else 0.dp
|
|
||||||
Layout(
|
Layout(
|
||||||
modifier = modifier.then(backgroundModifier),
|
modifier = modifier.then(backgroundModifier),
|
||||||
contents = listOf(
|
contents = listOf(
|
||||||
{
|
{
|
||||||
TimelineItemRowContent(
|
TimelineItemRowContent(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
selectionData = selectionData,
|
||||||
.offset(x = contentSlide),
|
|
||||||
selectionContentOffset = contentSlide,
|
|
||||||
timelineItem = timelineItem,
|
timelineItem = timelineItem,
|
||||||
timelineMode = timelineMode,
|
timelineMode = timelineMode,
|
||||||
timelineRoomInfo = timelineRoomInfo,
|
timelineRoomInfo = timelineRoomInfo,
|
||||||
@@ -177,11 +171,15 @@ internal fun TimelineItemRow(
|
|||||||
{
|
{
|
||||||
if (isSelectionModeActive) {
|
if (isSelectionModeActive) {
|
||||||
// In selection mode the whole row toggles selection.
|
// In selection mode the whole row toggles selection.
|
||||||
Box(modifier = Modifier.clickable(enabled = selectionData.canBeSelected, role = Role.Checkbox) {
|
Box(
|
||||||
if (selectableEvent != null) {
|
modifier = Modifier
|
||||||
onContentClick(selectableEvent)
|
.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. */
|
/** Width reserved for the selection indicator : the row content slides by this amount. */
|
||||||
private val SELECTION_COLUMN_WIDTH = 36.dp
|
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")
|
@Suppress("LongMethod")
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimelineItemRowContent(
|
private fun TimelineItemRowContent(
|
||||||
@@ -242,7 +229,7 @@ private fun TimelineItemRowContent(
|
|||||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||||
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
eventSink: (TimelineEvent.TimelineItemEvent) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selectionContentOffset: Dp = 0.dp,
|
selectionData: TimelineItemSelectionData = TimelineItemSelectionData(),
|
||||||
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
eventContentView: @Composable (TimelineItem.Event, Modifier, (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
@@ -292,6 +279,10 @@ private fun TimelineItemRowContent(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val a11yVoiceMessage = stringResource(CommonStrings.a11y_voice_message)
|
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(
|
TimelineItemEventRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics(mergeDescendants = true) {
|
.semantics(mergeDescendants = true) {
|
||||||
@@ -301,6 +292,9 @@ private fun TimelineItemRowContent(
|
|||||||
} else {
|
} else {
|
||||||
timelineItem.safeSenderName
|
timelineItem.safeSenderName
|
||||||
}
|
}
|
||||||
|
if (isSelectionToggle) {
|
||||||
|
toggleableState = ToggleableState(selectionData.isSelected)
|
||||||
|
}
|
||||||
// For Polls, allow the answers to be traversed by Talkback
|
// For Polls, allow the answers to be traversed by Talkback
|
||||||
isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
|
isTraversalGroup = timelineItem.content is TimelineItemPollContent ||
|
||||||
timelineItem.failedToSend ||
|
timelineItem.failedToSend ||
|
||||||
@@ -309,16 +303,18 @@ private fun TimelineItemRowContent(
|
|||||||
}
|
}
|
||||||
// Custom clickable that applies over the whole item for accessibility
|
// Custom clickable that applies over the whole item for accessibility
|
||||||
.then(
|
.then(
|
||||||
if (isTalkbackActive()) {
|
when {
|
||||||
Modifier
|
!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(
|
.combinedClickable(
|
||||||
onClick = { onContentClick(timelineItem) },
|
onClick = { onContentClick(timelineItem) },
|
||||||
onLongClick = { onLongClick(timelineItem) },
|
onLongClick = { onLongClick(timelineItem) },
|
||||||
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
|
||||||
)
|
)
|
||||||
.onKeyboardContextMenuAction { onLongClick(timelineItem) }
|
.onKeyboardContextMenuAction { onLongClick(timelineItem) }
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
event = timelineItem,
|
event = timelineItem,
|
||||||
@@ -340,7 +336,8 @@ private fun TimelineItemRowContent(
|
|||||||
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
onSwipeToReply = { onSwipeToReply(timelineItem) },
|
||||||
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
|
onGalleryItemClick = { index -> onGalleryItemClick(timelineItem, index) },
|
||||||
eventSink = eventSink,
|
eventSink = eventSink,
|
||||||
selectionContentOffset = selectionContentOffset,
|
selectionContentOffset = contentSlide,
|
||||||
|
isSelectionToggle = isSelectionToggle,
|
||||||
eventContentView = { contentModifier, onContentLayoutChange ->
|
eventContentView = { contentModifier, onContentLayoutChange ->
|
||||||
eventContentView(timelineItem, 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.Modifier
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
@@ -34,10 +39,18 @@ fun SelectionModeTopBar(
|
|||||||
onConfirmClick: () -> Unit,
|
onConfirmClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val a11ySelectedCount = pluralStringResource(CommonPlurals.screen_room_selection_count, selectedCount, selectedCount)
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = {
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
Reference in New Issue
Block a user