Skip to content

Update CaptureButton sizing, colors, and Material 3 Expressive motion specs - #578

Open
temcguir wants to merge 12 commits into
mainfrom
temcguir/capture_button_ring_cleanup
Open

temcguir wants to merge 12 commits into
mainfrom
temcguir/capture_button_ring_cleanup

Conversation

@temcguir

@temcguir temcguir commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

This PR updates CaptureButton, CaptureButtonRing, and CaptureButtonNucleus to use Material 3 Expressive motion specifications and refined sizing and geometry.

Key Changes

  • Button and Ring Sizing:

    • Updated default capture button diameter from 76.dp to 86.dp.
    • Animated outer ring stroke width (3.dp <-> 0.dp) using MotionScheme.expressive().fastSpatialSpec() so the outer border smoothly transitions when entering or leaving CaptureMode.STANDARD.
    • Added KDoc documentation to CaptureButtonRing and keyed isCaptureButtonPressed with remember(initialPressed) to properly support LocalInitialPressedState.
  • Spatial and Effects Motion Specs:

    • Updated CaptureButtonNucleus size and corner radius animations to use MotionScheme.expressive().fastSpatialSpec(), synchronizing the scale transition and corner radius morph (8.dp when locked).
    • Replaced fixed-duration color transitions with MotionScheme.expressive().fastEffectsSpec(), updated recording indicator color to #ED0000, and updated the CaptureMode.IMAGE_ONLY pressed nucleus to be fully opaque white to match the design spec.
  • Standard (Hybrid) Mode Transitions:

    • Added latent idle scale (0.80f) and pressed capture scale (0.86f) with a responsive spring spec (stiffness = 1800f, dampingRatio = 0.65f), instant color materialization, and a 50ms visual press hold for quick taps so taps reliably materialize and bounce within the outer ring border.
    • Configured stopping video recording in standard mode to expand smoothly outward instead of collapsing to 0.dp.
  • Testing:

    • Added unit tests verifying outer ring border visibility across capture modes and recording states.
    • Added unit tests verifying spring scale dynamics, disabled animation behavior, and image-only nucleus scaling.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@temcguir
temcguir requested a review from Kimblebee September 24, 2026 22:30
Comment on lines +902 to 907
when {
disableAnimations -> snap()
NucleusSizeState.PressedStandard in setOf(initialState, targetState) ->
snappyStandardTapSpatialSpec
else -> fastSpatialSpec
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using NucleusSizeState.PressedStandard in setOf(initialState, targetState) has two issues:

  1. When starting a long-press recording in CaptureMode.STANDARD, the button first enters PressedStandard on finger-down and then transitions from PressedStandard (initialState) to RecordingPressed (targetState) once the long-press timeout elapses. Because initialState == PressedStandard, that transition into video recording unintentionally uses snappyStandardTapSpatialSpec (stiffness = 1800f, dampingRatio = 0.65f) instead of fastSpatialSpec.
  2. setOf(initialState, targetState) allocates a new Set on every transition check.

Consider scoping this specifically to the IdleStandard <-> PressedStandard tap transitions using isTransitioningTo:

Suggested change
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
}

Comment on lines +687 to +699
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)
)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Even though color and borderWidth are passed as lambdas, borderWidth() (line 687) and color() (line 697) are still invoked directly in the composition body of CaptureButtonRing, causing CaptureButtonRing to recompose on every animation frame. To defer reading until the draw phase (or isolate the conditional visibility), consider extracting val showBorder by remember(borderWidth) { derivedStateOf { borderWidth() > 0f } } and drawing the border inside Modifier.drawBehind / Canvas (which would also resolve the todo() on line 689).
  2. nit: animatedBorderWidth is State<Dp>, so line 616 unwraps animatedBorderWidth.value.value (Dp -> Float) only for line 697 to re-wrap currentBorderWidth.dp (Float -> Dp). Changing borderWidth to () -> Dp = { BORDER_WIDTH.dp } avoids the double .value.value and re-wrapping.

Comment on lines +506 to +511
scope.launch {
if (!disableAnimations) {
delay(50) // Ensure visible press state for fast taps
}
interactionSource.emit(PressInteraction.Release(press))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +867 to +871
val fastSpatialSpec = remember { MotionScheme.expressive().fastSpatialSpec<Dp>() }
val snappyStandardTapSpatialSpec = remember {
spring<Dp>(dampingRatio = 0.65f, stiffness = 1800f)
}
val fastEffectsSpec = remember { MotionScheme.expressive().fastEffectsSpec<Color>() }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 838 to 840
* @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.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 979 to 984
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)
) {}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants