Skip to content

Commit e923fbc

Browse files
authored
custom capture toggle button (#403)
1 parent c3d3812 commit e923fbc

File tree

8 files changed

+700
-248
lines changed

8 files changed

+700
-248
lines changed

app/src/androidTest/java/com/google/jetpackcamera/CaptureModeSettingsTest.kt

Lines changed: 192 additions & 58 deletions
Large diffs are not rendered by default.

app/src/androidTest/java/com/google/jetpackcamera/DebugHideComponentsTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import com.google.jetpackcamera.ui.components.capture.ZOOM_RATIO_TAG
3737
import com.google.jetpackcamera.utils.TEST_REQUIRED_PERMISSIONS
3838
import com.google.jetpackcamera.utils.debugExtra
3939
import com.google.jetpackcamera.utils.runMainActivityScenarioTest
40-
import com.google.jetpackcamera.utils.waitForStartup
40+
import com.google.jetpackcamera.utils.waitForCaptureButton
4141
import org.junit.Before
4242
import org.junit.Rule
4343
import org.junit.Test
@@ -60,7 +60,7 @@ class DebugHideComponentsTest {
6060
@Test
6161
fun hideComponentsButton_togglesUiVisibility() {
6262
runMainActivityScenarioTest(debugExtra) {
63-
composeTestRule.waitForStartup()
63+
composeTestRule.waitForCaptureButton()
6464
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists()
6565
composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertExists()
6666
composeTestRule.onNodeWithTag(DEBUG_OVERLAY_BUTTON).assertExists()

app/src/androidTest/java/com/google/jetpackcamera/PermissionsTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import com.google.jetpackcamera.utils.ensureTagNotAppears
4646
import com.google.jetpackcamera.utils.grantPermissionDialog
4747
import com.google.jetpackcamera.utils.onNodeWithText
4848
import com.google.jetpackcamera.utils.runMainActivityScenarioTest
49-
import com.google.jetpackcamera.utils.waitForStartup
49+
import com.google.jetpackcamera.utils.waitForCaptureButton
5050
import org.junit.Rule
5151
import org.junit.Test
5252
import org.junit.runner.RunWith
@@ -250,7 +250,7 @@ class PermissionsTest {
250250
.isNotDisplayed()
251251
}
252252

253-
composeTestRule.waitForStartup()
253+
composeTestRule.waitForCaptureButton()
254254

255255
// check for image capture success
256256
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick()
@@ -293,7 +293,7 @@ class PermissionsTest {
293293
.isNotDisplayed()
294294
}
295295

296-
composeTestRule.waitForStartup()
296+
composeTestRule.waitForCaptureButton()
297297

298298
// check for image capture failure
299299
composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertExists().performClick()

app/src/androidTest/java/com/google/jetpackcamera/utils/ComposeTestRuleExt.kt

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,15 @@ fun SemanticsNodeInteraction.assume(
135135
// idles
136136
//
137137
// ////////////////////////////
138-
fun ComposeTestRule.waitForStartup(timeoutMillis: Long = APP_START_TIMEOUT_MILLIS) {
138+
139+
fun ComposeTestRule.wait(timeoutMillis: Long) {
140+
try {
141+
waitUntil(timeoutMillis) { false }
142+
} catch (e: ComposeTimeoutException) {
143+
/* do nothing, we just want to time out*/
144+
}
145+
}
146+
fun ComposeTestRule.waitForCaptureButton(timeoutMillis: Long = APP_START_TIMEOUT_MILLIS) {
139147
// Wait for the capture button to be displayed
140148
waitUntil(timeoutMillis = timeoutMillis) {
141149
onNodeWithTag(CAPTURE_BUTTON).isDisplayed()
@@ -294,9 +302,9 @@ fun ComposeTestRule.tapStartLockedVideoRecording() {
294302
// ///////////////////////
295303

296304
/**
297-
* checks if the hdr capture mode toggle is enabled
305+
* checks if the capture mode toggle is enabled
298306
*/
299-
fun ComposeTestRule.isHdrToggleEnabled(): Boolean =
307+
fun ComposeTestRule.isCaptureModeToggleEnabled(): Boolean =
300308
checkComponentStateDescriptionState<Boolean>(CAPTURE_MODE_TOGGLE_BUTTON) { description ->
301309
when (description) {
302310
getResString(CaptureR.string.capture_mode_image_capture_content_description),
@@ -309,20 +317,19 @@ fun ComposeTestRule.isHdrToggleEnabled(): Boolean =
309317
CaptureR.string.capture_mode_video_recording_content_description_disabled
310318
) -> return@checkComponentStateDescriptionState false
311319

312-
else -> false
320+
else -> throw (AssertionError("Unexpected content description: $description"))
313321
}
314322
}
315323

316324
/**
317325
* Returns the current state of the capture mode toggle button
318326
*/
319-
fun ComposeTestRule.getHdrToggleState(): CaptureMode =
320-
checkComponentStateDescriptionState(CAPTURE_MODE_TOGGLE_BUTTON) { description ->
327+
328+
fun ComposeTestRule.getCaptureModeToggleState(): CaptureMode =
329+
checkComponentStateDescriptionState<CaptureMode>(CAPTURE_MODE_TOGGLE_BUTTON) { description ->
321330
when (description) {
322331
getResString(CaptureR.string.capture_mode_image_capture_content_description),
323-
getResString(
324-
CaptureR.string.capture_mode_image_capture_content_description_disabled
325-
) ->
332+
getResString(CaptureR.string.capture_mode_image_capture_content_description_disabled) ->
326333
CaptureMode.IMAGE_ONLY
327334

328335
getResString(CaptureR.string.capture_mode_video_recording_content_description),
@@ -331,7 +338,7 @@ fun ComposeTestRule.getHdrToggleState(): CaptureMode =
331338
) ->
332339
CaptureMode.VIDEO_ONLY
333340

334-
else -> null
341+
else -> throw (AssertionError("Unexpected content description: $description"))
335342
}
336343
}
337344

@@ -345,7 +352,7 @@ inline fun <reified T> ComposeTestRule.checkComponentContentDescriptionState(
345352
crossinline block: (String) -> T?
346353
): T {
347354
waitForNodeWithTag(nodeTag)
348-
onNodeWithTag(nodeTag).assume(isEnabled())
355+
onNodeWithTag(nodeTag).assume(isEnabled()) { "$nodeTag is not enabled" }
349356
.fetchSemanticsNode().let { node ->
350357
node.config[SemanticsProperties.ContentDescription].forEach { description ->
351358
val result = block(description)
@@ -360,7 +367,7 @@ inline fun <reified T> ComposeTestRule.checkComponentStateDescriptionState(
360367
crossinline block: (String) -> T?
361368
): T {
362369
waitForNodeWithTag(nodeTag)
363-
onNodeWithTag(nodeTag).assume(isEnabled())
370+
onNodeWithTag(nodeTag)
364371
.fetchSemanticsNode().let { node ->
365372
val result = block(node.config[SemanticsProperties.StateDescription])
366373
if (result != null) return result

ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt

Lines changed: 28 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,13 @@ import androidx.compose.animation.core.animateFloatAsState
3232
import androidx.compose.animation.core.spring
3333
import androidx.compose.animation.core.tween
3434
import androidx.compose.foundation.background
35-
import androidx.compose.foundation.clickable
3635
import androidx.compose.foundation.gestures.detectTapGestures
3736
import androidx.compose.foundation.gestures.rememberTransformableState
3837
import androidx.compose.foundation.gestures.transformable
39-
import androidx.compose.foundation.layout.Arrangement
4038
import androidx.compose.foundation.layout.Box
4139
import androidx.compose.foundation.layout.BoxWithConstraints
42-
import androidx.compose.foundation.layout.Row
43-
import androidx.compose.foundation.layout.aspectRatio
44-
import androidx.compose.foundation.layout.fillMaxHeight
4540
import androidx.compose.foundation.layout.fillMaxSize
4641
import androidx.compose.foundation.layout.height
47-
import androidx.compose.foundation.layout.padding
4842
import androidx.compose.foundation.layout.size
4943
import androidx.compose.foundation.layout.width
5044
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -66,10 +60,8 @@ import androidx.compose.material3.Icon
6660
import androidx.compose.material3.IconButton
6761
import androidx.compose.material3.IconButtonDefaults
6862
import androidx.compose.material3.LocalContentColor
69-
import androidx.compose.material3.MaterialTheme
7063
import androidx.compose.material3.SnackbarHostState
7164
import androidx.compose.material3.SnackbarResult
72-
import androidx.compose.material3.Surface
7365
import androidx.compose.material3.Text
7466
import androidx.compose.runtime.Composable
7567
import androidx.compose.runtime.CompositionLocalProvider
@@ -88,17 +80,12 @@ import androidx.compose.ui.draw.clip
8880
import androidx.compose.ui.draw.drawBehind
8981
import androidx.compose.ui.draw.rotate
9082
import androidx.compose.ui.graphics.Color
91-
import androidx.compose.ui.graphics.painter.Painter
9283
import androidx.compose.ui.graphics.vector.rememberVectorPainter
9384
import androidx.compose.ui.input.pointer.pointerInput
94-
import androidx.compose.ui.layout.layout
9585
import androidx.compose.ui.platform.LocalContext
9686
import androidx.compose.ui.platform.testTag
9787
import androidx.compose.ui.res.painterResource
9888
import androidx.compose.ui.res.stringResource
99-
import androidx.compose.ui.semantics.Role
100-
import androidx.compose.ui.semantics.semantics
101-
import androidx.compose.ui.semantics.stateDescription
10289
import androidx.compose.ui.text.style.TextAlign
10390
import androidx.compose.ui.unit.Dp
10491
import androidx.compose.ui.unit.dp
@@ -237,40 +224,25 @@ fun CaptureModeToggleButton(
237224
onToggleWhenDisabled: (DisableRationale) -> Unit,
238225
modifier: Modifier = Modifier
239226
) {
240-
// Captures hdr image (left) when output format is UltraHdr, else captures hdr video (right).
227+
// Captures image (left), else captures video (right).
241228
val toggleState = remember(uiState.selectedCaptureMode) {
242229
when (uiState.selectedCaptureMode) {
243-
CaptureMode.IMAGE_ONLY, CaptureMode.STANDARD -> ToggleState.Left
244-
CaptureMode.VIDEO_ONLY -> ToggleState.Right
230+
CaptureMode.IMAGE_ONLY, CaptureMode.STANDARD -> false
231+
CaptureMode.VIDEO_ONLY -> true
245232
}
246233
}
234+
247235
val enabled =
248236
uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) &&
249237
uiState.isCaptureModeSelectable(
250238
CaptureMode.IMAGE_ONLY
251239
) && uiState.selectedCaptureMode != CaptureMode.STANDARD
252240

253-
ToggleButton(
254-
leftIcon = if (uiState.selectedCaptureMode ==
255-
CaptureMode.IMAGE_ONLY
256-
) {
257-
rememberVectorPainter(image = Icons.Filled.CameraAlt)
258-
} else {
259-
rememberVectorPainter(image = Icons.Outlined.CameraAlt)
260-
},
261-
rightIcon = if (uiState.selectedCaptureMode ==
262-
CaptureMode.VIDEO_ONLY
263-
) {
264-
rememberVectorPainter(image = Icons.Filled.Videocam)
265-
} else {
266-
rememberVectorPainter(image = Icons.Outlined.Videocam)
267-
},
268-
toggleState = toggleState,
269-
onToggle = {
270-
val newCaptureMode = when (toggleState) {
271-
ToggleState.Right -> CaptureMode.IMAGE_ONLY
272-
ToggleState.Left -> CaptureMode.VIDEO_ONLY
273-
}
241+
ToggleSwitch(
242+
modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON),
243+
checked = toggleState,
244+
onCheckedChange = { isChecked ->
245+
val newCaptureMode = if (isChecked) CaptureMode.VIDEO_ONLY else CaptureMode.IMAGE_ONLY
274246
onChangeCaptureMode(newCaptureMode)
275247
},
276248
onToggleWhenDisabled = {
@@ -286,144 +258,32 @@ fun CaptureModeToggleButton(
286258
?.disabledReason
287259
disabledReason?.let { onToggleWhenDisabled(it) }
288260
},
289-
// toggle only enabled when both capture modes are available
290261
enabled = enabled,
291-
leftIconDescription =
292-
if (enabled) {
293-
stringResource(id = R.string.capture_mode_image_capture_content_description)
262+
leftIcon = if (uiState.selectedCaptureMode ==
263+
CaptureMode.IMAGE_ONLY
264+
) {
265+
Icons.Filled.CameraAlt
294266
} else {
295-
stringResource(
296-
id = R.string.capture_mode_image_capture_content_description_disabled
297-
)
267+
Icons.Outlined.CameraAlt
298268
},
299-
rightIconDescription =
300-
if (enabled) {
301-
stringResource(id = R.string.capture_mode_video_recording_content_description)
269+
rightIcon = if (uiState.selectedCaptureMode ==
270+
CaptureMode.VIDEO_ONLY
271+
) {
272+
Icons.Filled.Videocam
302273
} else {
303-
stringResource(
304-
id = R.string.capture_mode_video_recording_content_description_disabled
305-
)
274+
Icons.Outlined.Videocam
306275
},
307-
modifier = modifier
308-
)
309-
}
310-
311-
enum class ToggleState {
312-
Left,
313-
Right
314-
}
315-
316-
// todo(kc): need to recreate image toggle button to be scalable and support drag
317-
@Composable
318-
fun ToggleButton(
319-
leftIcon: Painter,
320-
rightIcon: Painter,
321-
modifier: Modifier = Modifier,
322-
toggleState: ToggleState? = null,
323-
onToggle: () -> Unit = {},
324-
onToggleWhenDisabled: () -> Unit = {},
325-
enabled: Boolean = true,
326-
leftIconDescription: String = "leftIcon",
327-
rightIconDescription: String = "rightIcon",
328-
iconPadding: Dp = 8.dp
329-
) {
330-
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerHighest
331-
val disableColor = MaterialTheme.colorScheme.onSurface
332-
val iconSelectionColor = MaterialTheme.colorScheme.onPrimary
333-
val iconUnSelectionColor = MaterialTheme.colorScheme.primary
334-
val circleSelectionColor = MaterialTheme.colorScheme.primary
335-
val circleColor = if (enabled) circleSelectionColor else disableColor.copy(alpha = 0.12f)
336-
val animatedTogglePosition by animateFloatAsState(
337-
when (toggleState) {
338-
ToggleState.Left -> 0f
339-
ToggleState.Right -> 1f
340-
null -> 0f
276+
leftIconDescription = if (enabled) {
277+
stringResource(id = R.string.capture_mode_image_capture_content_description)
278+
} else {
279+
stringResource(id = R.string.capture_mode_image_capture_content_description_disabled)
341280
},
342-
label = "togglePosition"
343-
)
344-
345-
Surface(
346-
modifier = modifier
347-
.clip(shape = RoundedCornerShape(50))
348-
.then(
349-
Modifier.clickable(
350-
role = Role.Switch
351-
) {
352-
if (enabled && toggleState != null) {
353-
onToggle()
354-
} else {
355-
onToggleWhenDisabled()
356-
}
357-
}
358-
)
359-
.semantics {
360-
stateDescription = when (toggleState) {
361-
ToggleState.Left -> leftIconDescription
362-
ToggleState.Right -> rightIconDescription
363-
null -> "unknown togglestate"
364-
}
365-
}
366-
.width(64.dp)
367-
.height(32.dp),
368-
color = backgroundColor
369-
) {
370-
Box {
371-
Row(
372-
modifier = Modifier.matchParentSize(),
373-
verticalAlignment = Alignment.CenterVertically
374-
) {
375-
Box(
376-
Modifier
377-
.layout { measurable, constraints ->
378-
val placeable = measurable.measure(constraints)
379-
layout(placeable.width, placeable.height) {
380-
val xPos = animatedTogglePosition *
381-
(constraints.maxWidth - placeable.width)
382-
placeable.placeRelative(xPos.toInt(), 0)
383-
}
384-
}
385-
.fillMaxHeight()
386-
.aspectRatio(1f)
387-
.clip(RoundedCornerShape(50))
388-
.background(circleColor)
389-
)
390-
}
391-
Row(
392-
modifier = Modifier
393-
.matchParentSize()
394-
.then(
395-
if (enabled) Modifier else Modifier.alpha(0.38f)
396-
),
397-
verticalAlignment = Alignment.CenterVertically,
398-
horizontalArrangement = Arrangement.SpaceBetween
399-
) {
400-
Icon(
401-
painter = leftIcon,
402-
contentDescription = "leftIcon",
403-
modifier = Modifier.padding(iconPadding),
404-
tint = if (!enabled) {
405-
disableColor
406-
} else if (toggleState == ToggleState.Left) {
407-
iconSelectionColor
408-
} else {
409-
iconUnSelectionColor
410-
}
411-
)
412-
Icon(
413-
painter = rightIcon,
414-
contentDescription = "rightIcon",
415-
modifier = Modifier.padding(iconPadding),
416-
tint = if (!enabled) {
417-
disableColor
418-
} else if (toggleState == ToggleState.Right) {
419-
iconSelectionColor
420-
} else {
421-
iconUnSelectionColor
422-
}
423-
)
424-
}
281+
rightIconDescription = if (enabled) {
282+
stringResource(id = R.string.capture_mode_video_recording_content_description)
283+
} else {
284+
stringResource(id = R.string.capture_mode_video_recording_content_description_disabled)
425285
}
426-
}
286+
)
427287
}
428288

429289
@Composable

0 commit comments

Comments
 (0)