diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index c7e9abe8e..94439c074 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -49,6 +49,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. 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 da1728a82..f00b55091 100644 --- a/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt +++ b/app/src/androidTest/java/com/google/jetpackcamera/utils/UiTestUtil.kt @@ -78,6 +78,12 @@ internal val compatMainActivityExtras: Bundle? extras.putString(MainActivity.KEY_DEBUG_SINGLE_LENS_MODE, "back") } + val resolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val args = TestStorage(resolver).getInputArgs() + val disableAnimations = args["disable_animations"]?.toBoolean() ?: false + if (disableAnimations) { + extras.putBoolean(MainActivity.KEY_DISABLE_ANIMATIONS, true) + } return extras.takeIf { !it.isEmpty() } } diff --git a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt index b9e72c9c1..592712cf5 100644 --- a/app/src/main/java/com/google/jetpackcamera/MainActivity.kt +++ b/app/src/main/java/com/google/jetpackcamera/MainActivity.kt @@ -41,6 +41,7 @@ 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 androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -68,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 @@ -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 6c43007a5..dcaa2701c 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 @@ -550,10 +550,15 @@ private fun ContentScreen( captureUiStateProvider()?.videoRecordingState is VideoRecordingState.Active } } + val disableAnimations = LocalDisableAnimations.current AnimatedVisibility( visible = isVisible.value, - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), + exit = if (disableAnimations) { + fadeOut(animationSpec = snap()) + } else { + fadeOut(animationSpec = tween(delayMillis = 1_500)) + } ) { val elapsedTimeModifier = remember(modifier) { modifier.testTag(ELAPSED_TIME_TAG) @@ -573,10 +578,15 @@ private fun ContentScreen( @Composable { modifier: Modifier -> val readyState = captureUiStateProvider() if (readyState != null) { + val disableAnimations = LocalDisableAnimations.current AnimatedVisibility( visible = (readyState.videoRecordingState !is VideoRecordingState.Active), - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(delayMillis = 1_500)) + enter = if (disableAnimations) fadeIn(animationSpec = snap()) else fadeIn(), + 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 eb24ff095..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 @@ -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 @@ -158,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 */ @@ -359,13 +367,19 @@ 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" ) @@ -562,22 +576,39 @@ 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()) + ) + } } } } @@ -594,10 +625,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( @@ -655,6 +687,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( @@ -675,7 +708,11 @@ 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 @@ -690,7 +727,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 @@ -716,20 +753,35 @@ 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 9117253f9..4be5fcbdc 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 androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOutExpo import androidx.compose.animation.core.LinearEasing @@ -35,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 @@ -223,13 +226,14 @@ fun AmplitudeToggleButton( val currentUiState = rememberUpdatedState(audioUiState) // Tweak the multiplier to amplitude to adjust the visualizer sensitivity + val disableAnimations = LocalDisableAnimations.current val animatedAudioAlpha by animateFloatAsState( - targetValue = 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" ) Box(contentAlignment = Alignment.Center) { @@ -508,12 +512,17 @@ 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( - durationMillis = (BLINK_TIME / 2).toInt(), - easing = LinearEasing - ), + animationSpec = if (disableAnimations) { + snap() + } else { + tween( + durationMillis = (BLINK_TIME / 2).toInt(), + easing = LinearEasing + ) + }, label = "" ) @@ -810,17 +819,22 @@ 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 - animatedRotation.animateTo( - targetValue = rotation, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessVeryLow + 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 { @@ -856,16 +870,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) } @@ -894,24 +914,34 @@ private fun FocusMeteringIndicator( } } val showFocusMeteringIndicator = status == FocusMeteringUiState.Status.RUNNING + val isVisible = showFocusMeteringIndicator || showResultIndicator 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() + 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 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) { + if (focusMeteringUiState.status == + FocusMeteringUiState.Status.SUCCESS + ) { 1f } else { alpha 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..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,6 +20,7 @@ import android.util.Log import android.view.Window import android.view.WindowManager import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -77,10 +78,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..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,6 +19,7 @@ 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.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.Orientation @@ -106,12 +107,17 @@ 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 +126,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" )