Loading aconfig/systemui.aconfig +17 −0 Original line number Diff line number Diff line Loading @@ -143,3 +143,20 @@ flag { description: "Enables squeeze effect on power button long press launching Gemini" bug: "396099245" } flag { name: "cursor_hot_corner" namespace: "systemui" description: "Enables hot corner navigation by cursor" bug: "397182595" } flag { name: "smartspace_remoteviews_intent_handler" namespace: "systemui" description: "Enables Smartspace RemoteViews intent handling on lockscreen" bug: "399416038" metadata { purpose: PURPOSE_BUGFIX } } iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +1 −1 Original line number Diff line number Diff line Loading @@ -641,7 +641,7 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 8 else 7 @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 10 else 9 @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" Loading mechanics/src/com/android/mechanics/MotionValue.kt +71 −10 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.mechanics import android.util.Log import androidx.compose.runtime.FloatState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue Loading @@ -28,6 +29,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastIsFinite import androidx.compose.ui.util.lerp import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.unpackFloat1 Loading Loading @@ -196,6 +198,13 @@ class MotionValue( try { debugIsAnimating = true // indicates whether withFrameNanos is called continuously (as opposed to being // suspended for an undetermined amount of time in between withFrameNanos). // This is essential after `withFrameNanos` returned: if true at this point, // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. var isAnimatingUninterrupted = false while (continueRunning.invoke(this@MotionValue)) { withFrameNanos { frameTimeNanos -> Loading @@ -222,6 +231,19 @@ class MotionValue( // cause a re-computation if the current state is being read before the next // frame). if (isAnimatingUninterrupted) { val currentDirectMapped = currentDirectMapped val lastDirectMapped = lastSegment.mapping.map(lastInput) - lastAnimation.targetValue val frameDuration = (currentAnimationTimeNanos - lastFrameTimeNanos) / 1_000_000_000.0 val staticDelta = (currentDirectMapped - lastDirectMapped) directMappedVelocity = (staticDelta / frameDuration).toFloat() } else { directMappedVelocity = 0f } var scheduleNextFrame = !isStable if (capturedSegment != currentSegment) { capturedSegment = currentSegment Loading Loading @@ -273,6 +295,7 @@ class MotionValue( ) } isAnimatingUninterrupted = scheduleNextFrame if (scheduleNextFrame) { continue } Loading Loading @@ -374,6 +397,11 @@ class MotionValue( private var lastAnimation: DiscontinuityAnimation by mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy()) // The change velocity of the `currentDirectMapped`, in `units/sec`. Only non-zero if the // animation loop is processing every frame (while animating or while the input changes // continuously). private var directMappedVelocity: Float = 0f // ---- Last frame's input and output ---------------------------------------------------------- // The state below captures relevant input values (including frame time) and the computed spring Loading Loading @@ -670,10 +698,23 @@ class MotionValue( SegmentChangeType.Direction, SegmentChangeType.Spec -> { // Determine the delta in the output, as produced by the old and new mapping. val delta = currentSegment.mapping.map(currentInput) - lastSegment.mapping.map(currentInput) val currentMapping = currentSegment.mapping.map(currentInput) val lastMapping = lastSegment.mapping.map(currentInput) val delta = currentMapping - lastMapping val deltaIsFinite = delta.fastIsFinite() if (!deltaIsFinite) { Log.wtf( TAG, "Delta between mappings is undefined!\n" + " MotionValue: $label\n" + " input: $currentInput\n" + " lastMapping: $lastMapping (lastSegment: $lastSegment)\n" + " currentMapping: $currentMapping (currentSegment: $currentSegment)", ) } if (delta == 0f) { if (delta == 0f || !deltaIsFinite) { // Nothing new to animate. lastAnimation } else { Loading @@ -687,7 +728,7 @@ class MotionValue( val newTarget = delta - lastSpringState.displacement DiscontinuityAnimation( newTarget, SpringState(-newTarget, lastSpringState.velocity), SpringState(-newTarget, lastSpringState.velocity + directMappedVelocity), springParameters, lastFrameTimeNanos, ) Loading Loading @@ -765,14 +806,28 @@ class MotionValue( ) lastAnimationTime = nextBreakpointCrossTime val beforeBreakpoint = mappings[segmentIndex].map(nextBreakpoint.position) val afterBreakpoint = mappings[segmentIndex + directionOffset].map(nextBreakpoint.position) val mappingBefore = mappings[segmentIndex] val beforeBreakpoint = mappingBefore.map(nextBreakpoint.position) val mappingAfter = mappings[segmentIndex + directionOffset] val afterBreakpoint = mappingAfter.map(nextBreakpoint.position) val delta = afterBreakpoint - beforeBreakpoint springTarget += delta springState = springState.addDisplacement(-delta) val deltaIsFinite = delta.fastIsFinite() if (!deltaIsFinite) { Log.wtf( TAG, "Delta between breakpoints is undefined!\n" + " MotionValue: $label\n" + " position: ${nextBreakpoint.position}\n" + " before: $beforeBreakpoint (mapping: $mappingBefore)\n" + " after: $afterBreakpoint (mapping: $mappingAfter)", ) } if (deltaIsFinite) { springTarget += delta springState = springState.nudge(displacementDelta = -delta) } segmentIndex += directionOffset lastBreakpoint = nextBreakpoint guaranteeState = Loading @@ -793,6 +848,10 @@ class MotionValue( } } if (springState.displacement != 0f) { springState = springState.nudge(velocityDelta = directMappedVelocity) } val tightened = currentGuaranteeState.updatedSpringParameters( currentSegment.entryBreakpoint Loading Loading @@ -828,7 +887,9 @@ class MotionValue( } private val currentDirectMapped: Float get() = currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue get() { return currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue } private val currentAnimatedDelta: Float get() = currentAnimation.targetValue + currentSpringState.displacement Loading mechanics/src/com/android/mechanics/behavior/EdgeContainerExpansionBackground.kt 0 → 100644 +141 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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.android.mechanics.behavior import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.requireGraphicsContext import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp import kotlin.math.min /** * Draws the background of an edge container, and applies clipping to it. * * Intended to be used with a [EdgeContainerExpansionSpec] motion. */ fun Modifier.edgeContainerExpansionBackground( backgroundColor: Color, spec: EdgeContainerExpansionSpec, ): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec)) internal class EdgeContainerExpansionBackgroundNode( var backgroundColor: Color, var spec: EdgeContainerExpansionSpec, ) : Modifier.Node(), DrawModifierNode { private var graphicsLayer: GraphicsLayer? = null private var lastOutlineSize = Size.Zero fun invalidateOutline() { lastOutlineSize = Size.Zero } override fun onAttach() { graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true } } override fun onDetach() { requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer)) } override fun ContentDrawScope.draw() { val height = size.height // The width is growing between visibleHeight and detachHeight val visibleHeight = spec.visibleHeight.toPx() val widthFraction = ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn( 0f, 1f, ) val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction) val horizontalInset = (size.width - width) / 2f // The radius is growing at the beginning of the transition val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx()) // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom. val upperHeight = height - radius // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii. // The clipRect limits this to the relevant part (-1 to avoid a hairline gap being visible // between this and the fill below. clipRect(top = (upperHeight - 1).fastCoerceAtLeast(0f)) { drawRoundRect( color = backgroundColor, cornerRadius = CornerRadius(radius), size = Size(width, radius * 2f), topLeft = Offset(horizontalInset, size.height - radius * 2f), ) } if (upperHeight > 0) { // Fill the space above the bottom shape. drawRect( color = backgroundColor, topLeft = Offset(horizontalInset, 0f), size = Size(width, upperHeight), ) } // Draw the node's content in a separate layer. val graphicsLayer = checkNotNull(graphicsLayer) graphicsLayer.record { this@draw.drawContent() } if (size != lastOutlineSize) { // The clip outline is a rounded corner shape matching the bottom of the shape. // At the top, the rounded corner shape extends by radiusPx above top. // This clipping thus would not prevent the containers content to overdraw at the top, // however this is off-screen anyways. val top = min(-radius, height - radius * 2f) val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height) graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius) lastOutlineSize = size } this.drawLayer(graphicsLayer) } } private data class EdgeContainerExpansionBackgroundElement( val backgroundColor: Color, val spec: EdgeContainerExpansionSpec, ) : ModifierNodeElement<EdgeContainerExpansionBackgroundNode>() { override fun create(): EdgeContainerExpansionBackgroundNode = EdgeContainerExpansionBackgroundNode(backgroundColor, spec) override fun update(node: EdgeContainerExpansionBackgroundNode) { node.backgroundColor = backgroundColor if (node.spec != spec) { node.spec = spec node.invalidateOutline() } } } mechanics/src/com/android/mechanics/behavior/EdgeContainerExpansionSpec.kt 0 → 100644 +157 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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. */ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package com.android.mechanics.behavior import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MotionScheme import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.OnChangeSegmentHandler import com.android.mechanics.spec.SegmentData import com.android.mechanics.spec.SegmentKey import com.android.mechanics.spec.builder import com.android.mechanics.spec.reverseBuilder import com.android.mechanics.spring.SpringParameters /** Motion spec for a vertically expandable container. */ class EdgeContainerExpansionSpec( val visibleHeight: Dp = Defaults.VisibleHeight, val preDetachRatio: Float = Defaults.PreDetachRatio, val detachHeight: Dp = Defaults.DetachHeight, val attachHeight: Dp = Defaults.AttachHeight, val widthOffset: Dp = Defaults.WidthOffset, val minRadius: Dp = Defaults.MinRadius, val radius: Dp = Defaults.Radius, val attachSpring: SpringParameters = Defaults.AttachSpring, val detachSpring: SpringParameters = Defaults.DetachSpring, val opacitySpring: SpringParameters = Defaults.OpacitySpring, ) { fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec { return with(density) { val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec()) val detachSpec = DirectionalMotionSpec.builder( initialMapping = Mapping.Zero, defaultSpring = spatialSpring, ) .toBreakpoint(0f, key = Breakpoints.Attach) .continueWith(Mapping.Linear(preDetachRatio)) .toBreakpoint(detachHeight.toPx(), key = Breakpoints.Detach) .completeWith(Mapping.Identity, detachSpring) val attachSpec = DirectionalMotionSpec.reverseBuilder(defaultSpring = spatialSpring) .toBreakpoint(attachHeight.toPx(), key = Breakpoints.Detach) .completeWith(mapping = Mapping.Zero, attachSpring) val segmentHandlers = mapOf<SegmentKey, OnChangeSegmentHandler>( SegmentKey(Breakpoints.Detach, Breakpoint.maxLimit.key, InputDirection.Min) to { currentSegment, _, newDirection -> if (newDirection != currentSegment.direction) currentSegment else null }, SegmentKey(Breakpoints.Attach, Breakpoints.Detach, InputDirection.Max) to { currentSegment: SegmentData, newInput: Float, newDirection: InputDirection -> if (newDirection != currentSegment.direction && newInput >= 0) currentSegment else null }, ) MotionSpec( maxDirection = detachSpec, minDirection = attachSpec, segmentHandlers = segmentHandlers, ) } } fun createWidthSpec( intrinsicWidth: Float, motionScheme: MotionScheme, density: Density, ): MotionSpec { return with(density) { MotionSpec.builder( SpringParameters(motionScheme.defaultSpatialSpec()), initialMapping = { input -> val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f) intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction) }, ) .complete() } } fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec { return with(density) { val detachSpec = DirectionalMotionSpec.builder( SpringParameters(motionScheme.defaultEffectsSpec()), initialMapping = Mapping.Zero, ) .toBreakpoint(visibleHeight.toPx()) .completeWith(Mapping.One, opacitySpring) val attachSpec = DirectionalMotionSpec.builder( SpringParameters(motionScheme.defaultEffectsSpec()), initialMapping = Mapping.Zero, ) .toBreakpoint(visibleHeight.toPx()) .completeWith(Mapping.One, opacitySpring) MotionSpec(maxDirection = detachSpec, minDirection = attachSpec) } } companion object { object Breakpoints { val Attach = BreakpointKey("EdgeContainerExpansion::Attach") val Detach = BreakpointKey("EdgeContainerExpansion::Detach") } object Defaults { val VisibleHeight = 24.dp val PreDetachRatio = .25f val DetachHeight = 80.dp val AttachHeight = 40.dp val WidthOffset = 28.dp val MinRadius = 28.dp val Radius = 46.dp val AttachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) val DetachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) val OpacitySpring = SpringParameters(stiffness = 1200f, dampingRatio = 0.99f) } } } Loading
aconfig/systemui.aconfig +17 −0 Original line number Diff line number Diff line Loading @@ -143,3 +143,20 @@ flag { description: "Enables squeeze effect on power button long press launching Gemini" bug: "396099245" } flag { name: "cursor_hot_corner" namespace: "systemui" description: "Enables hot corner navigation by cursor" bug: "397182595" } flag { name: "smartspace_remoteviews_intent_handler" namespace: "systemui" description: "Enables Smartspace RemoteViews intent handling on lockscreen" bug: "399416038" metadata { purpose: PURPOSE_BUGFIX } }
iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +1 −1 Original line number Diff line number Diff line Loading @@ -641,7 +641,7 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 8 else 7 @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 10 else 9 @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" Loading
mechanics/src/com/android/mechanics/MotionValue.kt +71 −10 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.mechanics import android.util.Log import androidx.compose.runtime.FloatState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue Loading @@ -28,6 +29,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastIsFinite import androidx.compose.ui.util.lerp import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.unpackFloat1 Loading Loading @@ -196,6 +198,13 @@ class MotionValue( try { debugIsAnimating = true // indicates whether withFrameNanos is called continuously (as opposed to being // suspended for an undetermined amount of time in between withFrameNanos). // This is essential after `withFrameNanos` returned: if true at this point, // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. var isAnimatingUninterrupted = false while (continueRunning.invoke(this@MotionValue)) { withFrameNanos { frameTimeNanos -> Loading @@ -222,6 +231,19 @@ class MotionValue( // cause a re-computation if the current state is being read before the next // frame). if (isAnimatingUninterrupted) { val currentDirectMapped = currentDirectMapped val lastDirectMapped = lastSegment.mapping.map(lastInput) - lastAnimation.targetValue val frameDuration = (currentAnimationTimeNanos - lastFrameTimeNanos) / 1_000_000_000.0 val staticDelta = (currentDirectMapped - lastDirectMapped) directMappedVelocity = (staticDelta / frameDuration).toFloat() } else { directMappedVelocity = 0f } var scheduleNextFrame = !isStable if (capturedSegment != currentSegment) { capturedSegment = currentSegment Loading Loading @@ -273,6 +295,7 @@ class MotionValue( ) } isAnimatingUninterrupted = scheduleNextFrame if (scheduleNextFrame) { continue } Loading Loading @@ -374,6 +397,11 @@ class MotionValue( private var lastAnimation: DiscontinuityAnimation by mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy()) // The change velocity of the `currentDirectMapped`, in `units/sec`. Only non-zero if the // animation loop is processing every frame (while animating or while the input changes // continuously). private var directMappedVelocity: Float = 0f // ---- Last frame's input and output ---------------------------------------------------------- // The state below captures relevant input values (including frame time) and the computed spring Loading Loading @@ -670,10 +698,23 @@ class MotionValue( SegmentChangeType.Direction, SegmentChangeType.Spec -> { // Determine the delta in the output, as produced by the old and new mapping. val delta = currentSegment.mapping.map(currentInput) - lastSegment.mapping.map(currentInput) val currentMapping = currentSegment.mapping.map(currentInput) val lastMapping = lastSegment.mapping.map(currentInput) val delta = currentMapping - lastMapping val deltaIsFinite = delta.fastIsFinite() if (!deltaIsFinite) { Log.wtf( TAG, "Delta between mappings is undefined!\n" + " MotionValue: $label\n" + " input: $currentInput\n" + " lastMapping: $lastMapping (lastSegment: $lastSegment)\n" + " currentMapping: $currentMapping (currentSegment: $currentSegment)", ) } if (delta == 0f) { if (delta == 0f || !deltaIsFinite) { // Nothing new to animate. lastAnimation } else { Loading @@ -687,7 +728,7 @@ class MotionValue( val newTarget = delta - lastSpringState.displacement DiscontinuityAnimation( newTarget, SpringState(-newTarget, lastSpringState.velocity), SpringState(-newTarget, lastSpringState.velocity + directMappedVelocity), springParameters, lastFrameTimeNanos, ) Loading Loading @@ -765,14 +806,28 @@ class MotionValue( ) lastAnimationTime = nextBreakpointCrossTime val beforeBreakpoint = mappings[segmentIndex].map(nextBreakpoint.position) val afterBreakpoint = mappings[segmentIndex + directionOffset].map(nextBreakpoint.position) val mappingBefore = mappings[segmentIndex] val beforeBreakpoint = mappingBefore.map(nextBreakpoint.position) val mappingAfter = mappings[segmentIndex + directionOffset] val afterBreakpoint = mappingAfter.map(nextBreakpoint.position) val delta = afterBreakpoint - beforeBreakpoint springTarget += delta springState = springState.addDisplacement(-delta) val deltaIsFinite = delta.fastIsFinite() if (!deltaIsFinite) { Log.wtf( TAG, "Delta between breakpoints is undefined!\n" + " MotionValue: $label\n" + " position: ${nextBreakpoint.position}\n" + " before: $beforeBreakpoint (mapping: $mappingBefore)\n" + " after: $afterBreakpoint (mapping: $mappingAfter)", ) } if (deltaIsFinite) { springTarget += delta springState = springState.nudge(displacementDelta = -delta) } segmentIndex += directionOffset lastBreakpoint = nextBreakpoint guaranteeState = Loading @@ -793,6 +848,10 @@ class MotionValue( } } if (springState.displacement != 0f) { springState = springState.nudge(velocityDelta = directMappedVelocity) } val tightened = currentGuaranteeState.updatedSpringParameters( currentSegment.entryBreakpoint Loading Loading @@ -828,7 +887,9 @@ class MotionValue( } private val currentDirectMapped: Float get() = currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue get() { return currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue } private val currentAnimatedDelta: Float get() = currentAnimation.targetValue + currentSpringState.displacement Loading
mechanics/src/com/android/mechanics/behavior/EdgeContainerExpansionBackground.kt 0 → 100644 +141 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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.android.mechanics.behavior import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.requireGraphicsContext import androidx.compose.ui.util.fastCoerceAtLeast import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp import kotlin.math.min /** * Draws the background of an edge container, and applies clipping to it. * * Intended to be used with a [EdgeContainerExpansionSpec] motion. */ fun Modifier.edgeContainerExpansionBackground( backgroundColor: Color, spec: EdgeContainerExpansionSpec, ): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec)) internal class EdgeContainerExpansionBackgroundNode( var backgroundColor: Color, var spec: EdgeContainerExpansionSpec, ) : Modifier.Node(), DrawModifierNode { private var graphicsLayer: GraphicsLayer? = null private var lastOutlineSize = Size.Zero fun invalidateOutline() { lastOutlineSize = Size.Zero } override fun onAttach() { graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true } } override fun onDetach() { requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer)) } override fun ContentDrawScope.draw() { val height = size.height // The width is growing between visibleHeight and detachHeight val visibleHeight = spec.visibleHeight.toPx() val widthFraction = ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn( 0f, 1f, ) val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction) val horizontalInset = (size.width - width) / 2f // The radius is growing at the beginning of the transition val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx()) // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom. val upperHeight = height - radius // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii. // The clipRect limits this to the relevant part (-1 to avoid a hairline gap being visible // between this and the fill below. clipRect(top = (upperHeight - 1).fastCoerceAtLeast(0f)) { drawRoundRect( color = backgroundColor, cornerRadius = CornerRadius(radius), size = Size(width, radius * 2f), topLeft = Offset(horizontalInset, size.height - radius * 2f), ) } if (upperHeight > 0) { // Fill the space above the bottom shape. drawRect( color = backgroundColor, topLeft = Offset(horizontalInset, 0f), size = Size(width, upperHeight), ) } // Draw the node's content in a separate layer. val graphicsLayer = checkNotNull(graphicsLayer) graphicsLayer.record { this@draw.drawContent() } if (size != lastOutlineSize) { // The clip outline is a rounded corner shape matching the bottom of the shape. // At the top, the rounded corner shape extends by radiusPx above top. // This clipping thus would not prevent the containers content to overdraw at the top, // however this is off-screen anyways. val top = min(-radius, height - radius * 2f) val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height) graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius) lastOutlineSize = size } this.drawLayer(graphicsLayer) } } private data class EdgeContainerExpansionBackgroundElement( val backgroundColor: Color, val spec: EdgeContainerExpansionSpec, ) : ModifierNodeElement<EdgeContainerExpansionBackgroundNode>() { override fun create(): EdgeContainerExpansionBackgroundNode = EdgeContainerExpansionBackgroundNode(backgroundColor, spec) override fun update(node: EdgeContainerExpansionBackgroundNode) { node.backgroundColor = backgroundColor if (node.spec != spec) { node.spec = spec node.invalidateOutline() } } }
mechanics/src/com/android/mechanics/behavior/EdgeContainerExpansionSpec.kt 0 → 100644 +157 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 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. */ @file:OptIn(ExperimentalMaterial3ExpressiveApi::class) package com.android.mechanics.behavior import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MotionScheme import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.lerp import com.android.mechanics.spec.Breakpoint import com.android.mechanics.spec.BreakpointKey import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.Mapping import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.OnChangeSegmentHandler import com.android.mechanics.spec.SegmentData import com.android.mechanics.spec.SegmentKey import com.android.mechanics.spec.builder import com.android.mechanics.spec.reverseBuilder import com.android.mechanics.spring.SpringParameters /** Motion spec for a vertically expandable container. */ class EdgeContainerExpansionSpec( val visibleHeight: Dp = Defaults.VisibleHeight, val preDetachRatio: Float = Defaults.PreDetachRatio, val detachHeight: Dp = Defaults.DetachHeight, val attachHeight: Dp = Defaults.AttachHeight, val widthOffset: Dp = Defaults.WidthOffset, val minRadius: Dp = Defaults.MinRadius, val radius: Dp = Defaults.Radius, val attachSpring: SpringParameters = Defaults.AttachSpring, val detachSpring: SpringParameters = Defaults.DetachSpring, val opacitySpring: SpringParameters = Defaults.OpacitySpring, ) { fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec { return with(density) { val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec()) val detachSpec = DirectionalMotionSpec.builder( initialMapping = Mapping.Zero, defaultSpring = spatialSpring, ) .toBreakpoint(0f, key = Breakpoints.Attach) .continueWith(Mapping.Linear(preDetachRatio)) .toBreakpoint(detachHeight.toPx(), key = Breakpoints.Detach) .completeWith(Mapping.Identity, detachSpring) val attachSpec = DirectionalMotionSpec.reverseBuilder(defaultSpring = spatialSpring) .toBreakpoint(attachHeight.toPx(), key = Breakpoints.Detach) .completeWith(mapping = Mapping.Zero, attachSpring) val segmentHandlers = mapOf<SegmentKey, OnChangeSegmentHandler>( SegmentKey(Breakpoints.Detach, Breakpoint.maxLimit.key, InputDirection.Min) to { currentSegment, _, newDirection -> if (newDirection != currentSegment.direction) currentSegment else null }, SegmentKey(Breakpoints.Attach, Breakpoints.Detach, InputDirection.Max) to { currentSegment: SegmentData, newInput: Float, newDirection: InputDirection -> if (newDirection != currentSegment.direction && newInput >= 0) currentSegment else null }, ) MotionSpec( maxDirection = detachSpec, minDirection = attachSpec, segmentHandlers = segmentHandlers, ) } } fun createWidthSpec( intrinsicWidth: Float, motionScheme: MotionScheme, density: Density, ): MotionSpec { return with(density) { MotionSpec.builder( SpringParameters(motionScheme.defaultSpatialSpec()), initialMapping = { input -> val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f) intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction) }, ) .complete() } } fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec { return with(density) { val detachSpec = DirectionalMotionSpec.builder( SpringParameters(motionScheme.defaultEffectsSpec()), initialMapping = Mapping.Zero, ) .toBreakpoint(visibleHeight.toPx()) .completeWith(Mapping.One, opacitySpring) val attachSpec = DirectionalMotionSpec.builder( SpringParameters(motionScheme.defaultEffectsSpec()), initialMapping = Mapping.Zero, ) .toBreakpoint(visibleHeight.toPx()) .completeWith(Mapping.One, opacitySpring) MotionSpec(maxDirection = detachSpec, minDirection = attachSpec) } } companion object { object Breakpoints { val Attach = BreakpointKey("EdgeContainerExpansion::Attach") val Detach = BreakpointKey("EdgeContainerExpansion::Detach") } object Defaults { val VisibleHeight = 24.dp val PreDetachRatio = .25f val DetachHeight = 80.dp val AttachHeight = 40.dp val WidthOffset = 28.dp val MinRadius = 28.dp val Radius = 46.dp val AttachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) val DetachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) val OpacitySpring = SpringParameters(stiffness = 1200f, dampingRatio = 0.99f) } } }