Keep the capture mode bar from taking taps meant for the text story send.

This commit is contained in:
Alex Hart
2026-08-27 14:01:53 -03:00
committed by GitHub
parent 1c6dc715c3
commit d1e42845fc
3 changed files with 63 additions and 16 deletions
@@ -46,6 +46,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@@ -79,12 +80,16 @@ private val MODE_SETTLE_SPEC = spring<Float>(dampingRatio = Spring.DampingRatioN
* The highlight is fixed to the center of the screen and the bar slides so the selected mode is the one under it. A mode
* can be picked by tapping it or by swiping the bar until it is under the highlight; a swipe only selects once it is
* released, so the user can slide back and forth before committing.
*
* @param endReservation Width at the end of the row the bar stops short of claiming, so taps there reach whatever floats
* over it. Layout still uses the full row, so this moves nothing.
*/
@Composable
internal fun MediaCaptureModeBar(
availableCaptureModes: List<MediaCaptureMode>,
selectedCaptureMode: MediaCaptureMode,
onEvent: (MediaCaptureScreenEvents) -> Unit,
endReservation: Dp = 0.dp,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
@@ -169,18 +174,19 @@ internal fun MediaCaptureModeBar(
val modes = measurables.drop(2).map { it.measure(Constraints(maxWidth = modeWidthLimit, minHeight = modeHeight, maxHeight = modeHeight)) }
val measuredModeWidth = modes.maxOf { it.width }
val barWidth = measuredModeWidth * modes.size + barPadding * 2
val containerWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else barWidth
val rowWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else barWidth
val containerWidth = (rowWidth - endReservation.roundToPx()).coerceAtLeast(0)
val bar = measurables[0].measure(Constraints.fixed(barWidth, barHeight))
val highlight = measurables[1].measure(Constraints.fixed(measuredModeWidth, modeHeight))
layout(width = containerWidth, height = barHeight) {
// The bar slides by however far the centered mode is from the middle of the container, which is where the
// highlight always sits.
val slide = containerWidth / 2f - barPadding - (centeredMode.value + 0.5f) * measuredModeWidth
// The bar slides by however far the centered mode is from the middle of the row, which is where the highlight
// always sits.
val slide = rowWidth / 2f - barPadding - (centeredMode.value + 0.5f) * measuredModeWidth
bar.placeRelative(x = slide.roundToInt(), y = 0)
highlight.placeRelative(x = (containerWidth - measuredModeWidth) / 2, y = barPadding)
highlight.placeRelative(x = (rowWidth - measuredModeWidth) / 2, y = barPadding)
modes.forEachIndexed { index, mode ->
mode.placeRelative(
@@ -103,6 +103,9 @@ internal fun MediaCaptureScreen(
* centered, and the button floats over its end.
*
* The row keeps a fixed height so neither one moving in or out shifts the other.
*
* The text story editor floats its own send button over that same end from underneath, so the bar is held off that
* corner while the editor is up.
*/
@Composable
private fun MediaCaptureBottomControls(
@@ -134,13 +137,14 @@ private fun MediaCaptureBottomControls(
enter = fadeIn(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
exit = fadeOut(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)),
modifier = Modifier
.align(Alignment.BottomCenter)
.align(Alignment.BottomStart)
.padding(bottom = MODE_BAR_BOTTOM_INSET)
) {
MediaCaptureModeBar(
availableCaptureModes = state.availableCaptureModes,
selectedCaptureMode = state.selectedCaptureMode,
onEvent = onEvent,
endReservation = if (state.selectedCaptureScreen is MediaSendRoute.Capture.TextStory) endMargin + NEXT_BUTTON_TOUCH_TARGET else 0.dp,
modifier = Modifier.endFadingEdge(
fadeWidth = MODE_BAR_FADE_WIDTH * fadeFraction,
inset = (endMargin + NEXT_BUTTON_CIRCLE_SIZE) * fadeFraction
@@ -6,8 +6,13 @@
package org.signal.mediasend.screens.capture
import android.app.Application
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
@@ -21,12 +26,14 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isCloseTo
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -74,10 +81,10 @@ class MediaCaptureScreenTest {
fun `Given a flow that offers every mode, when displayed, then the selected one is centered under the highlight`() {
setContent(cameraFirstState())
val bar = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).getUnclippedBoundsInRoot()
val screen = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_SCREEN).getUnclippedBoundsInRoot()
val selected = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).getUnclippedBoundsInRoot()
assertThat(selected.centerX.value).isCloseTo(bar.centerX.value, 1f)
assertThat(selected.centerX.value).isCloseTo(screen.centerX.value, 1f)
}
@Test
@@ -192,6 +199,28 @@ class MediaCaptureScreenTest {
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed()
}
@Test
fun `Given the text story route, when the editor's send button is tapped, then the tap reaches the editor`() {
var sendClicks = 0
setContent(cameraFirstState()) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = TEXT_STORY_SEND_END_MARGIN, bottom = TEXT_STORY_SEND_BOTTOM_MARGIN)
.size(TEXT_STORY_SEND_SIZE)
.clickable { sendClicks++ }
.testTag(TEXT_STORY_SEND)
)
}
}
composeTestRule.onNodeWithTag(TEXT_STORY_SEND).performClick()
assertThat(sendClicks).isEqualTo(1)
}
//region The next button over the bar's end
@Test
@@ -251,19 +280,22 @@ class MediaCaptureScreenTest {
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection
)
private fun setContent(state: MediaCaptureState) {
private fun setContent(
state: MediaCaptureState,
textStoryEditorSlot: @Composable () -> Unit = {
Box(
modifier = Modifier
.fillMaxSize()
.testTag(TEXT_STORY_SLOT)
)
}
) {
composeTestRule.setContent {
SignalTheme {
MediaCaptureScreen(
state = state,
onEvent = { events += it },
textStoryEditorSlot = {
Box(
modifier = Modifier
.fillMaxSize()
.testTag(TEXT_STORY_SLOT)
)
}
textStoryEditorSlot = textStoryEditorSlot
)
}
}
@@ -273,6 +305,11 @@ class MediaCaptureScreenTest {
private companion object {
private const val TEXT_STORY_SLOT = "text_story_slot"
private const val TEXT_STORY_SEND = "text_story_send"
private val TEXT_STORY_SEND_SIZE = 48.dp
private val TEXT_STORY_SEND_END_MARGIN = 12.dp
private val TEXT_STORY_SEND_BOTTOM_MARGIN = 16.dp
private val MEDIA = Media(
uri = "content://capture".toUri(),