From 4872182bdb5e72672cc302646706525edcf847fc Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Thu, 14 May 2026 21:39:47 +0000 Subject: [PATCH 1/6] Implement animation disable mechanism for instrumentation tests --- .../google/jetpackcamera/utils/UiTestUtil.kt | 26 ++- .../com/google/jetpackcamera/MainActivity.kt | 102 ++++++------ .../feature/preview/PreviewScreen.kt | 78 ++++++--- .../capture/CaptureButtonComponents.kt | 101 +++++++---- .../capture/CaptureScreenComponents.kt | 157 +++++++++++------- .../capture/LocalDisableAnimations.kt | 23 +++ .../capture/ScreenFlashComponents.kt | 5 +- .../ui/components/capture/ToggleSwitch.kt | 9 +- 8 files changed, 328 insertions(+), 173 deletions(-) create mode 100644 ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/LocalDisableAnimations.kt diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index bf1a9a2f6..fec956788 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -27,6 +27,7 @@ import android.os.Bundle import android.provider.MediaStore import android.util.Log import androidx.compose.ui.semantics.SemanticsProperties +import androidx.test.services.storage.TestStorage import androidx.compose.ui.test.SemanticsMatcher import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario @@ -64,15 +65,24 @@ val isEmulatorWithFakeFrontCamera: Boolean (Build.VERSION.SDK_INT == 28 || Build.VERSION.SDK_INT == 34) val compatMainActivityExtras: Bundle? - get() = if (isEmulatorWithFakeFrontCamera) { - // The GMD API 28 and 34 emulators' PackageInfo reports it has front and back cameras, but - // GMD is only configured for a back camera. This causes CameraX to take a long time - // to initialize. Set the device to use single lens mode to work around this issue. - Bundle().apply { - putString(MainActivity.KEY_DEBUG_SINGLE_LENS_MODE, "back") + get() { + val extras = Bundle() + if (isEmulatorWithFakeFrontCamera) { + // The GMD API 28 and 34 emulators' PackageInfo reports it has front and back cameras, but + // GMD is only configured for a back camera. This causes CameraX to take a long time + // to initialize. Set the device to use single lens mode to work around this issue. + extras.putString(MainActivity.KEY_DEBUG_SINGLE_LENS_MODE, "back") } - } else { - null + + val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val args = TestStorage(resolver).getInputArgs() + Log.d("UiTestUtil", "TestStorage Args: $args") + val disableAnimations = args["disable_animations"]?.toBoolean() ?: false + if (disableAnimations) { + extras.putBoolean(MainActivity.KEY_DISABLE_ANIMATIONS, true) + } + + return if (extras.size() == 0) null else extras } val debugExtra: Bundle = Bundle().apply { putBoolean("KEY_DEBUG_MODE", true) } diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index b9e72c9c1..fbaf72b9f 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -41,7 +41,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -113,57 +115,62 @@ class MainActivity : ComponentActivity() { } } - setContent { - when (uiState) { - Loading -> { - Column( - modifier = Modifier - .fillMaxSize() - .background(Color.Black), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator(modifier = Modifier.size(50.dp)) - Text(text = stringResource(R.string.jca_loading), color = Color.White) - } - } + val disableAnimations = intent?.getBooleanExtra(KEY_DISABLE_ANIMATIONS, false) ?: false + Log.d(TAG, "LocalDisableAnimations: $disableAnimations") - is Success -> { - // TODO(kimblebee@): add app setting to enable/disable dynamic color - JetpackCameraTheme( - darkTheme = isInDarkMode(uiState = uiState), - dynamicColor = false - ) { - Surface( + setContent { + CompositionLocalProvider(LocalDisableAnimations provides disableAnimations) { + when (uiState) { + Loading -> { + Column( modifier = Modifier .fillMaxSize() - .semantics { - testTagsAsResourceId = true - }, - color = MaterialTheme.colorScheme.background + .background(Color.Black), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - JcaApp( - externalCaptureMode = externalCaptureMode, - shouldReviewAfterCapture = shouldReviewAfterCapture, - captureUris = captureUris, - debugSettings = debugSettings, - openAppSettings = ::openAppSettings, - onRequestWindowColorMode = { colorMode -> - // Window color mode APIs require API level 26+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Log.d( - TAG, - "Setting window color mode to:" + - " ${colorMode.toColorModeString()}" - ) - window?.colorMode = colorMode - } - }, - onFirstFrameCaptureCompleted = { - firstFrameComplete?.complete(Unit) - }, - onCaptureEvent = captureEventCallback - ) + CircularProgressIndicator(modifier = Modifier.size(50.dp)) + Text(text = stringResource(R.string.jca_loading), color = Color.White) + } + } + + is Success -> { + // TODO(kimblebee@): add app setting to enable/disable dynamic color + JetpackCameraTheme( + darkTheme = isInDarkMode(uiState = uiState), + dynamicColor = false + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + color = MaterialTheme.colorScheme.background + ) { + JcaApp( + externalCaptureMode = externalCaptureMode, + shouldReviewAfterCapture = shouldReviewAfterCapture, + captureUris = captureUris, + debugSettings = debugSettings, + openAppSettings = ::openAppSettings, + onRequestWindowColorMode = { colorMode -> + // Window color mode APIs require API level 26+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Log.d( + TAG, + "Setting window color mode to:" + + " ${colorMode.toColorModeString()}" + ) + window?.colorMode = colorMode + } + }, + onFirstFrameCaptureCompleted = { + firstFrameComplete?.complete(Unit) + }, + onCaptureEvent = captureEventCallback + ) + } } } } @@ -310,6 +317,7 @@ class MainActivity : ComponentActivity() { private const val KEY_DEBUG_MODE = "KEY_DEBUG_MODE" const val KEY_DEBUG_SINGLE_LENS_MODE = "KEY_DEBUG_SINGLE_LENS_MODE" + const val KEY_DISABLE_ANIMATIONS = "KEY_DISABLE_ANIMATIONS" } } diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 90cfbe02a..50da1e257 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -474,33 +475,60 @@ private fun ContentScreen( } }, elapsedTimeDisplay = { - AnimatedVisibility( - visible = (captureUiState.videoRecordingState is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - ElapsedTimeText( - modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiState = captureUiState.elapsedTimeUiState - ) + val disableAnimations = LocalDisableAnimations.current + val isVisible = captureUiState.videoRecordingState is VideoRecordingState.Active + if (disableAnimations) { + if (isVisible) { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiState = captureUiState.elapsedTimeUiState + ) + } + } else { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiState = captureUiState.elapsedTimeUiState + ) + } } }, - quickSettingsButton = { - AnimatedVisibility( - visible = (captureUiState.videoRecordingState !is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - quickSettingsController?.let { quickSettingsController -> - ToggleQuickSettingsButton( - modifier = it, - isOpen = ( - captureUiState.quickSettingsUiState - as? QuickSettingsUiState.Available - )?.quickSettingsIsOpen == true, - quickSettingsController = quickSettingsController - - ) + quickSettingsButton = { modifier -> + val isQuickSettingsVisible = captureUiState.videoRecordingState !is VideoRecordingState.Active + val disableAnimations = LocalDisableAnimations.current + if (disableAnimations) { + if (isQuickSettingsVisible) { + quickSettingsController?.let { qsc -> + ToggleQuickSettingsButton( + modifier = modifier, + isOpen = ( + captureUiState.quickSettingsUiState + as? QuickSettingsUiState.Available + )?.quickSettingsIsOpen == true, + quickSettingsController = qsc + ) + } + } + } else { + AnimatedVisibility( + visible = isQuickSettingsVisible, + enter = fadeIn(), + exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + quickSettingsController?.let { qsc -> + ToggleQuickSettingsButton( + modifier = modifier, + isOpen = ( + captureUiState.quickSettingsUiState + as? QuickSettingsUiState.Available + )?.quickSettingsIsOpen == true, + quickSettingsController = qsc + ) + } } } }, diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index f9a1e5f14..97442211d 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -22,6 +22,7 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -356,13 +357,15 @@ private fun CaptureButton( captureButtonUiState = captureButtonUiState ) + val disableAnimations = LocalDisableAnimations.current + val animatedColor by animateColorAsState( targetValue = if (isVisuallyDisabled) { LocalContentColor.current.copy(alpha = 0.38f) } else { LocalContentColor.current }, - animationSpec = tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), + animationSpec = if (disableAnimations) snap() else tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), label = "Capture Button Color" ) @@ -544,22 +547,38 @@ private fun LockSwitchCaptureButtonNucleus( .offset(x = -(switchWidth - pressedNucleusSize) / 2) ) { // grey cylinder offset to the left and fades in when pressed recording - AnimatedVisibility( - visible = captureButtonUiState == - CaptureButtonUiState.Enabled.Recording.PressedRecording, - enter = fadeIn(), - exit = ExitTransition.None - ) { - // grey cylinder - Canvas( - modifier = Modifier - .size(switchWidth, switchHeight) - .alpha(LOCK_SWITCH_ALPHA) + val disableAnimations = LocalDisableAnimations.current + val isVisible = captureButtonUiState == CaptureButtonUiState.Enabled.Recording.PressedRecording + if (disableAnimations) { + if (isVisible) { + Canvas( + modifier = Modifier + .size(switchWidth, switchHeight) + .alpha(LOCK_SWITCH_ALPHA) + ) { + drawRoundRect( + color = Color.Black, + cornerRadius = CornerRadius((switchWidth / 2).toPx()) + ) + } + } + } else { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = ExitTransition.None ) { - drawRoundRect( - color = Color.Black, - cornerRadius = CornerRadius((switchWidth / 2).toPx()) - ) + // grey cylinder + Canvas( + modifier = Modifier + .size(switchWidth, switchHeight) + .alpha(LOCK_SWITCH_ALPHA) + ) { + drawRoundRect( + color = Color.Black, + cornerRadius = CornerRadius((switchWidth / 2).toPx()) + ) + } } } } @@ -576,10 +595,11 @@ private fun LockSwitchCaptureButtonNucleus( ) // locked icon, matches cylinder offset + val disableAnimations = LocalDisableAnimations.current AnimatedVisibility( visible = captureButtonUiState == CaptureButtonUiState.Enabled.Recording.PressedRecording, - enter = fadeIn(), + enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), exit = ExitTransition.None ) { Icon( @@ -637,6 +657,7 @@ private fun CaptureButtonNucleus( } val currentUiState = rememberUpdatedState(captureButtonUiState) + val disableAnimations = LocalDisableAnimations.current // smoothly animate between the size changes of the capture button center val centerShapeSize by animateDpAsState( @@ -657,7 +678,7 @@ private fun CaptureButtonNucleus( CaptureMode.VIDEO_ONLY -> (captureButtonSize * idleVideoCaptureScale).dp } }, - animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + animationSpec = if (disableAnimations) snap() else tween(durationMillis = 500, easing = FastOutSlowInEasing) ) // used to fade between red/white in the center of the capture button @@ -672,7 +693,7 @@ private fun CaptureButtonNucleus( is CaptureButtonUiState.Enabled.Recording -> recordingColor is CaptureButtonUiState.Unavailable -> Color.Transparent }, - animationSpec = tween(durationMillis = 500) + animationSpec = if (disableAnimations) snap() else tween(durationMillis = 500) ) // this box contains and centers everything @@ -698,20 +719,34 @@ private fun CaptureButtonNucleus( ) {} } // central "square" stop icon - AnimatedVisibility( - visible = currentUiState.value is - CaptureButtonUiState.Enabled.Recording.LockedRecording, - enter = scaleIn(initialScale = .5f) + fadeIn(), - exit = fadeOut() - ) { - val smallBoxSize = (captureButtonSize / 5f).dp - Canvas(modifier = Modifier) { - drawRoundRect( - color = Color.White, - topLeft = Offset(-smallBoxSize.toPx() / 2f, -smallBoxSize.toPx() / 2f), - size = Size(smallBoxSize.toPx(), smallBoxSize.toPx()), - cornerRadius = CornerRadius(smallBoxSize.toPx() * .15f) - ) + val isVisible = currentUiState.value is CaptureButtonUiState.Enabled.Recording.LockedRecording + if (disableAnimations) { + if (isVisible) { + val smallBoxSize = (captureButtonSize / 5f).dp + Canvas(modifier = Modifier) { + drawRoundRect( + color = Color.White, + topLeft = Offset(-smallBoxSize.toPx() / 2f, -smallBoxSize.toPx() / 2f), + size = Size(smallBoxSize.toPx(), smallBoxSize.toPx()), + cornerRadius = CornerRadius(smallBoxSize.toPx() * .15f) + ) + } + } + } else { + AnimatedVisibility( + visible = isVisible, + enter = scaleIn(initialScale = .5f) + fadeIn(), + exit = fadeOut() + ) { + val smallBoxSize = (captureButtonSize / 5f).dp + Canvas(modifier = Modifier) { + drawRoundRect( + color = Color.White, + topLeft = Offset(-smallBoxSize.toPx() / 2f, -smallBoxSize.toPx() / 2f), + size = Size(smallBoxSize.toPx(), smallBoxSize.toPx()), + cornerRadius = CornerRadius(smallBoxSize.toPx() * .15f) + ) + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 59578e45a..25daf6d35 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -26,6 +26,8 @@ import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.AnimatedVisibility +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations +import androidx.compose.animation.core.snap import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.LinearEasing @@ -210,15 +212,21 @@ fun AmplitudeToggleButton( val currentUiState = rememberUpdatedState(audioUiState) // Tweak the multiplier to amplitude to adjust the visualizer sensitivity - val animatedAudioAlpha by animateFloatAsState( - targetValue = EaseOutExpo.transform( - (currentUiState.value.amplitude.toFloat()).coerceIn( - 0f, - 1f - ) - ), - label = "AudioAnimation" - ) + val disableAnimations = LocalDisableAnimations.current + val animatedAudioAlpha = if (disableAnimations) { + 1f + } else { + val alpha by animateFloatAsState( + targetValue = EaseOutExpo.transform( + (currentUiState.value.amplitude.toFloat()).coerceIn( + 0f, + 1f + ) + ), + label = "AudioAnimation" + ) + alpha + } Box(contentAlignment = Alignment.Center) { FilledIconToggleButton( modifier = modifier @@ -495,9 +503,10 @@ fun PreviewDisplay( val height = if (!shouldUseMaxWidth) maxHeight else maxWidth / aspectRatioFloat var imageVisible by remember { mutableStateOf(true) } + val disableAnimations = LocalDisableAnimations.current val imageAlpha: Float by animateFloatAsState( targetValue = if (imageVisible) 1f else 0f, - animationSpec = tween( + animationSpec = if (disableAnimations) snap() else tween( durationMillis = (BLINK_TIME / 2).toInt(), easing = LinearEasing ), @@ -801,13 +810,18 @@ fun FlipCameraButton( if (initialLaunch) { // full 360 rotation -= 180f - animatedRotation.animateTo( - targetValue = rotation, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessVeryLow + val disableAnimations = LocalDisableAnimations.current + if (disableAnimations) { + animatedRotation.snapTo(rotation) + } else { + animatedRotation.animateTo( + targetValue = rotation, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessVeryLow + ) ) - ) + } } // dont rotate on the initial launch else { @@ -843,16 +857,22 @@ private fun FocusMeteringIndicator( coordinateTransformer: CoordinateTransformer ) { if (focusMeteringUiState is FocusMeteringUiState.Specified) { - val transition = rememberInfiniteTransition(label = "FocusPulse") - val alpha by transition.animateFloat( - initialValue = 1f, - targetValue = 0.5f, - animationSpec = infiniteRepeatable( - animation = tween(500), - repeatMode = RepeatMode.Reverse - ), - label = "FocusPulseAlpha" - ) + val disableAnimations = LocalDisableAnimations.current + val alpha = if (disableAnimations) { + 1f + } else { + val transition = rememberInfiniteTransition(label = "FocusPulse") + val a by transition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse + ), + label = "FocusPulseAlpha" + ) + a + } // The indicator for SUCCESS/FAILURE is shown for a short duration var showResultIndicator by remember { mutableStateOf(false) } @@ -881,36 +901,61 @@ private fun FocusMeteringIndicator( } } val showFocusMeteringIndicator = status == FocusMeteringUiState.Status.RUNNING - AnimatedVisibility( - visible = showFocusMeteringIndicator || showResultIndicator, - enter = fadeIn() + scaleIn(initialScale = 1.5f), - exit = fadeOut() + when (focusMeteringUiState.status) { - FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) - FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) - else -> fadeOut() - }, - modifier = Modifier - .offset { tapCoords.round() } - // Offset the indicator to be centered on the tap coordinates - .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) - ) { - Box( - Modifier - .testTag(FOCUS_METERING_INDICATOR_TAG) - .alpha( - if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) { - 1f - } else { - alpha - } - ) - .border( - 1.dp, - Color.White, - CircleShape - ) - .size(TAP_TO_FOCUS_INDICATOR_SIZE) - ) + val isVisible = showFocusMeteringIndicator || showResultIndicator + if (disableAnimations) { + if (isVisible) { + Box( + Modifier + .testTag(FOCUS_METERING_INDICATOR_TAG) + .alpha( + if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) { + 1f + } else { + alpha + } + ) + .border( + 1.dp, + Color.White, + CircleShape + ) + .size(TAP_TO_FOCUS_INDICATOR_SIZE) + .offset { tapCoords.round() } + .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) + ) + } + } else { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn() + scaleIn(initialScale = 1.5f), + exit = fadeOut() + when (focusMeteringUiState.status) { + FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) + FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) + else -> fadeOut() + }, + modifier = Modifier + .offset { tapCoords.round() } + // Offset the indicator to be centered on the tap coordinates + .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) + ) { + Box( + Modifier + .testTag(FOCUS_METERING_INDICATOR_TAG) + .alpha( + if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) { + 1f + } else { + alpha + } + ) + .border( + 1.dp, + Color.White, + CircleShape + ) + .size(TAP_TO_FOCUS_INDICATOR_SIZE) + ) + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/LocalDisableAnimations.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/LocalDisableAnimations.kt new file mode 100644 index 000000000..d89fc7c82 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/LocalDisableAnimations.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * CompositionLocal to disable animations in tests. + */ +val LocalDisableAnimations = staticCompositionLocalOf { false } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt index e87cc4e36..c8cf9b532 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt @@ -21,6 +21,8 @@ import android.view.Window import android.view.WindowManager import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.core.snap +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -77,10 +79,11 @@ private fun ScreenFlashOverlay( modifier: Modifier = Modifier ) { // Update overlay transparency gradually + val disableAnimations = LocalDisableAnimations.current val alpha by animateFloatAsState( targetValue = if (screenFlashUiState.enabled) 1f else 0f, label = "screenFlashAlphaAnimation", - animationSpec = tween(), + animationSpec = if (disableAnimations) snap() else tween(), finishedListener = { screenFlashUiState.onChangeComplete() } ) Box( diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt index 5fd8cae9d..26fce3d85 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt @@ -20,6 +20,8 @@ import androidx.annotation.DrawableRes import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.core.snap +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures @@ -106,12 +108,13 @@ fun ToggleSwitch( // --- 2. Drag and Animation State --- var dragOffset by remember { mutableFloatStateOf(0f) } val targetPosition = if (checked) 1f else 0f + val disableAnimations = LocalDisableAnimations.current // Animate the thumb position. // This snaps (0ms) during drag and animates (300ms) on tap or drag release. val animatedPosition by animateFloatAsState( targetValue = (targetPosition + (dragOffset / dims.dragRange)).coerceIn(0f, 1f), - animationSpec = tween(durationMillis = if (dragOffset == 0f) 300 else 0), + animationSpec = if (disableAnimations) snap() else tween(durationMillis = if (dragOffset == 0f) 300 else 0), label = "thumbPosition" ) val initialThumbX = dims.startX + dims.dragRange * animatedPosition @@ -120,12 +123,12 @@ fun ToggleSwitch( // color changes if togglemode is off (to indicate on/off state) val trackAnimatedColor by animateColorAsState( targetValue = trackColor, - animationSpec = tween(durationMillis = 300), + animationSpec = if (disableAnimations) snap() else tween(durationMillis = 300), label = "trackColor" ) val thumbAnimatedColor by animateColorAsState( targetValue = if (!enabled) disableColor.copy(alpha = 0.12f) else thumbColor, - animationSpec = tween(durationMillis = 300), + animationSpec = if (disableAnimations) snap() else tween(durationMillis = 300), label = "thumbColor" ) From c65217954b3eaaa53bdae6d7e142b6c22737565c Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 00:18:24 +0000 Subject: [PATCH 2/6] docs: add animation bypassing guideline to styleguide --- .gemini/styleguide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index dd76e4e19..969d05744 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -47,6 +47,7 @@ When reviewing a pull request, focus on the following key areas: * **Test Stability & Timeouts:** * **Explicit Timeouts:** Avoid using `waitUntil` (or similar synchronization) without explicitly defining a `timeoutMillis`. Default timeouts are often too short for slower emulators (like API 28) or low-end devices, leading to flakiness. * **Helper Functions for Waits:** If a wait condition is repeated (e.g., waiting for a specific UI element), extract it into a helper function (e.g., `waitForNodeWithTag`). This consolidates the logic and allows the timeout duration to be tuned centrally for that specific scenario. + * **Animation Bypassing for Tests:** Any new animation added to the UI **must** respect `LocalDisableAnimations` and snap to its end state or use a fixed state when animations are disabled, to prevent Espresso timeouts on slow emulators. [Introduced in PR #519] 6. **Documentation Sync** * **Check for necessary updates:** Analyze if the PR's changes (e.g., adding a new feature, changing build logic, deprecating functionality) require updates to `README.md` or other documentation files. From 8163e54906da6cea8125cd9aa54ffbef32c539c4 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 00:28:53 +0000 Subject: [PATCH 3/6] refactor: apply review comments to simplify AnimatedVisibility bypass --- .../capture/CaptureScreenComponents.kt | 81 +++++++------------ 1 file changed, 27 insertions(+), 54 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 25daf6d35..c770d6627 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -26,6 +26,8 @@ import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.animation.core.snap import androidx.compose.animation.core.Animatable @@ -902,60 +904,31 @@ private fun FocusMeteringIndicator( } val showFocusMeteringIndicator = status == FocusMeteringUiState.Status.RUNNING val isVisible = showFocusMeteringIndicator || showResultIndicator - if (disableAnimations) { - if (isVisible) { - Box( - Modifier - .testTag(FOCUS_METERING_INDICATOR_TAG) - .alpha( - if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) { - 1f - } else { - alpha - } - ) - .border( - 1.dp, - Color.White, - CircleShape - ) - .size(TAP_TO_FOCUS_INDICATOR_SIZE) - .offset { tapCoords.round() } - .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) - ) - } - } else { - AnimatedVisibility( - visible = isVisible, - enter = fadeIn() + scaleIn(initialScale = 1.5f), - exit = fadeOut() + when (focusMeteringUiState.status) { - FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) - FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) - else -> fadeOut() - }, - modifier = Modifier - .offset { tapCoords.round() } - // Offset the indicator to be centered on the tap coordinates - .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) - ) { - Box( - Modifier - .testTag(FOCUS_METERING_INDICATOR_TAG) - .alpha( - if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) { - 1f - } else { - alpha - } - ) - .border( - 1.dp, - Color.White, - CircleShape - ) - .size(TAP_TO_FOCUS_INDICATOR_SIZE) - ) - } + AnimatedVisibility( + visible = isVisible, + enter = if (disableAnimations) EnterTransition.None else fadeIn() + scaleIn(initialScale = 1.5f), + exit = if (disableAnimations) ExitTransition.None else fadeOut() + when (focusMeteringUiState.status) { + FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) + FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) + else -> fadeOut() + }, + modifier = Modifier + .offset { tapCoords.round() } + .offset(-TAP_TO_FOCUS_INDICATOR_SIZE / 2, -TAP_TO_FOCUS_INDICATOR_SIZE / 2) + ) { + Box( + Modifier + .testTag(FOCUS_METERING_INDICATOR_TAG) + .alpha( + if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) 1f else alpha + ) + .border( + 1.dp, + Color.White, + CircleShape + ) + .size(TAP_TO_FOCUS_INDICATOR_SIZE) + ) } } } From cb5dc6ab6e7ab71fa736bfa1602b149b6b49f57a Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 15 May 2026 17:45:53 +0000 Subject: [PATCH 4/6] Refactor to address review comments on PR #519 --- .../google/jetpackcamera/utils/UiTestUtil.kt | 2 +- .../capture/CaptureScreenComponents.kt | 19 +++++-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index fec956788..b3b082b5f 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -82,7 +82,7 @@ val compatMainActivityExtras: Bundle? extras.putBoolean(MainActivity.KEY_DISABLE_ANIMATIONS, true) } - return if (extras.size() == 0) null else extras + return extras.takeIf { !it.isEmpty() } } val debugExtra: Bundle = Bundle().apply { putBoolean("KEY_DEBUG_MODE", true) } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index c770d6627..3ed8e7ae1 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -215,20 +215,11 @@ fun AmplitudeToggleButton( // Tweak the multiplier to amplitude to adjust the visualizer sensitivity val disableAnimations = LocalDisableAnimations.current - val animatedAudioAlpha = if (disableAnimations) { - 1f - } else { - val alpha by animateFloatAsState( - targetValue = EaseOutExpo.transform( - (currentUiState.value.amplitude.toFloat()).coerceIn( - 0f, - 1f - ) - ), - label = "AudioAnimation" - ) - alpha - } + val animatedAudioAlpha by animateFloatAsState( + targetValue = if (disableAnimations) 1f else EaseOutExpo.transform((currentUiState.value.amplitude.toFloat()).coerceIn(0f, 1f)), + animationSpec = if (disableAnimations) snap() else tween(), + label = "AudioAnimation" + ) Box(contentAlignment = Alignment.Center) { FilledIconToggleButton( modifier = modifier From 1010ca966083fb0246c7609e4a9b3a1718d296e4 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 18 May 2026 17:53:16 +0000 Subject: [PATCH 5/6] Refactor animation bypass in PreviewScreen.kt to use disableAnimations property --- .../feature/preview/PreviewScreen.kt | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index 50da1e257..5b214697e 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -21,6 +21,7 @@ import android.util.Log import android.util.Range import androidx.camera.core.SurfaceRequest import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -477,58 +478,34 @@ private fun ContentScreen( elapsedTimeDisplay = { val disableAnimations = LocalDisableAnimations.current val isVisible = captureUiState.videoRecordingState is VideoRecordingState.Active - if (disableAnimations) { - if (isVisible) { - ElapsedTimeText( - modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiState = captureUiState.elapsedTimeUiState - ) - } - } else { - AnimatedVisibility( - visible = isVisible, - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - ElapsedTimeText( - modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiState = captureUiState.elapsedTimeUiState - ) - } + AnimatedVisibility( + visible = isVisible, + enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), + exit = if (disableAnimations) fadeOut(animationSpec = snap()) else fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiState = captureUiState.elapsedTimeUiState + ) } }, quickSettingsButton = { modifier -> val isQuickSettingsVisible = captureUiState.videoRecordingState !is VideoRecordingState.Active val disableAnimations = LocalDisableAnimations.current - if (disableAnimations) { - if (isQuickSettingsVisible) { - quickSettingsController?.let { qsc -> - ToggleQuickSettingsButton( - modifier = modifier, - isOpen = ( - captureUiState.quickSettingsUiState - as? QuickSettingsUiState.Available - )?.quickSettingsIsOpen == true, - quickSettingsController = qsc - ) - } - } - } else { - AnimatedVisibility( - visible = isQuickSettingsVisible, - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) - ) { - quickSettingsController?.let { qsc -> - ToggleQuickSettingsButton( - modifier = modifier, - isOpen = ( - captureUiState.quickSettingsUiState - as? QuickSettingsUiState.Available - )?.quickSettingsIsOpen == true, - quickSettingsController = qsc - ) - } + AnimatedVisibility( + visible = isQuickSettingsVisible, + enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), + exit = if (disableAnimations) fadeOut(animationSpec = snap()) else fadeOut(animationSpec = tween(delayMillis = 1_500)) + ) { + quickSettingsController?.let { qsc -> + ToggleQuickSettingsButton( + modifier = modifier, + isOpen = ( + captureUiState.quickSettingsUiState + as? QuickSettingsUiState.Available + )?.quickSettingsIsOpen == true, + quickSettingsController = qsc + ) } } }, From f2ceb050d8e0f21c1a02c0c926b8845bfb7356ad Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Fri, 22 May 2026 16:50:24 +0000 Subject: [PATCH 6/6] Fix line length and composable invocation for PR 519 --- .../google/jetpackcamera/utils/UiTestUtil.kt | 1 - .../com/google/jetpackcamera/MainActivity.kt | 2 +- .../feature/preview/PreviewScreen.kt | 17 +++++-- .../capture/CaptureButtonComponents.kt | 51 ++++++++++++------- .../capture/CaptureScreenComponents.kt | 49 +++++++++++++----- .../capture/ScreenFlashComponents.kt | 3 +- .../ui/components/capture/ToggleSwitch.kt | 9 ++-- 7 files changed, 90 insertions(+), 42 deletions(-) diff --git a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt index 2b5382abd..812cd4203 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -27,7 +27,6 @@ import android.os.Bundle import android.provider.MediaStore import android.util.Log import androidx.compose.ui.semantics.SemanticsProperties -import androidx.test.services.storage.TestStorage import androidx.compose.ui.test.SemanticsMatcher import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index fbaf72b9f..592712cf5 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -70,6 +69,7 @@ import com.google.jetpackcamera.model.ImageCaptureEvent import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.VideoCaptureEvent import com.google.jetpackcamera.ui.JcaApp +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import com.google.jetpackcamera.ui.theme.JetpackCameraTheme import dagger.hilt.android.AndroidEntryPoint import kotlin.collections.emptyList diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index dabecbe58..5c17757fa 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -82,6 +81,7 @@ import com.google.jetpackcamera.ui.components.capture.ElapsedTimeText import com.google.jetpackcamera.ui.components.capture.FLIP_CAMERA_BUTTON import com.google.jetpackcamera.ui.components.capture.FlipCameraButton import com.google.jetpackcamera.ui.components.capture.ImageWell +import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations import com.google.jetpackcamera.ui.components.capture.PauseResumeToggleButton import com.google.jetpackcamera.ui.components.capture.PreviewDisplay import com.google.jetpackcamera.ui.components.capture.PreviewLayout @@ -544,7 +544,11 @@ private fun ContentScreen( AnimatedVisibility( visible = isVisible, enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), - exit = if (disableAnimations) fadeOut(animationSpec = snap()) else fadeOut(animationSpec = tween(delayMillis = 1_500)) + exit = if (disableAnimations) { + fadeOut(animationSpec = snap()) + } else { + fadeOut(animationSpec = tween(delayMillis = 1_500)) + } ) { val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) @@ -562,12 +566,17 @@ private fun ContentScreen( @Composable { modifier: Modifier -> val readyState = captureUiStateProvider() if (readyState != null) { - val isQuickSettingsVisible = readyState.videoRecordingState !is VideoRecordingState.Active + val isQuickSettingsVisible = + readyState.videoRecordingState !is VideoRecordingState.Active val disableAnimations = LocalDisableAnimations.current AnimatedVisibility( visible = isQuickSettingsVisible, enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), - exit = if (disableAnimations) fadeOut(animationSpec = snap()) else fadeOut(animationSpec = tween(delayMillis = 1_500)) + exit = if (disableAnimations) { + fadeOut(animationSpec = snap()) + } else { + fadeOut(animationSpec = tween(delayMillis = 1_500)) + } ) { quickSettingsController?.let { quickSettingsController -> ToggleQuickSettingsButton( diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index 898fe6d02..d214b6ea3 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -159,27 +159,34 @@ private fun CaptureKeyHandler( } /** - * A capture button that can be used for both image and video capture, supporting drag-to-lock for hands-free recording. + * A capture button that can be used for both image and video capture, supporting drag-to-lock + * for hands-free recording. * - * This component handles user interactions such as tap for image capture, long-press for video recording, - * and a drag-to-lock gesture to enable continuous, "hands-free recording." When a video recording - * is initiated via a long press, the user can drag their finger towards the lock icon to lock the recording, - * allowing them to lift their finger and continue recording without interruption. Additionally, users can - * drag vertically *above* the capture button (where Y coordinates are negative relative to the button's top edge) + * This component handles user interactions such as tap for image capture, long-press for + * video recording, and a drag-to-lock gesture to enable continuous, "hands-free recording." + * When a video recording is initiated via a long press, the user can drag their finger + * towards the lock icon to lock the recording, allowing them to lift their finger and + * continue recording without interruption. Additionally, users can drag vertically *above* + * the capture button (where Y coordinates are negative relative to the button's top edge) * to zoom in (dragging upwards) or zoom out (dragging downwards). * - * The button supports three distinct capture modes: Hybrid, Image-only, and Video-only, each with its - * own UI and behavior: - * - **Hybrid Mode:** A single tap captures an image, and a long press initiates video recording. - * - **Image-only Mode:** Only single taps are active for image capture. Long press for video recording is disabled. - * - **Video-only Mode:** A single tap initiates video recording, and a long press also initiates video recording, with the drag-to-lock feature available. + * The button supports three distinct capture modes: Hybrid, Image-only, and Video-only, + * each with its own UI and behavior: + * - **Hybrid Mode:** A single tap captures an image, and a long press initiates video + * recording. + * - **Image-only Mode:** Only single taps are active for image capture. Long press for + * video recording is disabled. + * - **Video-only Mode:** A single tap initiates video recording, and a long press also + * initiates video recording, with the drag-to-lock feature available. * * @param modifier the modifier for this component * @param onImageCapture the callback for an image capture event * @param onStartRecording the callback for a start recording event * @param onStopRecording the callback for a stop recording event - * @param onLockVideoRecording The callback for a lock video recording event. The boolean parameter indicates if the recording should be locked. - * @param onIncrementZoom The callback for a zoom increment event, providing the zoom increment value. + * @param onLockVideoRecording The callback for a lock video recording event. The boolean + * parameter indicates if the recording should be locked. + * @param onIncrementZoom The callback for a zoom increment event, providing the zoom + * increment value. * @param captureButtonUiState the [CaptureButtonUiState] for this component * @param captureButtonSize the size of the capture button */ @@ -368,7 +375,11 @@ private fun CaptureButton( } else { LocalContentColor.current }, - animationSpec = if (disableAnimations) snap() else tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), + animationSpec = if (disableAnimations) { + snap() + } else { + tween(durationMillis = if (isVisuallyDisabled) 1000 else 300) + }, label = "Capture Button Color" ) @@ -566,7 +577,8 @@ private fun LockSwitchCaptureButtonNucleus( ) { // grey cylinder offset to the left and fades in when pressed recording val disableAnimations = LocalDisableAnimations.current - val isVisible = captureButtonUiState == CaptureButtonUiState.Enabled.Recording.PressedRecording + val isVisible = + captureButtonUiState == CaptureButtonUiState.Enabled.Recording.PressedRecording if (disableAnimations) { if (isVisible) { Canvas( @@ -696,7 +708,11 @@ private fun CaptureButtonNucleus( CaptureMode.VIDEO_ONLY -> (captureButtonSize * idleVideoCaptureScale).dp } }, - animationSpec = if (disableAnimations) snap() else tween(durationMillis = 500, easing = FastOutSlowInEasing) + animationSpec = if (disableAnimations) { + snap() + } else { + tween(durationMillis = 500, easing = FastOutSlowInEasing) + } ) // used to fade between red/white in the center of the capture button @@ -737,7 +753,8 @@ private fun CaptureButtonNucleus( ) {} } // central "square" stop icon - val isVisible = currentUiState.value is CaptureButtonUiState.Enabled.Recording.LockedRecording + val isVisible = + currentUiState.value is CaptureButtonUiState.Enabled.Recording.LockedRecording if (disableAnimations) { if (isVisible) { val smallBoxSize = (captureButtonSize / 5f).dp diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index 0570c3321..c4b6129d7 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -28,8 +28,6 @@ import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations -import androidx.compose.animation.core.snap import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.LinearEasing @@ -39,6 +37,7 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.snap import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -225,7 +224,11 @@ fun AmplitudeToggleButton( // Tweak the multiplier to amplitude to adjust the visualizer sensitivity val disableAnimations = LocalDisableAnimations.current val animatedAudioAlpha by animateFloatAsState( - targetValue = if (disableAnimations) 1f else EaseOutExpo.transform((currentUiState.value.amplitude.toFloat()).coerceIn(0f, 1f)), + targetValue = if (disableAnimations) { + 1f + } else { + EaseOutExpo.transform((currentUiState.value.amplitude.toFloat()).coerceIn(0f, 1f)) + }, animationSpec = if (disableAnimations) snap() else tween(), label = "AudioAnimation" ) @@ -508,10 +511,14 @@ fun PreviewDisplay( val disableAnimations = LocalDisableAnimations.current val imageAlpha: Float by animateFloatAsState( targetValue = if (imageVisible) 1f else 0f, - animationSpec = if (disableAnimations) snap() else tween( - durationMillis = (BLINK_TIME / 2).toInt(), - easing = LinearEasing - ), + animationSpec = if (disableAnimations) { + snap() + } else { + tween( + durationMillis = (BLINK_TIME / 2).toInt(), + easing = LinearEasing + ) + }, label = "" ) @@ -808,11 +815,11 @@ fun FlipCameraButton( var initialLaunch by remember { mutableStateOf(false) } // spin animate whenever lensfacing changes + val disableAnimations = LocalDisableAnimations.current LaunchedEffect(flipLensUiState.selectedLensFacing) { if (initialLaunch) { // full 360 rotation -= 180f - val disableAnimations = LocalDisableAnimations.current if (disableAnimations) { animatedRotation.snapTo(rotation) } else { @@ -906,11 +913,19 @@ private fun FocusMeteringIndicator( val isVisible = showFocusMeteringIndicator || showResultIndicator AnimatedVisibility( visible = isVisible, - enter = if (disableAnimations) EnterTransition.None else fadeIn() + scaleIn(initialScale = 1.5f), - exit = if (disableAnimations) ExitTransition.None else fadeOut() + when (focusMeteringUiState.status) { - FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) - FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) - else -> fadeOut() + enter = if (disableAnimations) { + EnterTransition.None + } else { + fadeIn() + scaleIn(initialScale = 1.5f) + }, + exit = if (disableAnimations) { + ExitTransition.None + } else { + fadeOut() + when (focusMeteringUiState.status) { + FocusMeteringUiState.Status.SUCCESS -> scaleOut(targetScale = 0.5f) + FocusMeteringUiState.Status.FAILURE -> scaleOut(targetScale = 1.5f) + else -> fadeOut() + } }, modifier = Modifier .offset { tapCoords.round() } @@ -920,7 +935,13 @@ private fun FocusMeteringIndicator( Modifier .testTag(FOCUS_METERING_INDICATOR_TAG) .alpha( - if (focusMeteringUiState.status == FocusMeteringUiState.Status.SUCCESS) 1f else alpha + if (focusMeteringUiState.status == + FocusMeteringUiState.Status.SUCCESS + ) { + 1f + } else { + alpha + } ) .border( 1.dp, diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt index c8cf9b532..0a9ecdcd2 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ScreenFlashComponents.kt @@ -20,9 +20,8 @@ import android.util.Log import android.view.Window import android.view.WindowManager import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.core.snap -import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt index 26fce3d85..1f39e38ae 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt @@ -19,9 +19,8 @@ import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.core.snap -import com.google.jetpackcamera.ui.components.capture.LocalDisableAnimations +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures @@ -114,7 +113,11 @@ fun ToggleSwitch( // This snaps (0ms) during drag and animates (300ms) on tap or drag release. val animatedPosition by animateFloatAsState( targetValue = (targetPosition + (dragOffset / dims.dragRange)).coerceIn(0f, 1f), - animationSpec = if (disableAnimations) snap() else tween(durationMillis = if (dragOffset == 0f) 300 else 0), + animationSpec = if (disableAnimations) { + snap() + } else { + tween(durationMillis = if (dragOffset == 0f) 300 else 0) + }, label = "thumbPosition" ) val initialThumbX = dims.startX + dims.dragRange * animatedPosition