mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Fix lock button in locking state.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user