Fix lock button in locking state.

This commit is contained in:
Alex Hart
2026-08-26 15:22:20 -04:00
committed by Michelle Tang
parent 40819de6d7
commit 984a98d181
7 changed files with 77 additions and 11 deletions
@@ -69,6 +69,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Keyboard(icon(R.drawable.ic_keyboard_24)),
Link(icon(R.drawable.symbol_link_24)),
Lock(icon(R.drawable.symbol_lock_24)),
LockFill(icon(R.drawable.symbol_lock_fill_24)),
Maximize(icon(R.drawable.symbol_maximize_24)),
Mic(icon(R.drawable.symbol_mic_24)),
MobileNextDisplay(icon(R.drawable.symbol_mobile_next_display_48)),
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12 0.63c-3.38 0-6.13 2.74-6.13 6.12V9.9c-0.64 0.34-1.16 0.86-1.49 1.51-0.2 0.4-0.3 0.85-0.34 1.38C4 13.3 4 13.96 4 14.78v2.94c0 0.82 0 1.47 0.04 2 0.05 0.52 0.14 0.97 0.34 1.37 0.34 0.66 0.87 1.2 1.53 1.53 0.4 0.2 0.85 0.3 1.38 0.34C7.8 23 8.46 23 9.28 23h5.44c0.82 0 1.47 0 2-0.04 0.52-0.05 0.97-0.14 1.37-0.34 0.66-0.34 1.2-0.87 1.53-1.53 0.2-0.4 0.3-0.85 0.34-1.38C20 19.2 20 18.54 20 17.72v-2.94c0-0.82 0-1.47-0.04-2-0.05-0.52-0.14-0.97-0.34-1.37-0.33-0.65-0.85-1.17-1.5-1.51V6.75c0-3.38-2.74-6.13-6.12-6.13Zm4.38 8.9c-0.46-0.03-1-0.03-1.66-0.03H9.28c-0.66 0-1.2 0-1.65 0.02V6.75c0-2.42 1.95-4.38 4.37-4.38s4.38 1.96 4.38 4.38v2.77Zm-6 5.6c0-0.9 0.72-1.63 1.62-1.63 0.9 0 1.63 0.73 1.63 1.63 0 0.57-0.3 1.08-0.76 1.36v1.76c0 0.48-0.39 0.88-0.87 0.88s-0.88-0.4-0.88-0.88v-1.76c-0.45-0.28-0.74-0.79-0.74-1.36Z"/>
</vector>
@@ -171,6 +171,9 @@ private val CaptureButtonState.innerShape: CaptureButtonInnerShape
* @param onLongPressEnd Callback when long press ends (video recording stop)
* @param onZoomChange Callback for zoom level changes during recording (0f to 1f)
* @param onLock Callback when a drag has reached the lock, asking for the recording to run unheld
* @param onOverLockChanged Callback when a drag arrives over the lock or leaves it again, so the lock can show that
* lifting there is what takes it. The circle this button carries there arrives underneath it, so the lock is what is
* seen at the end of the drag.
* @param lockOffset Where the lock sits relative to this button's center, in pixels of this button's own frame.
* [Offset.Zero] for a recording that has no lock to be dragged to.
* @param modifier Modifier to be applied to the button
@@ -183,6 +186,7 @@ fun CaptureButton(
onLongPressEnd: () -> Unit,
onZoomChange: (Float) -> Unit,
onLock: () -> Unit = {},
onOverLockChanged: (Boolean) -> Unit = {},
lockOffset: Offset = Offset.Zero,
modifier: Modifier = Modifier
) {
@@ -200,6 +204,7 @@ fun CaptureButton(
val currentOnLongPressEnd by rememberUpdatedState(onLongPressEnd)
val currentOnZoomChange by rememberUpdatedState(onZoomChange)
val currentOnLock by rememberUpdatedState(onLock)
val currentOnOverLockChanged by rememberUpdatedState(onOverLockChanged)
val currentLockOffset by rememberUpdatedState(lockOffset)
// A drag toward the lock takes the shape part of the way to what it will be once it gets there, so the button shows
@@ -299,10 +304,14 @@ fun CaptureButton(
val wasOverLock = overLock
overLock = isOverLock(pointer.position, wasOverLock)
// Taking hold is felt as it happens, so the finger knows it has arrived without having to commit to find
// out. Only the crossing plays, not every event that follows it.
if (overLock && !wasOverLock) {
haptics.performHapticFeedback(LockSnapHaptic)
// Taking hold is felt as it happens, and the lock is told so it can show it, so the finger knows it has
// arrived without having to commit to find out. Only the crossing is reported, not every event after it.
if (overLock != wasOverLock) {
currentOnOverLockChanged(overLock)
if (overLock) {
haptics.performHapticFeedback(LockSnapHaptic)
}
}
if (!pointer.pressed) {
@@ -347,6 +356,7 @@ fun CaptureButton(
} finally {
isPressed = false
lockProgress = 0f
currentOnOverLockChanged(false)
}
}
},
@@ -55,11 +55,16 @@ enum class CaptureButtonState {
enum class GallerySlotContent {
GALLERY,
LOCK,
/** The lock with a thumb over it, which says that lifting there is what takes it. */
LOCK_ENGAGED,
PAUSE;
companion object {
fun of(captureButtonState: CaptureButtonState): GallerySlotContent = when (captureButtonState) {
CaptureButtonState.RECORDING_HELD -> LOCK
/** @param isOverLock Whether a drag from the capture button has reached the lock, which only it has any use for. */
fun of(captureButtonState: CaptureButtonState, isOverLock: Boolean = false): GallerySlotContent = when (captureButtonState) {
CaptureButtonState.RECORDING_HELD -> if (isOverLock) LOCK_ENGAGED else LOCK
CaptureButtonState.RECORDING_LOCKED -> PAUSE
CaptureButtonState.PHOTO, CaptureButtonState.VIDEO -> GALLERY
}
@@ -41,12 +41,22 @@ private val ActionIconSize = 24.dp
* recording is held, which puts it within reach of the finger already on the capture button.
*
* There is nothing to tap: sliding onto it is what takes the offer up.
*
* @param isEngaged Whether a thumb has arrived over it, which it answers in the recording red so that letting go there
* reads as taking the offer rather than as landing on an offer still being made. It is swapped for rather than
* animated in place, so the two looks are told apart the same way every other control in this corner is.
*/
@Composable
fun RecordingLockButton(modifier: Modifier = Modifier) {
RecordingActionButton(modifier = modifier.testTag(TestTags.CAMERA_HUD_LOCK_BUTTON)) {
fun RecordingLockButton(
isEngaged: Boolean = false,
modifier: Modifier = Modifier
) {
RecordingActionButton(
backgroundColor = colorResource(if (isEngaged) R.color.CameraHud_control_red_background else R.color.CameraHud_control_background),
modifier = modifier.testTag(TestTags.CAMERA_HUD_LOCK_BUTTON)
) {
Icon(
imageVector = SignalIcons.Lock.imageVector,
imageVector = if (isEngaged) SignalIcons.LockFill.imageVector else SignalIcons.Lock.imageVector,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(ActionIconSize)
@@ -107,6 +117,7 @@ fun RecordingPauseButton(
@Composable
private fun RecordingActionButton(
modifier: Modifier = Modifier,
backgroundColor: Color = colorResource(R.color.CameraHud_control_background),
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
@@ -115,7 +126,7 @@ private fun RecordingActionButton(
modifier = modifier
.size(RecordingActionButtonSize)
.clip(CircleShape)
.background(colorResource(R.color.CameraHud_control_background), CircleShape)
.background(backgroundColor, CircleShape)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
) {
content()
@@ -130,6 +141,14 @@ private fun RecordingLockButtonPreview() {
}
}
@NightPreview
@Composable
private fun RecordingLockButtonEngagedPreview() {
Previews.Preview {
RecordingLockButton(isEngaged = true)
}
}
@NightPreview
@Composable
private fun RecordingPauseButtonPreview() {
@@ -416,17 +416,19 @@ private enum class RequestedRecording {
/**
* What stands in the gallery's corner. Everything it can hold is the same circle, so each fades into the next in place.
*
* @param isOverLock Whether a drag from the capture button has reached the lock, which the lock answers for itself.
* @param onLockCenterChanged Where the lock sits in the root's frame, which is one end of the drag that takes it.
*/
@Composable
private fun GallerySlot(
captureButtonState: CaptureButtonState,
isRecordingPaused: Boolean,
isOverLock: Boolean,
emitter: (StandardCameraHudEvents) -> Unit,
onLockCenterChanged: (Offset) -> Unit
) {
AnimatedContent(
targetState = GallerySlotContent.of(captureButtonState),
targetState = GallerySlotContent.of(captureButtonState, isOverLock),
transitionSpec = { CameraHudMotion.swap },
label = "GallerySlotContent",
modifier = Modifier.onGloballyPositioned { onLockCenterChanged(it.boundsInRoot().center) }
@@ -438,6 +440,7 @@ private fun GallerySlot(
)
GallerySlotContent.LOCK -> RecordingLockButton()
GallerySlotContent.LOCK_ENGAGED -> RecordingLockButton(isEngaged = true)
GallerySlotContent.PAUSE -> RecordingPauseButton(
isPaused = isRecordingPaused,
onClick = { emitter(StandardCameraHudEvents.RecordingPauseToggled) }
@@ -483,11 +486,16 @@ private fun CameraControls(
var captureButtonCenter by remember { mutableStateOf(Offset.Zero) }
var lockCenter by remember { mutableStateOf(Offset.Zero) }
// Whether the thumb dragging from the capture button has reached the lock. The lock is the one that shows it: the
// circle the drag carries there arrives underneath it, since this corner is drawn after the capture button.
var isOverLock by remember { mutableStateOf(false) }
val gallery: @Composable (CaptureButtonState) -> Unit = remember {
movableContentOf { captureButtonState ->
GallerySlot(
captureButtonState = captureButtonState,
isRecordingPaused = currentIsRecordingPaused,
isOverLock = isOverLock,
emitter = currentEmitter,
onLockCenterChanged = { lockCenter = it }
)
@@ -539,6 +547,7 @@ private fun CameraControls(
requestedRecording = RequestedRecording.UNHELD
currentEmitter(StandardCameraHudEvents.VideoCaptureLocked)
},
onOverLockChanged = { isOverLock = it },
onTap = { captureButtonState.tapRequest?.let { request(it) } },
onLongPressStart = {
if (!captureButtonState.isRecording) {
@@ -103,11 +103,24 @@ class CaptureButtonStateTest {
assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_HELD)).isEqualTo(GallerySlotContent.LOCK)
}
/** The thumb covers the lock on the way to it, so the lock answering for itself is all there is to go by. */
@Test
fun `Given a thumb over the lock, when the gallery's corner is filled, then the engaged lock is what fills it`() {
assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_HELD, isOverLock = true)).isEqualTo(GallerySlotContent.LOCK_ENGAGED)
}
@Test
fun `Given a recording that is locked, when the gallery's corner is filled, then the pause is what fills it`() {
assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_LOCKED)).isEqualTo(GallerySlotContent.PAUSE)
}
/** Only a held recording has a lock on offer, so nothing else has any use for a thumb being over one. */
@Test
fun `Given no lock on offer, when a thumb is reported over one, then the corner is filled as it would have been`() {
assertThat(GallerySlotContent.of(CaptureButtonState.PHOTO, isOverLock = true)).isEqualTo(GallerySlotContent.GALLERY)
assertThat(GallerySlotContent.of(CaptureButtonState.RECORDING_LOCKED, isOverLock = true)).isEqualTo(GallerySlotContent.PAUSE)
}
private fun stateOf(
captureButtonMode: CaptureButtonMode,
isRecording: Boolean,