Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors the CaptureButton animations to utilize Material 3 Expressive Motion schemes, replacing hardcoded durations and transitions. It also introduces new tests to verify the behavior of the button ring border and scaling animations. The review feedback suggests optimizing performance by deferring state reading of animatedColor and animatedBorderWidth using lambda providers to prevent excessive recompositions of the CaptureButton. Additionally, it is recommended to update the corresponding tests for these lambda parameters and to import CompositionLocalProvider directly in the test file instead of using its fully qualified name.
Extracts the recording red color (Color(0xFFED0000)) into an internal CaptureTokens object in :ui:components:capture. Updates CaptureButtonNucleus and ElapsedTimeText to use the shared token instead of duplicating the literal color value, and adds a unit test verifying the token value.
…creenshot goldens
| when { | ||
| disableAnimations -> snap() | ||
| NucleusSizeState.PressedStandard in setOf(initialState, targetState) -> | ||
| snappyStandardTapSpatialSpec | ||
| else -> fastSpatialSpec | ||
| } |
There was a problem hiding this comment.
Using NucleusSizeState.PressedStandard in setOf(initialState, targetState) has two issues:
- When starting a long-press recording in
CaptureMode.STANDARD, the button first entersPressedStandardon finger-down and then transitions fromPressedStandard(initialState) toRecordingPressed(targetState) once the long-press timeout elapses. BecauseinitialState == PressedStandard, that transition into video recording unintentionally usessnappyStandardTapSpatialSpec(stiffness = 1800f, dampingRatio = 0.65f) instead offastSpatialSpec. setOf(initialState, targetState)allocates a newSeton every transition check.
Consider scoping this specifically to the IdleStandard <-> PressedStandard tap transitions using isTransitioningTo:
| when { | |
| disableAnimations -> snap() | |
| NucleusSizeState.PressedStandard in setOf(initialState, targetState) -> | |
| snappyStandardTapSpatialSpec | |
| else -> fastSpatialSpec | |
| } | |
| when { | |
| disableAnimations -> snap() | |
| (NucleusSizeState.IdleStandard isTransitioningTo NucleusSizeState.PressedStandard) || | |
| (NucleusSizeState.PressedStandard isTransitioningTo NucleusSizeState.IdleStandard) -> | |
| snappyStandardTapSpatialSpec | |
| else -> fastSpatialSpec | |
| } |
| val currentBorderWidth = borderWidth() | ||
| if (currentBorderWidth > 0f) { | ||
| // todo(): use a canvas instead of a box. | ||
| // the sizing gets funny so the scales need to be completely readjusted | ||
| Box( | ||
| modifier = Modifier | ||
| .testTag(CAPTURE_BUTTON_RING_BORDER) | ||
| .size( | ||
| captureButtonSize.dp | ||
| ) | ||
| .border(currentBorderWidth.dp, color(), CircleShape) | ||
| ) | ||
| } |
There was a problem hiding this comment.
- Even though
colorandborderWidthare passed as lambdas,borderWidth()(line 687) andcolor()(line 697) are still invoked directly in the composition body ofCaptureButtonRing, causingCaptureButtonRingto recompose on every animation frame. To defer reading until the draw phase (or isolate the conditional visibility), consider extractingval showBorder by remember(borderWidth) { derivedStateOf { borderWidth() > 0f } }and drawing the border insideModifier.drawBehind/Canvas(which would also resolve thetodo()on line 689). nit:animatedBorderWidthisState<Dp>, so line 616 unwrapsanimatedBorderWidth.value.value(Dp->Float) only for line 697 to re-wrapcurrentBorderWidth.dp(Float->Dp). ChangingborderWidthto() -> Dp = { BORDER_WIDTH.dp }avoids the double.value.valueand re-wrapping.
| scope.launch { | ||
| if (!disableAnimations) { | ||
| delay(50) // Ensure visible press state for fast taps | ||
| } | ||
| interactionSource.emit(PressInteraction.Release(press)) | ||
| } |
There was a problem hiding this comment.
Here the 50ms press release delay is guarded by if (!disableAnimations), whereas the volume key release path in onKeyUp (line 299) calls delay(50) unconditionally. Please also check !disableAnimations in onKeyUp and extract 50L into a shared constant (e.g., private const val PRESS_RELEASE_DELAY_MS = 50L).
| val fastSpatialSpec = remember { MotionScheme.expressive().fastSpatialSpec<Dp>() } | ||
| val snappyStandardTapSpatialSpec = remember { | ||
| spring<Dp>(dampingRatio = 0.65f, stiffness = 1800f) | ||
| } | ||
| val fastEffectsSpec = remember { MotionScheme.expressive().fastEffectsSpec<Color>() } |
There was a problem hiding this comment.
nit: MotionScheme.expressive() and its spec factories (fastSpatialSpec, fastEffectsSpec, defaultEffectsSpec) do not depend on composition state, so wrapping them in 6 separate remember blocks across CaptureButton (lines 430–432), CaptureButtonRing (line 670), and CaptureButtonNucleus (lines 867–871) is redundant. Consider defining these specs (along with the 0.65f damping ratio and 1800f stiffness on line 869) once in CaptureTokens or as file-level constants.
| * @param idleVideoCaptureScale the scale factor for the idle size of the video-only nucleus. Must be between 0 and 1. | ||
| * @param pressedVideoCaptureScale the scale factor for the pressed size of the video-only nucleus. Must be between 0 and 1. | ||
| */ |
There was a problem hiding this comment.
nit: @param idleImageCaptureScale on line 837 states it is only for the "idle size of the image-only nucleus", but lines 921–923 now also use it for NucleusSizeState.PressedStandard. Please update the KDoc to reflect both uses.
| modifier = Modifier | ||
| .size(centerShapeSize) | ||
| .clip(RoundedCornerShape(cornerRadius)) | ||
| .alpha( | ||
| if (isTapping && | ||
| currentUiState.value == | ||
| CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) | ||
| ) { | ||
| .5f // transparency to indicate click ONLY on IMAGE_ONLY | ||
| } else { | ||
| 1f // solid alpha the rest of the time | ||
| } | ||
| ) | ||
| .background(animatedColor) | ||
| ) {} | ||
| } |
There was a problem hiding this comment.
nit: Now that the .alpha(...) modifier has been removed from the inner Box, the middle Box(modifier = Modifier) on line 977 (and the empty {} body on line 984) can be simplified to a single inner Box.
| import androidx.compose.ui.semantics.SemanticsPropertyKey | ||
| import androidx.compose.ui.semantics.SemanticsPropertyReceiver | ||
| const val CAPTURE_BUTTON = "CaptureButton" | ||
| const val CAPTURE_BUTTON_RING_BORDER = "CaptureButtonRingBorder" |
There was a problem hiding this comment.
nit: Per the repository style guide (Section 10: Test Tags and Semantics), new test tag values should use lower_snake_case (e.g., "capture_button_ring_border", matching CAPTURE_BUTTON_NUCLEUS = "capture_button_nucleus" in CaptureButtonTest.kt). Also consider marking it internal if it is only used within :ui:components:capture.
This PR updates
CaptureButton,CaptureButtonRing, andCaptureButtonNucleusto use Material 3 Expressive motion specifications and refined sizing and geometry.Key Changes
Button and Ring Sizing:
76.dpto86.dp.3.dp<->0.dp) usingMotionScheme.expressive().fastSpatialSpec()so the outer border smoothly transitions when entering or leavingCaptureMode.STANDARD.CaptureButtonRingand keyedisCaptureButtonPressedwithremember(initialPressed)to properly supportLocalInitialPressedState.Spatial and Effects Motion Specs:
CaptureButtonNucleussize and corner radius animations to useMotionScheme.expressive().fastSpatialSpec(), synchronizing the scale transition and corner radius morph (8.dpwhen locked).MotionScheme.expressive().fastEffectsSpec(), updated recording indicator color to#ED0000, and updated theCaptureMode.IMAGE_ONLYpressed nucleus to be fully opaque white to match the design spec.Standard (Hybrid) Mode Transitions:
0.80f) and pressed capture scale (0.86f) with a responsive spring spec (stiffness = 1800f,dampingRatio = 0.65f), instant color materialization, and a50msvisual press hold for quick taps so taps reliably materialize and bounce within the outer ring border.0.dp.Testing: