diff --git a/README.md b/README.md
index 5797480..9b96c06 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,12 @@ This repository contains multiple libraries in SystemUI used by Lawnchair.
A brief explanation of what each library does:
* `animationlib`: Handling playback and interpolator of the animations
* `contextualeducationlib`: Store "education" type
+* `displaylib`: Handling presumably desktop displays
* `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons
+* `mechanics`: Complement the `animationlib`
* `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16
* `searchuilib`: Store search-related layout type
+ * See [AOSP Commit][searchuilib-url] instead because it's gone private after U
+* `viewcapturelib`: Capture views... yep that's really that it
+
+[searchuilib-url]: https://cs.android.com/android/_/android/platform/frameworks/libs/systemui/+/main:searchuilib/src/com/android/app/search/;drc=ace90b2ec32d3730141387c56e8abc761c380550;bpv=1;bpt=0
diff --git a/animationlib/build.gradle b/animationlib/build.gradle
index f40669b..02036e3 100644
--- a/animationlib/build.gradle
+++ b/animationlib/build.gradle
@@ -15,10 +15,11 @@ android {
manifest.srcFile 'AndroidManifest.xml'
}
androidTest {
- java.srcDirs = ["tests/src"]
+ java.srcDirs = ["tests/src", "tests/robolectric/src"]
manifest.srcFile 'tests/AndroidManifest.xml'
}
}
+
lintOptions {
abortOnError false
}
@@ -34,4 +35,7 @@ dependencies {
implementation libs.kotlin.stdlib.jdk7
implementation libs.androidx.core.animation
implementation libs.androidx.core.ktx
+// androidTestImplementation libs.robolectric
+// androidTestImplementation "androidx.test.ext:junit:1.1.3"
+// androidTestImplementation "androidx.test:rules:1.4.0"
}
diff --git a/animationlib/src/com/android/app/animation/Animations.kt b/animationlib/src/com/android/app/animation/Animations.kt
index 140cc20..2b787fe 100644
--- a/animationlib/src/com/android/app/animation/Animations.kt
+++ b/animationlib/src/com/android/app/animation/Animations.kt
@@ -18,7 +18,6 @@ package com.android.app.animation
import android.animation.Animator
import android.view.View
-import com.android.app.animation.Animations.Companion.setOngoingAnimation
/** A static class for general animation-related utilities. */
class Animations {
diff --git a/animationlib/src/com/android/app/animation/Interpolators.java b/animationlib/src/com/android/app/animation/Interpolators.java
index 871241c..c723a12 100644
--- a/animationlib/src/com/android/app/animation/Interpolators.java
+++ b/animationlib/src/com/android/app/animation/Interpolators.java
@@ -58,14 +58,14 @@ public class Interpolators {
* is disappearing e.g. when moving off screen.
*/
public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator(
- 0.3f, 0f, 0.8f, 0.15f);
+ 0.3f, 0f, 0.8f, 0.15f);
/**
* The decelerating emphasized interpolator. Used for hero / emphasized movement of content that
* is appearing e.g. when coming from off screen
*/
public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
- 0.05f, 0.7f, 0.1f, 1f);
+ 0.05f, 0.7f, 0.1f, 1f);
public static final Interpolator EXAGGERATED_EASE;
@@ -98,21 +98,21 @@ public class Interpolators {
* The standard interpolator that should be used on every normal animation
*/
public static final Interpolator STANDARD = new PathInterpolator(
- 0.2f, 0f, 0f, 1f);
+ 0.2f, 0f, 0f, 1f);
/**
* The standard accelerating interpolator that should be used on every regular movement of
* content that is disappearing e.g. when moving off screen.
*/
public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator(
- 0.3f, 0f, 1f, 1f);
+ 0.3f, 0f, 1f, 1f);
/**
* The standard decelerating interpolator that should be used on every regular movement of
* content that is appearing e.g. when coming from off screen.
*/
public static final Interpolator STANDARD_DECELERATE = new PathInterpolator(
- 0f, 0f, 0f, 1f);
+ 0f, 0f, 0f, 1f);
/*
* ============================================================================================
@@ -158,7 +158,7 @@ public class Interpolators {
* goes from 1 to 0 instead of 0 to 1).
*/
public static final Interpolator FAST_OUT_SLOW_IN_REVERSE =
- new PathInterpolator(0.8f, 0f, 0.6f, 1f);
+ new PathInterpolator(0.8f, 0f, 0.6f, 1f);
public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f);
public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f);
public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1);
@@ -182,31 +182,31 @@ public class Interpolators {
public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f);
public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f);
public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f,
- 1.1f);
+ 1.1f);
public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f,
- 1);
+ 1);
public static final Interpolator BOUNCE = new BounceInterpolator();
/**
* For state transitions on the control panel that lives in GlobalActions.
*/
public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f,
- 1.0f);
+ 1.0f);
/**
* Interpolator to be used when animating a move based on a click. Pair with enough duration.
*/
public static final Interpolator TOUCH_RESPONSE =
- new PathInterpolator(0.3f, 0f, 0.1f, 1f);
+ new PathInterpolator(0.3f, 0f, 0.1f, 1f);
/**
* Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t
* goes from 1 to 0 instead of 0 to 1).
*/
public static final Interpolator TOUCH_RESPONSE_REVERSE =
- new PathInterpolator(0.9f, 0f, 0.7f, 1f);
+ new PathInterpolator(0.9f, 0f, 0.7f, 1f);
public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL =
- v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v));
+ v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v));
/**
@@ -236,7 +236,7 @@ public float getInterpolation(float v) {
*/
private float zInterpolate(float input) {
return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) /
- (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f));
+ (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f));
}
};
@@ -297,13 +297,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) {
* @return the interpolated overshoot
*/
public static float getOvershootInterpolation(float progress, float overshootAmount,
- float overshootStart) {
+ float overshootStart) {
if (overshootAmount == 0.0f || overshootStart == 0.0f) {
throw new IllegalArgumentException("Invalid values for overshoot");
}
float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart;
return MathUtils.max(0.0f,
- (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f));
+ (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f));
}
/**
@@ -344,11 +344,11 @@ private static PathInterpolator createEmphasizedComplement() {
* 1 by upperBound.
*/
public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound,
- float upperBound) {
+ float upperBound) {
if (upperBound < lowerBound) {
throw new IllegalArgumentException(
- String.format("upperBound (%f) must be greater than lowerBound (%f)",
- upperBound, lowerBound));
+ String.format("upperBound (%f) must be greater than lowerBound (%f)",
+ upperBound, lowerBound));
}
return t -> clampToProgress(interpolator, t, lowerBound, upperBound);
}
@@ -361,11 +361,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe
* interpolator.
*/
public static float clampToProgress(
- Interpolator interpolator, float progress, float lowerBound, float upperBound) {
+ Interpolator interpolator, float progress, float lowerBound, float upperBound) {
if (upperBound < lowerBound) {
throw new IllegalArgumentException(
- String.format("upperBound (%f) must be greater than lowerBound (%f)",
- upperBound, lowerBound));
+ String.format("upperBound (%f) must be greater than lowerBound (%f)",
+ upperBound, lowerBound));
}
if (progress == lowerBound && progress == upperBound) {
@@ -398,7 +398,7 @@ private static float mapRange(float value, float min, float max) {
* such as to take over a user-controlled animation when they let go.
*/
public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound,
- float upperBound) {
+ float upperBound) {
return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound);
}
@@ -411,4 +411,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB
public static Interpolator reverse(Interpolator interpolator) {
return t -> 1 - interpolator.getInterpolation(1 - t);
}
-}
+}
\ No newline at end of file
diff --git a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
index 73c95f8..47f4d74 100644
--- a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
+++ b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java
@@ -65,14 +65,14 @@ public class InterpolatorsAndroidX {
* is disappearing e.g. when moving off screen.
*/
public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator(
- 0.3f, 0f, 0.8f, 0.15f);
+ 0.3f, 0f, 0.8f, 0.15f);
/**
* The decelerating emphasized interpolator. Used for hero / emphasized movement of content that
* is appearing e.g. when coming from off screen
*/
public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
- 0.05f, 0.7f, 0.1f, 1f);
+ 0.05f, 0.7f, 0.1f, 1f);
public static final Interpolator EXAGGERATED_EASE;
static {
@@ -104,21 +104,21 @@ public class InterpolatorsAndroidX {
* The standard interpolator that should be used on every normal animation
*/
public static final Interpolator STANDARD = new PathInterpolator(
- 0.2f, 0f, 0f, 1f);
+ 0.2f, 0f, 0f, 1f);
/**
* The standard accelerating interpolator that should be used on every regular movement of
* content that is disappearing e.g. when moving off screen.
*/
public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator(
- 0.3f, 0f, 1f, 1f);
+ 0.3f, 0f, 1f, 1f);
/**
* The standard decelerating interpolator that should be used on every regular movement of
* content that is appearing e.g. when coming from off screen.
*/
public static final Interpolator STANDARD_DECELERATE = new PathInterpolator(
- 0f, 0f, 0f, 1f);
+ 0f, 0f, 0f, 1f);
/*
* ============================================================================================
@@ -164,7 +164,7 @@ public class InterpolatorsAndroidX {
* goes from 1 to 0 instead of 0 to 1).
*/
public static final Interpolator FAST_OUT_SLOW_IN_REVERSE =
- new PathInterpolator(0.8f, 0f, 0.6f, 1f);
+ new PathInterpolator(0.8f, 0f, 0.6f, 1f);
public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f);
public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f);
public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1);
@@ -188,31 +188,31 @@ public class InterpolatorsAndroidX {
public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f);
public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f);
public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f,
- 1.1f);
+ 1.1f);
public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f,
- 1);
+ 1);
public static final Interpolator BOUNCE = new BounceInterpolator();
/**
* For state transitions on the control panel that lives in GlobalActions.
*/
public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f,
- 1.0f);
+ 1.0f);
/**
* Interpolator to be used when animating a move based on a click. Pair with enough duration.
*/
public static final Interpolator TOUCH_RESPONSE =
- new PathInterpolator(0.3f, 0f, 0.1f, 1f);
+ new PathInterpolator(0.3f, 0f, 0.1f, 1f);
/**
* Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t
* goes from 1 to 0 instead of 0 to 1).
*/
public static final Interpolator TOUCH_RESPONSE_REVERSE =
- new PathInterpolator(0.9f, 0f, 0.7f, 1f);
+ new PathInterpolator(0.9f, 0f, 0.7f, 1f);
public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL =
- v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v));
+ v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v));
/**
@@ -242,7 +242,7 @@ public float getInterpolation(float v) {
*/
private float zInterpolate(float input) {
return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) /
- (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f));
+ (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f));
}
};
@@ -303,13 +303,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) {
* @return the interpolated overshoot
*/
public static float getOvershootInterpolation(float progress, float overshootAmount,
- float overshootStart) {
+ float overshootStart) {
if (overshootAmount == 0.0f || overshootStart == 0.0f) {
throw new IllegalArgumentException("Invalid values for overshoot");
}
float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart;
return MathUtils.max(0.0f,
- (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f));
+ (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f));
}
/**
@@ -350,11 +350,11 @@ private static PathInterpolator createEmphasizedComplement() {
* 1 by upperBound.
*/
public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound,
- float upperBound) {
+ float upperBound) {
if (upperBound < lowerBound) {
throw new IllegalArgumentException(
- String.format("upperBound (%f) must be greater than lowerBound (%f)",
- upperBound, lowerBound));
+ String.format("upperBound (%f) must be greater than lowerBound (%f)",
+ upperBound, lowerBound));
}
return t -> clampToProgress(interpolator, t, lowerBound, upperBound);
}
@@ -367,11 +367,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe
* interpolator.
*/
public static float clampToProgress(
- Interpolator interpolator, float progress, float lowerBound, float upperBound) {
+ Interpolator interpolator, float progress, float lowerBound, float upperBound) {
if (upperBound < lowerBound) {
throw new IllegalArgumentException(
- String.format("upperBound (%f) must be greater than lowerBound (%f)",
- upperBound, lowerBound));
+ String.format("upperBound (%f) must be greater than lowerBound (%f)",
+ upperBound, lowerBound));
}
if (progress == lowerBound && progress == upperBound) {
@@ -404,7 +404,7 @@ private static float mapRange(float value, float min, float max) {
* such as to take over a user-controlled animation when they let go.
*/
public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound,
- float upperBound) {
+ float upperBound) {
return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound);
}
@@ -417,4 +417,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB
public static Interpolator reverse(Interpolator interpolator) {
return t -> 1 - interpolator.getInterpolation(1 - t);
}
-}
+}
\ No newline at end of file
diff --git a/animationlib/tests/robolectric/config/robolectric.properties b/animationlib/tests/robolectric/config/robolectric.properties
index 527eab6..fab7251 100644
--- a/animationlib/tests/robolectric/config/robolectric.properties
+++ b/animationlib/tests/robolectric/config/robolectric.properties
@@ -1,2 +1 @@
sdk=NEWEST_SDK
-shadows=com.android.app.animation.robolectric.ShadowAnimationUtils2
diff --git a/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt
new file mode 100644
index 0000000..decc503
--- /dev/null
+++ b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2024 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.app.animation
+
+import android.animation.ValueAnimator
+import android.content.Context
+import android.view.View
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class AnimationsTest {
+ companion object {
+ const val TEST_DURATION = 1000L
+ }
+
+ private val context: Context = InstrumentationRegistry.getInstrumentation().context
+
+ @Test
+ fun ongoingAnimationsAreStoredAndCancelledCorrectly() {
+ val view = View(context)
+
+ val oldAnimation = FakeAnimator()
+ Animations.setOngoingAnimation(view, oldAnimation)
+ oldAnimation.start()
+ assertEquals(oldAnimation, view.getTag(R.id.ongoing_animation))
+ assertTrue(oldAnimation.started)
+
+ val newAnimation = FakeAnimator()
+ Animations.setOngoingAnimation(view, newAnimation)
+ newAnimation.start()
+ assertEquals(newAnimation, view.getTag(R.id.ongoing_animation))
+ assertTrue(oldAnimation.cancelled)
+ assertTrue(newAnimation.started)
+
+ Animations.cancelOngoingAnimation(view)
+ assertNull(view.getTag(R.id.ongoing_animation))
+ assertTrue(newAnimation.cancelled)
+ }
+}
+
+/** Test animator for tracking start and cancel signals. */
+private class FakeAnimator : ValueAnimator() {
+ var started = false
+ var cancelled = false
+
+ override fun start() {
+ started = true
+ cancelled = false
+ }
+
+ override fun cancel() {
+ started = false
+ cancelled = true
+ }
+}
diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
index ed4670e..f54493e 100644
--- a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
+++ b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt
@@ -3,23 +3,23 @@ package com.android.app.animation
import android.annotation.InterpolatorRes
import android.content.Context
import android.view.animation.AnimationUtils
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
@SmallTest
-@RunWith(JUnit4::class)
+@RunWith(AndroidJUnit4::class)
class InterpolatorResourcesTest {
private lateinit var context: Context
@Before
fun setup() {
- context = InstrumentationRegistry.getInstrumentation().context
+ context = InstrumentationRegistry.getInstrumentation().targetContext
}
@Test
diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
index ffa706e..bed06cd 100644
--- a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
+++ b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt
@@ -16,18 +16,18 @@
package com.android.app.animation
+import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import java.lang.reflect.Modifier
import junit.framework.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
-import org.junit.runners.JUnit4
private const val ANDROIDX_ANIM_PACKAGE_NAME = "androidx.core.animation."
private const val ANDROID_ANIM_PACKAGE_NAME = "android.view.animation."
@SmallTest
-@RunWith(JUnit4::class)
+@RunWith(AndroidJUnit4::class)
class InterpolatorsAndroidXTest {
@Test
diff --git a/contextualeducationlib/build.gradle b/contextualeducationlib/build.gradle
index 558b313..909304d 100644
--- a/contextualeducationlib/build.gradle
+++ b/contextualeducationlib/build.gradle
@@ -27,4 +27,4 @@ android {
manifest.srcFile 'AndroidManifest.xml'
}
}
-}
+}
\ No newline at end of file
diff --git a/displaylib/Android.bp b/displaylib/Android.bp
new file mode 100644
index 0000000..85eefb8
--- /dev/null
+++ b/displaylib/Android.bp
@@ -0,0 +1,30 @@
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+ name: "displaylib",
+ manifest: "AndroidManifest.xml",
+ static_libs: [
+ "kotlinx_coroutines_android",
+ "dagger2",
+ "jsr330",
+ "//frameworks/libs/systemui:tracinglib-platform",
+ ],
+ plugins: ["dagger2-compiler"],
+ srcs: ["src/**/*.kt"],
+}
diff --git a/displaylib/AndroidManifest.xml b/displaylib/AndroidManifest.xml
new file mode 100644
index 0000000..4f3234b
--- /dev/null
+++ b/displaylib/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/displaylib/README.MD b/displaylib/README.MD
new file mode 100644
index 0000000..2739a46
--- /dev/null
+++ b/displaylib/README.MD
@@ -0,0 +1,4 @@
+# displaylib
+
+This library contains utilities that make the management of multiple displays easier, more
+performant and elegant.
\ No newline at end of file
diff --git a/displaylib/TEST_MAPPING b/displaylib/TEST_MAPPING
new file mode 100644
index 0000000..31260e9
--- /dev/null
+++ b/displaylib/TEST_MAPPING
@@ -0,0 +1,7 @@
+{
+ "presubmit": [
+ {
+ "name": "displaylib_tests"
+ }
+ ]
+}
diff --git a/displaylib/build.gradle b/displaylib/build.gradle
new file mode 100644
index 0000000..1135b1e
--- /dev/null
+++ b/displaylib/build.gradle
@@ -0,0 +1,28 @@
+plugins {
+ id 'com.android.library'
+ id 'org.jetbrains.kotlin.android'
+ alias(libs.plugins.google.ksp)
+}
+
+android {
+ namespace "com.android.app.displaylib"
+ sourceSets {
+ main {
+ java.srcDirs = ['src']
+ }
+ }
+}
+
+addFrameworkJar('framework-16.jar')
+
+dependencies {
+ implementation libs.javax.inject
+ implementation libs.kotlinx.coroutines.android
+ ksp libs.dagger.compiler
+ implementation libs.dagger.hilt.android
+ ksp libs.dagger.hilt.compiler
+}
+
+ksp {
+ arg("dagger.hilt.disableModulesHaveInstallInCheck", "true")
+}
diff --git a/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt
new file mode 100644
index 0000000..e40c1ca
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.app.displaylib
+
+import android.hardware.display.DisplayManager
+import android.os.Handler
+import android.view.IWindowManager
+import dagger.Binds
+import dagger.BindsInstance
+import dagger.Component
+import dagger.Module
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+
+/**
+ * Component that creates all classes in displaylib.
+ *
+ * Each user of this library will bind the required element in the factory constructor. It's advised
+ * to use this component through [createDisplayLibComponent], which wraps the dagger generated
+ * method.
+ */
+@Component(modules = [DisplayLibModule::class])
+@Singleton
+interface DisplayLibComponent {
+
+ @Component.Factory
+ interface Factory {
+ fun create(
+ @BindsInstance displayManager: DisplayManager,
+ @BindsInstance windowManager: IWindowManager,
+ @BindsInstance bgHandler: Handler,
+ @BindsInstance bgApplicationScope: CoroutineScope,
+ @BindsInstance backgroundCoroutineDispatcher: CoroutineDispatcher,
+ ): DisplayLibComponent
+ }
+
+ val displayRepository: DisplayRepository
+ val displaysWithDecorationsRepository: DisplaysWithDecorationsRepository
+ val displaysWithDecorationsRepositoryCompat: DisplaysWithDecorationsRepositoryCompat
+}
+
+@Module
+interface DisplayLibModule {
+ @Binds fun bindDisplayManagerImpl(impl: DisplayRepositoryImpl): DisplayRepository
+
+ @Binds
+ fun bindDisplaysWithDecorationsRepositoryImpl(
+ impl: DisplaysWithDecorationsRepositoryImpl
+ ): DisplaysWithDecorationsRepository
+}
+
+/**
+ * Just a wrapper to make the generated code to create the component more explicit.
+ *
+ * This should be called only once per process. Note that [bgHandler], [bgApplicationScope] and
+ * [backgroundCoroutineDispatcher] are expected to be backed by background threads. In the future
+ * this might throw an exception if they are tied to the main thread!
+ */
+fun createDisplayLibComponent(
+ displayManager: DisplayManager,
+ windowManager: IWindowManager,
+ bgHandler: Handler,
+ bgApplicationScope: CoroutineScope,
+ backgroundCoroutineDispatcher: CoroutineDispatcher,
+): DisplayLibComponent {
+ return DaggerDisplayLibComponent.factory()
+ .create(
+ displayManager,
+ windowManager,
+ bgHandler,
+ bgApplicationScope,
+ backgroundCoroutineDispatcher,
+ )
+}
diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt
new file mode 100644
index 0000000..7b43355
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt
@@ -0,0 +1,489 @@
+/*
+ * 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.app.displaylib
+
+import android.hardware.display.DisplayManager
+import android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED
+import android.hardware.display.DisplayManager.DisplayListener
+import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED
+import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED
+import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED
+import android.os.Handler
+import android.util.Log
+import android.view.Display
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.flow.stateIn
+
+/** Repository for providing access to display related information and events. */
+interface DisplayRepository {
+ /** Provides the current set of displays. */
+ val displays: StateFlow>
+
+ /** Display change event indicating a change to the given displayId has occurred. */
+ val displayChangeEvent: Flow
+
+ /** Display addition event indicating a new display has been added. */
+ val displayAdditionEvent: Flow
+
+ /** Display removal event indicating a display has been removed. */
+ val displayRemovalEvent: Flow
+
+ /**
+ * Provides the current set of display ids.
+ *
+ * Note that it is preferred to use this instead of [displays] if only the
+ * [Display.getDisplayId] is needed.
+ */
+ val displayIds: StateFlow>
+
+ /**
+ * Pending display id that can be enabled/disabled.
+ *
+ * When `null`, it means there is no pending display waiting to be enabled.
+ */
+ val pendingDisplay: Flow
+
+ /** Whether the default display is currently off. */
+ val defaultDisplayOff: Flow
+
+ /**
+ * Given a display ID int, return the corresponding Display object, or null if none exist.
+ *
+ * This method will not result in a binder call in most cases. The only exception is if there is
+ * an existing binder call ongoing to get the [Display] instance already. In that case, this
+ * will wait for the end of the binder call.
+ */
+ fun getDisplay(displayId: Int): Display?
+
+ /**
+ * As [getDisplay], but it's always guaranteed to not block on any binder call.
+ *
+ * This might return null if the display id was not mapped to a [Display] object yet.
+ */
+ fun getCachedDisplay(displayId: Int): Display? =
+ displays.value.firstOrNull { it.displayId == displayId }
+
+ /**
+ * Returns whether the given displayId is in the set of enabled displays.
+ *
+ * This is guaranteed to not cause a binder call. Use this instead of [getDisplay] (see its docs
+ * for why)
+ */
+ fun containsDisplay(displayId: Int): Boolean = displayIds.value.contains(displayId)
+
+ /** Represents a connected display that has not been enabled yet. */
+ interface PendingDisplay {
+ /** Id of the pending display. */
+ val id: Int
+
+ /** Enables the display, making it available to the system. */
+ suspend fun enable()
+
+ /**
+ * Ignores the pending display. When called, this specific display id doesn't appear as
+ * pending anymore until the display is disconnected and reconnected again.
+ */
+ suspend fun ignore()
+
+ /** Disables the display, making it unavailable to the system. */
+ suspend fun disable()
+ }
+}
+
+@Singleton
+class DisplayRepositoryImpl
+@Inject
+constructor(
+ private val displayManager: DisplayManager,
+ backgroundHandler: Handler,
+ bgApplicationScope: CoroutineScope,
+ backgroundCoroutineDispatcher: CoroutineDispatcher,
+) : DisplayRepository {
+ private val allDisplayEvents: Flow =
+ callbackFlow {
+ val callback =
+ object : DisplayListener {
+ override fun onDisplayAdded(displayId: Int) {
+ trySend(DisplayEvent.Added(displayId))
+ }
+
+ override fun onDisplayRemoved(displayId: Int) {
+ trySend(DisplayEvent.Removed(displayId))
+ }
+
+ override fun onDisplayChanged(displayId: Int) {
+ trySend(DisplayEvent.Changed(displayId))
+ }
+ }
+ displayManager.registerDisplayListener(
+ callback,
+ backgroundHandler,
+ EVENT_TYPE_DISPLAY_ADDED or
+ EVENT_TYPE_DISPLAY_CHANGED or
+ EVENT_TYPE_DISPLAY_REMOVED,
+ )
+ awaitClose { displayManager.unregisterDisplayListener(callback) }
+ }
+ .conflate()
+ .onStart { emit(DisplayEvent.Changed(Display.DEFAULT_DISPLAY)) }
+ .debugLog("allDisplayEvents")
+ .flowOn(backgroundCoroutineDispatcher)
+
+ override val displayChangeEvent: Flow =
+ allDisplayEvents.filterIsInstance().map { event -> event.displayId }
+
+ override val displayRemovalEvent: Flow =
+ allDisplayEvents.filterIsInstance().map { it.displayId }
+
+ // This is necessary because there might be multiple displays, and we could
+ // have missed events for those added before this process or flow started.
+ // Note it causes a binder call from the main thread (it's traced).
+ private val initialDisplays: Set = displayManager.displays?.toSet() ?: emptySet()
+ private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet()
+
+ /** Propagate to the listeners only enabled displays */
+ private val enabledDisplayIds: StateFlow> =
+ allDisplayEvents
+ .scan(initial = initialDisplayIds) { previousIds: Set, event: DisplayEvent ->
+ val id = event.displayId
+ when (event) {
+ is DisplayEvent.Removed -> previousIds - id
+ is DisplayEvent.Added,
+ is DisplayEvent.Changed -> previousIds + id
+ }
+ }
+ .distinctUntilChanged()
+ .debugLog("enabledDisplayIds")
+ .stateIn(bgApplicationScope, SharingStarted.WhileSubscribed(), initialDisplayIds)
+
+ private val defaultDisplay by lazy {
+ getDisplayFromDisplayManager(Display.DEFAULT_DISPLAY)
+ ?: error("Unable to get default display.")
+ }
+ /**
+ * Represents displays that went though the [DisplayListener.onDisplayAdded] callback.
+ *
+ * Those are commonly the ones provided by [DisplayManager.getDisplays] by default.
+ */
+ private val enabledDisplays: StateFlow> =
+ enabledDisplayIds
+ .mapElementsLazily { displayId -> getDisplayFromDisplayManager(displayId) }
+ .onEach {
+ if (it.isEmpty()) Log.wtf(TAG, "No enabled displays. This should never happen.")
+ }
+ .flowOn(backgroundCoroutineDispatcher)
+ .debugLog("enabledDisplays")
+ .stateIn(
+ bgApplicationScope,
+ started = SharingStarted.WhileSubscribed(),
+ // This triggers a single binder call on the UI thread per process. The
+ // alternative would be to use sharedFlows, but they are prohibited due to
+ // performance concerns.
+ // Ultimately, this is a trade-off between a one-time UI thread binder call and
+ // the constant overhead of sharedFlows.
+ initialValue = initialDisplays,
+ )
+
+ /**
+ * Represents displays that went though the [DisplayListener.onDisplayAdded] callback.
+ *
+ * Those are commonly the ones provided by [DisplayManager.getDisplays] by default.
+ */
+ override val displays: StateFlow> = enabledDisplays
+
+ override val displayIds: StateFlow> = enabledDisplayIds
+
+ /**
+ * Implementation that maps from [displays], instead of [allDisplayEvents] for 2 reasons:
+ * 1. Guarantee that it emits __after__ [displays] emitted. This way it is guaranteed that
+ * calling [getDisplay] for the newly added display will be non-null.
+ * 2. Reuse the existing instance of [Display] without a new call to [DisplayManager].
+ */
+ override val displayAdditionEvent: Flow =
+ displays
+ .pairwiseBy { previousDisplays, currentDisplays -> currentDisplays - previousDisplays }
+ .flatMapLatest { it.asFlow() }
+
+ val _ignoredDisplayIds = MutableStateFlow>(emptySet())
+ private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds")
+
+ private fun getInitialConnectedDisplays(): Set =
+ displayManager
+ .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED)
+ .map { it.displayId }
+ .toSet()
+ .also {
+ if (DEBUG) {
+ Log.d(TAG, "getInitialConnectedDisplays: $it")
+ }
+ }
+
+ /* keeps connected displays until they are disconnected. */
+ private val connectedDisplayIds: StateFlow> =
+ callbackFlow {
+ val connectedIds = getInitialConnectedDisplays().toMutableSet()
+ val callback =
+ object : DisplayConnectionListener {
+ override fun onDisplayConnected(id: Int) {
+ if (DEBUG) {
+ Log.d(TAG, "display with id=$id connected.")
+ }
+ connectedIds += id
+ _ignoredDisplayIds.value -= id
+ trySend(connectedIds.toSet())
+ }
+
+ override fun onDisplayDisconnected(id: Int) {
+ connectedIds -= id
+ if (DEBUG) {
+ Log.d(TAG, "display with id=$id disconnected.")
+ }
+ _ignoredDisplayIds.value -= id
+ trySend(connectedIds.toSet())
+ }
+ }
+ trySend(connectedIds.toSet())
+ displayManager.registerDisplayListener(
+ callback,
+ backgroundHandler,
+ /* eventFlags */ 0,
+ DisplayManager.PRIVATE_EVENT_TYPE_DISPLAY_CONNECTION_CHANGED,
+ )
+ awaitClose { displayManager.unregisterDisplayListener(callback) }
+ }
+ .conflate()
+ .distinctUntilChanged()
+ .debugLog("connectedDisplayIds")
+ .stateIn(
+ bgApplicationScope,
+ started = SharingStarted.WhileSubscribed(),
+ // The initial value is set to empty, but connected displays are gathered as soon as
+ // the flow starts being collected. This is to ensure the call to get displays (an
+ // IPC) happens in the background instead of when this object
+ // is instantiated.
+ initialValue = emptySet(),
+ )
+
+ private val connectedExternalDisplayIds: Flow> =
+ connectedDisplayIds
+ .map { connectedDisplayIds ->
+ connectedDisplayIds
+ .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL }
+ .toSet()
+ }
+ .flowOn(backgroundCoroutineDispatcher)
+ .debugLog("connectedExternalDisplayIds")
+
+ private fun getDisplayType(displayId: Int): Int? = displayManager.getDisplay(displayId)?.type
+
+ private fun getDisplayFromDisplayManager(displayId: Int): Display? = displayManager.getDisplay(displayId)
+
+ /**
+ * Pending displays are the ones connected, but not enabled and not ignored.
+ *
+ * A connected display is ignored after the user makes the decision to use it or not. For now,
+ * the initial decision from the user is final and not reversible.
+ */
+ private val pendingDisplayIds: Flow> =
+ combine(enabledDisplayIds, connectedExternalDisplayIds, ignoredDisplayIds) {
+ enabledDisplaysIds,
+ connectedExternalDisplayIds,
+ ignoredDisplayIds ->
+ if (DEBUG) {
+ Log.d(
+ TAG,
+ "combining enabled=$enabledDisplaysIds, " +
+ "connectedExternalDisplayIds=$connectedExternalDisplayIds, " +
+ "ignored=$ignoredDisplayIds",
+ )
+ }
+ connectedExternalDisplayIds - enabledDisplaysIds - ignoredDisplayIds
+ }
+ .debugLog("allPendingDisplayIds")
+
+ /** Which display id should be enabled among the pending ones. */
+ private val pendingDisplayId: Flow =
+ pendingDisplayIds.map { it.maxOrNull() }.distinctUntilChanged().debugLog("pendingDisplayId")
+
+ override val pendingDisplay: Flow =
+ pendingDisplayId
+ .map { displayId ->
+ val id = displayId ?: return@map null
+ object : DisplayRepository.PendingDisplay {
+ override val id = id
+
+ override suspend fun enable() {
+ if (DEBUG) {
+ Log.d(TAG, "Enabling display with id=$id")
+ }
+ displayManager.enableConnectedDisplay(id)
+ // After the display has been enabled, it is automatically ignored.
+ ignore()
+ }
+
+ override suspend fun ignore() {
+ _ignoredDisplayIds.value += id
+ }
+
+ override suspend fun disable() {
+ ignore()
+ if (DEBUG) {
+ Log.d(TAG, "Disabling display with id=$id")
+ }
+ displayManager.disableConnectedDisplay(id)
+ }
+ }
+ }
+ .debugLog("pendingDisplay")
+
+ override val defaultDisplayOff: Flow =
+ displayChangeEvent
+ .filter { it == Display.DEFAULT_DISPLAY }
+ .map { defaultDisplay.state == Display.STATE_OFF }
+ .distinctUntilChanged()
+
+ override fun getDisplay(displayId: Int): Display? {
+ val cachedDisplay = getCachedDisplay(displayId)
+ if (cachedDisplay != null) return cachedDisplay
+ // cachedDisplay could be null for 2 reasons:
+ // 1. the displayId is being mapped to a display in the background, but the binder call is
+ // not done
+ // 2. the display is not there
+ // In case of option one, let's get it synchronously from display manager to make sure for
+ // this to be consistent.
+ return if (displayIds.value.contains(displayId)) {
+ getDisplayFromDisplayManager(displayId)
+ } else {
+ null
+ }
+ }
+
+ private fun Flow.debugLog(flowName: String): Flow {
+ return if (DEBUG) {
+ // LC-Ignored
+ this
+ } else {
+ this
+ }
+ }
+
+ /**
+ * Maps a set of T to a set of V, minimizing the number of `createValue` calls taking into
+ * account the diff between each root flow emission.
+ *
+ * This is needed to minimize the number of [getDisplayFromDisplayManager] in this class. Note
+ * that if the [createValue] returns a null element, it will not be added in the output set.
+ */
+ private fun Flow>.mapElementsLazily(createValue: (T) -> V?): Flow> {
+ data class State(
+ val previousSet: Set,
+ // Caches T values from the previousSet that were already converted to V
+ val valueMap: Map,
+ val resultSet: Set,
+ )
+
+ val emptyInitialState = State(emptySet(), emptyMap(), emptySet())
+ return this.scan(emptyInitialState) { state, currentSet ->
+ if (currentSet == state.previousSet) {
+ state
+ } else {
+ val removed = state.previousSet - currentSet
+ val added = currentSet - state.previousSet
+ val newMap = state.valueMap.toMutableMap()
+
+ added.forEach { key -> createValue(key)?.let { newMap[key] = it } }
+ removed.forEach { key -> newMap.remove(key) }
+
+ val resultSet = newMap.values.toSet()
+ State(currentSet, newMap, resultSet)
+ }
+ }
+ .filter { it != emptyInitialState }
+ .map { it.resultSet }
+ }
+
+ private companion object {
+ const val TAG = "DisplayRepository"
+ val DEBUG = Log.isLoggable(TAG, Log.DEBUG)
+ }
+}
+
+/** Used to provide default implementations for all methods. */
+private interface DisplayConnectionListener : DisplayListener {
+
+ override fun onDisplayConnected(id: Int) {}
+
+ override fun onDisplayDisconnected(id: Int) {}
+
+ override fun onDisplayAdded(id: Int) {}
+
+ override fun onDisplayRemoved(id: Int) {}
+
+ override fun onDisplayChanged(id: Int) {}
+}
+
+private sealed interface DisplayEvent {
+ val displayId: Int
+
+ data class Added(override val displayId: Int) : DisplayEvent
+
+ data class Removed(override val displayId: Int) : DisplayEvent
+
+ data class Changed(override val displayId: Int) : DisplayEvent
+}
+
+/**
+ * Returns a new [Flow] that combines the two most recent emissions from [this] using [transform].
+ * Note that the new Flow will not start emitting until it has received two emissions from the
+ * upstream Flow.
+ *
+ * Useful for code that needs to compare the current value to the previous value.
+ *
+ * Note this has been taken from com.android.systemui.util.kotlin. It was copied to keep deps of
+ * displaylib minimal (and avoid creating a new shared lib for it).
+ */
+fun Flow.pairwiseBy(transform: suspend (old: T, new: T) -> R): Flow = flow {
+ val noVal = Any()
+ var previousValue: Any? = noVal
+ collect { newVal ->
+ if (previousValue != noVal) {
+ @Suppress("UNCHECKED_CAST") emit(transform(previousValue as T, newVal))
+ }
+ previousValue = newVal
+ }
+}
diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt
new file mode 100644
index 0000000..b184bd9
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt
@@ -0,0 +1,119 @@
+/*
+ * 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.app.displaylib
+
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.view.IDisplayWindowListener
+import android.view.IWindowManager
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.scan
+import kotlinx.coroutines.flow.stateIn
+
+/** Provides the displays with decorations. */
+interface DisplaysWithDecorationsRepository {
+ /** A [StateFlow] that maintains a set of display IDs that should have system decorations. */
+ val displayIdsWithSystemDecorations: StateFlow>
+}
+
+@Singleton
+class DisplaysWithDecorationsRepositoryImpl
+@Inject
+constructor(
+ private val windowManager: IWindowManager,
+ bgApplicationScope: CoroutineScope,
+ displayRepository: DisplayRepository,
+) : DisplaysWithDecorationsRepository {
+
+ private val decorationEvents: Flow = callbackFlow {
+ val callback =
+ object : IDisplayWindowListener.Stub() {
+ override fun onDisplayAddSystemDecorations(displayId: Int) {
+ trySend(Event.Add(displayId))
+ }
+
+ override fun onDisplayRemoveSystemDecorations(displayId: Int) {
+ trySend(Event.Remove(displayId))
+ }
+
+ override fun onDesktopModeEligibleChanged(displayId: Int) {}
+
+ override fun onDisplayAdded(p0: Int) {}
+
+ override fun onDisplayConfigurationChanged(p0: Int, p1: Configuration?) {}
+
+ override fun onDisplayRemoved(p0: Int) {}
+
+ override fun onFixedRotationStarted(p0: Int, p1: Int) {}
+
+ override fun onFixedRotationFinished(p0: Int) {}
+
+ override fun onKeepClearAreasChanged(
+ p0: Int,
+ p1: MutableList?,
+ p2: MutableList?,
+ ) {}
+ }
+ windowManager.registerDisplayWindowListener(callback)
+ awaitClose { windowManager.unregisterDisplayWindowListener(callback) }
+ }
+
+ private val initialDisplayIdsWithDecorations: Set =
+ displayRepository.displayIds.value
+ .filter { windowManager.shouldShowSystemDecors(it) }
+ .toSet()
+
+ /**
+ * A [StateFlow] that maintains a set of display IDs that should have system decorations.
+ *
+ * Updates to the set are triggered by:
+ * - Removing displays via [displayRemovalEvent] emissions.
+ *
+ * The set is initialized with displays that qualify for system decorations based on
+ * [WindowManager.shouldShowSystemDecors].
+ */
+ override val displayIdsWithSystemDecorations: StateFlow> =
+ merge(decorationEvents, displayRepository.displayRemovalEvent.map { Event.Remove(it) })
+ .scan(initialDisplayIdsWithDecorations) { displayIds: Set, event: Event ->
+ when (event) {
+ is Event.Add -> displayIds + event.displayId
+ is Event.Remove -> displayIds - event.displayId
+ }
+ }
+ .distinctUntilChanged()
+ .stateIn(
+ scope = bgApplicationScope,
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = initialDisplayIdsWithDecorations,
+ )
+
+ private sealed class Event(val displayId: Int) {
+ class Add(displayId: Int) : Event(displayId)
+
+ class Remove(displayId: Int) : Event(displayId)
+ }
+}
diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt
new file mode 100644
index 0000000..66aa7cc
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.app.displaylib
+
+import com.android.internal.annotations.GuardedBy
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+/** Listener for display system decorations changes. */
+interface DisplayDecorationListener {
+ /** Called when system decorations should be added to the display.* */
+ fun onDisplayAddSystemDecorations(displayId: Int)
+
+ /** Called when a display is removed. */
+ fun onDisplayRemoved(displayId: Int)
+
+ /** Called when system decorations should be removed from the display. */
+ fun onDisplayRemoveSystemDecorations(displayId: Int)
+}
+
+/**
+ * This class is a compatibility layer that allows to register and unregister listeners for display
+ * decorations changes. It uses a [DisplaysWithDecorationsRepository] to get the current list of
+ * displays with decorations and notifies the listeners when the list changes.
+ */
+@Singleton
+class DisplaysWithDecorationsRepositoryCompat
+@Inject
+constructor(
+ private val bgApplicationScope: CoroutineScope,
+ private val displayRepository: DisplaysWithDecorationsRepository,
+) {
+ private val mutex = Mutex()
+ private var collectorJob: Job? = null
+ private val displayDecorationListenersWithDispatcher =
+ ConcurrentHashMap()
+
+ /**
+ * Registers a [DisplayDecorationListener] to be notified when the list of displays with
+ * decorations changes.
+ *
+ * @param listener The listener to register.
+ * @param dispatcher The dispatcher to use when notifying the listener.
+ */
+ fun registerDisplayDecorationListener(
+ listener: DisplayDecorationListener,
+ dispatcher: CoroutineDispatcher,
+ ) {
+ var initialDisplayIdsForListener: Set = emptySet()
+ bgApplicationScope.launch {
+ mutex.withLock {
+ displayDecorationListenersWithDispatcher[listener] = dispatcher
+ initialDisplayIdsForListener =
+ displayRepository.displayIdsWithSystemDecorations.value
+ startCollectingIfNeeded(initialDisplayIdsForListener)
+ }
+ // Emit all the existing displays with decorations when registering.
+ initialDisplayIdsForListener.forEach { displayId ->
+ withContext(dispatcher) { listener.onDisplayAddSystemDecorations(displayId) }
+ }
+ }
+ }
+
+ /**
+ * Unregisters a [DisplayDecorationListener].
+ *
+ * @param listener The listener to unregister.
+ */
+ fun unregisterDisplayDecorationListener(listener: DisplayDecorationListener) {
+ bgApplicationScope.launch {
+ mutex.withLock {
+ displayDecorationListenersWithDispatcher.remove(listener)
+ // stop collecting if no listeners
+ if (displayDecorationListenersWithDispatcher.isEmpty()) {
+ collectorJob?.cancel()
+ collectorJob = null
+ }
+ }
+ }
+ }
+
+ @GuardedBy("mutex")
+ private fun startCollectingIfNeeded(lastDisplaysWithDecorations: Set) {
+ if (collectorJob?.isActive == true) {
+ return
+ }
+ var oldDisplays: Set = lastDisplaysWithDecorations
+ collectorJob =
+ bgApplicationScope.launch {
+ displayRepository.displayIdsWithSystemDecorations.collect { currentDisplays ->
+ val previous = oldDisplays
+ oldDisplays = currentDisplays
+
+ val newDisplaysWithDecorations = currentDisplays - previous
+ val removedDisplays = previous - currentDisplays
+ displayDecorationListenersWithDispatcher.forEach { (listener, dispatcher) ->
+ withContext(dispatcher) {
+ newDisplaysWithDecorations.forEach { displayId ->
+ listener.onDisplayAddSystemDecorations(displayId)
+ }
+ removedDisplays.forEach { displayId ->
+ listener.onDisplayRemoveSystemDecorations(displayId)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt
new file mode 100644
index 0000000..c80315b
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.app.displaylib
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Reports the display ids that should have a per-display instance, if any.
+ *
+ * This can be overridden to support different policies (e.g. display being connected, display
+ * having decorations, etc..). A [PerDisplayRepository] instance is expected to be cleaned up when a
+ * displayId is removed from this set.
+ */
+interface DisplayInstanceLifecycleManager {
+ /** Set of display ids that are allowed to have an instance. */
+ val displayIds: StateFlow>
+}
+
+/** Meant to be used in tests. */
+class FakeDisplayInstanceLifecycleManager : DisplayInstanceLifecycleManager {
+ override val displayIds = MutableStateFlow>(emptySet())
+}
diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt
new file mode 100644
index 0000000..74bc572
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt
@@ -0,0 +1,291 @@
+/*
+ * 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.app.displaylib
+
+import android.util.Log
+import android.view.Display
+import android.view.Display.DEFAULT_DISPLAY
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+import javax.inject.Qualifier
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+
+/**
+ * Used to create instances of type `T` for a specific display.
+ *
+ * This is useful for resources or objects that need to be managed independently for each connected
+ * display (e.g., UI state, rendering contexts, or display-specific configurations).
+ *
+ * Note that in most cases this can be implemented by a simple `@AssistedFactory` with `displayId`
+ * parameter
+ *
+ * ```kotlin
+ * class SomeType @AssistedInject constructor(@Assisted displayId: Int,..)
+ * @AssistedFactory
+ * interface Factory {
+ * fun create(displayId: Int): SomeType
+ * }
+ * }
+ * ```
+ *
+ * Then it can be used to create a [PerDisplayRepository] as follows:
+ * ```kotlin
+ * // Injected:
+ * val repositoryFactory: PerDisplayRepositoryImpl.Factory
+ * val instanceFactory: PerDisplayRepositoryImpl.Factory
+ * // repository creation:
+ * repositoryFactory.create(instanceFactory::create)
+ * ```
+ *
+ * @see PerDisplayRepository For how to retrieve and manage instances created by this factory.
+ */
+fun interface PerDisplayInstanceProvider {
+ /** Creates an instance for a display. */
+ fun createInstance(displayId: Int): T?
+}
+
+/**
+ * Extends [PerDisplayInstanceProvider], adding support for destroying the instance.
+ *
+ * This is useful for releasing resources associated with a display when it is disconnected or when
+ * the per-display instance is no longer needed.
+ */
+interface PerDisplayInstanceProviderWithTeardown : PerDisplayInstanceProvider {
+ /** Destroys a previously created instance of `T` forever. */
+ fun destroyInstance(instance: T)
+}
+
+/**
+ * Provides access to per-display instances of type `T`.
+ *
+ * Acts as a repository, managing the caching and retrieval of instances created by a
+ * [PerDisplayInstanceProvider]. It ensures that only one instance of `T` exists per display ID.
+ */
+interface PerDisplayRepository {
+ /** Gets the cached instance or create a new one for a given display. */
+ operator fun get(displayId: Int): T?
+
+ /** Debug name for this repository, mainly for tracing and logging. */
+ val debugName: String
+
+ /**
+ * Callback to run when a given repository is initialized.
+ *
+ * This allows the caller to perform custom logic when the repository is ready to be used, e.g.
+ * register to dumpManager.
+ *
+ * Note that the instance is *leaked* outside of this class, so it should only be done when
+ * repository is meant to live as long as the caller. In systemUI this is ok because the
+ * repository lives as long as the process itself.
+ */
+ fun interface InitCallback {
+ fun onInit(debugName: String, instance: Any)
+ }
+
+ /**
+ * Iterate over all the available displays performing the action on each object of type T.
+ *
+ * @param createIfAbsent If true, create instances of T if they are not already created. If
+ * false, do not and skip calling action..
+ * @param action The action to perform on each instance.
+ */
+ fun forEach(createIfAbsent: Boolean, action: Consumer)
+}
+
+/** Qualifier for [CoroutineScope] used for displaylib background tasks. */
+@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class DisplayLibBackground
+
+/**
+ * Default implementation of [PerDisplayRepository].
+ *
+ * This class manages a cache of per-display instances of type `T`, creating them using a provided
+ * [PerDisplayInstanceProvider] and optionally tearing them down using a
+ * [PerDisplayInstanceProviderWithTeardown] when based on [lifecycleManager].
+ *
+ * An instance will be destroyed when either
+ * - The display is not connected anymore
+ * - or based on [lifecycleManager]. If no lifecycle manager is provided, instances are destroyed
+ * when the display is disconnected.
+ *
+ * [DisplayInstanceLifecycleManager] can decide to delete instances for a display even before it is
+ * disconnected. An example of usecase for it, is to delete instances when screen decorations are
+ * removed.
+ *
+ * Note that this is a [PerDisplayStoreImpl] 2.0 that doesn't require [CoreStartable] bindings,
+ * providing all args in the constructor.
+ */
+class PerDisplayInstanceRepositoryImpl
+@AssistedInject
+constructor(
+ @Assisted override val debugName: String,
+ @Assisted private val instanceProvider: PerDisplayInstanceProvider,
+ @Assisted lifecycleManager: DisplayInstanceLifecycleManager? = null,
+ @DisplayLibBackground bgApplicationScope: CoroutineScope,
+ private val displayRepository: DisplayRepository,
+ private val initCallback: PerDisplayRepository.InitCallback,
+) : PerDisplayRepository {
+
+ private val perDisplayInstances = ConcurrentHashMap()
+
+ private val allowedDisplays: StateFlow> =
+ (if (lifecycleManager == null) {
+ displayRepository.displayIds
+ } else {
+ // If there is a lifecycle manager, we still consider the smallest subset between
+ // the ones connected and the ones from the lifecycle. This is to safeguard against
+ // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and
+ // we can't guarantee it's correct).
+ combine(lifecycleManager.displayIds, displayRepository.displayIds) {
+ lifecycleAllowedDisplayIds,
+ connectedDisplays ->
+ lifecycleAllowedDisplayIds.intersect(connectedDisplays)
+ }
+ }) as StateFlow>
+
+ init {
+ bgApplicationScope.launch { start() }
+ }
+
+ private suspend fun start() {
+ initCallback.onInit(debugName, this)
+ allowedDisplays.collectLatest { displayIds ->
+ val toRemove = perDisplayInstances.keys - displayIds
+ toRemove.forEach { displayId ->
+ Log.d(TAG, "<$debugName> destroying instance for displayId=$displayId.")
+ perDisplayInstances.remove(displayId)?.let { instance ->
+ (instanceProvider as? PerDisplayInstanceProviderWithTeardown)?.destroyInstance(
+ instance
+ )
+ }
+ }
+ }
+ }
+
+ override fun get(displayId: Int): T? {
+ if (!displayRepository.containsDisplay(displayId)) {
+ Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.")
+ return null
+ }
+
+ if (displayId !in allowedDisplays.value) {
+ Log.e(
+ TAG,
+ "<$debugName: Display with id $displayId exists but it's not " +
+ "allowed by lifecycle manager.",
+ )
+ return null
+ }
+
+ // If it doesn't exist, create it and put it in the map.
+ return perDisplayInstances.computeIfAbsent(displayId) { key ->
+ Log.d(TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.")
+ val instance = instanceProvider.createInstance(key)
+ if (instance == null) {
+ Log.e(
+ TAG,
+ "<$debugName> returning null because createInstance($key) returned null.",
+ )
+ }
+ instance
+ }
+ }
+
+ @AssistedFactory
+ interface Factory {
+ fun create(
+ debugName: String,
+ instanceProvider: PerDisplayInstanceProvider,
+ overrideLifecycleManager: DisplayInstanceLifecycleManager? = null,
+ ): PerDisplayInstanceRepositoryImpl
+ }
+
+ companion object {
+ private const val TAG = "PerDisplayInstanceRepo"
+ }
+
+ override fun toString(): String {
+ return "PerDisplayInstanceRepositoryImpl(" +
+ "debugName='$debugName', instances=$perDisplayInstances)"
+ }
+
+ override fun forEach(createIfAbsent: Boolean, action: Consumer) {
+ if (createIfAbsent) {
+ allowedDisplays.value.forEach { displayId -> get(displayId)?.let { action.accept(it) } }
+ } else {
+ perDisplayInstances.forEach { (_, instance) -> instance?.let { action.accept(it) } }
+ }
+ }
+}
+
+/**
+ * Provides an instance of a given class **only** for the default display, even if asked for another
+ * display.
+ *
+ * This is useful in case of **flag refactors**: it can be provided instead of an instance of
+ * [PerDisplayInstanceRepositoryImpl] when a flag related to multi display refactoring is off.
+ *
+ * Note that this still requires all instances to be provided by a [PerDisplayInstanceProvider]. If
+ * you want to provide an existing instance instead for the default display, either implement it in
+ * a custom [PerDisplayInstanceProvider] (e.g. inject it in the constructor and return it if the
+ * displayId is zero), or use [SingleInstanceRepositoryImpl].
+ */
+class DefaultDisplayOnlyInstanceRepositoryImpl(
+ override val debugName: String,
+ private val instanceProvider: PerDisplayInstanceProvider,
+) : PerDisplayRepository {
+ private val lazyDefaultDisplayInstanceDelegate = lazy {
+ instanceProvider.createInstance(Display.DEFAULT_DISPLAY)
+ }
+ private val lazyDefaultDisplayInstance by lazyDefaultDisplayInstanceDelegate
+
+ override fun get(displayId: Int): T? = lazyDefaultDisplayInstance
+
+ override fun forEach(createIfAbsent: Boolean, action: Consumer) {
+ if (createIfAbsent) {
+ get(DEFAULT_DISPLAY)?.let { action.accept(it) }
+ } else {
+ if (lazyDefaultDisplayInstanceDelegate.isInitialized()) {
+ lazyDefaultDisplayInstance?.let { action.accept(it) }
+ }
+ }
+ }
+}
+
+/**
+ * Always returns [instance] for any display.
+ *
+ * This can be used to provide a single instance based on a flag value during a refactor. Similar to
+ * [DefaultDisplayOnlyInstanceRepositoryImpl], but also avoids creating the
+ * [PerDisplayInstanceProvider]. This is useful when you want to provide an existing instance only,
+ * without even instantiating a [PerDisplayInstanceProvider].
+ */
+class SingleInstanceRepositoryImpl(override val debugName: String, private val instance: T) :
+ PerDisplayRepository {
+ override fun get(displayId: Int): T? = instance
+
+ override fun forEach(createIfAbsent: Boolean, action: Consumer) {
+ action.accept(instance)
+ }
+}
diff --git a/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt
new file mode 100644
index 0000000..c832462
--- /dev/null
+++ b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.app.displaylib.fakes
+
+import com.android.app.displaylib.PerDisplayRepository
+import java.util.function.Consumer
+
+/** Fake version of [PerDisplayRepository], to be used in tests. */
+class FakePerDisplayRepository(private val defaultIfAbsent: ((Int) -> T)? = null) :
+ PerDisplayRepository {
+
+ private val instances = mutableMapOf()
+
+ fun add(displayId: Int, instance: T) {
+ instances[displayId] = instance
+ }
+
+ fun remove(displayId: Int) {
+ instances.remove(displayId)
+ }
+
+ override fun get(displayId: Int): T? {
+ return if (defaultIfAbsent != null) {
+ instances.getOrPut(displayId) { defaultIfAbsent(displayId) }
+ } else {
+ instances[displayId]
+ }
+ }
+
+ override val debugName: String
+ get() = "FakePerDisplayRepository"
+
+ override fun forEach(createIfAbsent: Boolean, action: Consumer) {
+ instances.forEach { (_, t) -> action.accept(t) }
+ }
+}
diff --git a/displaylib/tests/Android.bp b/displaylib/tests/Android.bp
new file mode 100644
index 0000000..2c7d115
--- /dev/null
+++ b/displaylib/tests/Android.bp
@@ -0,0 +1,34 @@
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+ name: "displaylib_tests",
+ manifest: "AndroidManifest.xml",
+ static_libs: [
+ "displaylib",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "truth",
+ "//frameworks/libs/systemui:tracinglib-platform",
+ ],
+ srcs: [
+ "tests/src/**/*.kt",
+ ],
+ kotlincflags: ["-Xjvm-default=all"],
+ test_suites: ["device-tests"],
+}
diff --git a/displaylib/tests/AndroidManifest.xml b/displaylib/tests/AndroidManifest.xml
new file mode 100644
index 0000000..b45a4ec
--- /dev/null
+++ b/displaylib/tests/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt
new file mode 100644
index 0000000..81a26cb
--- /dev/null
+++ b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.app.displaylib
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import org.junit.runner.RunWith
+
+/**
+ * Tests for display repository are in SystemUI:
+ * frameworks/base/packages/SystemUI/multivalentTestsForDevice/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt
+ *
+ * This is because the repository was initially there, and tests depend on kosmos for dependency
+ * injection (which is sysui-specific).
+ *
+ * In case of changes, update tests in sysui.
+ */
+@SmallTest @RunWith(AndroidJUnit4::class) class DisplayRepositoryTest
diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp
index 6867e6b..e991888 100644
--- a/iconloaderlib/Android.bp
+++ b/iconloaderlib/Android.bp
@@ -19,30 +19,41 @@ package {
android_library {
name: "iconloader_base",
sdk_version: "current",
- min_sdk_version: "26",
+ min_sdk_version: "31",
static_libs: [
"androidx.core_core",
+ "com_android_launcher3_flags_lib",
+ "com_android_systemui_shared_flags_lib",
],
resource_dirs: [
"res",
],
srcs: [
"src/**/*.java",
+ "src/**/*.kt",
],
}
android_library {
name: "iconloader",
sdk_version: "system_current",
- min_sdk_version: "26",
+ min_sdk_version: "31",
static_libs: [
"androidx.core_core",
+ "com_android_launcher3_flags_lib",
+ "com_android_systemui_shared_flags_lib",
],
resource_dirs: [
"res",
],
srcs: [
"src/**/*.java",
+ "src/**/*.kt",
"src_full_lib/**/*.java",
+ "src_full_lib/**/*.kt",
+ ],
+ apex_available: [
+ "//apex_available:platform",
+ "com.android.permission",
],
}
diff --git a/iconloaderlib/res/values/config.xml b/iconloaderlib/res/values/config.xml
index 71a38f2..893f955 100644
--- a/iconloaderlib/res/values/config.xml
+++ b/iconloaderlib/res/values/config.xml
@@ -27,7 +27,4 @@
-
- false
-
\ No newline at end of file
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
index f5a16dc..5523ecb 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java
@@ -7,6 +7,7 @@
import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction;
import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT;
+import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR;
import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR;
import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS;
@@ -19,21 +20,21 @@
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
+import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PaintFlagsDrawFilter;
import android.graphics.Path;
import android.graphics.Rect;
+import android.graphics.Shader.TileMode;
import android.graphics.drawable.AdaptiveIconDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.DrawableWrapper;
import android.graphics.drawable.InsetDrawable;
import android.os.Build;
import android.os.UserHandle;
-import android.util.Log;
import android.util.SparseArray;
import androidx.annotation.ColorInt;
@@ -43,7 +44,6 @@
import com.android.launcher3.Flags;
import com.android.launcher3.icons.BitmapInfo.Extender;
-import com.android.launcher3.icons.mono.ThemedIconDrawable;
import com.android.launcher3.util.FlagOp;
import com.android.launcher3.util.UserIconInfo;
@@ -99,15 +99,15 @@ public class BaseIconFactory implements AutoCloseable {
@Nullable
private ShadowGenerator mShadowGenerator;
- // Shadow bitmap used as background for theme icons
+ /** Shadow bitmap used as background for theme icons */
private Bitmap mWhiteShadowLayer;
+ /** Bitmap used for {@link BitmapShader} to mask Adaptive Icons when drawing */
+ private Bitmap mShaderBitmap;
private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND;
private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245);
- private final boolean mShouldForceThemeIcon;
-
protected BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize,
boolean unused) {
this(context, fullResIconDpi, iconBitmapSize);
@@ -123,9 +123,6 @@ public BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize)
mCanvas = new Canvas();
mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG));
clear();
-
- mShouldForceThemeIcon = mContext.getResources().getBoolean(
- R.bool.enable_forced_themed_icon);
}
protected void clear() {
@@ -178,7 +175,7 @@ public BitmapInfo createIconBitmap(String placeholder, int color) {
AdaptiveIconDrawable drawable = new AdaptiveIconDrawable(
new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR),
new CenterTextDrawable(placeholder, color));
- Bitmap icon = createIconBitmap(drawable, IconNormalizer.ICON_VISIBLE_AREA_FACTOR);
+ Bitmap icon = createIconBitmap(drawable, ICON_VISIBLE_AREA_FACTOR);
return BitmapInfo.of(icon, color);
}
@@ -198,8 +195,9 @@ public AdaptiveIconDrawable createShapedAdaptiveIcon(Bitmap iconBitmap) {
Drawable drawable = new FixedSizeBitmapDrawable(iconBitmap);
float inset = getExtraInsetFraction();
inset = inset / (1 + 2 * inset);
- return new AdaptiveIconDrawable(new ColorDrawable(Color.BLACK),
- new InsetDrawable(drawable, inset, inset, inset, inset));
+ return new AdaptiveIconDrawable(new ColorDrawable(BLACK),
+ new InsetDrawable(drawable, inset, inset, inset, inset)
+ );
}
@NonNull
@@ -231,7 +229,6 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon,
AdaptiveIconDrawable adaptiveIcon = normalizeAndWrapToAdaptiveIcon(tempIcon, scale);
Bitmap bitmap = createIconBitmap(adaptiveIcon, scale[0],
options == null ? MODE_WITH_SHADOW : options.mGenerationMode);
-
int color = (options != null && options.mExtractedColor != null)
? options.mExtractedColor : ColorExtractor.findDominantColorByHue(bitmap);
BitmapInfo info = BitmapInfo.of(bitmap, color);
@@ -248,7 +245,11 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon,
)
);
}
- info = info.withFlags(getBitmapFlagOp(options));
+ FlagOp flagOp = getBitmapFlagOp(options);
+ if (adaptiveIcon instanceof WrappedAdaptiveIcon) {
+ flagOp = flagOp.addFlag(BitmapInfo.FLAG_WRAPPED_NON_ADAPTIVE);
+ }
+ info = info.withFlags(flagOp);
return info;
}
@@ -271,13 +272,6 @@ public FlagOp getBitmapFlagOp(@Nullable IconOptions options) {
return op;
}
- /**
- * @return True if forced theme icon is enabled
- */
- public boolean shouldForceThemeIcon() {
- return mShouldForceThemeIcon;
- }
-
@NonNull
protected UserIconInfo getUserInfo(@NonNull UserHandle user) {
int key = user.hashCode();
@@ -301,10 +295,6 @@ public Path getShapePath(AdaptiveIconDrawable drawable, Rect iconBounds) {
return drawable.getIconMask();
}
- public float getIconScale() {
- return 1f;
- }
-
@NonNull
public Bitmap getWhiteShadowLayer() {
if (mWhiteShadowLayer == null) {
@@ -315,6 +305,42 @@ public Bitmap getWhiteShadowLayer() {
return mWhiteShadowLayer;
}
+ /**
+ * Takes an {@link AdaptiveIconDrawable} and uses it to create a new Shader Bitmap.
+ * {@link mShaderBitmap} will be used to create a {@link BitmapShader} for masking,
+ * such as for icon shapes. Will reuse underlying Bitmap where possible.
+ *
+ * @param adaptiveIcon AdaptiveIconDrawable to draw with shader
+ */
+ @NonNull
+ private Bitmap getAdaptiveShaderBitmap(AdaptiveIconDrawable adaptiveIcon) {
+ Rect bounds = adaptiveIcon.getBounds();
+ int iconWidth = bounds.width();
+ int iconHeight = bounds.width();
+
+ BitmapRenderer shaderRenderer = new BitmapRenderer() {
+ @Override
+ public void draw(Canvas canvas) {
+ canvas.translate(-bounds.left, -bounds.top);
+ canvas.drawColor(BLACK);
+ if (adaptiveIcon.getBackground() != null) {
+ adaptiveIcon.getBackground().draw(canvas);
+ }
+ if (adaptiveIcon.getForeground() != null) {
+ adaptiveIcon.getForeground().draw(canvas);
+ }
+ }
+ };
+ if (mShaderBitmap == null || iconWidth != mShaderBitmap.getWidth()
+ || iconHeight != mShaderBitmap.getHeight()) {
+ mShaderBitmap = BitmapRenderer.createSoftwareBitmap(iconWidth, iconHeight,
+ shaderRenderer);
+ } else {
+ shaderRenderer.draw(new Canvas(mShaderBitmap));
+ }
+ return mShaderBitmap;
+ }
+
@NonNull
public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) {
float[] scale = new float[1];
@@ -395,6 +421,8 @@ public AdaptiveIconDrawable wrapToAdaptiveIcon(@NonNull Drawable icon) {
int wrapperBackgroundColor = IconPreferencesKt.getWrapperBackgroundColor(mContext, icon);
FixedScaleDrawable foreground = new FixedScaleDrawable();
+ // pE-TODO(QPR1): Investigate
+ // foreground = createScaledDrawable(icon, scale * LEGACY_ICON_SCALE)
CustomAdaptiveIconDrawable dr = new CustomAdaptiveIconDrawable(
new ColorDrawable(wrapperBackgroundColor), foreground);
dr.setBounds(0, 0, 1, 1);
@@ -423,7 +451,7 @@ public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale,
case MODE_HARDWARE:
case MODE_HARDWARE_WITH_SHADOW: {
return BitmapRenderer.createHardwareBitmap(size, size, canvas ->
- drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null));
+ drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null));
}
case MODE_WITH_SHADOW:
default:
@@ -465,6 +493,7 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon,
} else {
drawAdaptiveIcon(canvas, aid, shapePath);
}
+
canvas.restoreToCount(count);
} else {
if (icon instanceof BitmapDrawable) {
@@ -512,28 +541,28 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon,
}
/**
- * Draws AdaptiveIconDrawable onto canvas.
- * @param canvas canvas to draw on
- * @param drawable AdaptiveIconDrawable to draw
- * @param overridePath path to clip icon with for shapes
+ * Draws AdaptiveIconDrawable onto canvas using provided Path
+ * and {@link mShaderBitmap} as a shader.
+ *
+ * @param canvas canvas to draw on
+ * @param drawable AdaptiveIconDrawable to draw
+ * @param shapePath path to clip icon with for shapes
*/
protected void drawAdaptiveIcon(
@NonNull Canvas canvas,
@NonNull AdaptiveIconDrawable drawable,
- @NonNull Path overridePath
+ @NonNull Path shapePath
) {
- if (!Flags.enableLauncherIconShapes()) {
+ Drawable background = drawable.getBackground();
+ Drawable foreground = drawable.getForeground();
+ if (!Flags.enableLauncherIconShapes() || (background == null && foreground == null)) {
drawable.draw(canvas);
return;
}
- canvas.clipPath(overridePath);
- canvas.drawColor(BLACK);
- if (drawable.getBackground() != null) {
- drawable.getBackground().draw(canvas);
- }
- if (drawable.getForeground() != null) {
- drawable.getForeground().draw(canvas);
- }
+ Bitmap shaderBitmap = getAdaptiveShaderBitmap(drawable);
+ Paint paint = new Paint();
+ paint.setShader(new BitmapShader(shaderBitmap, TileMode.CLAMP, TileMode.CLAMP));
+ canvas.drawPath(shapePath, paint);
}
@Override
@@ -700,16 +729,10 @@ public void draw(Canvas canvas) {
}
}
- private static class EmptyWrapper extends DrawableWrapper {
+ private static class WrappedAdaptiveIcon extends AdaptiveIconDrawable {
- EmptyWrapper() {
- super(new ColorDrawable());
- }
-
- @Override
- public ConstantState getConstantState() {
- Drawable d = getDrawable();
- return d == null ? null : d.getConstantState();
+ WrappedAdaptiveIcon(Drawable backgroundDrawable, Drawable foregroundDrawable) {
+ super(backgroundDrawable, foregroundDrawable);
}
}
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
deleted file mode 100644
index 62ca2ed..0000000
--- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
+++ /dev/null
@@ -1,268 +0,0 @@
-/*
- * Copyright (C) 2017 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.launcher3.icons;
-
-import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG;
-
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.Bitmap.Config;
-import android.graphics.Canvas;
-import android.graphics.Path;
-import android.graphics.drawable.Drawable;
-
-import androidx.annotation.IntDef;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.launcher3.icons.cache.CacheLookupFlag;
-import com.android.launcher3.util.FlagOp;
-
-public class BitmapInfo {
-
- public static final int FLAG_WORK = 1 << 0;
- public static final int FLAG_INSTANT = 1 << 1;
- public static final int FLAG_CLONE = 1 << 2;
- public static final int FLAG_PRIVATE = 1 << 3;
- @IntDef(flag = true, value = {
- FLAG_WORK,
- FLAG_INSTANT,
- FLAG_CLONE,
- FLAG_PRIVATE
- })
- @interface BitmapInfoFlags {}
-
- public static final int FLAG_THEMED = 1 << 0;
- public static final int FLAG_NO_BADGE = 1 << 1;
- public static final int FLAG_SKIP_USER_BADGE = 1 << 2;
- @IntDef(flag = true, value = {
- FLAG_THEMED,
- FLAG_NO_BADGE,
- FLAG_SKIP_USER_BADGE,
- })
- public @interface DrawableCreationFlags {}
-
- public static final Bitmap LOW_RES_ICON = Bitmap.createBitmap(1, 1, Config.ALPHA_8);
- public static final BitmapInfo LOW_RES_INFO = fromBitmap(LOW_RES_ICON);
-
- public static final String TAG = "BitmapInfo";
-
- @NonNull
- public final Bitmap icon;
- public final int color;
-
- @Nullable
- private ThemedBitmap mThemedBitmap;
-
- public @BitmapInfoFlags int flags;
-
- // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps
- public @DrawableCreationFlags int creationFlags;
-
- private BitmapInfo badgeInfo;
-
- public BitmapInfo(@NonNull Bitmap icon, int color) {
- this.icon = icon;
- this.color = color;
- }
-
- public BitmapInfo withBadgeInfo(BitmapInfo badgeInfo) {
- BitmapInfo result = clone();
- result.badgeInfo = badgeInfo;
- return result;
- }
-
- /**
- * Returns a bitmapInfo with the flagOP applied
- */
- public BitmapInfo withFlags(@NonNull FlagOp op) {
- if (op == FlagOp.NO_OP) {
- return this;
- }
- BitmapInfo result = clone();
- result.flags = op.apply(result.flags);
- return result;
- }
-
- protected BitmapInfo copyInternalsTo(BitmapInfo target) {
- target.mThemedBitmap = mThemedBitmap;
- target.flags = flags;
- target.badgeInfo = badgeInfo;
- return target;
- }
-
- @Override
- public BitmapInfo clone() {
- return copyInternalsTo(new BitmapInfo(icon, color));
- }
-
- public void setThemedBitmap(@Nullable ThemedBitmap themedBitmap) {
- mThemedBitmap = themedBitmap;
- }
-
- @Nullable
- public ThemedBitmap getThemedBitmap() {
- return mThemedBitmap;
- }
-
- /**
- * Ideally icon should not be null, except in cases when generating hardware bitmap failed
- */
- public final boolean isNullOrLowRes() {
- return icon == null || icon == LOW_RES_ICON;
- }
-
- public final boolean isLowRes() {
- return LOW_RES_ICON == icon;
- }
-
- /**
- * Returns the lookup flag to match this current state of this info
- */
- public CacheLookupFlag getMatchingLookupFlag() {
- return DEFAULT_LOOKUP_FLAG.withUseLowRes(isLowRes());
- }
-
- /**
- * BitmapInfo can be stored on disk or other persistent storage
- */
- public boolean canPersist() {
- return !isNullOrLowRes();
- }
-
- /**
- * Creates a drawable for the provided BitmapInfo
- */
- public FastBitmapDrawable newIcon(Context context) {
- return newIcon(context, 0);
- }
-
- /**
- * Creates a drawable for the provided BitmapInfo
- */
- public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags) {
- return newIcon(context, creationFlags, null);
- }
-
- /**
- * Creates a drawable for the provided BitmapInfo
- *
- * @param context Context
- * @param creationFlags Flags for creating the FastBitmapDrawable
- * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100.
- * @return FastBitmapDrawable
- */
- public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags,
- @Nullable Path badgeShape) {
- FastBitmapDrawable drawable;
- if (isLowRes()) {
- drawable = new PlaceHolderIconDrawable(this, context);
- } else if ((creationFlags & FLAG_THEMED) != 0 && mThemedBitmap != null) {
- drawable = mThemedBitmap.newDrawable(this, context);
- } else {
- drawable = new FastBitmapDrawable(this);
- }
- applyFlags(context, drawable, creationFlags, badgeShape);
- return drawable;
- }
-
- protected void applyFlags(Context context, FastBitmapDrawable drawable,
- @DrawableCreationFlags int creationFlags, @Nullable Path badgeShape) {
- this.creationFlags = creationFlags;
- drawable.mDisabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f);
- drawable.mCreationFlags = creationFlags;
- if ((creationFlags & FLAG_NO_BADGE) == 0) {
- Drawable badge = getBadgeDrawable(context, (creationFlags & FLAG_THEMED) != 0,
- (creationFlags & FLAG_SKIP_USER_BADGE) != 0, badgeShape);
- if (badge != null) {
- drawable.setBadge(badge);
- }
- }
- }
-
- /**
- * Gets Badge drawable based on current flags
- * @param context Context
- * @param isThemed If Drawable is themed.
- * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100.
- * @return Drawable for the badge.
- */
- public Drawable getBadgeDrawable(Context context, boolean isThemed, @Nullable Path badgeShape) {
- return getBadgeDrawable(context, isThemed, false, badgeShape);
- }
-
-
- /**
- * Creates a Drawable for an icon badge for this BitmapInfo
- * @param context Context
- * @param isThemed If the drawable is themed.
- * @param skipUserBadge If should skip User Profile badging.
- * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100.
- * @return Drawable for an icon Badge.
- */
- @Nullable
- private Drawable getBadgeDrawable(Context context, boolean isThemed, boolean skipUserBadge,
- @Nullable Path badgeShape) {
- if (badgeInfo != null) {
- int creationFlag = isThemed ? FLAG_THEMED : 0;
- if (skipUserBadge) {
- creationFlag |= FLAG_SKIP_USER_BADGE;
- }
- return badgeInfo.newIcon(context, creationFlag, badgeShape);
- }
- if (skipUserBadge) {
- return null;
- } else if ((flags & FLAG_INSTANT) != 0) {
- return new UserBadgeDrawable(context, R.drawable.ic_instant_app_badge,
- R.color.badge_tint_instant, isThemed, badgeShape);
- } else if ((flags & FLAG_WORK) != 0) {
- return new UserBadgeDrawable(context, R.drawable.ic_work_app_badge,
- R.color.badge_tint_work, isThemed, badgeShape);
- } else if ((flags & FLAG_CLONE) != 0) {
- return new UserBadgeDrawable(context, R.drawable.ic_clone_app_badge,
- R.color.badge_tint_clone, isThemed, badgeShape);
- } else if ((flags & FLAG_PRIVATE) != 0) {
- return new UserBadgeDrawable(context, R.drawable.ic_private_profile_app_badge,
- R.color.badge_tint_private, isThemed, badgeShape);
- }
- return null;
- }
-
- public static BitmapInfo fromBitmap(@NonNull Bitmap bitmap) {
- return of(bitmap, 0);
- }
-
- public static BitmapInfo of(@NonNull Bitmap bitmap, int color) {
- return new BitmapInfo(bitmap, color);
- }
-
- /**
- * Interface to be implemented by drawables to provide a custom BitmapInfo
- */
- public interface Extender {
-
- /**
- * Called for creating a custom BitmapInfo
- */
- BitmapInfo getExtendedInfo(Bitmap bitmap, int color,
- BaseIconFactory iconFactory, float normalizationScale);
-
- /**
- * Called to draw the UI independent of any runtime configurations like time or theme
- */
- void drawForPersistence(Canvas canvas);
- }
-}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt
new file mode 100644
index 0000000..1b3f0fa
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2017 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.launcher3.icons
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Path
+import android.graphics.drawable.Drawable
+import androidx.annotation.ColorRes
+import androidx.annotation.DrawableRes
+import androidx.annotation.IntDef
+import com.android.launcher3.icons.cache.CacheLookupFlag
+import com.android.launcher3.util.FlagOp
+
+open class BitmapInfo(
+ @JvmField val icon: Bitmap,
+ @JvmField val color: Int,
+ @BitmapInfoFlags @JvmField var flags: Int = 0,
+ var themedBitmap: ThemedBitmap? = null,
+) {
+ @IntDef(
+ flag = true,
+ value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_WRAPPED_NON_ADAPTIVE],
+ )
+ internal annotation class BitmapInfoFlags
+
+ @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE])
+ annotation class DrawableCreationFlags
+
+ // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps
+ @DrawableCreationFlags @JvmField var creationFlags: Int = 0
+
+ private var badgeInfo: BitmapInfo? = null
+
+ fun withBadgeInfo(badgeInfo: BitmapInfo?) = clone().also { it.badgeInfo = badgeInfo }
+
+ /** Returns a bitmapInfo with the flagOP applied */
+ fun withFlags(op: FlagOp): BitmapInfo {
+ if (op === FlagOp.NO_OP) {
+ return this
+ }
+ return clone().also { it.flags = op.apply(it.flags) }
+ }
+
+ @Override
+ open fun clone(): BitmapInfo {
+ return copyInternalsTo(BitmapInfo(icon, color))
+ }
+
+ protected fun copyInternalsTo(target: BitmapInfo): BitmapInfo {
+ target.themedBitmap = themedBitmap
+ target.flags = flags
+ target.badgeInfo = badgeInfo
+ return target
+ }
+
+ // TODO: rename or remove because icon can no longer be null?
+ val isNullOrLowRes: Boolean
+ get() = icon == LOW_RES_ICON
+
+ val isLowRes: Boolean
+ get() = matchingLookupFlag.useLowRes()
+
+ open val matchingLookupFlag: CacheLookupFlag
+ /** Returns the lookup flag to match this current state of this info */
+ get() =
+ CacheLookupFlag.DEFAULT_LOOKUP_FLAG.withUseLowRes(LOW_RES_ICON == icon)
+ .withThemeIcon(themedBitmap != null)
+
+ /** BitmapInfo can be stored on disk or other persistent storage */
+ open fun canPersist(): Boolean {
+ return !isNullOrLowRes
+ }
+
+ /** Creates a drawable for the provided BitmapInfo */
+ @JvmOverloads
+ fun newIcon(
+ context: Context,
+ @DrawableCreationFlags creationFlags: Int = 0,
+ ): FastBitmapDrawable {
+ return newIcon(context, creationFlags, null)
+ }
+
+ /**
+ * Creates a drawable for the provided BitmapInfo
+ *
+ * @param context Context
+ * @param creationFlags Flags for creating the FastBitmapDrawable
+ * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100.
+ * @return FastBitmapDrawable
+ */
+ open fun newIcon(
+ context: Context,
+ @DrawableCreationFlags creationFlags: Int,
+ badgeShape: Path?,
+ ): FastBitmapDrawable {
+ val drawable: FastBitmapDrawable =
+ if (isLowRes) {
+ PlaceHolderIconDrawable(this, context)
+ } else if (
+ (creationFlags and FLAG_THEMED) != 0 &&
+ themedBitmap != null &&
+ themedBitmap !== ThemedBitmap.NOT_SUPPORTED
+ ) {
+ themedBitmap!!.newDrawable(this, context)
+ } else {
+ FastBitmapDrawable(this)
+ }
+ applyFlags(context, drawable, creationFlags, badgeShape)
+ return drawable
+ }
+
+ protected fun applyFlags(
+ context: Context, drawable: FastBitmapDrawable,
+ @DrawableCreationFlags creationFlags: Int, badgeShape: Path?
+ ) {
+ this.creationFlags = creationFlags
+ drawable.disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f)
+ drawable.creationFlags = creationFlags
+ if ((creationFlags and FLAG_NO_BADGE) == 0) {
+ val badge = getBadgeDrawable(
+ context, (creationFlags and FLAG_THEMED) != 0,
+ (creationFlags and FLAG_SKIP_USER_BADGE) != 0, badgeShape
+ )
+ if (badge != null) {
+ drawable.badge = badge
+ }
+ }
+ }
+
+ /**
+ * Gets Badge drawable based on current flags
+ *
+ * @param context Context
+ * @param isThemed If Drawable is themed.
+ * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100.
+ * @return Drawable for the badge.
+ */
+ fun getBadgeDrawable(context: Context, isThemed: Boolean, badgeShape: Path?): Drawable? {
+ return getBadgeDrawable(context, isThemed, false, badgeShape)
+ }
+
+ /**
+ * Creates a Drawable for an icon badge for this BitmapInfo
+ * @param context Context
+ * @param isThemed If the drawable is themed.
+ * @param skipUserBadge If should skip User Profile badging.
+ * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100.
+ * @return Drawable for an icon Badge.
+ */
+ private fun getBadgeDrawable(
+ context: Context, isThemed: Boolean, skipUserBadge: Boolean, badgeShape: Path?
+ ): Drawable? {
+ if (badgeInfo != null) {
+ var creationFlag = if (isThemed) FLAG_THEMED else 0
+ if (skipUserBadge) {
+ creationFlag = creationFlag or FLAG_SKIP_USER_BADGE
+ }
+ return badgeInfo!!.newIcon(context, creationFlag, badgeShape)
+ }
+ if (skipUserBadge) {
+ return null
+ } else {
+ getBadgeDrawableInfo()?.let {
+ return UserBadgeDrawable(
+ context,
+ it.drawableRes,
+ it.colorRes,
+ isThemed,
+ badgeShape
+ )
+ }
+ }
+ return null
+ }
+
+ /**
+ * Returns information about the badge to apply based on current flags.
+ */
+ fun getBadgeDrawableInfo(): BadgeDrawableInfo? {
+ return when {
+ (flags and FLAG_INSTANT) != 0 -> BadgeDrawableInfo(
+ R.drawable.ic_instant_app_badge,
+ R.color.badge_tint_instant
+ )
+ (flags and FLAG_WORK) != 0 -> BadgeDrawableInfo(
+ R.drawable.ic_work_app_badge,
+ R.color.badge_tint_work
+ )
+ (flags and FLAG_CLONE) != 0 -> BadgeDrawableInfo(
+ R.drawable.ic_clone_app_badge,
+ R.color.badge_tint_clone
+ )
+ (flags and FLAG_PRIVATE) != 0 -> BadgeDrawableInfo(
+ R.drawable.ic_private_profile_app_badge,
+ R.color.badge_tint_private
+ )
+ else -> null
+ }
+ }
+
+
+ /** Interface to be implemented by drawables to provide a custom BitmapInfo */
+ interface Extender {
+ /** Called for creating a custom BitmapInfo */
+ fun getExtendedInfo(
+ bitmap: Bitmap?,
+ color: Int,
+ iconFactory: BaseIconFactory?,
+ normalizationScale: Float,
+ ): BitmapInfo?
+
+ /** Called to draw the UI independent of any runtime configurations like time or theme */
+ fun drawForPersistence(canvas: Canvas?)
+ }
+
+ /**
+ * Drawables backing a specific badge shown on app icons.
+ * @param drawableRes Drawable resource for the badge.
+ * @param colorRes Color resource to tint the badge.
+ */
+ @JvmRecord
+ data class BadgeDrawableInfo(
+ @field:DrawableRes @param:DrawableRes val drawableRes: Int,
+ @field:ColorRes @param:ColorRes val colorRes: Int
+ )
+
+ companion object {
+ const val TAG: String = "BitmapInfo"
+
+ // BitmapInfo flags
+ const val FLAG_WORK: Int = 1 shl 0
+ const val FLAG_INSTANT: Int = 1 shl 1
+ const val FLAG_CLONE: Int = 1 shl 2
+ const val FLAG_PRIVATE: Int = 1 shl 3
+ const val FLAG_WRAPPED_NON_ADAPTIVE: Int = 1 shl 4
+
+ // Drawable creation flags
+ const val FLAG_THEMED: Int = 1 shl 0
+ const val FLAG_NO_BADGE: Int = 1 shl 1
+ const val FLAG_SKIP_USER_BADGE: Int = 1 shl 2
+
+ @JvmField
+ val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8)
+ @JvmField
+ val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON)
+
+ @JvmStatic
+ fun fromBitmap(bitmap: Bitmap): BitmapInfo {
+ return of(bitmap, 0)
+ }
+
+ @JvmStatic
+ fun of(bitmap: Bitmap, color: Int): BitmapInfo {
+ return BitmapInfo(bitmap, color)
+ }
+ }
+}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java
index 3e8874a..33fc4ee 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java
@@ -16,6 +16,7 @@
package com.android.launcher3.icons;
import static com.android.launcher3.icons.IconProvider.ATLEAST_T;
+import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG;
import android.annotation.TargetApi;
import android.content.Context;
@@ -42,8 +43,7 @@
import androidx.annotation.NonNull;
import androidx.core.util.Supplier;
-import app.lawnchair.icons.ClockMetadata;
-import app.lawnchair.icons.CustomAdaptiveIconDrawable;
+import com.android.launcher3.icons.cache.CacheLookupFlag;
import com.android.launcher3.icons.mono.ThemedIconDrawable;
import java.util.Calendar;
@@ -51,6 +51,9 @@
import java.util.concurrent.TimeUnit;
import java.util.function.IntFunction;
+import app.lawnchair.icons.ClockMetadata;
+import app.lawnchair.icons.CustomAdaptiveIconDrawable;
+
/**
* Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic
* clock icons
@@ -302,7 +305,7 @@ static class ClockBitmapInfo extends BitmapInfo {
ClockBitmapInfo(Bitmap icon, int color, float scale,
AnimationInfo animInfo, Bitmap background,
AnimationInfo themeInfo, Bitmap themeBackground) {
- super(icon, color);
+ super(icon, color, /* flags */ 0, /* themedBitmap */ null);
this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2);
this.animInfo = animInfo;
this.mFlattenedBackground = background;
@@ -313,7 +316,7 @@ static class ClockBitmapInfo extends BitmapInfo {
@Override
@TargetApi(Build.VERSION_CODES.TIRAMISU)
public FastBitmapDrawable newIcon(Context context,
- @DrawableCreationFlags int creationFlags, Path badgeShape) {
+ @DrawableCreationFlags int creationFlags, Path badgeShape) {
AnimationInfo info;
Bitmap bg;
int themedFgColor;
@@ -349,8 +352,14 @@ public boolean canPersist() {
@Override
public BitmapInfo clone() {
- return copyInternalsTo(new ClockBitmapInfo(icon, color, 1 - 2 * boundsOffset, animInfo,
- mFlattenedBackground, themeData, themeBackground));
+ return copyInternalsTo(new ClockBitmapInfo(icon, color,
+ 1 - 2 * boundsOffset, animInfo, mFlattenedBackground,
+ themeData, themeBackground));
+ }
+
+ @Override
+ public CacheLookupFlag getMatchingLookupFlag() {
+ return DEFAULT_LOOKUP_FLAG.withThemeIcon(themeData != null);
}
}
@@ -371,7 +380,7 @@ private static class ClockIconDrawable extends FastBitmapDrawable implements Run
private final float mCanvasScale;
ClockIconDrawable(ClockConstantState cs) {
- super(cs.mBitmapInfo);
+ super(cs.getBitmapInfo());
mBoundsOffset = cs.mBoundsOffset;
mAnimInfo = cs.mAnimInfo;
@@ -434,10 +443,11 @@ public boolean isThemed() {
@Override
protected void updateFilter() {
super.updateFilter();
- int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE;
+ boolean isDisabled = isDisabled();
+ int alpha = isDisabled ? (int) (disabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE;
setAlpha(alpha);
- mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter);
- mFG.setColorFilter(mIsDisabled ? getDisabledColorFilter() : null);
+ mBgPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : mBgFilter);
+ mFG.setColorFilter(isDisabled ? getDisabledColorFilter() : null);
}
@Override
@@ -477,7 +487,7 @@ private void reschedule() {
@Override
public FastBitmapConstantState newConstantState() {
- return new ClockConstantState(mBitmapInfo, mThemedFgColor, mBoundsOffset,
+ return new ClockConstantState(bitmapInfo, mThemedFgColor, mBoundsOffset,
mAnimInfo, mBG, mBgPaint.getColorFilter());
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java
deleted file mode 100644
index f6ad4d1..0000000
--- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java
+++ /dev/null
@@ -1,471 +0,0 @@
-/*
- * Copyright (C) 2008 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.launcher3.icons;
-
-import static com.android.launcher3.icons.BaseIconFactory.getBadgeSizeForIconSize;
-import static com.android.launcher3.icons.BitmapInfo.FLAG_NO_BADGE;
-import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED;
-import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound;
-
-import android.animation.ObjectAnimator;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.ColorFilter;
-import android.graphics.ColorMatrix;
-import android.graphics.ColorMatrixColorFilter;
-import android.graphics.Paint;
-import android.graphics.PixelFormat;
-import android.graphics.Rect;
-import android.graphics.drawable.Drawable;
-import android.util.FloatProperty;
-import android.view.animation.AccelerateInterpolator;
-import android.view.animation.DecelerateInterpolator;
-import android.view.animation.Interpolator;
-import android.view.animation.PathInterpolator;
-
-import androidx.annotation.Nullable;
-import androidx.annotation.VisibleForTesting;
-import androidx.core.graphics.ColorUtils;
-
-import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags;
-
-public class FastBitmapDrawable extends Drawable implements Drawable.Callback {
-
- private static final Interpolator ACCEL = new AccelerateInterpolator();
- private static final Interpolator DEACCEL = new DecelerateInterpolator();
- private static final Interpolator HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR =
- new PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f);
-
- @VisibleForTesting protected static final float PRESSED_SCALE = 1.1f;
- @VisibleForTesting protected static final float HOVERED_SCALE = 1.1f;
- public static final int WHITE_SCRIM_ALPHA = 138;
-
- private static final float DISABLED_DESATURATION = 1f;
- private static final float DISABLED_BRIGHTNESS = 0.5f;
- protected static final int FULLY_OPAQUE = 255;
-
- public static final int CLICK_FEEDBACK_DURATION = 200;
- public static final int HOVER_FEEDBACK_DURATION = 300;
-
- private static boolean sFlagHoverEnabled = false;
-
- protected final Paint mPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG);
- public final BitmapInfo mBitmapInfo;
-
- @Nullable private ColorFilter mColorFilter;
-
- @VisibleForTesting protected boolean mIsPressed;
- @VisibleForTesting protected boolean mIsHovered;
- protected boolean mIsDisabled;
- protected float mDisabledAlpha = 1f;
-
- @DrawableCreationFlags int mCreationFlags = 0;
-
- // Animator and properties for the fast bitmap drawable's scale
- @VisibleForTesting protected static final FloatProperty SCALE
- = new FloatProperty("scale") {
- @Override
- public Float get(FastBitmapDrawable fastBitmapDrawable) {
- return fastBitmapDrawable.mScale;
- }
-
- @Override
- public void setValue(FastBitmapDrawable fastBitmapDrawable, float value) {
- fastBitmapDrawable.mScale = value;
- fastBitmapDrawable.invalidateSelf();
- }
- };
- @VisibleForTesting protected ObjectAnimator mScaleAnimation;
- private float mScale = 1;
- private int mAlpha = 255;
-
- private Drawable mBadge;
-
- private boolean mHoverScaleEnabledForDisplay = true;
-
- protected FastBitmapDrawable(Bitmap b, int iconColor) {
- this(BitmapInfo.of(b, iconColor));
- }
-
- public FastBitmapDrawable(Bitmap b) {
- this(BitmapInfo.fromBitmap(b));
- }
-
- public FastBitmapDrawable(BitmapInfo info) {
- mBitmapInfo = info;
- setFilterBitmap(true);
- }
-
- /**
- * Returns true if the drawable points to the same bitmap icon object
- */
- public boolean isSameInfo(BitmapInfo info) {
- return mBitmapInfo == info;
- }
-
- @Override
- protected void onBoundsChange(Rect bounds) {
- super.onBoundsChange(bounds);
- updateBadgeBounds(bounds);
- }
-
- private void updateBadgeBounds(Rect bounds) {
- if (mBadge != null) {
- setBadgeBounds(mBadge, bounds);
- }
- }
-
- @Override
- public final void draw(Canvas canvas) {
- if (mScale != 1f) {
- int count = canvas.save();
- Rect bounds = getBounds();
- canvas.scale(mScale, mScale, bounds.exactCenterX(), bounds.exactCenterY());
- drawInternal(canvas, bounds);
- if (mBadge != null) {
- mBadge.draw(canvas);
- }
- canvas.restoreToCount(count);
- } else {
- drawInternal(canvas, getBounds());
- if (mBadge != null) {
- mBadge.draw(canvas);
- }
- }
- }
-
- protected void drawInternal(Canvas canvas, Rect bounds) {
- canvas.drawBitmap(mBitmapInfo.icon, null, bounds, mPaint);
- }
-
- /**
- * Returns the primary icon color, slightly tinted white
- */
- public int getIconColor() {
- int whiteScrim = setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA);
- return ColorUtils.compositeColors(whiteScrim, mBitmapInfo.color);
- }
-
- /**
- * Returns if this represents a themed icon
- */
- public boolean isThemed() {
- return false;
- }
-
- /**
- * Returns true if the drawable was created with theme, even if it doesn't
- * support theming itself.
- */
- public boolean isCreatedForTheme() {
- return isThemed() || (mCreationFlags & FLAG_THEMED) != 0;
- }
-
- @Override
- public void setColorFilter(ColorFilter cf) {
- mColorFilter = cf;
- updateFilter();
- }
-
- @Override
- public int getOpacity() {
- return PixelFormat.TRANSLUCENT;
- }
-
- @Override
- public void setAlpha(int alpha) {
- if (mAlpha != alpha) {
- mAlpha = alpha;
- mPaint.setAlpha(alpha);
- invalidateSelf();
- if (mBadge != null) {
- mBadge.setAlpha(alpha);
- }
- }
- }
-
- @Override
- public void setFilterBitmap(boolean filterBitmap) {
- mPaint.setFilterBitmap(filterBitmap);
- mPaint.setAntiAlias(filterBitmap);
- }
-
- @Override
- public int getAlpha() {
- return mAlpha;
- }
-
- public void resetScale() {
- if (mScaleAnimation != null) {
- mScaleAnimation.cancel();
- mScaleAnimation = null;
- }
- mScale = 1;
- invalidateSelf();
- }
-
- public float getAnimatedScale() {
- return mScaleAnimation == null ? 1 : mScale;
- }
-
- @Override
- public int getIntrinsicWidth() {
- return mBitmapInfo.icon.getWidth();
- }
-
- @Override
- public int getIntrinsicHeight() {
- return mBitmapInfo.icon.getHeight();
- }
-
- @Override
- public int getMinimumWidth() {
- return getBounds().width();
- }
-
- @Override
- public int getMinimumHeight() {
- return getBounds().height();
- }
-
- @Override
- public boolean isStateful() {
- return true;
- }
-
- @Override
- public ColorFilter getColorFilter() {
- return mPaint.getColorFilter();
- }
-
- @Override
- protected boolean onStateChange(int[] state) {
- boolean isPressed = false;
- boolean isHovered = false;
- for (int s : state) {
- if (s == android.R.attr.state_pressed) {
- isPressed = true;
- break;
- } else if (sFlagHoverEnabled
- && s == android.R.attr.state_hovered
- && mHoverScaleEnabledForDisplay) {
- isHovered = true;
- // Do not break on hovered state, as pressed state should take precedence.
- }
- }
- if (mIsPressed != isPressed || mIsHovered != isHovered) {
- if (mScaleAnimation != null) {
- mScaleAnimation.cancel();
- }
-
- float endScale = isPressed ? PRESSED_SCALE : (isHovered ? HOVERED_SCALE : 1f);
- if (mScale != endScale) {
- if (isVisible()) {
- Interpolator interpolator =
- isPressed != mIsPressed ? (isPressed ? ACCEL : DEACCEL)
- : HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR;
- int duration =
- isPressed != mIsPressed ? CLICK_FEEDBACK_DURATION
- : HOVER_FEEDBACK_DURATION;
- mScaleAnimation = ObjectAnimator.ofFloat(this, SCALE, endScale);
- mScaleAnimation.setDuration(duration);
- mScaleAnimation.setInterpolator(interpolator);
- mScaleAnimation.start();
- } else {
- mScale = endScale;
- invalidateSelf();
- }
- }
- mIsPressed = isPressed;
- mIsHovered = isHovered;
- return true;
- }
- return false;
- }
-
- public void setIsDisabled(boolean isDisabled) {
- if (mIsDisabled != isDisabled) {
- mIsDisabled = isDisabled;
- if (mBadge instanceof FastBitmapDrawable fbd) {
- fbd.setIsDisabled(isDisabled);
- }
- updateFilter();
- }
- }
-
- protected boolean isDisabled() {
- return mIsDisabled;
- }
-
- public void setBadge(Drawable badge) {
- if (mBadge != null) {
- mBadge.setCallback(null);
- }
- mBadge = badge;
- if (mBadge != null) {
- mBadge.setCallback(this);
- }
- updateBadgeBounds(getBounds());
- updateFilter();
- }
-
- @VisibleForTesting
- public Drawable getBadge() {
- return mBadge;
- }
-
- /**
- * Updates the paint to reflect the current brightness and saturation.
- */
- protected void updateFilter() {
- mPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter(mDisabledAlpha) : mColorFilter);
- if (mBadge != null) {
- mBadge.setColorFilter(getColorFilter());
- }
- invalidateSelf();
- }
-
- protected FastBitmapConstantState newConstantState() {
- return new FastBitmapConstantState(mBitmapInfo);
- }
-
- @Override
- public final ConstantState getConstantState() {
- FastBitmapConstantState cs = newConstantState();
- cs.mIsDisabled = mIsDisabled;
- if (mBadge != null) {
- cs.mBadgeConstantState = mBadge.getConstantState();
- }
- cs.mCreationFlags = mCreationFlags;
- return cs;
- }
-
- public static ColorFilter getDisabledColorFilter() {
- return getDisabledColorFilter(1);
- }
-
- // Returns if the FastBitmapDrawable contains a badge.
- public boolean hasBadge() {
- return (mCreationFlags & FLAG_NO_BADGE) == 0;
- }
-
- private static ColorFilter getDisabledColorFilter(float disabledAlpha) {
- ColorMatrix tempBrightnessMatrix = new ColorMatrix();
- ColorMatrix tempFilterMatrix = new ColorMatrix();
-
- tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION);
- float scale = 1 - DISABLED_BRIGHTNESS;
- int brightnessI = (int) (255 * DISABLED_BRIGHTNESS);
- float[] mat = tempBrightnessMatrix.getArray();
- mat[0] = scale;
- mat[6] = scale;
- mat[12] = scale;
- mat[4] = brightnessI;
- mat[9] = brightnessI;
- mat[14] = brightnessI;
- mat[18] = disabledAlpha;
- tempFilterMatrix.preConcat(tempBrightnessMatrix);
- return new ColorMatrixColorFilter(tempFilterMatrix);
- }
-
- protected static final int getDisabledColor(int color) {
- int component = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3;
- float scale = 1 - DISABLED_BRIGHTNESS;
- int brightnessI = (int) (255 * DISABLED_BRIGHTNESS);
- component = Math.min(Math.round(scale * component + brightnessI), FULLY_OPAQUE);
- return Color.rgb(component, component, component);
- }
-
- /**
- * Sets the bounds for the badge drawable based on the main icon bounds
- */
- public static void setBadgeBounds(Drawable badge, Rect iconBounds) {
- int size = getBadgeSizeForIconSize(iconBounds.width());
- badge.setBounds(iconBounds.right - size, iconBounds.bottom - size,
- iconBounds.right, iconBounds.bottom);
- }
-
- @Override
- public void invalidateDrawable(Drawable who) {
- if (who == mBadge) {
- invalidateSelf();
- }
- }
-
- @Override
- public void scheduleDrawable(Drawable who, Runnable what, long when) {
- if (who == mBadge) {
- scheduleSelf(what, when);
- }
- }
-
- @Override
- public void unscheduleDrawable(Drawable who, Runnable what) {
- unscheduleSelf(what);
- }
-
- /**
- * Sets whether hover state functionality is enabled.
- */
- public static void setFlagHoverEnabled(boolean isFlagHoverEnabled) {
- sFlagHoverEnabled = isFlagHoverEnabled;
- }
-
- public void setHoverScaleEnabledForDisplay(boolean hoverScaleEnabledForDisplay) {
- mHoverScaleEnabledForDisplay = hoverScaleEnabledForDisplay;
- }
-
- public static class FastBitmapConstantState extends ConstantState {
- protected final BitmapInfo mBitmapInfo;
-
- // These are initialized later so that subclasses don't need to
- // pass everything in constructor
- protected boolean mIsDisabled;
- private ConstantState mBadgeConstantState;
-
- @DrawableCreationFlags int mCreationFlags = 0;
-
- public FastBitmapConstantState(Bitmap bitmap, int color) {
- this(BitmapInfo.of(bitmap, color));
- }
-
- public FastBitmapConstantState(BitmapInfo info) {
- mBitmapInfo = info;
- }
-
- protected FastBitmapDrawable createDrawable() {
- return new FastBitmapDrawable(mBitmapInfo);
- }
-
- @Override
- public final FastBitmapDrawable newDrawable() {
- FastBitmapDrawable drawable = createDrawable();
- drawable.setIsDisabled(mIsDisabled);
- if (mBadgeConstantState != null) {
- drawable.setBadge(mBadgeConstantState.newDrawable());
- }
- drawable.mCreationFlags = mCreationFlags;
- return drawable;
- }
-
- @Override
- public int getChangingConfigurations() {
- return 0;
- }
- }
-}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt
new file mode 100644
index 0000000..670915a
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt
@@ -0,0 +1,369 @@
+/*
+ * Copyright (C) 2008 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.launcher3.icons
+
+import android.R
+import android.animation.ObjectAnimator
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorFilter
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+import android.graphics.Paint
+import android.graphics.Paint.ANTI_ALIAS_FLAG
+import android.graphics.Paint.FILTER_BITMAP_FLAG
+import android.graphics.PixelFormat
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.Drawable.Callback
+import android.util.FloatProperty
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import android.view.animation.Interpolator
+import android.view.animation.PathInterpolator
+import androidx.annotation.VisibleForTesting
+import androidx.core.graphics.ColorUtils
+import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags
+import kotlin.math.min
+
+open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback {
+
+ @JvmOverloads constructor(b: Bitmap, iconColor: Int = 0) : this(BitmapInfo.of(b, iconColor))
+
+ @JvmField val bitmapInfo: BitmapInfo = info ?: BitmapInfo.LOW_RES_INFO
+ var isAnimationEnabled: Boolean = true
+
+ @JvmField protected val paint: Paint = Paint(FILTER_BITMAP_FLAG or ANTI_ALIAS_FLAG)
+
+ @JvmField @VisibleForTesting var isPressed: Boolean = false
+ @JvmField @VisibleForTesting var isHovered: Boolean = false
+
+ @JvmField var disabledAlpha: Float = 1f
+
+ var isDisabled: Boolean = false
+ set(value) {
+ if (field != value) {
+ field = value
+ badge.let { if (it is FastBitmapDrawable) it.isDisabled = value }
+ updateFilter()
+ }
+ }
+
+ @JvmField @DrawableCreationFlags var creationFlags: Int = 0
+ @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null
+ var hoverScaleEnabledForDisplay = true
+
+ private var scale = 1f
+
+ private var paintAlpha = 255
+ private var paintFilter: ColorFilter? = null
+
+ init {
+ isFilterBitmap = true
+ }
+
+ var badge: Drawable? = null
+ set(value) {
+ field?.callback = null
+ field = value
+ field?.let {
+ it.callback = this
+ it.setBadgeBounds(bounds)
+ }
+ updateFilter()
+ }
+
+ /** Returns true if the drawable points to the same bitmap icon object */
+ fun isSameInfo(info: BitmapInfo): Boolean = bitmapInfo === info
+
+ override fun onBoundsChange(bounds: Rect) {
+ super.onBoundsChange(bounds)
+ badge?.setBadgeBounds(bounds)
+ }
+
+ override fun draw(canvas: Canvas) {
+ if (scale != 1f) {
+ val count = canvas.save()
+ val bounds = bounds
+ canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY())
+ drawInternal(canvas, bounds)
+ badge?.draw(canvas)
+ canvas.restoreToCount(count)
+ } else {
+ drawInternal(canvas, bounds)
+ badge?.draw(canvas)
+ }
+ }
+
+ protected open fun drawInternal(canvas: Canvas, bounds: Rect) {
+ canvas.drawBitmap(bitmapInfo.icon, null, bounds, paint)
+ }
+
+ /** Returns the primary icon color, slightly tinted white */
+ open fun getIconColor(): Int =
+ ColorUtils.compositeColors(
+ GraphicsUtils.setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA),
+ bitmapInfo.color,
+ )
+
+ /** Returns if this represents a themed icon */
+ open fun isThemed(): Boolean = false
+
+ /**
+ * Returns true if the drawable was created with theme, even if it doesn't support theming
+ * itself.
+ */
+ fun isCreatedForTheme(): Boolean = isThemed() || (creationFlags and BitmapInfo.FLAG_THEMED) != 0
+
+ override fun setColorFilter(cf: ColorFilter?) {
+ paintFilter = cf
+ updateFilter()
+ }
+
+ override fun getColorFilter(): ColorFilter? = paint.colorFilter
+
+ @Deprecated("This method is no longer used in graphics optimizations")
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
+
+ override fun setAlpha(alpha: Int) {
+ if (paintAlpha != alpha) {
+ paintAlpha = alpha
+ paint.alpha = alpha
+ invalidateSelf()
+ badge?.alpha = alpha
+ }
+ }
+
+ override fun getAlpha(): Int = paintAlpha
+
+ override fun setFilterBitmap(filterBitmap: Boolean) {
+ paint.isFilterBitmap = filterBitmap
+ paint.isAntiAlias = filterBitmap
+ }
+
+ fun resetScale() {
+ scaleAnimation?.cancel()
+ scaleAnimation = null
+ scale = 1f
+ invalidateSelf()
+ }
+
+ fun getAnimatedScale(): Float = if (scaleAnimation == null) 1f else scale
+
+ override fun getIntrinsicWidth(): Int = bitmapInfo.icon.width
+
+ override fun getIntrinsicHeight(): Int = bitmapInfo.icon.height
+
+ override fun getMinimumWidth(): Int = bounds.width()
+
+ override fun getMinimumHeight(): Int = bounds.height()
+
+ override fun isStateful(): Boolean = true
+
+ public override fun onStateChange(state: IntArray): Boolean {
+ if (!isAnimationEnabled) {
+ return false
+ }
+
+ var isPressed = false
+ var isHovered = false
+ for (s in state) {
+ if (s == R.attr.state_pressed) {
+ isPressed = true
+ break
+ } else if (s == R.attr.state_hovered && hoverScaleEnabledForDisplay) {
+ isHovered = true
+ // Do not break on hovered state, as pressed state should take precedence.
+ }
+ }
+ if (this.isPressed != isPressed || this.isHovered != isHovered) {
+ scaleAnimation?.cancel()
+
+ val endScale =
+ when {
+ isPressed -> PRESSED_SCALE
+ isHovered -> HOVERED_SCALE
+ else -> 1f
+ }
+ if (scale != endScale) {
+ if (isVisible) {
+ scaleAnimation =
+ ObjectAnimator.ofFloat(this, SCALE, endScale).apply {
+ duration =
+ if (isPressed != this@FastBitmapDrawable.isPressed)
+ CLICK_FEEDBACK_DURATION.toLong()
+ else HOVER_FEEDBACK_DURATION.toLong()
+
+ interpolator =
+ if (isPressed != this@FastBitmapDrawable.isPressed)
+ (if (isPressed) ACCEL else DEACCEL)
+ else HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR
+ }
+ scaleAnimation?.start()
+ } else {
+ scale = endScale
+ invalidateSelf()
+ }
+ }
+ this.isPressed = isPressed
+ this.isHovered = isHovered
+ return true
+ }
+ return false
+ }
+
+ /** Updates the paint to reflect the current brightness and saturation. */
+ protected open fun updateFilter() {
+ paint.setColorFilter(if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter)
+ badge?.colorFilter = colorFilter
+ invalidateSelf()
+ }
+
+ protected open fun newConstantState(): FastBitmapConstantState {
+ return FastBitmapConstantState(bitmapInfo)
+ }
+
+ override fun getConstantState(): ConstantState {
+ val cs = newConstantState()
+ cs.mIsDisabled = isDisabled
+ cs.mBadgeConstantState = badge?.constantState
+ cs.mCreationFlags = creationFlags
+ return cs
+ }
+
+ // Returns if the FastBitmapDrawable contains a badge.
+ fun hasBadge(): Boolean = (creationFlags and BitmapInfo.FLAG_NO_BADGE) == 0
+
+ override fun invalidateDrawable(who: Drawable) {
+ if (who === badge) {
+ invalidateSelf()
+ }
+ }
+
+ override fun scheduleDrawable(who: Drawable, what: Runnable, time: Long) {
+ if (who === badge) {
+ scheduleSelf(what, time)
+ }
+ }
+
+ override fun unscheduleDrawable(who: Drawable, what: Runnable) {
+ unscheduleSelf(what)
+ }
+
+ open class FastBitmapConstantState(val bitmapInfo: BitmapInfo) : ConstantState() {
+ // These are initialized later so that subclasses don't need to
+ // pass everything in constructor
+ var mIsDisabled: Boolean = false
+ var mBadgeConstantState: ConstantState? = null
+
+ @DrawableCreationFlags var mCreationFlags: Int = 0
+
+ constructor(bitmap: Bitmap, color: Int) : this(BitmapInfo.of(bitmap, color))
+
+ protected open fun createDrawable(): FastBitmapDrawable {
+ return FastBitmapDrawable(bitmapInfo)
+ }
+
+ override fun newDrawable(): FastBitmapDrawable {
+ val drawable = createDrawable()
+ drawable.isDisabled = mIsDisabled
+ if (mBadgeConstantState != null) {
+ drawable.badge = mBadgeConstantState!!.newDrawable()
+ }
+ drawable.creationFlags = mCreationFlags
+ return drawable
+ }
+
+ override fun getChangingConfigurations(): Int = 0
+ }
+
+ companion object {
+ private val ACCEL: Interpolator = AccelerateInterpolator()
+ private val DEACCEL: Interpolator = DecelerateInterpolator()
+ private val HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR: Interpolator =
+ PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f)
+
+ @VisibleForTesting const val PRESSED_SCALE: Float = 1.1f
+
+ @VisibleForTesting const val HOVERED_SCALE: Float = 1.1f
+ const val WHITE_SCRIM_ALPHA: Int = 138
+
+ private const val DISABLED_DESATURATION = 1f
+ private const val DISABLED_BRIGHTNESS = 0.5f
+ const val FULLY_OPAQUE: Int = 255
+
+ const val CLICK_FEEDBACK_DURATION: Int = 200
+ const val HOVER_FEEDBACK_DURATION: Int = 300
+
+ // Animator and properties for the fast bitmap drawable's scale
+ @VisibleForTesting
+ @JvmField
+ val SCALE: FloatProperty =
+ object : FloatProperty("scale") {
+ override fun get(fastBitmapDrawable: FastBitmapDrawable): Float {
+ return fastBitmapDrawable.scale
+ }
+
+ override fun setValue(fastBitmapDrawable: FastBitmapDrawable, value: Float) {
+ fastBitmapDrawable.scale = value
+ fastBitmapDrawable.invalidateSelf()
+ }
+ }
+
+ @JvmStatic
+ @JvmOverloads
+ fun getDisabledColorFilter(disabledAlpha: Float = 1f): ColorFilter {
+ val tempBrightnessMatrix = ColorMatrix()
+ val tempFilterMatrix = ColorMatrix()
+
+ tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION)
+ val scale = 1 - DISABLED_BRIGHTNESS
+ val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt()
+ val mat = tempBrightnessMatrix.array
+ mat[0] = scale
+ mat[6] = scale
+ mat[12] = scale
+ mat[4] = brightnessI.toFloat()
+ mat[9] = brightnessI.toFloat()
+ mat[14] = brightnessI.toFloat()
+ mat[18] = disabledAlpha
+ tempFilterMatrix.preConcat(tempBrightnessMatrix)
+ return ColorMatrixColorFilter(tempFilterMatrix)
+ }
+
+ @JvmStatic
+ fun getDisabledColor(color: Int): Int {
+ val avgComponent = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3
+ val scale = 1 - DISABLED_BRIGHTNESS
+ val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt()
+ val component = min(Math.round(scale * avgComponent + brightnessI), FULLY_OPAQUE)
+ return Color.rgb(component, component, component)
+ }
+
+ /** Sets the bounds for the badge drawable based on the main icon bounds */
+ @JvmStatic
+ fun Drawable.setBadgeBounds(iconBounds: Rect) {
+ val size = BaseIconFactory.getBadgeSizeForIconSize(iconBounds.width())
+ setBounds(
+ iconBounds.right - size,
+ iconBounds.bottom - size,
+ iconBounds.right,
+ iconBounds.bottom,
+ )
+ }
+ }
+}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java
index 1abac90..b17b006 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java
@@ -95,7 +95,12 @@ public static void noteNewBitmapCreated() {
*/
public static int getAttrColor(Context context, int attr) {
TypedArray ta = context.obtainStyledAttributes(new int[]{attr});
- int colorAccent = ta.getColor(0, 0);
+ // pE-TODO(CompatTier2): wtf?
+ int colorAccent = 0;
+ try {
+ colorAccent = ta.getColor(0, 0);
+ } catch (UnsupportedOperationException ignored) {
+ }
ta.recycle();
return colorAccent;
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java
index 23eed3b..66057e6 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java
@@ -53,6 +53,8 @@
import androidx.annotation.Nullable;
import androidx.core.os.BuildCompat;
+import com.android.launcher3.icons.cache.CachingLogic;
+import com.android.launcher3.util.ComponentKey;
import com.android.launcher3.util.SafeCloseable;
import java.util.Calendar;
@@ -155,7 +157,7 @@ public Drawable getIcon(PackageItemInfo info, ApplicationInfo appInfo, int iconD
icon = ClockDrawableWrapper.forPackage(mContext, mClock.getPackageName(), iconDpi);
}
if (icon == null) {
- icon = loadPackageIcon(info, appInfo, iconDpi);
+ icon = loadPackageIconWithFallback(info, appInfo, iconDpi);
if (ATLEAST_T && icon instanceof AdaptiveIconDrawable && td != null) {
AdaptiveIconDrawable aid = (AdaptiveIconDrawable) icon;
if (aid.getMonochrome() == null) {
@@ -171,36 +173,39 @@ protected ThemeData getThemeDataForPackage(String packageName) {
return null;
}
- private Drawable loadPackageIcon(PackageItemInfo info, ApplicationInfo appInfo, int density) {
+ private Drawable loadPackageIconWithFallback(
+ PackageItemInfo info, ApplicationInfo appInfo, int density) {
Drawable icon = null;
if (BuildCompat.isAtLeastV() && info.isArchived) {
// Icons for archived apps com from system service, let the default impl handle that
icon = info.loadIcon(mContext.getPackageManager());
}
if (icon == null && density != 0 && (info.icon != 0 || appInfo.icon != 0)) {
- try {
- final Resources resources = mContext.getPackageManager()
- .getResourcesForApplication(appInfo);
- // Try to load the package item icon first
- if (info != appInfo && info.icon != 0) {
- try {
- icon = resources.getDrawableForDensity(info.icon, density);
- } catch (Resources.NotFoundException exc) { }
- }
- if (icon == null && appInfo.icon != 0) {
- // Load the fallback app icon
- icon = loadAppInfoIcon(appInfo, resources, density);
- }
- } catch (NameNotFoundException | Resources.NotFoundException exc) { }
+ icon = loadPackageIcon(info, appInfo, density);
}
return icon != null ? icon : getFullResDefaultActivityIcon(density);
}
@Nullable
- protected Drawable loadAppInfoIcon(ApplicationInfo info, Resources resources, int density) {
+ protected Drawable loadPackageIcon(
+ @NonNull PackageItemInfo info, @NonNull ApplicationInfo appInfo, int density) {
try {
- return resources.getDrawableForDensity(info.icon, density);
- } catch (Resources.NotFoundException exc) { }
+ final Resources resources = mContext.getPackageManager()
+ .getResourcesForApplication(appInfo);
+ // Try to load the package item icon first
+ if (info != appInfo && info.icon != 0) {
+ try {
+ Drawable icon = resources.getDrawableForDensity(info.icon, density);
+ if (icon != null) return icon;
+ } catch (Resources.NotFoundException exc) { }
+ }
+ if (appInfo.icon != 0) {
+ // Load the fallback app icon
+ try {
+ return resources.getDrawableForDensity(appInfo.icon, density);
+ } catch (Resources.NotFoundException exc) { }
+ }
+ } catch (NameNotFoundException | Resources.NotFoundException exc) { }
return null;
}
@@ -306,6 +311,12 @@ public SafeCloseable registerIconChangeListener(IconChangeListener listener, Han
return new IconChangeReceiver(listener, handler);
}
+ /**
+ * Notifies the provider when an icon is loaded from cache
+ */
+ public void notifyIconLoaded(
+ @NonNull BitmapInfo icon, @NonNull ComponentKey key, @NonNull CachingLogic> logic) { }
+
public static class ThemeData {
final Resources mResources;
diff --git a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java
index ae71236..e6ae124 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java
@@ -100,12 +100,12 @@ private void drawDrawable(Drawable drawable) {
* Creates a monochrome version of the provided drawable
*/
@WorkerThread
- public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath, Float iconScale) {
+ public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath) {
mFlatCanvas.drawColor(Color.BLACK);
drawDrawable(icon.getBackground());
drawDrawable(icon.getForeground());
generateMono();
- return new ClippedMonoDrawable(this, shapePath, iconScale);
+ return new ClippedMonoDrawable(this, shapePath);
}
@WorkerThread
diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java
index 00f1942..531c35a 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java
@@ -42,7 +42,7 @@ public class PlaceHolderIconDrawable extends FastBitmapDrawable {
public PlaceHolderIconDrawable(BitmapInfo info, Context context) {
super(info);
mProgressPath = getDefaultPath();
- mPaint.setColor(ColorUtils.compositeColors(
+ paint.setColor(ColorUtils.compositeColors(
GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color));
}
@@ -62,13 +62,13 @@ protected void drawInternal(Canvas canvas, Rect bounds) {
int saveCount = canvas.save();
canvas.translate(bounds.left, bounds.top);
canvas.scale(bounds.width() / 100f, bounds.height() / 100f);
- canvas.drawPath(mProgressPath, mPaint);
+ canvas.drawPath(mProgressPath, paint);
canvas.restoreToCount(saveCount);
}
/** Updates this placeholder to {@code newIcon} with animation. */
public void animateIconUpdate(Drawable newIcon) {
- int placeholderColor = mPaint.getColor();
+ int placeholderColor = paint.getColor();
int originalAlpha = Color.alpha(placeholderColor);
ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0);
diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt
index 6c937db..77b34ac 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt
@@ -28,6 +28,18 @@ interface ThemedBitmap {
fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable
fun serialize(): ByteArray
+
+ companion object {
+
+ @JvmField
+ /** ThemedBitmap to be used when theming is not supported for a particular bitmap */
+ val NOT_SUPPORTED =
+ object : ThemedBitmap {
+ override fun newDrawable(info: BitmapInfo, context: Context) = info.newIcon(context)
+
+ override fun serialize() = ByteArray(0)
+ }
+ }
}
interface IconThemeController {
@@ -46,8 +58,14 @@ interface IconThemeController {
info: BitmapInfo,
factory: BaseIconFactory,
sourceHint: SourceHint,
- ): ThemedBitmap?
+ ): ThemedBitmap
+ /**
+ * Creates an adaptive icon representation of the themed bitmap for various surface effects. The
+ * controller can return the [originalIcon] for using an un-themed icon for these effects or
+ * null to disable any surface effects in which can the static themed icon will be used without
+ * any additional effects.
+ */
fun createThemedAdaptiveIcon(
context: Context,
originalIcon: AdaptiveIconDrawable,
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt
index c305607..0e4544e 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt
@@ -42,12 +42,15 @@ import android.util.SparseArray
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import com.android.launcher3.Flags
+import com.android.systemui.shared.Flags.extendibleThemeManager
import com.android.launcher3.icons.BaseIconFactory
import com.android.launcher3.icons.BaseIconFactory.IconOptions
import com.android.launcher3.icons.BitmapInfo
+import com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_ICON
import com.android.launcher3.icons.GraphicsUtils
import com.android.launcher3.icons.IconProvider
import com.android.launcher3.icons.SourceHint
+import com.android.launcher3.icons.ThemedBitmap
import com.android.launcher3.icons.cache.CacheLookupFlag.Companion.DEFAULT_LOOKUP_FLAG
import com.android.launcher3.util.ComponentKey
import com.android.launcher3.util.FlagOp
@@ -223,9 +226,11 @@ constructor(
}
// Only add an entry in memory, if there was already something previously
- if (cache[key] != null) {
+ val existingEntry = cache[key]
+ if (existingEntry != null) {
val entry = CacheEntry()
- entry.bitmap = bitmapInfo
+ entry.bitmap =
+ bitmapInfo.downSampleToLookupFlag(existingEntry.bitmap.matchingLookupFlag)
entry.title = entryTitle
entry.contentDescription = getUserBadgedLabel(entryTitle, user)
cache[key] = entry
@@ -292,7 +297,7 @@ constructor(
obj,
entry,
cachingLogic,
- lookupFlags.usePackageIcon(),
+ lookupFlags,
/* usePackageTitle= */ true,
componentName,
user,
@@ -311,7 +316,7 @@ constructor(
obj: T?,
entry: CacheEntry,
cachingLogic: CachingLogic,
- usePackageIcon: Boolean,
+ lookupFlag: CacheLookupFlag,
usePackageTitle: Boolean,
componentName: ComponentName,
user: UserHandle,
@@ -319,8 +324,9 @@ constructor(
if (obj != null) {
entry.bitmap = cachingLogic.loadIcon(context, this, obj)
} else {
- if (usePackageIcon) {
- val packageEntry = getEntryForPackageLocked(componentName.packageName, user)
+ if (lookupFlag.usePackageIcon()) {
+ val packageEntry =
+ getEntryForPackageLocked(componentName.packageName, user, lookupFlag)
if (DEBUG) {
Log.d(TAG, "using package default icon for " + componentName.toShortString())
}
@@ -331,6 +337,7 @@ constructor(
entry.title = packageEntry.title
}
}
+ entry.bitmap = entry.bitmap.downSampleToLookupFlag(lookupFlag)
}
}
@@ -442,8 +449,7 @@ constructor(
// only keep the low resolution icon instead of the larger full-sized icon
val iconInfo = appInfoCachingLogic.loadIcon(context, this, appInfo)
entry.bitmap =
- if (lookupFlags.useLowRes())
- BitmapInfo.of(BitmapInfo.LOW_RES_ICON, iconInfo.color)
+ if (lookupFlags.useLowRes()) BitmapInfo.of(LOW_RES_ICON, iconInfo.color)
else iconInfo
loadFallbackTitle(appInfo, entry, appInfoCachingLogic, user)
@@ -516,7 +522,7 @@ constructor(
// Set the alpha to be 255, so that we never have a wrong color
entry.bitmap =
BitmapInfo.of(
- BitmapInfo.LOW_RES_ICON,
+ LOW_RES_ICON,
GraphicsUtils.setColorAlphaBound(c.getInt(INDEX_COLOR), 255),
)
c.getString(INDEX_TITLE).let {
@@ -546,23 +552,29 @@ constructor(
return false
}
- iconFactory.use { factory ->
- val themeController = factory.themeController
- val monoIconData = c.getBlob(INDEX_MONO_ICON)
- if (themeController != null && monoIconData != null) {
- entry.bitmap.themedBitmap =
- themeController.decode(
- data = monoIconData,
- info = entry.bitmap,
- factory = factory,
- sourceHint =
- SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)),
- )
+ if (!extendibleThemeManager() || lookupFlags.hasThemeIcon()) {
+ // Always set a non-null theme bitmap if theming was requested
+ entry.bitmap.themedBitmap = ThemedBitmap.NOT_SUPPORTED
+
+ iconFactory.use { factory ->
+ val themeController = factory.themeController
+ val monoIconData = c.getBlob(INDEX_MONO_ICON)
+ if (themeController != null && monoIconData != null) {
+ entry.bitmap.themedBitmap =
+ themeController.decode(
+ data = monoIconData,
+ info = entry.bitmap,
+ factory = factory,
+ sourceHint =
+ SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)),
+ )
+ }
}
}
}
entry.bitmap.flags = c.getInt(INDEX_FLAGS)
entry.bitmap = entry.bitmap.withFlags(getUserFlagOpLocked(cacheKey.user))
+ iconProvider.notifyIconLoaded(entry.bitmap, cacheKey, logic)
return true
}
@@ -645,7 +657,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()) 10 else 9
+ @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 11 else 10
@JvmField val TABLE_NAME = "icons"
@JvmField val COLUMN_ROWID = "rowid"
@@ -662,12 +674,17 @@ constructor(
val COLUMNS_LOW_RES =
arrayOf(COLUMN_COMPONENT, COLUMN_LABEL, COLUMN_ICON_COLOR, COLUMN_FLAGS)
+ @JvmField
+ val COLUMNS_HIGH_RES_NO_THEME =
+ COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 2).apply {
+ this[size - 1] = COLUMN_ICON
+ this[size - 2] = COLUMN_FRESHNESS_ID
+ }
+
@JvmField
val COLUMNS_HIGH_RES =
- COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 3).apply {
- this[size - 3] = COLUMN_ICON
- this[size - 2] = COLUMN_MONO_ICON
- this[size - 1] = COLUMN_FRESHNESS_ID
+ COLUMNS_HIGH_RES_NO_THEME.copyOf(COLUMNS_HIGH_RES_NO_THEME.size + 1).apply {
+ this[size - 1] = COLUMN_MONO_ICON
}
@JvmField val INDEX_TITLE = COLUMNS_HIGH_RES.indexOf(COLUMN_LABEL)
@@ -679,6 +696,20 @@ constructor(
@JvmStatic
fun CacheLookupFlag.toLookupColumns() =
- if (useLowRes()) COLUMNS_LOW_RES else COLUMNS_HIGH_RES
+ when {
+ useLowRes() -> COLUMNS_LOW_RES
+ extendibleThemeManager() && !hasThemeIcon() -> COLUMNS_HIGH_RES_NO_THEME
+ else -> COLUMNS_HIGH_RES
+ }
+
+ @JvmStatic
+ protected fun BitmapInfo.downSampleToLookupFlag(flag: CacheLookupFlag) =
+ when {
+ !extendibleThemeManager() -> this
+ flag.useLowRes() -> BitmapInfo.of(LOW_RES_ICON, color)
+ !flag.hasThemeIcon() && themedBitmap != null ->
+ clone().apply { themedBitmap = null }
+ else -> this
+ }
}
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
index 42fda24..9e56dbe 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
@@ -16,6 +16,7 @@
package com.android.launcher3.icons.cache
import androidx.annotation.IntDef
+import com.android.systemui.shared.Flags.extendibleThemeManager
import kotlin.annotation.AnnotationRetention.SOURCE
/** Flags to control cache lookup behavior */
@@ -45,18 +46,30 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int
fun withSkipAddToMemCache(skipAddToMemCache: Boolean = true) =
updateMask(SKIP_ADD_TO_MEM_CACHE, skipAddToMemCache)
+ /** Entry will include theme icon. Note that theme icon is only loaded for high-res icons */
+ fun hasThemeIcon() = hasFlag(LOAD_THEME_ICON)
+
+ @JvmOverloads
+ fun withThemeIcon(addThemeIcon: Boolean = true) = updateMask(LOAD_THEME_ICON, addThemeIcon)
+
private fun hasFlag(@LookupFlag mask: Int) = flag.and(mask) != 0
private fun updateMask(@LookupFlag mask: Int, addMask: Boolean) =
if (addMask) flagCache[flag.or(mask)] else flagCache[flag.and(mask.inv())]
/** Returns `true` if this flag has less UI information then [other] */
- fun isVisuallyLessThan(other: CacheLookupFlag): Boolean {
- return useLowRes() && !other.useLowRes()
- }
+ fun isVisuallyLessThan(other: CacheLookupFlag) =
+ when {
+ useLowRes() && !other.useLowRes() -> true
+ extendibleThemeManager() && !hasThemeIcon() && other.hasThemeIcon() -> true
+ else -> false
+ }
@Retention(SOURCE)
- @IntDef(value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE], flag = true)
+ @IntDef(
+ value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE, LOAD_THEME_ICON],
+ flag = true,
+ )
/** Various options to control cache lookup */
private annotation class LookupFlag
@@ -64,8 +77,9 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int
private const val USE_LOW_RES: Int = 1 shl 0
private const val USE_PACKAGE_ICON: Int = 1 shl 1
private const val SKIP_ADD_TO_MEM_CACHE: Int = 1 shl 2
+ private const val LOAD_THEME_ICON: Int = 1 shl 3
- private val flagCache = Array(8) { CacheLookupFlag(it) }
+ private val flagCache = Array(1 shl 4) { CacheLookupFlag(it) }
@JvmField val DEFAULT_LOOKUP_FLAG = CacheLookupFlag(0)
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt
index 8457628..c7dd470 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt
@@ -22,12 +22,13 @@ import android.content.pm.LauncherActivityInfo
import android.os.Build.VERSION
import android.os.UserHandle
import android.util.Log
-import app.lawnchair.icons.getCustomAppNameForComponent
import com.android.launcher3.Flags.useNewIconForArchivedApps
import com.android.launcher3.icons.BaseIconFactory.IconOptions
import com.android.launcher3.icons.BitmapInfo
import com.android.launcher3.icons.IconProvider
+import app.lawnchair.icons.getCustomAppNameForComponent
+
object LauncherActivityCachingLogic : CachingLogic {
const val TAG = "LauncherActivityCachingLogic"
diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt
index 411d714..8af8fd9 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt
@@ -46,7 +46,8 @@ import java.nio.ByteBuffer
@TargetApi(Build.VERSION_CODES.TIRAMISU)
class MonoIconThemeController(
- private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors
+ private val shouldForceThemeIcon: Boolean = false,
+ private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors,
) : IconThemeController {
override val themeID = "with-theme"
@@ -63,9 +64,8 @@ class MonoIconThemeController(
icon,
info,
factory.getShapePath(icon, Rect(0, 0, info.icon.width, info.icon.height)),
- factory.iconScale,
sourceHint?.isFileDrawable ?: false,
- factory.shouldForceThemeIcon(),
+ shouldForceThemeIcon,
)
if (mono != null) {
return MonoThemedBitmap(
@@ -86,16 +86,15 @@ class MonoIconThemeController(
base: AdaptiveIconDrawable,
info: BitmapInfo,
shapePath: Path,
- iconScale: Float,
isFileDrawable: Boolean,
shouldForceThemeIcon: Boolean,
): Drawable? {
val mono = base.monochrome
if (mono != null) {
- return ClippedMonoDrawable(mono, shapePath, iconScale)
+ return ClippedMonoDrawable(mono, shapePath)
}
if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) {
- return MonochromeIconFactory(info.icon.width).wrap(base, shapePath, iconScale)
+ return MonochromeIconFactory(info.icon.width).wrap(base, shapePath)
}
return null
}
@@ -105,9 +104,9 @@ class MonoIconThemeController(
info: BitmapInfo,
factory: BaseIconFactory,
sourceHint: SourceHint,
- ): ThemedBitmap? {
+ ): ThemedBitmap {
val icon = info.icon
- if (data.size != icon.height * icon.width) return null
+ if (data.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED
var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8)
monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data))
@@ -124,7 +123,7 @@ class MonoIconThemeController(
context: Context,
originalIcon: AdaptiveIconDrawable,
info: BitmapInfo?,
- ): AdaptiveIconDrawable? {
+ ): AdaptiveIconDrawable {
val colors = colorProvider(context)
originalIcon.mutate()
var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) }
@@ -148,13 +147,11 @@ class MonoIconThemeController(
}
return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) }
+ ?: originalIcon
}
- class ClippedMonoDrawable(
- base: Drawable?,
- private val shapePath: Path,
- private val iconScale: Float,
- ) : InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) {
+ class ClippedMonoDrawable(base: Drawable?, private val shapePath: Path) :
+ InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) {
// TODO(b/399666950): remove this after launcher icon shapes is fully enabled
private val mCrop = AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null)
@@ -163,7 +160,6 @@ class MonoIconThemeController(
val saveCount = canvas.save()
if (Flags.enableLauncherIconShapes()) {
canvas.clipPath(shapePath)
- canvas.scale(iconScale, iconScale, bounds.width() / 2f, bounds.height() / 2f)
} else {
canvas.clipPath(mCrop.iconMask)
}
diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt
index a0cabf1..4ed5017 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt
+++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt
@@ -29,14 +29,15 @@ import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.os.Build
import androidx.core.graphics.ColorUtils
-import app.lawnchair.icons.shouldTransparentBGIcons
import com.android.launcher3.icons.BitmapInfo
import com.android.launcher3.icons.FastBitmapDrawable
import com.android.launcher3.icons.R
+import app.lawnchair.icons.shouldTransparentBGIcons
+
/** Class to handle monochrome themed app icons */
class ThemedIconDrawable(constantState: ThemedConstantState) :
- FastBitmapDrawable(constantState.getBitmapInfo()) {
+ FastBitmapDrawable(constantState.bitmapInfo) {
private val colorFg = constantState.colorFg
private val colorBg = constantState.colorBg
@@ -66,10 +67,10 @@ class ThemedIconDrawable(constantState: ThemedConstantState) :
override fun updateFilter() {
super.updateFilter()
- val alpha = if (mIsDisabled) (mDisabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE
+ val alpha = if (isDisabled) (disabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE
mBgPaint.alpha = alpha
mBgPaint.setColorFilter(
- if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
BlendModeColorFilter(getDisabledColor(colorBg), SRC_IN)
} else {
PorterDuffColorFilter(getDisabledColor(colorBg), PorterDuff.Mode.SRC_IN)
@@ -78,7 +79,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) :
monoPaint.alpha = alpha
monoPaint.setColorFilter(
- if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
BlendModeColorFilter(
getDisabledColor(colorFg),
SRC_IN,
@@ -92,7 +93,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) :
override fun isThemed() = true
override fun newConstantState() =
- ThemedConstantState(mBitmapInfo, monoIcon, bgBitmap, colorBg, colorFg)
+ ThemedConstantState(bitmapInfo, monoIcon, bgBitmap, colorBg, colorFg)
override fun getIconColor() = colorFg
@@ -105,8 +106,6 @@ class ThemedIconDrawable(constantState: ThemedConstantState) :
) : FastBitmapConstantState(bitmapInfo) {
public override fun createDrawable() = ThemedIconDrawable(this)
-
- fun getBitmapInfo(): BitmapInfo = mBitmapInfo
}
companion object {
diff --git a/mechanics/Android.bp b/mechanics/Android.bp
new file mode 100644
index 0000000..d683892
--- /dev/null
+++ b/mechanics/Android.bp
@@ -0,0 +1,35 @@
+// Copyright (C) 2024 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 {
+ default_team: "trendy_team_motion",
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+ name: "mechanics",
+ manifest: "AndroidManifest.xml",
+ sdk_version: "system_current",
+ min_sdk_version: "31",
+ static_libs: [
+ "androidx.compose.runtime_runtime",
+ "androidx.compose.material3_material3",
+ "androidx.compose.ui_ui-util",
+ "androidx.compose.foundation_foundation-layout",
+ ],
+ srcs: [
+ "src/**/*.kt",
+ ],
+ kotlincflags: ["-Xjvm-default=all"],
+}
diff --git a/mechanics/AndroidManifest.xml b/mechanics/AndroidManifest.xml
new file mode 100644
index 0000000..29874f3
--- /dev/null
+++ b/mechanics/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/mechanics/TEST_MAPPING b/mechanics/TEST_MAPPING
new file mode 100644
index 0000000..4dd86b9
--- /dev/null
+++ b/mechanics/TEST_MAPPING
@@ -0,0 +1,38 @@
+{
+ "presubmit": [
+ {
+ "name": "mechanics_tests",
+ "options": [
+ {"exclude-annotation": "org.junit.Ignore"},
+ {"exclude-annotation": "androidx.test.filters.FlakyTest"}
+ ]
+ },
+ {
+ "name": "SystemUIGoogleTests",
+ "options": [
+ {"exclude-annotation": "org.junit.Ignore"},
+ {"exclude-annotation": "androidx.test.filters.FlakyTest"}
+ ]
+ },
+ {
+ "name": "PlatformComposeSceneTransitionLayoutTests"
+ },
+ {
+ "name": "PlatformComposeCoreTests"
+ }
+ ],
+ "presubmit-large": [
+ {
+ "name": "SystemUITests",
+ "options": [
+ {"exclude-annotation": "org.junit.Ignore"},
+ {"exclude-annotation": "androidx.test.filters.FlakyTest"}
+ ]
+ }
+ ],
+ "wm-cf": [
+ {
+ "name": "WMShellUnitTests"
+ }
+ ]
+}
diff --git a/mechanics/benchmark/AndroidManifest.xml b/mechanics/benchmark/AndroidManifest.xml
new file mode 100644
index 0000000..405595c
--- /dev/null
+++ b/mechanics/benchmark/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mechanics/benchmark/benchmark-proguard-rules.pro b/mechanics/benchmark/benchmark-proguard-rules.pro
new file mode 100644
index 0000000..e4061d2
--- /dev/null
+++ b/mechanics/benchmark/benchmark-proguard-rules.pro
@@ -0,0 +1,37 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+
+-dontobfuscate
+
+-ignorewarnings
+
+-keepattributes *Annotation*
+
+-dontnote junit.framework.**
+-dontnote junit.runner.**
+
+-dontwarn androidx.test.**
+-dontwarn org.junit.**
+-dontwarn org.hamcrest.**
+-dontwarn com.squareup.javawriter.JavaWriter
+
+-keepclasseswithmembers @org.junit.runner.RunWith public class *
\ No newline at end of file
diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt
new file mode 100644
index 0000000..c000dfe
--- /dev/null
+++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt
@@ -0,0 +1,105 @@
+/*
+ * 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.benchmark
+
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.compose.animation.core.Animatable
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.util.fastForEach
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.compose.runMonotonicClockTest
+
+/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */
+@RunWith(AndroidJUnit4::class)
+class ComposeBaselineBenchmark {
+ @get:Rule val benchmarkRule = BenchmarkRule()
+
+ // Compose specific
+
+ @Test
+ fun writeState_1snapshotFlow() = runMonotonicClockTest {
+ val composeState = mutableFloatStateOf(0f)
+
+ var lastRead = 0f
+ snapshotFlow { composeState.floatValue }.onEach { lastRead = it }.launchIn(backgroundScope)
+
+ benchmarkRule.measureRepeated {
+ composeState.floatValue++
+ Snapshot.sendApplyNotifications()
+ testScheduler.advanceTimeBy(16)
+ }
+
+ check(lastRead == composeState.floatValue) {
+ "snapshotFlow lastRead $lastRead != ${composeState.floatValue} (current composeState)"
+ }
+ }
+
+ @Test
+ fun writeState_100snapshotFlow() = runMonotonicClockTest {
+ val composeState = mutableFloatStateOf(0f)
+
+ repeat(100) { snapshotFlow { composeState.floatValue }.launchIn(backgroundScope) }
+
+ benchmarkRule.measureRepeated {
+ composeState.floatValue++
+ Snapshot.sendApplyNotifications()
+ testScheduler.advanceTimeBy(16)
+ }
+ }
+
+ @Test
+ fun readAnimatableValue_100animatables_keepRunning() = runMonotonicClockTest {
+ val anim = List(100) { Animatable(0f) }
+
+ benchmarkRule.measureRepeated {
+ testScheduler.advanceTimeBy(16)
+ anim.fastForEach {
+ it.value
+
+ if (!it.isRunning) {
+ launch { it.animateTo(if (it.targetValue != 0f) 0f else 1f) }
+ }
+ }
+ }
+
+ testScheduler.advanceTimeBy(2000)
+ }
+
+ @Test
+ fun readAnimatableValue_100animatables_restartEveryFrame() = runMonotonicClockTest {
+ val animatables = List(100) { Animatable(0f) }
+
+ benchmarkRule.measureRepeated {
+ testScheduler.advanceTimeBy(16)
+ animatables.fastForEach { animatable ->
+ animatable.value
+ launch { animatable.animateTo(if (animatable.targetValue != 0f) 0f else 1f) }
+ }
+ }
+
+ testScheduler.advanceTimeBy(2000)
+ }
+}
diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt
new file mode 100644
index 0000000..e70bc2b
--- /dev/null
+++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt
@@ -0,0 +1,168 @@
+/*
+ * 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.benchmark
+
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.Snapshot
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.compose.runMonotonicClockTest
+
+@RunWith(AndroidJUnit4::class)
+class ComposeStateTest {
+ @Test
+ fun mutableState_sendApplyNotifications() = runMonotonicClockTest {
+ val mutableState = mutableStateOf(0f)
+
+ var lastRead = -1f
+ snapshotFlow { mutableState.value }.onEach { lastRead = it }.launchIn(backgroundScope)
+ check(lastRead == -1f) { "[1] lastRead $lastRead, snapshotFlow launchIn" }
+
+ // snapshotFlow will emit the first value (0f).
+ testScheduler.advanceTimeBy(1)
+ check(lastRead == 0f) { "[2] lastRead $lastRead, first advanceTimeBy()" }
+
+ // update composeState x5.
+ repeat(5) {
+ mutableState.value++
+ check(lastRead == 0f) { "[3 loop] lastRead $lastRead, composeState.floatValue++" }
+
+ testScheduler.advanceTimeBy(1)
+ check(lastRead == 0f) { "[4 loop] lastRead $lastRead, advanceTimeBy()" }
+ }
+
+ // Try to wait with a delay. It does nothing (lastRead == 0f).
+ delay(1)
+ check(mutableState.value == 5f) { "[5] mutableState ${mutableState.value}, after loop" }
+ check(lastRead == 0f) { "[5] lastRead $lastRead, after loop" }
+
+ // This should trigger the flow.
+ Snapshot.sendApplyNotifications()
+ check(lastRead == 0f) { "[6] lastRead $lastRead, Snapshot.sendApplyNotifications()" }
+
+ // lastRead will be updated (5f) after advanceTimeBy (or a delay).
+ testScheduler.advanceTimeBy(1)
+ check(lastRead == 5f) { "[7] lastRead $lastRead, advanceTimeBy" }
+ }
+
+ @Test
+ fun derivedState_readNotRequireASendApplyNotifications() = runMonotonicClockTest {
+ val mutableState = mutableStateOf(0f)
+
+ var derivedRuns = 0
+ val derived = derivedStateOf {
+ derivedRuns++
+ mutableState.value * 2f
+ }
+ check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns, should be 0" }
+
+ var lastRead = -1f
+ snapshotFlow { derived.value }.onEach { lastRead = it }.launchIn(backgroundScope)
+ check(lastRead == -1f) { "[2] lastRead $lastRead, snapshotFlow launchIn" }
+ check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns, should be 0" }
+
+ // snapshotFlow will emit the first value (0f * 2f = 0f).
+ testScheduler.advanceTimeBy(16)
+ check(lastRead == 0f) { "[3] lastRead $lastRead, first advanceTimeBy()" }
+ check(derivedRuns == 1) { "[3] derivedRuns: $derivedRuns, should be 1" }
+
+ // update composeState x5.
+ repeat(5) {
+ mutableState.value++
+ check(lastRead == 0f) { "[4 loop] lastRead $lastRead, composeState.floatValue++" }
+
+ testScheduler.advanceTimeBy(16)
+ check(lastRead == 0f) { "[5 loop] lastRead $lastRead, advanceTimeBy()" }
+ }
+
+ // Try to wait with a delay. It does nothing (lastRead == 0f).
+ delay(1)
+ check(mutableState.value == 5f) { "[6] mutableState ${mutableState.value}, after loop" }
+ check(lastRead == 0f) { "[6] lastRead $lastRead, after loop" }
+ check(derivedRuns == 1) { "[6] derivedRuns $derivedRuns, after loop" }
+
+ // Reading a derived state, this will trigger the flow.
+ // NOTE: We are not using Snapshot.sendApplyNotifications()
+ derived.value
+ check(lastRead == 0f) { "[7] lastRead $lastRead, read derivedDouble" }
+ check(derivedRuns == 2) { "[7] derivedRuns $derivedRuns, read derived" } // Triggered
+
+ // lastRead will be updated (5f * 2f = 10f) after advanceTimeBy (or a delay)
+ testScheduler.advanceTimeBy(16)
+ check(lastRead == 5f * 2f) { "[8] lastRead $lastRead, advanceTimeBy" } // New value
+ check(derivedRuns == 2) { "[8] derivedRuns $derivedRuns, read derived" }
+ }
+
+ @Test
+ fun derivedState_readADerivedStateTriggerOthersDerivedState() = runMonotonicClockTest {
+ val mutableState = mutableStateOf(0f)
+
+ var derivedRuns = 0
+ val derived = derivedStateOf {
+ derivedRuns++
+ mutableState.value
+ }
+
+ var otherRuns = 0
+ repeat(100) {
+ val otherState = derivedStateOf {
+ otherRuns++
+ mutableState.value
+ }
+ // Observer all otherStates.
+ snapshotFlow { otherState.value }.launchIn(backgroundScope)
+ }
+ check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns" }
+ check(otherRuns == 0) { "[1] otherRuns: $otherRuns" }
+
+ // Wait for snapshotFlow.
+ testScheduler.advanceTimeBy(16)
+ check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns" }
+ check(otherRuns == 100) { "[2] otherRuns: $otherRuns" }
+
+ // This write might trigger all otherStates observed, but it does not.
+ mutableState.value++
+ check(derivedRuns == 0) { "[3] derivedRuns: $derivedRuns" }
+ check(otherRuns == 100) { "[3] otherRuns: $otherRuns" }
+
+ // Wait for several frames, but still doesn't trigger otherStates.
+ repeat(10) { testScheduler.advanceTimeBy(16) }
+ check(derivedRuns == 0) { "[4] derivedRuns: $derivedRuns" }
+ check(otherRuns == 100) { "[4] otherRuns: $otherRuns" }
+
+ // Reading derived state will trigger all otherStates.
+ // This behavior is causing us some problems, because reading a derived state causes all
+ // the
+ // dirty derived states to be reread, and this can happen multiple times per frame,
+ // making
+ // derived states much more expensive than one might expect.
+ derived.value
+ check(derivedRuns == 1) { "[5] derivedRuns: $derivedRuns" }
+ check(otherRuns == 100) { "[5] otherRuns: $otherRuns" }
+
+ // Now we pay the cost of those derived states.
+ testScheduler.advanceTimeBy(1)
+ check(derivedRuns == 1) { "[6] derivedRuns: $derivedRuns" }
+ check(otherRuns == 200) { "[6] otherRuns: $otherRuns" }
+ }
+}
diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt
new file mode 100644
index 0000000..f5eab76
--- /dev/null
+++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.benchmark
+
+import androidx.benchmark.junit4.BenchmarkRule
+import androidx.benchmark.junit4.measureRepeated
+import androidx.compose.runtime.MutableFloatState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.util.fastForEach
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.mechanics.DistanceGestureContext
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.builder.directionalMotionSpec
+import com.android.mechanics.spring.SpringParameters
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.launch
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.compose.MonotonicClockTestScope
+
+/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */
+@RunWith(AndroidJUnit4::class)
+class MotionValueBenchmark {
+ @get:Rule val benchmarkRule = BenchmarkRule()
+
+ private val tearDownOperations = mutableListOf<() -> Unit>()
+
+ /**
+ * Runs a test block within a [MonotonicClockTestScope] provided by the underlying
+ * [platform.test.motion.compose.runMonotonicClockTest] and ensures automatic cleanup.
+ *
+ * This mechanism provides a convenient way to register cleanup actions (e.g., stopping
+ * coroutines, resetting states) that should reliably run at the end of the test, simplifying
+ * test setup and teardown.
+ */
+ private fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) {
+ return platform.test.motion.compose.runMonotonicClockTest {
+ try {
+ block()
+ } finally {
+ tearDownOperations.fastForEach { it.invoke() }
+ }
+ }
+ }
+
+ private data class TestData(
+ val motionValue: MotionValue,
+ val gestureContext: DistanceGestureContext,
+ val input: MutableFloatState,
+ val spec: MotionSpec,
+ )
+
+ private fun testData(
+ gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f),
+ input: Float = 0f,
+ spec: MotionSpec = MotionSpec.Empty,
+ ): TestData {
+ val inputState = mutableFloatStateOf(input)
+ return TestData(
+ motionValue = MotionValue(inputState::floatValue, gestureContext, spec),
+ gestureContext = gestureContext,
+ input = inputState,
+ spec = spec,
+ )
+ }
+
+ // Fundamental operations on MotionValue: create, read, update.
+
+ @Test
+ fun createMotionValue() {
+ val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f)
+ val input = { 0f }
+
+ benchmarkRule.measureRepeated { MotionValue(input, gestureContext) }
+ }
+
+ @Test
+ fun stable_readOutput_noChanges() {
+ val data = testData()
+
+ // The first read may cost more than the others, it is not interesting for this test.
+ data.motionValue.floatValue
+
+ benchmarkRule.measureRepeated { data.motionValue.floatValue }
+ }
+
+ @Test
+ fun stable_readOutput_afterWriteInput() {
+ val data = testData()
+
+ benchmarkRule.measureRepeated {
+ runWithMeasurementDisabled { data.input.floatValue += 1f }
+ data.motionValue.floatValue
+ }
+ }
+
+ @Test
+ fun stable_writeInput_AND_readOutput() {
+ val data = testData()
+
+ benchmarkRule.measureRepeated {
+ data.input.floatValue += 1f
+ data.motionValue.floatValue
+ }
+ }
+
+ @Test
+ fun stable_writeInput_AND_readOutput_keepRunning() = runMonotonicClockTest {
+ val data = testData()
+ keepRunningDuringTest(data.motionValue)
+
+ benchmarkRule.measureRepeated {
+ data.input.floatValue += 1f
+ testScheduler.advanceTimeBy(16)
+ data.motionValue.floatValue
+ }
+ }
+
+ @Test
+ fun stable_writeInput_AND_readOutput_100motionValues_keepRunning() = runMonotonicClockTest {
+ val dataList = List(100) { testData() }
+ dataList.forEach { keepRunningDuringTest(it.motionValue) }
+
+ benchmarkRule.measureRepeated {
+ dataList.fastForEach { it.input.floatValue += 1f }
+ testScheduler.advanceTimeBy(16)
+ dataList.fastForEach { it.motionValue.floatValue }
+ }
+ }
+
+ @Test
+ fun stable_readOutput_100motionValues_keepRunning() = runMonotonicClockTest {
+ val dataList = List(100) { testData() }
+ dataList.forEach { keepRunningDuringTest(it.motionValue) }
+
+ benchmarkRule.measureRepeated {
+ testScheduler.advanceTimeBy(16)
+ dataList.fastForEach { it.motionValue.floatValue }
+ }
+ }
+
+ // Animations
+
+ private fun MonotonicClockTestScope.keepRunningDuringTest(motionValue: MotionValue) {
+ val keepRunningJob = launch { motionValue.keepRunning() }
+ tearDownOperations += { keepRunningJob.cancel() }
+ }
+
+ private val MotionSpec.Companion.ZeroToOne_AtOne
+ get() =
+ MotionSpec(
+ directionalMotionSpec(
+ defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
+ initialMapping = Mapping.Zero,
+ ) {
+ fixedValue(breakpoint = 1f, value = 1f)
+ }
+ )
+
+ private val InputDirection.opposite
+ get() = if (this == InputDirection.Min) InputDirection.Max else InputDirection.Min
+
+ @Test
+ fun unstable_resetGestureContext_readOutput() = runMonotonicClockTest {
+ val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne)
+ keepRunningDuringTest(data.motionValue)
+
+ benchmarkRule.measureRepeated {
+ if (data.motionValue.isStable) {
+ data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
+ }
+ testScheduler.advanceTimeBy(16)
+ data.motionValue.floatValue
+ }
+ }
+
+ @Test
+ fun unstable_resetGestureContext_readOutput_100motionValues() = runMonotonicClockTest {
+ val dataList = List(100) { testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) }
+ dataList.forEach { keepRunningDuringTest(it.motionValue) }
+
+ benchmarkRule.measureRepeated {
+ dataList.fastForEach { data ->
+ if (data.motionValue.isStable) {
+ data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
+ }
+ }
+ testScheduler.advanceTimeBy(16)
+ dataList.fastForEach { it.motionValue.floatValue }
+ }
+ }
+
+ @Test
+ fun unstable_resetGestureContext_snapshotFlowOutput() = runMonotonicClockTest {
+ val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne)
+ keepRunningDuringTest(data.motionValue)
+
+ snapshotFlow { data.motionValue.floatValue }.launchIn(backgroundScope)
+
+ benchmarkRule.measureRepeated {
+ if (data.motionValue.isStable) {
+ data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
+ }
+ testScheduler.advanceTimeBy(16)
+ }
+ }
+
+ private val MotionSpec.Companion.ZeroToOne_AtOne_WithGuarantee
+ get() =
+ MotionSpec(
+ directionalMotionSpec(
+ defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
+ initialMapping = Mapping.Zero,
+ ) {
+ fixedValue(
+ breakpoint = 1f,
+ value = 1f,
+ guarantee = Guarantee.GestureDragDelta(1f),
+ )
+ }
+ )
+
+ @Test
+ fun unstable_resetGestureContext_guarantee_readOutput() = runMonotonicClockTest {
+ val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne_WithGuarantee)
+ keepRunningDuringTest(data.motionValue)
+
+ benchmarkRule.measureRepeated {
+ if (data.motionValue.isStable) {
+ data.gestureContext.reset(0f, data.gestureContext.direction.opposite)
+ } else {
+ val isMax = data.gestureContext.direction == InputDirection.Max
+ data.gestureContext.dragOffset += if (isMax) 0.01f else -0.01f
+ }
+
+ testScheduler.advanceTimeBy(16)
+ data.motionValue.floatValue
+ }
+ }
+}
diff --git a/mechanics/build.gradle b/mechanics/build.gradle
new file mode 100644
index 0000000..d28e0db
--- /dev/null
+++ b/mechanics/build.gradle
@@ -0,0 +1,50 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
+
+android {
+ namespace = "com.android.mechanics"
+ testNamespace = "com.android.mechanics.tests"
+
+ defaultConfig {
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ sourceSets {
+ main {
+ java.srcDirs = ['src', 'compose']
+ manifest.srcFile 'AndroidManifest.xml'
+ }
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ tasks.lint.enabled = false
+
+ tasks.withType(JavaCompile) {
+ options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
+ }
+}
+
+addFrameworkJar('framework-16.jar')
+
+dependencies {
+ implementation libs.kotlin.stdlib.jdk7
+ implementation libs.androidx.core.animation
+ implementation libs.androidx.core.ktx
+
+ // Compose dependencies for compose source files
+ implementation platform(libs.compose.bom)
+ implementation libs.compose.ui
+ implementation libs.compose.ui.util
+ implementation libs.compose.ui.graphics
+ implementation libs.compose.runtime
+ implementation libs.kotlinx.coroutines.android
+ implementation libs.compose.material3
+}
diff --git a/mechanics/compose/Android.bp b/mechanics/compose/Android.bp
new file mode 100644
index 0000000..bc852eb
--- /dev/null
+++ b/mechanics/compose/Android.bp
@@ -0,0 +1,33 @@
+// 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 {
+ default_team: "trendy_team_motion",
+ default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_library {
+ name: "mechanics-compose",
+ manifest: "AndroidManifest.xml",
+ srcs: [
+ "src/**/*.kt",
+ ],
+ static_libs: [
+ "PlatformComposeCore",
+ "PlatformComposeSceneTransitionLayout",
+ "//frameworks/libs/systemui/mechanics:mechanics",
+ "androidx.compose.runtime_runtime",
+ ],
+ kotlincflags: ["-Xjvm-default=all"],
+}
diff --git a/mechanics/compose/AndroidManifest.xml b/mechanics/compose/AndroidManifest.xml
new file mode 100644
index 0000000..b84f740
--- /dev/null
+++ b/mechanics/compose/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt
new file mode 100644
index 0000000..6428d9d
--- /dev/null
+++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt
@@ -0,0 +1,229 @@
+///*
+// * 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.compose.modifier
+//
+//import androidx.compose.ui.Modifier
+//import androidx.compose.ui.geometry.Rect
+//import androidx.compose.ui.graphics.CompositingStrategy
+//import androidx.compose.ui.layout.ApproachLayoutModifierNode
+//import androidx.compose.ui.layout.ApproachMeasureScope
+//import androidx.compose.ui.layout.LayoutCoordinates
+//import androidx.compose.ui.layout.Measurable
+//import androidx.compose.ui.layout.MeasureResult
+//import androidx.compose.ui.layout.MeasureScope
+//import androidx.compose.ui.layout.Placeable
+//import androidx.compose.ui.layout.boundsInParent
+//import androidx.compose.ui.node.ModifierNodeElement
+//import androidx.compose.ui.platform.InspectorInfo
+//import androidx.compose.ui.unit.Constraints
+//import androidx.compose.ui.unit.IntOffset
+//import androidx.compose.ui.unit.IntSize
+//import androidx.compose.ui.util.fastCoerceAtLeast
+//import com.android.compose.animation.scene.ContentScope
+//import com.android.compose.animation.scene.ElementKey
+//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault
+//import com.android.mechanics.MotionValue
+//import com.android.mechanics.debug.findMotionValueDebugger
+//import com.android.mechanics.effects.FixedValue
+//import com.android.mechanics.spec.Mapping
+//import com.android.mechanics.spec.builder.MotionBuilderContext
+//import com.android.mechanics.spec.builder.effectsMotionSpec
+//import kotlinx.coroutines.Job
+//import kotlinx.coroutines.launch
+//
+///**
+// * This component remains hidden until it reach its target height.
+// *
+// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via
+// * CompositionLocalConsumerModifierNode, instead of passing it.
+// */
+//fun Modifier.verticalFadeContentReveal(
+// contentScope: ContentScope,
+// motionBuilderContext: MotionBuilderContext,
+// container: ElementKey,
+// deltaY: Float = 0f,
+// label: String? = null,
+// debug: Boolean = false,
+//): Modifier =
+// this then
+// FadeContentRevealElement(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// label = label,
+// debug = debug,
+// )
+//
+//private data class FadeContentRevealElement(
+// val contentScope: ContentScope,
+// val motionBuilderContext: MotionBuilderContext,
+// val container: ElementKey,
+// val deltaY: Float,
+// val label: String?,
+// val debug: Boolean,
+//) : ModifierNodeElement() {
+// override fun create(): FadeContentRevealNode =
+// FadeContentRevealNode(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// label = label,
+// debug = debug,
+// )
+//
+// override fun update(node: FadeContentRevealNode) {
+// node.update(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// )
+// }
+//
+// override fun InspectorInfo.inspectableProperties() {
+// name = "fadeContentReveal"
+// properties["container"] = container
+// properties["deltaY"] = deltaY
+// properties["label"] = label
+// properties["debug"] = debug
+// }
+//}
+//
+//internal class FadeContentRevealNode(
+// private var contentScope: ContentScope,
+// private var motionBuilderContext: MotionBuilderContext,
+// private var container: ElementKey,
+// private var deltaY: Float,
+// label: String?,
+// private val debug: Boolean,
+//) : Modifier.Node(), ApproachLayoutModifierNode {
+//
+// private val motionValue =
+// MotionValue(
+// currentInput = {
+// with(contentScope) {
+// val containerHeight =
+// container.lastSize(contentKey)?.height ?: return@MotionValue 0f
+// val containerCoordinates =
+// container.targetCoordinates(contentKey) ?: return@MotionValue 0f
+// val localCoordinates = lastCoordinates ?: return@MotionValue 0f
+//
+// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y
+// containerHeight - offsetY + deltaY
+// }
+// },
+// gestureContext = contentScope.gestureContextOrDefault(),
+// label = "FadeContentReveal(${label.orEmpty()})",
+// )
+//
+// fun update(
+// contentScope: ContentScope,
+// motionBuilderContext: MotionBuilderContext,
+// container: ElementKey,
+// deltaY: Float,
+// ) {
+// this.contentScope = contentScope
+// this.motionBuilderContext = motionBuilderContext
+// this.container = container
+// this.deltaY = deltaY
+// updateMotionSpec()
+// }
+//
+// private var motionValueJob: Job? = null
+//
+// override fun onAttach() {
+// motionValueJob =
+// coroutineScope.launch {
+// val disposableHandle =
+// if (debug) {
+// findMotionValueDebugger()?.register(motionValue)
+// } else {
+// null
+// }
+// try {
+// motionValue.keepRunning()
+// } finally {
+// disposableHandle?.dispose()
+// }
+// }
+// }
+//
+// override fun onDetach() {
+// motionValueJob?.cancel()
+// }
+//
+// private fun isAnimating(): Boolean {
+// return contentScope.layoutState.currentTransition != null || !motionValue.isStable
+// }
+//
+// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating()
+//
+// override fun Placeable.PlacementScope.isPlacementApproachInProgress(
+// lookaheadCoordinates: LayoutCoordinates
+// ) = isAnimating()
+//
+// private var targetBounds = Rect.Zero
+//
+// private var lastCoordinates: LayoutCoordinates? = null
+//
+// private fun updateMotionSpec() {
+// motionValue.spec =
+// motionBuilderContext.effectsMotionSpec(Mapping.Zero) {
+// after(targetBounds.bottom, FixedValue.One)
+// }
+// }
+//
+// override fun MeasureScope.measure(
+// measurable: Measurable,
+// constraints: Constraints,
+// ): MeasureResult {
+// val placeable = measurable.measure(constraints)
+// return layout(placeable.width, placeable.height) {
+// val coordinates = coordinates
+// if (isLookingAhead && coordinates != null) {
+// lastCoordinates = coordinates
+// val bounds = coordinates.boundsInParent()
+// if (targetBounds != bounds) {
+// targetBounds = bounds
+// updateMotionSpec()
+// }
+// }
+// placeable.place(IntOffset.Zero)
+// }
+// }
+//
+// override fun ApproachMeasureScope.approachMeasure(
+// measurable: Measurable,
+// constraints: Constraints,
+// ): MeasureResult {
+// return measurable.measure(constraints).run {
+// layout(width, height) {
+// val revealAlpha = motionValue.output
+// if (revealAlpha < 1) {
+// placeWithLayer(IntOffset.Zero) {
+// alpha = revealAlpha.fastCoerceAtLeast(0f)
+// compositingStrategy = CompositingStrategy.ModulateAlpha
+// }
+// } else {
+// place(IntOffset.Zero)
+// }
+// }
+// }
+// }
+//}
diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt
new file mode 100644
index 0000000..9bfd3db
--- /dev/null
+++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt
@@ -0,0 +1,250 @@
+///*
+// * 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.compose.modifier
+//
+//import androidx.compose.ui.Modifier
+//import androidx.compose.ui.geometry.Rect
+//import androidx.compose.ui.graphics.CompositingStrategy
+//import androidx.compose.ui.layout.ApproachLayoutModifierNode
+//import androidx.compose.ui.layout.ApproachMeasureScope
+//import androidx.compose.ui.layout.LayoutCoordinates
+//import androidx.compose.ui.layout.Measurable
+//import androidx.compose.ui.layout.MeasureResult
+//import androidx.compose.ui.layout.MeasureScope
+//import androidx.compose.ui.layout.Placeable
+//import androidx.compose.ui.layout.boundsInParent
+//import androidx.compose.ui.node.ModifierNodeElement
+//import androidx.compose.ui.platform.InspectorInfo
+//import androidx.compose.ui.unit.Constraints
+//import androidx.compose.ui.unit.IntOffset
+//import androidx.compose.ui.unit.IntSize
+//import androidx.compose.ui.util.fastCoerceAtLeast
+//import androidx.compose.ui.util.fastCoerceIn
+//import com.android.compose.animation.scene.ContentScope
+//import com.android.compose.animation.scene.ElementKey
+//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault
+//import com.android.mechanics.MotionValue
+//import com.android.mechanics.debug.findMotionValueDebugger
+//import com.android.mechanics.effects.RevealOnThreshold
+//import com.android.mechanics.spec.Mapping
+//import com.android.mechanics.spec.builder.MotionBuilderContext
+//import com.android.mechanics.spec.builder.spatialMotionSpec
+//import kotlin.math.roundToInt
+//import kotlinx.coroutines.Job
+//import kotlinx.coroutines.launch
+//
+///**
+// * This component remains hidden until its target height meets a minimum threshold. At that point,
+// * it reveals itself by animating its height from 0 to the current target height.
+// *
+// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via
+// * CompositionLocalConsumerModifierNode, instead of passing it.
+// */
+//fun Modifier.verticalTactileSurfaceReveal(
+// contentScope: ContentScope,
+// motionBuilderContext: MotionBuilderContext,
+// container: ElementKey,
+// deltaY: Float = 0f,
+// revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold,
+// label: String? = null,
+// debug: Boolean = false,
+//): Modifier =
+// this then
+// VerticalTactileSurfaceRevealElement(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// revealOnThreshold = revealOnThreshold,
+// label = label,
+// debug = debug,
+// )
+//
+//private val DefaultRevealOnThreshold = RevealOnThreshold()
+//
+//private data class VerticalTactileSurfaceRevealElement(
+// val contentScope: ContentScope,
+// val motionBuilderContext: MotionBuilderContext,
+// val container: ElementKey,
+// val deltaY: Float,
+// val revealOnThreshold: RevealOnThreshold,
+// val label: String?,
+// val debug: Boolean,
+//) : ModifierNodeElement() {
+// override fun create(): VerticalTactileSurfaceRevealNode =
+// VerticalTactileSurfaceRevealNode(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// revealOnThreshold = revealOnThreshold,
+// label = label,
+// debug = debug,
+// )
+//
+// override fun update(node: VerticalTactileSurfaceRevealNode) {
+// node.update(
+// contentScope = contentScope,
+// motionBuilderContext = motionBuilderContext,
+// container = container,
+// deltaY = deltaY,
+// revealOnThreshold = revealOnThreshold,
+// )
+// }
+//
+// override fun InspectorInfo.inspectableProperties() {
+// name = "tactileSurfaceReveal"
+// properties["container"] = container
+// properties["deltaY"] = deltaY
+// properties["revealOnThreshold"] = revealOnThreshold
+// properties["label"] = label
+// properties["debug"] = debug
+// }
+//}
+//
+//private class VerticalTactileSurfaceRevealNode(
+// private var contentScope: ContentScope,
+// private var motionBuilderContext: MotionBuilderContext,
+// private var container: ElementKey,
+// private var deltaY: Float,
+// private var revealOnThreshold: RevealOnThreshold,
+// label: String?,
+// private val debug: Boolean,
+//) : Modifier.Node(), ApproachLayoutModifierNode {
+//
+// private val motionValue =
+// MotionValue(
+// currentInput = {
+// with(contentScope) {
+// val containerHeight =
+// container.lastSize(contentKey)?.height ?: return@MotionValue 0f
+// val containerCoordinates =
+// container.targetCoordinates(contentKey) ?: return@MotionValue 0f
+// val localCoordinates = lastCoordinates ?: return@MotionValue 0f
+//
+// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y
+// containerHeight - offsetY + deltaY
+// }
+// },
+// gestureContext = contentScope.gestureContextOrDefault(),
+// label = "TactileSurfaceReveal(${label.orEmpty()})",
+// stableThreshold = MotionBuilderContext.StableThresholdSpatial,
+// )
+//
+// fun update(
+// contentScope: ContentScope,
+// motionBuilderContext: MotionBuilderContext,
+// container: ElementKey,
+// deltaY: Float,
+// revealOnThreshold: RevealOnThreshold,
+// ) {
+// this.contentScope = contentScope
+// this.motionBuilderContext = motionBuilderContext
+// this.container = container
+// this.deltaY = deltaY
+// this.revealOnThreshold = revealOnThreshold
+// updateMotionSpec()
+// }
+//
+// private var motionValueJob: Job? = null
+//
+// override fun onAttach() {
+// motionValueJob =
+// coroutineScope.launch {
+// val disposableHandle =
+// if (debug) {
+// findMotionValueDebugger()?.register(motionValue)
+// } else {
+// null
+// }
+// try {
+// motionValue.keepRunning()
+// } finally {
+// disposableHandle?.dispose()
+// }
+// }
+// }
+//
+// override fun onDetach() {
+// motionValueJob?.cancel()
+// }
+//
+// private fun isAnimating(): Boolean {
+// return contentScope.layoutState.currentTransition != null || !motionValue.isStable
+// }
+//
+// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating()
+//
+// override fun Placeable.PlacementScope.isPlacementApproachInProgress(
+// lookaheadCoordinates: LayoutCoordinates
+// ) = isAnimating()
+//
+// private var targetBounds = Rect.Zero
+//
+// private var lastCoordinates: LayoutCoordinates? = null
+//
+// private fun updateMotionSpec() {
+// motionValue.spec =
+// motionBuilderContext.spatialMotionSpec(Mapping.Zero) {
+// between(
+// start = targetBounds.top,
+// end = targetBounds.bottom,
+// effect = revealOnThreshold,
+// )
+// }
+// }
+//
+// override fun MeasureScope.measure(
+// measurable: Measurable,
+// constraints: Constraints,
+// ): MeasureResult {
+// val placeable = measurable.measure(constraints)
+// return layout(placeable.width, placeable.height) {
+// val coordinates = coordinates
+// if (isLookingAhead && coordinates != null) {
+// lastCoordinates = coordinates
+// val bounds = coordinates.boundsInParent()
+// if (targetBounds != bounds) {
+// targetBounds = bounds
+// updateMotionSpec()
+// }
+// }
+// placeable.place(IntOffset.Zero)
+// }
+// }
+//
+// override fun ApproachMeasureScope.approachMeasure(
+// measurable: Measurable,
+// constraints: Constraints,
+// ): MeasureResult {
+// val height = motionValue.output.roundToInt().fastCoerceAtLeast(0)
+// val animatedConstraints = Constraints.fixed(width = constraints.maxWidth, height = height)
+// return measurable.measure(animatedConstraints).run {
+// layout(width, height) {
+// val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceIn(0f, 1f)
+// if (revealAlpha < 1) {
+// placeWithLayer(IntOffset.Zero) {
+// alpha = revealAlpha
+// compositingStrategy = CompositingStrategy.ModulateAlpha
+// }
+// } else {
+// place(IntOffset.Zero)
+// }
+// }
+// }
+// }
+//}
diff --git a/mechanics/compose/tests/AndroidManifest.xml b/mechanics/compose/tests/AndroidManifest.xml
new file mode 100644
index 0000000..182f244
--- /dev/null
+++ b/mechanics/compose/tests/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/mechanics/src/com/android/mechanics/GestureContext.kt b/mechanics/src/com/android/mechanics/GestureContext.kt
new file mode 100644
index 0000000..f1fb3ee
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/GestureContext.kt
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2024 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
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalViewConfiguration
+import com.android.mechanics.spec.InputDirection
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Remembers [DistanceGestureContext] with the given initial distance / direction.
+ *
+ * Providing update [initDistance] or [initialDirection] will not re-create the
+ * [DistanceGestureContext].
+ *
+ * The `directionChangeSlop` is derived from `ViewConfiguration.touchSlop` and kept current without
+ * re-creating, should it ever change.
+ */
+@Composable
+fun rememberDistanceGestureContext(
+ initDistance: Float = 0f,
+ initialDirection: InputDirection = InputDirection.Max,
+): DistanceGestureContext {
+ val touchSlop = LocalViewConfiguration.current.touchSlop
+ return remember { DistanceGestureContext(initDistance, initialDirection, touchSlop) }
+ .also { it.directionChangeSlop = touchSlop }
+}
+
+/**
+ * Gesture-specific context to augment [MotionValue.currentInput].
+ *
+ * This context helps to capture the user's intent, and should be provided to [MotionValue]s that
+ * respond to a user gesture.
+ */
+@Stable
+interface GestureContext {
+
+ /**
+ * The intrinsic direction of the [MotionValue.currentInput].
+ *
+ * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used,
+ * and also prevents flip-flopping of the output value on tiny input-changes around a
+ * breakpoint.
+ *
+ * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture,
+ * this property should only change direction after the gesture travelled a significant distance
+ * in the opposite direction.
+ *
+ * @see DistanceGestureContext for a default implementation.
+ */
+ val direction: InputDirection
+
+ /**
+ * The gesture distance of the current gesture, in pixels.
+ *
+ * Used solely for the [GestureDragDelta] [Guarantee]. Can be hard-coded to a static value if
+ * this type of [Guarantee] is not used.
+ */
+ val dragOffset: Float
+}
+
+/**
+ * [GestureContext] with a mutable [dragOffset].
+ *
+ * The implementation class defines whether the [direction] is updated accordingly.
+ */
+interface MutableDragOffsetGestureContext : GestureContext {
+ /** The gesture distance of the current gesture, in pixels. */
+ override var dragOffset: Float
+}
+
+/** [GestureContext] implementation for manually set values. */
+class ProvidedGestureContext(dragOffset: Float, direction: InputDirection) :
+ MutableDragOffsetGestureContext {
+ override var direction by mutableStateOf(direction)
+ override var dragOffset by mutableFloatStateOf(dragOffset)
+}
+
+/**
+ * [GestureContext] driven by a gesture distance.
+ *
+ * The direction is determined from the gesture input, where going further than
+ * [directionChangeSlop] in the opposite direction toggles the direction.
+ *
+ * @param initialDragOffset The initial [dragOffset] of the [GestureContext]
+ * @param initialDirection The initial [direction] of the [GestureContext]
+ * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for
+ * the [direction] to flip.
+ */
+class DistanceGestureContext(
+ initialDragOffset: Float,
+ initialDirection: InputDirection,
+ directionChangeSlop: Float,
+) : MutableDragOffsetGestureContext {
+ init {
+ require(directionChangeSlop > 0) {
+ "directionChangeSlop must be greater than 0, was $directionChangeSlop"
+ }
+ }
+
+ override var direction by mutableStateOf(initialDirection)
+ private set
+
+ private var furthestDragOffset by mutableFloatStateOf(initialDragOffset)
+
+ private var _dragOffset by mutableFloatStateOf(initialDragOffset)
+
+ override var dragOffset: Float
+ get() = _dragOffset
+ /**
+ * Updates the [dragOffset].
+ *
+ * This flips the [direction], if the [value] is further than [directionChangeSlop] away
+ * from the furthest recorded value regarding to the current [direction].
+ */
+ set(value) {
+ _dragOffset = value
+ this.direction =
+ when (direction) {
+ InputDirection.Max -> {
+ if (furthestDragOffset - value > directionChangeSlop) {
+ furthestDragOffset = value
+ InputDirection.Min
+ } else {
+ furthestDragOffset = max(value, furthestDragOffset)
+ InputDirection.Max
+ }
+ }
+
+ InputDirection.Min -> {
+ if (value - furthestDragOffset > directionChangeSlop) {
+ furthestDragOffset = value
+ InputDirection.Max
+ } else {
+ furthestDragOffset = min(value, furthestDragOffset)
+ InputDirection.Min
+ }
+ }
+ }
+ }
+
+ private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop)
+
+ var directionChangeSlop: Float
+ get() = _directionChangeSlop
+
+ /**
+ * This flips the [direction], if the current [direction] is further than the new
+ * directionChangeSlop [value] away from the furthest recorded value regarding to the
+ * current [direction].
+ */
+ set(value) {
+ require(value > 0) { "directionChangeSlop must be greater than 0, was $value" }
+
+ _directionChangeSlop = value
+
+ when (direction) {
+ InputDirection.Max -> {
+ if (furthestDragOffset - dragOffset > directionChangeSlop) {
+ furthestDragOffset = dragOffset
+ direction = InputDirection.Min
+ }
+ }
+ InputDirection.Min -> {
+ if (dragOffset - furthestDragOffset > directionChangeSlop) {
+ furthestDragOffset = value
+ direction = InputDirection.Max
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets [dragOffset] and [direction] to the specified values.
+ *
+ * This also resets memoized [furthestDragOffset], which is used to determine the direction
+ * change.
+ */
+ fun reset(dragOffset: Float, direction: InputDirection) {
+ this.dragOffset = dragOffset
+ this.direction = direction
+ this.furthestDragOffset = dragOffset
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt
new file mode 100644
index 0000000..9d01c10
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/MotionValue.kt
@@ -0,0 +1,466 @@
+/*
+ * Copyright (C) 2024 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
+
+import androidx.compose.runtime.FloatState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.referentialEqualityPolicy
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
+import com.android.mechanics.debug.DebugInspector
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.impl.Computations
+import com.android.mechanics.impl.DiscontinuityAnimation
+import com.android.mechanics.impl.GuaranteeState
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spring.SpringState
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+
+/**
+ * Computes an animated [output] value, by mapping the [currentInput] according to the [spec].
+ *
+ * A [MotionValue] represents a single animated value within a larger animation. It takes a
+ * numerical [currentInput] value, typically a spatial value like width, height, or gesture length,
+ * and transforms it into an [output] value using a [MotionSpec].
+ *
+ * ## Mapping Input to Output
+ *
+ * The [MotionSpec] defines the relationship between the input and output values. It does this by
+ * specifying a series of [Mapping] functions and [Breakpoint]s. Breakpoints divide the input domain
+ * into segments. Each segment has an associated [Mapping] function, which determines how input
+ * values within that segment are transformed into output values.
+ *
+ * These [Mapping] functions can be arbitrary, as long as they are
+ * 1. deterministic: When invoked repeatedly for the same input, they must produce the same output.
+ * 2. continuous: meaning infinitesimally small changes in input result in infinitesimally small
+ * changes in output
+ *
+ * A valid [Mapping] function is one whose graph could be drawn without lifting your pen from the
+ * paper, meaning there are no abrupt jumps or breaks.
+ *
+ * ## Animating Discontinuities
+ *
+ * When the input value crosses a breakpoint, there might be a discontinuity in the output value due
+ * to the switch between mapping functions. `MotionValue` automatically animates these
+ * discontinuities using a spring animation. The spring parameters are defined for each
+ * [Breakpoint].
+ *
+ * ## Guarantees for Choreography
+ *
+ * Breakpoints can also define [Guarantee]s. These guarantees can make the spring animation finish
+ * faster, in response to quick input value changes. Thus, [Guarantee]s allows to maintain a
+ * predictable choreography, even as the input is unpredictably changed by a user's gesture.
+ *
+ * ## Updating the MotionSpec
+ *
+ * The [spec] property can be changed at any time. If the new spec produces a different output for
+ * the current input, the difference will be animated using the spring parameters defined in
+ * [MotionSpec.resetSpring].
+ *
+ * ## Gesture Context
+ *
+ * The [GestureContext] augments the [currentInput] value with the user's intent. The
+ * [GestureContext] is created wherever gesture input is handled. If the motion value is not driven
+ * by a gesture, it is OK for the [GestureContext] to return static values.
+ *
+ * ## Usage
+ *
+ * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput],
+ * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output]
+ * property is read, or the latest once the animation frame is complete.
+ * 1. Create an instance, providing the input value, gesture context, and an initial spec.
+ * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the
+ * `MotionValue` is in use.
+ * 3. Access the animated output value through the [output] property.
+ *
+ * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to
+ * animate.
+ *
+ * @param currentInput Provides the current input value.
+ * @param gestureContext The [GestureContext] augmenting the [currentInput].
+ * @param label An optional label to aid in debugging.
+ * @param stableThreshold A threshold value (in output units) that determines when the
+ * [MotionValue]'s internal spring animation is considered stable.
+ */
+class MotionValue(
+ currentInput: () -> Float,
+ gestureContext: GestureContext,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ label: String? = null,
+ stableThreshold: Float = StableThresholdEffect,
+) : FloatState {
+ private val impl =
+ ObservableComputations(currentInput, gestureContext, initialSpec, stableThreshold, label)
+
+ /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */
+ var spec: MotionSpec by impl::spec
+
+ /** Animated [output] value. */
+ val output: Float by impl::output
+
+ /**
+ * [output] value, but without animations.
+ *
+ * This value always reports the target value, even before a animation is finished.
+ *
+ * While [isStable], [outputTarget] and [output] are the same value.
+ */
+ val outputTarget: Float by impl::outputTarget
+
+ /** The [output] exposed as [FloatState]. */
+ override val floatValue: Float by impl::output
+
+ /** Whether an animation is currently running. */
+ val isStable: Boolean by impl::isStable
+
+ /**
+ * The current value for the [SemanticKey].
+ *
+ * `null` if not defined in the spec.
+ */
+ operator fun get(key: SemanticKey): T? {
+ return impl.semanticState(key)
+ }
+
+ /** The current segment used to compute the output. */
+ val segmentKey: SegmentKey
+ get() = impl.currentComputedValues.segment.key
+
+ /**
+ * Keeps the [MotionValue]'s animated output running.
+ *
+ * Clients must call [keepRunning], and keep the coroutine running while the [MotionValue] is in
+ * use. When disposing this [MotionValue], cancel the coroutine.
+ *
+ * Internally, this method does suspend, unless there are animations ongoing.
+ */
+ suspend fun keepRunning(): Nothing {
+ withContext(CoroutineName("MotionValue($label)")) { impl.keepRunning { true } }
+
+ // `keepRunning` above will never finish,
+ throw AssertionError("Unreachable code")
+ }
+
+ /**
+ * Keeps the [MotionValue]'s animated output running while [continueRunning] returns `true`.
+ *
+ * When [continueRunning] returns `false`, the coroutine will end by the next frame.
+ *
+ * To keep the [MotionValue] running until the current animations are complete, check for
+ * `isStable` as well.
+ *
+ * ```kotlin
+ * motionValue.keepRunningWhile { !shouldEnd() || !isStable }
+ * ```
+ */
+ suspend fun keepRunningWhile(continueRunning: MotionValue.() -> Boolean) =
+ withContext(CoroutineName("MotionValue($label)")) {
+ impl.keepRunning { continueRunning.invoke(this@MotionValue) }
+ }
+
+ val label: String? by impl::label
+
+ companion object {
+ /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */
+ fun createDerived(
+ source: MotionValue,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ label: String? = null,
+ stableThreshold: Float = 0.01f,
+ ): MotionValue {
+ return MotionValue(
+ currentInput = source::output,
+ gestureContext = source.impl.gestureContext,
+ initialSpec = initialSpec,
+ label = label,
+ stableThreshold = stableThreshold,
+ )
+ }
+
+ const val StableThresholdEffect = 0.01f
+ const val StableThresholdSpatial = 1f
+
+ internal const val TAG = "MotionValue"
+ }
+
+ private var debugInspectorRefCount = AtomicInteger(0)
+
+ private fun onDisposeDebugInspector() {
+ if (debugInspectorRefCount.decrementAndGet() == 0) {
+ impl.debugInspector = null
+ }
+ }
+
+ /**
+ * Provides access to internal state for debug tooling and tests.
+ *
+ * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
+ */
+ fun debugInspector(): DebugInspector {
+ if (debugInspectorRefCount.getAndIncrement() == 0) {
+ impl.debugInspector =
+ DebugInspector(
+ FrameData(
+ impl.lastInput,
+ impl.lastSegment.direction,
+ impl.lastGestureDragOffset,
+ impl.lastFrameTimeNanos,
+ impl.lastSpringState,
+ impl.lastSegment,
+ impl.lastAnimation,
+ ),
+ impl.isActive,
+ impl.debugIsAnimating,
+ ::onDisposeDebugInspector,
+ )
+ }
+
+ return checkNotNull(impl.debugInspector)
+ }
+}
+
+private class ObservableComputations(
+ val input: () -> Float,
+ val gestureContext: GestureContext,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ override val stableThreshold: Float,
+ override val label: String?,
+) : Computations() {
+
+ // ---- CurrentFrameInput ---------------------------------------------------------------------
+
+ override var spec by mutableStateOf(initialSpec)
+ override val currentInput: Float
+ get() = input.invoke()
+
+ override val currentDirection: InputDirection
+ get() = gestureContext.direction
+
+ override val currentGestureDragOffset: Float
+ get() = gestureContext.dragOffset
+
+ override var currentAnimationTimeNanos by mutableLongStateOf(-1L)
+
+ // ---- LastFrameState ---------------------------------------------------------------------
+
+ override var lastSegment: SegmentData by
+ mutableStateOf(
+ spec.segmentAtInput(currentInput, currentDirection),
+ referentialEqualityPolicy(),
+ )
+
+ override var lastGuaranteeState: GuaranteeState
+ get() = GuaranteeState(_lastGuaranteeStatePacked)
+ set(value) {
+ _lastGuaranteeStatePacked = value.packedValue
+ }
+
+ private var _lastGuaranteeStatePacked: Long by
+ mutableLongStateOf(GuaranteeState.Inactive.packedValue)
+
+ override var lastAnimation: DiscontinuityAnimation by
+ mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy())
+
+ override var directMappedVelocity: Float = 0f
+
+ override var lastSpringState: SpringState
+ get() = SpringState(_lastSpringStatePacked)
+ set(value) {
+ _lastSpringStatePacked = value.packedValue
+ }
+
+ private var _lastSpringStatePacked: Long by
+ mutableLongStateOf(lastAnimation.springStartState.packedValue)
+
+ override var lastFrameTimeNanos by mutableLongStateOf(-1L)
+
+ override var lastInput by mutableFloatStateOf(currentInput)
+
+ override var lastGestureDragOffset by mutableFloatStateOf(currentGestureDragOffset)
+
+ // ---- Computations ---------------------------------------------------------------------------
+
+ suspend fun keepRunning(continueRunning: () -> Boolean) {
+ check(!isActive) { "MotionValue($label) is already running" }
+ isActive = true
+
+ // These `captured*` values will be applied to the `last*` values, at the beginning
+ // of the each new frame.
+ // TODO(b/397837971): Encapsulate the state in a StateRecord.
+ val initialValues = currentComputedValues
+ var capturedSegment = initialValues.segment
+ var capturedGuaranteeState = initialValues.guarantee
+ var capturedAnimation = initialValues.animation
+ var capturedSpringState = currentSpringState
+ var capturedFrameTimeNanos = currentAnimationTimeNanos
+ var capturedInput = currentInput
+ var capturedGestureDragOffset = currentGestureDragOffset
+ var capturedDirection = currentDirection
+
+ 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()) {
+
+ withFrameNanos { frameTimeNanos ->
+ currentAnimationTimeNanos = frameTimeNanos
+
+ // With the new frame started, copy
+
+ lastSegment = capturedSegment
+ lastGuaranteeState = capturedGuaranteeState
+ lastAnimation = capturedAnimation
+ lastSpringState = capturedSpringState
+ lastFrameTimeNanos = capturedFrameTimeNanos
+ lastInput = capturedInput
+ lastGestureDragOffset = capturedGestureDragOffset
+ }
+
+ // At this point, the complete frame is done (including layout, drawing and
+ // everything else), and this MotionValue has been updated.
+
+ // Capture the `current*` MotionValue state, so that it can be applied as the
+ // `last*` state when the next frame starts. Its imperative to capture at this point
+ // already (since the input could change before the next frame starts), while at the
+ // same time not already applying the `last*` state (as this would cause a
+ // re-computation if the current state is being read before the next frame).
+ if (isAnimatingUninterrupted) {
+ directMappedVelocity =
+ computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos)
+ } else {
+ directMappedVelocity = 0f
+ }
+
+ var scheduleNextFrame = false
+ if (!isSameSegmentAndAtRest) {
+ // Read currentComputedValues only once and update it, if necessary
+ val currentValues = currentComputedValues
+
+ if (capturedSegment != currentValues.segment) {
+ capturedSegment = currentValues.segment
+ scheduleNextFrame = true
+ }
+
+ if (capturedGuaranteeState != currentValues.guarantee) {
+ capturedGuaranteeState = currentValues.guarantee
+ scheduleNextFrame = true
+ }
+
+ if (capturedAnimation != currentValues.animation) {
+ capturedAnimation = currentValues.animation
+ scheduleNextFrame = true
+ }
+
+ if (capturedSpringState != currentSpringState) {
+ capturedSpringState = currentSpringState
+ scheduleNextFrame = true
+ }
+ }
+
+ if (capturedInput != currentInput) {
+ capturedInput = currentInput
+ scheduleNextFrame = true
+ }
+
+ if (capturedGestureDragOffset != currentGestureDragOffset) {
+ capturedGestureDragOffset = currentGestureDragOffset
+ scheduleNextFrame = true
+ }
+
+ if (capturedDirection != currentDirection) {
+ capturedDirection = currentDirection
+ scheduleNextFrame = true
+ }
+
+ capturedFrameTimeNanos = currentAnimationTimeNanos
+
+ debugInspector?.run {
+ frame =
+ FrameData(
+ capturedInput,
+ capturedDirection,
+ capturedGestureDragOffset,
+ capturedFrameTimeNanos,
+ capturedSpringState,
+ capturedSegment,
+ capturedAnimation,
+ )
+ }
+
+ isAnimatingUninterrupted = scheduleNextFrame
+ if (scheduleNextFrame) {
+ continue
+ }
+
+ debugIsAnimating = false
+ snapshotFlow {
+ val wakeup =
+ !continueRunning() ||
+ spec != capturedSegment.spec ||
+ currentInput != capturedInput ||
+ currentDirection != capturedDirection ||
+ currentGestureDragOffset != capturedGestureDragOffset
+ wakeup
+ }
+ .first { it }
+ debugIsAnimating = true
+ }
+ } finally {
+ isActive = false
+ debugIsAnimating = false
+ }
+ }
+
+ /** Whether a [keepRunning] coroutine is active currently. */
+ var isActive = false
+ set(value) {
+ field = value
+ debugInspector?.isActive = value
+ }
+
+ /**
+ * `false` whenever the [keepRunning] coroutine is suspended while no animation is running and
+ * the input is not changing.
+ */
+ var debugIsAnimating = false
+ set(value) {
+ field = value
+ debugInspector?.isAnimating = value
+ }
+
+ var debugInspector: DebugInspector? = null
+}
diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt
new file mode 100644
index 0000000..9738424
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt
@@ -0,0 +1,196 @@
+/*
+ * 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.draw.drawWithCache
+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
+import kotlin.math.round
+
+/**
+ * Draws the background of a vertically container, and applies clipping to it.
+ *
+ * Intended to be used with a [VerticalExpandContainerSpec] motion.
+ */
+fun Modifier.verticalExpandContainerBackground(
+ backgroundColor: Color,
+ spec: VerticalExpandContainerSpec,
+): Modifier =
+ this.then(
+ if (spec.isFloating) {
+ Modifier.verticalFloatingExpandContainerBackground(backgroundColor, spec)
+ } else {
+ Modifier.verticalEdgeExpandContainerBackground(backgroundColor, spec)
+ }
+ )
+
+/**
+ * Draws the background of an floating container, and applies clipping to it.
+ *
+ * Intended to be used with a [VerticalExpandContainerSpec] motion.
+ */
+internal fun Modifier.verticalFloatingExpandContainerBackground(
+ backgroundColor: Color,
+ spec: VerticalExpandContainerSpec,
+): Modifier =
+ this.drawWithCache {
+ val targetRadiusPx = spec.radius.toPx()
+ val currentRadiusPx = min(targetRadiusPx, min(size.width, size.height) / 2f)
+ val horizontalInset = targetRadiusPx - currentRadiusPx
+ val shapeTopLeft = Offset(horizontalInset, 0f)
+ val shapeSize = Size(size.width - (horizontalInset * 2f), size.height)
+
+ val layer =
+ obtainGraphicsLayer().apply {
+ clip = true
+ setRoundRectOutline(shapeTopLeft, shapeSize, cornerRadius = currentRadiusPx)
+ }
+
+ onDrawWithContent {
+ layer.record { this@onDrawWithContent.drawContent() }
+ drawRoundRect(
+ color = backgroundColor,
+ topLeft = shapeTopLeft,
+ size = shapeSize,
+ cornerRadius = CornerRadius(currentRadiusPx),
+ )
+
+ drawLayer(layer)
+ }
+ }
+
+/**
+ * Draws the background of an edge container, and applies clipping to it.
+ *
+ * Intended to be used with a [VerticalExpandContainerSpec] motion.
+ */
+internal fun Modifier.verticalEdgeExpandContainerBackground(
+ backgroundColor: Color,
+ spec: VerticalExpandContainerSpec,
+): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec))
+
+internal class EdgeContainerExpansionBackgroundNode(
+ var backgroundColor: Color,
+ var spec: VerticalExpandContainerSpec,
+) : 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.
+ // Round upper height to the closest integer to avoid to avoid a hairline gap being visible
+ // due to the two rectangles overlapping.
+ val upperHeight = round((height - radius)).fastCoerceAtLeast(0f)
+
+ // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii.
+ // The clipRect limits this to the relevant part between this and the fill below.
+ clipRect(top = upperHeight) {
+ 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: VerticalExpandContainerSpec,
+) : ModifierNodeElement() {
+ override fun create(): EdgeContainerExpansionBackgroundNode =
+ EdgeContainerExpansionBackgroundNode(backgroundColor, spec)
+
+ override fun update(node: EdgeContainerExpansionBackgroundNode) {
+ node.backgroundColor = backgroundColor
+ if (node.spec != spec) {
+ node.spec = spec
+ node.invalidateOutline()
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt
new file mode 100644
index 0000000..3bc264a
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt
@@ -0,0 +1,161 @@
+/*
+ * 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.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.directionalMotionSpec
+import com.android.mechanics.spring.SpringParameters
+
+/** Motion spec for a vertically expandable container. */
+class VerticalExpandContainerSpec(
+ val isFloating: Boolean,
+ val minRadius: Dp = Defaults.MinRadius,
+ val radius: Dp = Defaults.Radius,
+ val visibleHeight: Dp = Defaults.VisibleHeight,
+ val preDetachRatio: Float = Defaults.PreDetachRatio,
+ val detachHeight: Dp = if (isFloating) radius * 3 else Defaults.DetachHeight,
+ val attachHeight: Dp = if (isFloating) radius * 2 else Defaults.AttachHeight,
+ val widthOffset: Dp = Defaults.WidthOffset,
+ val attachSpring: SpringParameters = Defaults.AttachSpring,
+ val detachSpring: SpringParameters = Defaults.DetachSpring,
+ val opacitySpring: SpringParameters = Defaults.OpacitySpring,
+) {
+ fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec {
+ // TODO: michschn@ - replace with MagneticDetach
+ return with(density) {
+ val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec())
+
+ val detachSpec =
+ directionalMotionSpec(
+ initialMapping = Mapping.Zero,
+ defaultSpring = spatialSpring,
+ ) {
+ fractionalInputFromCurrent(
+ breakpoint = 0f,
+ key = Breakpoints.Attach,
+ fraction = preDetachRatio,
+ )
+ identity(
+ breakpoint = detachHeight.toPx(),
+ key = Breakpoints.Detach,
+ spring = detachSpring,
+ )
+ }
+
+ val attachSpec =
+ directionalMotionSpec(
+ initialMapping = Mapping.Zero,
+ defaultSpring = spatialSpring,
+ ) {
+ identity(
+ breakpoint = attachHeight.toPx(),
+ key = Breakpoints.Detach,
+ spring = attachSpring,
+ )
+ }
+
+ val segmentHandlers =
+ mapOf(
+ 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) {
+ if (isFloating) {
+ MotionSpec(directionalMotionSpec(Mapping.Fixed(intrinsicWidth)))
+ } else {
+ MotionSpec(
+ directionalMotionSpec({ input ->
+ val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f)
+ intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction)
+ })
+ )
+ }
+ }
+ }
+
+ fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec {
+ return with(density) {
+ MotionSpec(
+ directionalMotionSpec(opacitySpring, initialMapping = Mapping.Zero) {
+ fixedValue(breakpoint = visibleHeight.toPx(), value = 1f)
+ }
+ )
+ }
+ }
+
+ 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)
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
new file mode 100644
index 0000000..088c78b
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2024 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.debug
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.android.mechanics.MotionValue
+import com.android.mechanics.impl.DiscontinuityAnimation
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import kotlinx.coroutines.DisposableHandle
+
+/** Utility to gain inspection access to internal [MotionValue] state. */
+class DebugInspector
+internal constructor(
+ initialFrameData: FrameData,
+ initialIsActive: Boolean,
+ initialIsAnimating: Boolean,
+ disposableHandle: DisposableHandle,
+) : DisposableHandle by disposableHandle {
+
+ /** The last completed frame's data. */
+ var frame: FrameData by mutableStateOf(initialFrameData)
+ internal set
+
+ /** Whether a [MotionValue.keepRunning] coroutine is active currently. */
+ var isActive: Boolean by mutableStateOf(initialIsActive)
+ internal set
+
+ /**
+ * `false` whenever the [MotionValue.keepRunning] coroutine internally is suspended while no
+ * animation is running and the input is not changing.
+ */
+ var isAnimating: Boolean by mutableStateOf(initialIsAnimating)
+ internal set
+}
+
+/** The input, output and internal state of a [MotionValue] for the frame. */
+data class FrameData
+internal constructor(
+ val input: Float,
+ val gestureDirection: InputDirection,
+ val gestureDragOffset: Float,
+ val frameTimeNanos: Long,
+ val springState: SpringState,
+ private val segment: SegmentData,
+ private val animation: DiscontinuityAnimation,
+) {
+ val isStable: Boolean
+ get() = springState == SpringState.AtRest
+
+ val springParameters: SpringParameters
+ get() = animation.springParameters
+
+ val segmentKey: SegmentKey
+ get() = segment.key
+
+ val output: Float
+ get() = segment.mapping.map(input) + springState.displacement
+
+ val outputTarget: Float
+ get() = segment.mapping.map(input)
+
+ fun semantic(semanticKey: SemanticKey): T? {
+ return segment.semantic(semanticKey)
+ }
+
+ val semantics: List>
+ get() = with(segment) { spec.semantics(key) }
+}
diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
new file mode 100644
index 0000000..b89728b
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
@@ -0,0 +1,510 @@
+/*
+ * Copyright (C) 2024 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.debug
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.node.DrawModifierNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.ObserverModifierNode
+import androidx.compose.ui.node.observeReads
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceAtLeast
+import androidx.compose.ui.util.fastCoerceAtMost
+import androidx.compose.ui.util.fastForEachIndexed
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentKey
+import kotlin.math.ceil
+import kotlin.math.max
+import kotlin.math.min
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+/**
+ * A debug visualization of the [motionValue].
+ *
+ * Draws both the [MotionValue.spec], as well as the input and output.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param motionValue The [MotionValue] to inspect.
+ * @param inputRange The relevant range of the input (x) axis, for which to draw the graph.
+ * @param maxAgeMillis Max age of the elements in the history trail.
+ */
+@Composable
+fun DebugMotionValueVisualization(
+ motionValue: MotionValue,
+ inputRange: ClosedFloatingPointRange,
+ modifier: Modifier = Modifier,
+ maxAgeMillis: Long = 1000L,
+) {
+ val spec = motionValue.spec
+ val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) }
+
+ val inspector = remember(motionValue) { motionValue.debugInspector() }
+
+ DisposableEffect(inspector) { onDispose { inspector.dispose() } }
+
+ val colorScheme = MaterialTheme.colorScheme
+ val axisColor = colorScheme.outline
+ val specColor = colorScheme.tertiary
+ val valueColor = colorScheme.primary
+
+ val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection)
+ val activeSegment = inspector.frame.segmentKey
+
+ Spacer(
+ modifier =
+ modifier
+ .debugMotionSpecGraph(
+ primarySpec,
+ inputRange,
+ outputRange,
+ axisColor,
+ specColor,
+ activeSegment,
+ )
+ .debugMotionValueGraph(
+ motionValue,
+ valueColor,
+ inputRange,
+ outputRange,
+ maxAgeMillis,
+ )
+ )
+}
+
+/**
+ * Draws a full-sized debug visualization of [spec].
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ */
+fun Modifier.debugMotionSpecGraph(
+ spec: DirectionalMotionSpec,
+ inputRange: ClosedFloatingPointRange,
+ outputRange: ClosedFloatingPointRange,
+ axisColor: Color = Color.Gray,
+ specColor: Color = Color.Blue,
+ activeSegment: SegmentKey? = null,
+): Modifier = drawBehind {
+ drawAxis(axisColor)
+ drawDirectionalSpec(spec, inputRange, outputRange, specColor, activeSegment)
+}
+
+/**
+ * Draws a full-sized debug visualization of the [motionValue] state.
+ *
+ * This can be combined with [debugMotionSpecGraph], when [inputRange] and [outputRange] are the
+ * same.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param color Color for the dots indicating the value
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ * @param maxAgeMillis Max age of the elements in the history trail.
+ */
+@Composable
+fun Modifier.debugMotionValueGraph(
+ motionValue: MotionValue,
+ color: Color,
+ inputRange: ClosedFloatingPointRange,
+ outputRange: ClosedFloatingPointRange,
+ maxAgeMillis: Long = 1000L,
+): Modifier =
+ this then
+ DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, maxAgeMillis)
+
+/**
+ * Utility to compute the min/max output values of the spec for the given input.
+ *
+ * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger
+ * values in between two breakpoints, this method might might not produce a correct result.
+ */
+fun MotionSpec.computeOutputValueRange(
+ inputRange: ClosedFloatingPointRange
+): ClosedFloatingPointRange {
+ return if (isUnidirectional) {
+ maxDirection.computeOutputValueRange(inputRange)
+ } else {
+ val maxRange = maxDirection.computeOutputValueRange(inputRange)
+ val minRange = minDirection.computeOutputValueRange(inputRange)
+
+ val start = min(minRange.start, maxRange.start)
+ val endInclusive = max(minRange.endInclusive, maxRange.endInclusive)
+
+ start..endInclusive
+ }
+}
+
+/**
+ * Utility to compute the min/max output values of the spec for the given input.
+ *
+ * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger
+ * values in between two breakpoints, this method might might not produce a correct result.
+ */
+fun DirectionalMotionSpec.computeOutputValueRange(
+ inputRange: ClosedFloatingPointRange
+): ClosedFloatingPointRange {
+
+ val start = findBreakpointIndex(inputRange.start)
+ val end = findBreakpointIndex(inputRange.endInclusive)
+
+ val samples = buildList {
+ add(mappings[start].map(inputRange.start))
+
+ for (breakpointIndex in (start + 1)..end) {
+
+ val position = breakpoints[breakpointIndex].position
+
+ add(mappings[breakpointIndex - 1].map(position))
+ add(mappings[breakpointIndex].map(position))
+ }
+
+ add(mappings[end].map(inputRange.endInclusive))
+ }
+
+ return samples.min()..samples.max()
+}
+
+private data class DebugMotionValueGraphElement(
+ val motionValue: MotionValue,
+ val color: Color,
+ val inputRange: ClosedFloatingPointRange,
+ val outputRange: ClosedFloatingPointRange,
+ val maxAgeMillis: Long,
+) : ModifierNodeElement() {
+
+ init {
+ require(maxAgeMillis > 0)
+ }
+
+ override fun create() =
+ DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, maxAgeMillis)
+
+ override fun update(node: DebugMotionValueGraphNode) {
+ node.motionValue = motionValue
+ node.color = color
+ node.inputRange = inputRange
+ node.outputRange = outputRange
+ node.maxAgeMillis = maxAgeMillis
+ }
+
+ override fun InspectorInfo.inspectableProperties() {
+ // intentionally empty
+ }
+}
+
+private class DebugMotionValueGraphNode(
+ motionValue: MotionValue,
+ var color: Color,
+ var inputRange: ClosedFloatingPointRange,
+ var outputRange: ClosedFloatingPointRange,
+ var maxAgeMillis: Long,
+) : DrawModifierNode, ObserverModifierNode, Modifier.Node() {
+
+ private var debugInspector by mutableStateOf(null)
+ private val history = mutableStateListOf()
+
+ var motionValue = motionValue
+ set(value) {
+ if (value != field) {
+ disposeDebugInspector()
+ field = value
+
+ if (isAttached) {
+ acquireDebugInspector()
+ }
+ }
+ }
+
+ override fun onAttach() {
+ acquireDebugInspector()
+
+ coroutineScope.launch {
+ while (true) {
+ if (history.size > 1) {
+
+ withFrameNanos { thisFrameTime ->
+ while (
+ history.size > 1 &&
+ (thisFrameTime - history.first().frameTimeNanos) >
+ maxAgeMillis * 1_000_000
+ ) {
+ history.removeFirst()
+ }
+ }
+ }
+
+ snapshotFlow { history.size > 1 }.first { it }
+ }
+ }
+ }
+
+ override fun onDetach() {
+ disposeDebugInspector()
+ }
+
+ private fun acquireDebugInspector() {
+ debugInspector = motionValue.debugInspector()
+ observeFrameAndAddToHistory()
+ }
+
+ private fun disposeDebugInspector() {
+ debugInspector?.dispose()
+ debugInspector = null
+ history.clear()
+ }
+
+ override fun ContentDrawScope.draw() {
+ if (history.isNotEmpty()) {
+ drawDirectionAndAnimationStatus(history.last())
+ }
+ drawInputOutputTrail(history, inputRange, outputRange, color)
+ drawContent()
+ }
+
+ private fun observeFrameAndAddToHistory() {
+ var lastFrame: FrameData? = null
+
+ observeReads { lastFrame = debugInspector?.frame }
+
+ lastFrame?.also { history.add(it) }
+ }
+
+ override fun onObservedReadsChanged() {
+ observeFrameAndAddToHistory()
+ }
+}
+
+private val MotionSpec.isUnidirectional: Boolean
+ get() = maxDirection == minDirection
+
+private fun DrawScope.mapPointInInputToX(
+ input: Float,
+ inputRange: ClosedFloatingPointRange,
+): Float {
+ val inputExtent = (inputRange.endInclusive - inputRange.start)
+ return ((input - inputRange.start) / (inputExtent)) * size.width
+}
+
+private fun DrawScope.mapPointInOutputToY(
+ output: Float,
+ outputRange: ClosedFloatingPointRange,
+): Float {
+ val outputExtent = (outputRange.endInclusive - outputRange.start)
+ return (1 - (output - outputRange.start) / (outputExtent)) * size.height
+}
+
+private fun DrawScope.drawDirectionalSpec(
+ spec: DirectionalMotionSpec,
+ inputRange: ClosedFloatingPointRange,
+ outputRange: ClosedFloatingPointRange,
+ color: Color,
+ activeSegment: SegmentKey?,
+) {
+
+ val startSegment = spec.findBreakpointIndex(inputRange.start)
+ val endSegment = spec.findBreakpointIndex(inputRange.endInclusive)
+
+ for (segmentIndex in startSegment..endSegment) {
+ val isActiveSegment =
+ activeSegment?.let { spec.findSegmentIndex(it) == segmentIndex } ?: false
+
+ val mapping = spec.mappings[segmentIndex]
+ val startBreakpoint = spec.breakpoints[segmentIndex]
+ val segmentStart = startBreakpoint.position
+ val fromInput = segmentStart.fastCoerceAtLeast(inputRange.start)
+ val endBreakpoint = spec.breakpoints[segmentIndex + 1]
+ val segmentEnd = endBreakpoint.position
+ val toInput = segmentEnd.fastCoerceAtMost(inputRange.endInclusive)
+
+ val strokeWidth = if (isActiveSegment) 2.dp.toPx() else Stroke.HairlineWidth
+ val dotSize = if (isActiveSegment) 4.dp.toPx() else 2.dp.toPx()
+ val fromY = mapPointInOutputToY(mapping.map(fromInput), outputRange)
+ val toY = mapPointInOutputToY(mapping.map(toInput), outputRange)
+
+ val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY)
+ val end = Offset(mapPointInInputToX(toInput, inputRange), toY)
+ if (mapping is Mapping.Fixed || mapping is Mapping.Identity || mapping is Mapping.Linear) {
+ drawLine(color, start, end, strokeWidth = strokeWidth)
+ } else {
+ val xStart = mapPointInInputToX(fromInput, inputRange)
+ val xEnd = mapPointInInputToX(toInput, inputRange)
+
+ val oneDpInPx = 1.dp.toPx()
+ val numberOfLines = ceil((xEnd - xStart) / oneDpInPx).toInt()
+ val inputLength = (toInput - fromInput) / numberOfLines
+
+ repeat(numberOfLines) {
+ val lineStart = fromInput + inputLength * it
+ val lineEnd = lineStart + inputLength
+
+ val partialFromY = mapPointInOutputToY(mapping.map(lineStart), outputRange)
+ val partialToY = mapPointInOutputToY(mapping.map(lineEnd), outputRange)
+
+ val partialStart = Offset(mapPointInInputToX(lineStart, inputRange), partialFromY)
+ val partialEnd = Offset(mapPointInInputToX(lineEnd, inputRange), partialToY)
+
+ drawLine(color, partialStart, partialEnd, strokeWidth = strokeWidth)
+ }
+ }
+
+ if (segmentStart == fromInput) {
+ drawCircle(color, dotSize, start)
+ }
+
+ if (segmentEnd == toInput) {
+ drawCircle(color, dotSize, end)
+ }
+
+ val guarantee = startBreakpoint.guarantee
+ if (guarantee is Guarantee.InputDelta) {
+ val guaranteePos = segmentStart + guarantee.delta
+ if (guaranteePos > inputRange.start) {
+
+ val guaranteeOffset =
+ Offset(
+ mapPointInInputToX(guaranteePos, inputRange),
+ mapPointInOutputToY(mapping.map(guaranteePos), outputRange),
+ )
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(
+ color,
+ guaranteeOffset,
+ guaranteeOffset.plus(Offset(arrowSize, -arrowSize)),
+ )
+ drawLine(color, guaranteeOffset, guaranteeOffset.plus(Offset(arrowSize, arrowSize)))
+ }
+ }
+ }
+}
+
+private fun DrawScope.drawDirectionAndAnimationStatus(currentFrame: FrameData) {
+ val indicatorSize = min(this.size.height, 24.dp.toPx())
+
+ this.scale(
+ scaleX = if (currentFrame.gestureDirection == InputDirection.Max) 1f else -1f,
+ scaleY = 1f,
+ ) {
+ val color = if (currentFrame.isStable) Color.Green else Color.Red
+ val strokeWidth = 1.dp.toPx()
+ val d1 = indicatorSize / 2f
+ val d2 = indicatorSize / 3f
+
+ translate(left = 2.dp.toPx()) {
+ drawLine(
+ color,
+ Offset(center.x - d2, center.y - d1),
+ center,
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ drawLine(
+ color,
+ Offset(center.x - d2, center.y + d1),
+ center,
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ }
+ translate(left = -2.dp.toPx()) {
+ drawLine(
+ color,
+ Offset(center.x - d2, center.y - d1),
+ center,
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ drawLine(
+ color,
+ Offset(center.x - d2, center.y + d1),
+ center,
+ strokeWidth = strokeWidth,
+ cap = StrokeCap.Round,
+ )
+ }
+ }
+}
+
+private fun DrawScope.drawInputOutputTrail(
+ history: List,
+ inputRange: ClosedFloatingPointRange,
+ outputRange: ClosedFloatingPointRange,
+ color: Color,
+) {
+ history.fastForEachIndexed { index, frame ->
+ val x = mapPointInInputToX(frame.input, inputRange)
+ val y = mapPointInOutputToY(frame.output, outputRange)
+
+ drawCircle(color, 2.dp.toPx(), Offset(x, y), alpha = index / history.size.toFloat())
+ }
+}
+
+private fun DrawScope.drawAxis(color: Color) {
+
+ drawXAxis(color)
+ drawYAxis(color)
+}
+
+private fun DrawScope.drawYAxis(color: Color, atX: Float = 0f) {
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(color, Offset(atX, size.height), Offset(atX, 0f))
+ drawLine(color, Offset(atX, 0f), Offset(atX + arrowSize, arrowSize))
+ drawLine(color, Offset(atX, 0f), Offset(atX - arrowSize, arrowSize))
+}
+
+private fun DrawScope.drawXAxis(color: Color, atY: Float = size.height) {
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(color, Offset(0f, atY), Offset(size.width, atY))
+ drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY + arrowSize))
+ drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY - arrowSize))
+}
diff --git a/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt
new file mode 100644
index 0000000..3c0109d
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.debug
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.DelegatableNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.node.TraversableNode
+import androidx.compose.ui.node.findNearestAncestor
+import androidx.compose.ui.platform.InspectorInfo
+import com.android.mechanics.MotionValue
+import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY
+import kotlinx.coroutines.DisposableHandle
+
+/** State for the [MotionValueDebugger]. */
+sealed interface MotionValueDebuggerState {
+ val observedMotionValues: List
+}
+
+/** Factory for [MotionValueDebugger]. */
+fun MotionValueDebuggerState(): MotionValueDebuggerState {
+ return MotionValueDebuggerStateImpl()
+}
+
+/** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */
+fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier =
+ this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl))
+
+/**
+ * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve
+ * it using [findMotionValueDebugger].
+ */
+sealed interface MotionValueDebugger {
+ fun register(motionValue: MotionValue): DisposableHandle
+}
+
+/** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */
+fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? {
+ return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger
+}
+
+/** Registers the motion value for debugging with the parent [MotionValue]. */
+fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier =
+ this.then(DebugMotionValueElement(motionValue))
+
+internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) :
+ Modifier.Node(), TraversableNode, MotionValueDebugger {
+
+ override val traverseKey = TRAVERSAL_NODE_KEY
+
+ override fun register(motionValue: MotionValue): DisposableHandle {
+ val state = state
+ state.observedMotionValues.add(motionValue)
+ return DisposableHandle { state.observedMotionValues.remove(motionValue) }
+ }
+
+ companion object {
+ const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY"
+ }
+}
+
+private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) :
+ ModifierNodeElement() {
+ override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state)
+
+ override fun InspectorInfo.inspectableProperties() {
+ // Intentionally empty
+ }
+
+ override fun update(node: MotionValueDebuggerNode) {
+ check(node.state === state)
+ }
+}
+
+internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() {
+
+ private var debugger: MotionValueDebugger? = null
+
+ internal var motionValue = motionValue
+ set(value) {
+ registration?.dispose()
+ registration = debugger?.register(value)
+ field = value
+ }
+
+ internal var registration: DisposableHandle? = null
+
+ override fun onAttach() {
+ debugger = findMotionValueDebugger()
+ registration = debugger?.register(motionValue)
+ }
+
+ override fun onDetach() {
+ debugger = null
+ registration?.dispose()
+ registration = null
+ }
+}
+
+private data class DebugMotionValueElement(val motionValue: MotionValue) :
+ ModifierNodeElement() {
+ override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue)
+
+ override fun InspectorInfo.inspectableProperties() {
+ // Intentionally empty
+ }
+
+ override fun update(node: DebugMotionValueNode) {
+ node.motionValue = motionValue
+ }
+}
+
+internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState {
+ override val observedMotionValues: MutableList = mutableStateListOf()
+}
diff --git a/mechanics/src/com/android/mechanics/effects/Fixed.kt b/mechanics/src/com/android/mechanics/effects/Fixed.kt
new file mode 100644
index 0000000..b1c5fb2
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/effects/Fixed.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.effects
+
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.builder.Effect
+import com.android.mechanics.spec.builder.EffectApplyScope
+import com.android.mechanics.spec.builder.EffectPlacement
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spec.builder.MotionSpecBuilderScope
+
+/** Creates a [FixedValue] effect with the given [value]. */
+fun MotionSpecBuilderScope.fixed(value: Float) = FixedValue(value)
+
+val MotionSpecBuilderScope.zero: FixedValue
+ get() = FixedValue.Zero
+val MotionSpecBuilderScope.one: FixedValue
+ get() = FixedValue.One
+
+/** Produces a fixed [value]. */
+class FixedValue(val value: Float) :
+ Effect.PlaceableAfter, Effect.PlaceableBefore, Effect.PlaceableBetween {
+
+ override fun MotionBuilderContext.intrinsicSize(): Float = Float.NaN
+
+ override fun EffectApplyScope.createSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ placement: EffectPlacement,
+ ) {
+ return unidirectional(Mapping.Fixed(value))
+ }
+
+ companion object {
+ val Zero = FixedValue(0f)
+ val One = FixedValue(1f)
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt
new file mode 100644
index 0000000..1e4e38b
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt
@@ -0,0 +1,259 @@
+/*
+ * 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.effects
+
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.lerp
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.builder.Effect
+import com.android.mechanics.spec.builder.EffectApplyScope
+import com.android.mechanics.spec.builder.EffectPlacemenType
+import com.android.mechanics.spec.builder.EffectPlacement
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spec.with
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Gesture effect that emulates effort to detach an element from its resting position.
+ *
+ * @param semanticState semantic state used to check the state of this effect.
+ * @param detachPosition distance from the origin to detach
+ * @param attachPosition distance from the origin to re-attach
+ * @param detachScale fraction of input changes propagated during detach.
+ * @param attachScale fraction of input changes propagated after re-attach.
+ * @param detachSpring spring used during detach
+ * @param attachSpring spring used during attach
+ */
+class MagneticDetach(
+ private val semanticState: SemanticKey = Defaults.AttachDetachState,
+ private val semanticAttachedValue: SemanticKey = Defaults.AttachedValue,
+ private val detachPosition: Dp = Defaults.DetachPosition,
+ private val attachPosition: Dp = Defaults.AttachPosition,
+ private val detachScale: Float = Defaults.AttachDetachScale,
+ private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition),
+ private val detachSpring: SpringParameters = Defaults.Spring,
+ private val attachSpring: SpringParameters = Defaults.Spring,
+) : Effect.PlaceableAfter, Effect.PlaceableBefore {
+
+ init {
+ require(attachPosition <= detachPosition)
+ }
+
+ enum class State {
+ Attached,
+ Detached,
+ }
+
+ override fun MotionBuilderContext.intrinsicSize(): Float {
+ return detachPosition.toPx()
+ }
+
+ override fun EffectApplyScope.createSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ placement: EffectPlacement,
+ ) {
+ if (placement.type == EffectPlacemenType.Before) {
+ createPlacedBeforeSpec(minLimit, minLimitKey, maxLimit, maxLimitKey)
+ } else {
+ assert(placement.type == EffectPlacemenType.After)
+ createPlacedAfterSpec(minLimit, minLimitKey, maxLimit, maxLimitKey)
+ }
+ }
+
+ object Defaults {
+ val AttachDetachState = SemanticKey()
+ val AttachedValue = SemanticKey()
+ val AttachDetachScale = .3f
+ val DetachPosition = 80.dp
+ val AttachPosition = 40.dp
+ val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f)
+ }
+
+ /* Effect is attached at minLimit, and detaches at maxLimit. */
+ private fun EffectApplyScope.createPlacedAfterSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ ) {
+ val attachedValue = baseValue(minLimit)
+ val detachedValue = baseValue(maxLimit)
+ val reattachPos = minLimit + attachPosition.toPx()
+ val reattachValue = baseValue(reattachPos)
+
+ val attachedSemantics =
+ listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue)
+ val detachedSemantics =
+ listOf(semanticState with State.Detached, semanticAttachedValue with null)
+
+ val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale
+ val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale
+
+ val attachKey = BreakpointKey("attach")
+ forward(
+ initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue),
+ semantics = attachedSemantics,
+ ) {
+ after(spring = detachSpring, semantics = detachedSemantics)
+ before(semantics = listOf(semanticAttachedValue with null))
+ }
+
+ backward(
+ initialMapping =
+ Mapping.Linear(minLimit, attachedValue, reattachPos, scaledReattachValue),
+ semantics = attachedSemantics,
+ ) {
+ mapping(
+ breakpoint = reattachPos,
+ key = attachKey,
+ spring = attachSpring,
+ semantics = detachedSemantics,
+ mapping = baseMapping,
+ )
+ before(semantics = listOf(semanticAttachedValue with null))
+ after(semantics = listOf(semanticAttachedValue with null))
+ }
+
+ addSegmentHandlers(
+ beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max),
+ beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min),
+ afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min),
+ minLimit = minLimit,
+ maxLimit = maxLimit,
+ )
+ }
+
+ /* Effect is attached at maxLimit, and detaches at minLimit. */
+ private fun EffectApplyScope.createPlacedBeforeSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ ) {
+ val attachedValue = baseValue(maxLimit)
+ val detachedValue = baseValue(minLimit)
+ val reattachPos = maxLimit - attachPosition.toPx()
+ val reattachValue = baseValue(reattachPos)
+
+ val attachedSemantics =
+ listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue)
+ val detachedSemantics =
+ listOf(semanticState with State.Detached, semanticAttachedValue with null)
+
+ val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale
+ val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale
+
+ val attachKey = BreakpointKey("attach")
+
+ backward(
+ initialMapping = Mapping.Linear(minLimit, scaledDetachValue, maxLimit, attachedValue),
+ semantics = attachedSemantics,
+ ) {
+ before(spring = detachSpring, semantics = detachedSemantics)
+ after(semantics = listOf(semanticAttachedValue with null))
+ }
+
+ forward(initialMapping = baseMapping, semantics = detachedSemantics) {
+ target(
+ breakpoint = reattachPos,
+ key = attachKey,
+ from = scaledReattachValue,
+ to = attachedValue,
+ spring = attachSpring,
+ semantics = attachedSemantics,
+ )
+ after(semantics = listOf(semanticAttachedValue with null))
+ }
+
+ addSegmentHandlers(
+ beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min),
+ beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max),
+ afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max),
+ minLimit = minLimit,
+ maxLimit = maxLimit,
+ )
+ }
+
+ private fun EffectApplyScope.addSegmentHandlers(
+ beforeDetachSegment: SegmentKey,
+ beforeAttachSegment: SegmentKey,
+ afterAttachSegment: SegmentKey,
+ minLimit: Float,
+ maxLimit: Float,
+ ) {
+ // Suppress direction change during detach. This prevents snapping to the origin when
+ // changing the direction while detaching.
+ addSegmentHandler(beforeDetachSegment, PreventDirectionChangeWithinCurrentSegment)
+ // Suppress direction when approaching attach. This prevents the detach effect when changing
+ // direction just before reattaching.
+ addSegmentHandler(beforeAttachSegment, PreventDirectionChangeWithinCurrentSegment)
+
+ // When changing direction after re-attaching, the pre-detach ratio is tweaked to
+ // interpolate between the direction change-position and the detach point.
+ addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection ->
+ val nextSegment = segmentAtInput(newInput, newDirection)
+ if (nextSegment.key == beforeDetachSegment) {
+ nextSegment.copy(
+ mapping =
+ switchMappingWithSamePivotValue(
+ currentSegment.mapping,
+ nextSegment.mapping,
+ minLimit,
+ newInput,
+ maxLimit,
+ )
+ )
+ } else {
+ nextSegment
+ }
+ }
+ }
+
+ private fun switchMappingWithSamePivotValue(
+ source: Mapping,
+ target: Mapping,
+ minLimit: Float,
+ pivot: Float,
+ maxLimit: Float,
+ ): Mapping {
+ val minValue = target.map(minLimit)
+ val pivotValue = source.map(pivot)
+ val maxValue = target.map(maxLimit)
+
+ return Mapping { input ->
+ if (input <= pivot) {
+ val t = (input - minLimit) / (pivot - minLimit)
+ lerp(minValue, pivotValue, t)
+ } else {
+ val t = (input - pivot) / (maxLimit - pivot)
+ lerp(pivotValue, maxValue, t)
+ }
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/effects/Overdrag.kt b/mechanics/src/com/android/mechanics/effects/Overdrag.kt
new file mode 100644
index 0000000..af1dca6
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/effects/Overdrag.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.effects
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.builder.Effect
+import com.android.mechanics.spec.builder.EffectApplyScope
+import com.android.mechanics.spec.builder.EffectPlacement
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spec.with
+
+/** Gesture effect to soft-limit. */
+class Overdrag(
+ private val overdragLimit: SemanticKey = Defaults.OverdragLimit,
+ private val maxOverdrag: Dp = Defaults.MaxOverdrag,
+ private val tilt: Float = Defaults.tilt,
+) : Effect.PlaceableBefore, Effect.PlaceableAfter {
+
+ override fun MotionBuilderContext.intrinsicSize() = Float.POSITIVE_INFINITY
+
+ override fun EffectApplyScope.createSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ placement: EffectPlacement,
+ ) {
+
+ val maxOverdragPx = maxOverdrag.toPx()
+
+ val limitValue = baseValue(placement.start)
+ val mapping = Mapping { input ->
+ val baseMapped = baseMapping.map(input)
+
+ maxOverdragPx * kotlin.math.tanh((baseMapped - limitValue) / (maxOverdragPx * tilt)) +
+ limitValue
+ }
+
+ unidirectional(mapping, listOf(overdragLimit with limitValue)) {
+ if (!placement.isForward) {
+ after(semantics = listOf(overdragLimit with null))
+ }
+ }
+ }
+
+ object Defaults {
+ val OverdragLimit = SemanticKey()
+ val MaxOverdrag = 30.dp
+ val tilt = 3f
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt
new file mode 100644
index 0000000..124f031
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.effects
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastCoerceAtMost
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.builder.Effect
+import com.android.mechanics.spec.builder.EffectApplyScope
+import com.android.mechanics.spec.builder.EffectPlacement
+
+/** An effect that reveals a component when the available space reaches a certain threshold. */
+data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween {
+ init {
+ require(minSize >= 0.dp)
+ }
+
+ override fun EffectApplyScope.createSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ placement: EffectPlacement,
+ ) {
+ val maxSize = maxLimit - minLimit
+ val minSize = minSize.toPx().fastCoerceAtMost(maxSize)
+
+ unidirectional(initialMapping = Mapping.Zero) {
+ before(mapping = Mapping.Zero)
+
+ target(breakpoint = minLimit + minSize, from = minSize, to = maxSize)
+
+ after(mapping = Mapping.Fixed(maxSize))
+ }
+ }
+
+ object Defaults {
+ val MinSize: Dp = 8.dp
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt
new file mode 100644
index 0000000..23ac183
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.impl
+
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spring.SpringState
+
+/** Static configuration that remains constant over a MotionValue's lifecycle. */
+internal interface StaticConfig {
+ /**
+ * A threshold value (in output units) that determines when the [MotionValue]'s internal spring
+ * animation is considered stable.
+ */
+ val stableThreshold: Float
+
+ /** Optional label for identifying a MotionValue for debugging purposes. */
+ val label: String?
+}
+
+/** The up-to-date [MotionValue] input, used by [Computations] to calculate the updated output. */
+internal interface CurrentFrameInput {
+ val spec: MotionSpec
+ val currentInput: Float
+ val currentAnimationTimeNanos: Long
+ val currentDirection: InputDirection
+ val currentGestureDragOffset: Float
+}
+
+/**
+ * The [MotionValue] state of the last completed frame.
+ *
+ * The values must be published at the start of the frame, together with the
+ * [CurrentFrameInput.currentAnimationTimeNanos].
+ */
+internal interface LastFrameState {
+ /**
+ * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This
+ * implicitly also captures the [InputDirection] and [MotionSpec].
+ */
+ val lastSegment: SegmentData
+ /**
+ * State of the [Guarantee]. Its interpretation is defined by the [lastSegment]'s
+ * [SegmentData.entryBreakpoint]'s [Breakpoint.guarantee]. If that breakpoint has no guarantee,
+ * this value will be [GuaranteeState.Inactive].
+ *
+ * This is the maximal guarantee value seen so far, as well as the guarantee's start value, and
+ * is used to compute the spring-tightening fraction.
+ */
+ val lastGuaranteeState: GuaranteeState
+ /**
+ * The state of an ongoing animation of a discontinuity.
+ *
+ * The spring animation is described by the [DiscontinuityAnimation.springStartState], which
+ * tracks the oscillation of the spring until the displacement is guaranteed not to exceed
+ * [stableThreshold] anymore. The spring animation started at
+ * [DiscontinuityAnimation.springStartTimeNanos], and uses the
+ * [DiscontinuityAnimation.springParameters]. The displacement's origin is at
+ * [DiscontinuityAnimation.targetValue].
+ *
+ * This state does not have to be updated every frame, even as an animation is ongoing: the
+ * spring animation can be computed with the same start parameters, and as time progresses, the
+ * [SpringState.calculateUpdatedState] is passed an ever larger `elapsedNanos` on each frame.
+ *
+ * The [DiscontinuityAnimation.targetValue] is a delta to the direct mapped output value from
+ * the [SegmentData.mapping]. It might accumulate the target value - it is not required to reset
+ * when the animation ends.
+ */
+ val lastAnimation: DiscontinuityAnimation
+ /**
+ * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward
+ * to [lastFrameTimeNanos].
+ */
+ val lastSpringState: SpringState
+ /** The time of the last frame, in nanoseconds. */
+ val lastFrameTimeNanos: Long
+ /** The [currentInput] of the last frame */
+ val lastInput: Float
+ val lastGestureDragOffset: Float
+
+ val directMappedVelocity: Float
+}
diff --git a/mechanics/src/com/android/mechanics/impl/Computations.kt b/mechanics/src/com/android/mechanics/impl/Computations.kt
new file mode 100644
index 0000000..2ac9574
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/impl/Computations.kt
@@ -0,0 +1,576 @@
+/*
+ * 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.impl
+
+import android.util.Log
+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 com.android.mechanics.MotionValue.Companion.TAG
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spring.SpringState
+import com.android.mechanics.spring.calculateUpdatedState
+
+internal abstract class Computations : CurrentFrameInput, LastFrameState, StaticConfig {
+ internal class ComputedValues(
+ val segment: SegmentData,
+ val guarantee: GuaranteeState,
+ val animation: DiscontinuityAnimation,
+ )
+
+ // currentComputedValues input
+ private var memoizedSpec: MotionSpec? = null
+ private var memoizedInput: Float = Float.MIN_VALUE
+ private var memoizedAnimationTimeNanos: Long = Long.MIN_VALUE
+ private var memoizedDirection: InputDirection = InputDirection.Min
+
+ // currentComputedValues output
+ private lateinit var memoizedComputedValues: ComputedValues
+
+ internal val currentComputedValues: ComputedValues
+ get() {
+ val currentSpec: MotionSpec = spec
+ val currentInput: Float = currentInput
+ val currentAnimationTimeNanos: Long = currentAnimationTimeNanos
+ val currentDirection: InputDirection = currentDirection
+
+ if (
+ memoizedSpec == currentSpec &&
+ memoizedInput == currentInput &&
+ memoizedAnimationTimeNanos == currentAnimationTimeNanos &&
+ memoizedDirection == currentDirection
+ ) {
+ return memoizedComputedValues
+ }
+
+ memoizedSpec = currentSpec
+ memoizedInput = currentInput
+ memoizedAnimationTimeNanos = currentAnimationTimeNanos
+ memoizedDirection = currentDirection
+
+ val segment: SegmentData =
+ computeSegmentData(
+ spec = currentSpec,
+ input = currentInput,
+ direction = currentDirection,
+ )
+
+ val segmentChange: SegmentChangeType =
+ getSegmentChangeType(
+ segment = segment,
+ input = currentInput,
+ direction = currentDirection,
+ )
+
+ val guarantee: GuaranteeState =
+ computeGuaranteeState(
+ segment = segment,
+ segmentChange = segmentChange,
+ input = currentInput,
+ )
+
+ val animation: DiscontinuityAnimation =
+ computeAnimation(
+ segment = segment,
+ guarantee = guarantee,
+ segmentChange = segmentChange,
+ spec = currentSpec,
+ input = currentInput,
+ animationTimeNanos = currentAnimationTimeNanos,
+ )
+
+ return ComputedValues(segment, guarantee, animation).also {
+ memoizedComputedValues = it
+ }
+ }
+
+ // currentSpringState input
+ private var memoizedAnimation: DiscontinuityAnimation? = null
+ private var memoizedTimeNanos: Long = Long.MIN_VALUE
+
+ // currentSpringState output
+ private var memoizedSpringState: SpringState = SpringState.AtRest
+
+ val currentSpringState: SpringState
+ get() {
+ val animation = currentComputedValues.animation
+ val timeNanos = currentAnimationTimeNanos
+ if (memoizedAnimation == animation && memoizedTimeNanos == timeNanos) {
+ return memoizedSpringState
+ }
+ memoizedAnimation = animation
+ memoizedTimeNanos = timeNanos
+ return computeSpringState(animation, timeNanos).also { memoizedSpringState = it }
+ }
+
+ val isSameSegmentAndAtRest: Boolean
+ get() =
+ lastSpringState == SpringState.AtRest &&
+ lastSegment.spec == spec &&
+ lastSegment.isValidForInput(currentInput, currentDirection)
+
+ val output: Float
+ get() =
+ if (isSameSegmentAndAtRest) {
+ lastSegment.mapping.map(currentInput)
+ } else {
+ outputTarget + currentSpringState.displacement
+ }
+
+ val outputTarget: Float
+ get() =
+ if (isSameSegmentAndAtRest) {
+ lastSegment.mapping.map(currentInput)
+ } else {
+ currentComputedValues.segment.mapping.map(currentInput)
+ }
+
+ val isStable: Boolean
+ get() =
+ if (isSameSegmentAndAtRest) {
+ true
+ } else {
+ currentSpringState == SpringState.AtRest
+ }
+
+ fun semanticState(semanticKey: SemanticKey): T? {
+ return with(if (isSameSegmentAndAtRest) lastSegment else currentComputedValues.segment) {
+ spec.semanticState(semanticKey, key)
+ }
+ }
+
+ fun computeDirectMappedVelocity(frameDurationNanos: Long): Float {
+ val directMappedDelta =
+ if (
+ lastSegment.spec == spec &&
+ lastSegment.isValidForInput(currentInput, currentDirection)
+ ) {
+ lastSegment.mapping.map(currentInput) - lastSegment.mapping.map(lastInput)
+ } else {
+ val springChange = currentSpringState.displacement - lastSpringState.displacement
+
+ currentComputedValues.segment.mapping.map(currentInput) -
+ lastSegment.mapping.map(lastInput) + springChange
+ }
+
+ val frameDuration = frameDurationNanos / 1_000_000_000.0
+ return (directMappedDelta / frameDuration).toFloat()
+ }
+
+ /**
+ * The current segment, which defines the [Mapping] function used to transform the input to the
+ * output.
+ *
+ * While both [spec] and [direction] remain the same, and [input] is within the segment (see
+ * [SegmentData.isValidForInput]), this is [LastFrameState.lastSegment].
+ *
+ * Otherwise, [MotionSpec.onChangeSegment] is queried for an up-dated segment.
+ */
+ private fun computeSegmentData(
+ spec: MotionSpec,
+ input: Float,
+ direction: InputDirection,
+ ): SegmentData {
+ val specChanged = lastSegment.spec != spec
+ return if (specChanged || !lastSegment.isValidForInput(input, direction)) {
+ spec.onChangeSegment(lastSegment, input, direction)
+ } else {
+ lastSegment
+ }
+ }
+
+ /** Computes the [SegmentChangeType] between [LastFrameState.lastSegment] and [segment]. */
+ private fun getSegmentChangeType(
+ segment: SegmentData,
+ input: Float,
+ direction: InputDirection,
+ ): SegmentChangeType {
+ if (segment.key == lastSegment.key) {
+ return SegmentChangeType.Same
+ }
+
+ if (
+ segment.key.minBreakpoint == lastSegment.key.minBreakpoint &&
+ segment.key.maxBreakpoint == lastSegment.key.maxBreakpoint
+ ) {
+ return SegmentChangeType.SameOppositeDirection
+ }
+
+ val currentSpec = segment.spec
+ val lastSpec = lastSegment.spec
+ if (currentSpec !== lastSpec) {
+ // Determine/guess whether the segment change was due to the changed spec, or
+ // whether lastSpec would return the same segment key for the update input.
+ val lastSpecSegmentForSameInput = lastSpec.segmentAtInput(input, direction).key
+ if (segment.key != lastSpecSegmentForSameInput) {
+ // Note: this might not be correct if the new [MotionSpec.segmentHandlers] were
+ // involved.
+ return SegmentChangeType.Spec
+ }
+ }
+
+ return if (segment.direction == lastSegment.direction) {
+ SegmentChangeType.Traverse
+ } else {
+ SegmentChangeType.Direction
+ }
+ }
+
+ /**
+ * Computes the fraction of [position] between [lastInput] and [currentInput].
+ *
+ * Essentially, this determines fractionally when [position] was crossed, between the current
+ * frame and the last frame.
+ *
+ * Since frames are updated periodically, not continuously, crossing a breakpoint happened
+ * sometime between the last frame's start and this frame's start.
+ *
+ * This fraction is used to estimate the time when a breakpoint was crossed since last frame,
+ * and simplifies the logic of crossing multiple breakpoints in one frame, as it offers the
+ * springs and guarantees time to be updated correctly.
+ *
+ * Of course, this is a simplification that assumes the input velocity was uniform during the
+ * last frame, but that is likely good enough.
+ */
+ private fun lastFrameFractionOfPosition(
+ position: Float,
+ lastInput: Float,
+ input: Float,
+ ): Float {
+ return ((position - lastInput) / (input - lastInput)).fastCoerceIn(0f, 1f)
+ }
+
+ /**
+ * The [GuaranteeState] for [segment].
+ *
+ * Without a segment change, this carries forward [lastGuaranteeState], adjusted to the new
+ * input if needed.
+ *
+ * If a segment change happened, this is a new [GuaranteeState] for the [segment]. Any remaining
+ * [LastFrameState.lastGuaranteeState] will be consumed in [currentAnimation].
+ */
+ private fun computeGuaranteeState(
+ segment: SegmentData,
+ segmentChange: SegmentChangeType,
+ input: Float,
+ ): GuaranteeState {
+ val entryBreakpoint = segment.entryBreakpoint
+
+ // First, determine the origin of the guarantee computations
+ val guaranteeOriginState =
+ when (segmentChange) {
+ // Still in the segment, the origin is carried over from the last frame
+ SegmentChangeType.Same -> lastGuaranteeState
+ // The direction changed within the same segment, no guarantee to enforce.
+ SegmentChangeType.SameOppositeDirection -> return GuaranteeState.Inactive
+ // The spec changes, there is no guarantee associated with the animation.
+ SegmentChangeType.Spec -> return GuaranteeState.Inactive
+ SegmentChangeType.Direction -> {
+ // Direction changed over a segment boundary. To make up for the
+ // directionChangeSlop, the guarantee starts at the current input.
+ GuaranteeState.withStartValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> input
+ is Guarantee.GestureDragDelta -> currentGestureDragOffset
+ is Guarantee.None -> return GuaranteeState.Inactive
+ }
+ )
+ }
+
+ SegmentChangeType.Traverse -> {
+ // Traversed over a segment boundary, the guarantee going forward is determined
+ // by the [entryBreakpoint].
+ GuaranteeState.withStartValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> entryBreakpoint.position
+ is Guarantee.GestureDragDelta -> {
+ // Guess the GestureDragDelta origin - since the gesture dragOffset
+ // is sampled, interpolate it according to when the breakpoint was
+ // crossed in the last frame.
+ val fractionalBreakpointPos =
+ lastFrameFractionOfPosition(
+ entryBreakpoint.position,
+ lastInput,
+ input,
+ )
+
+ lerp(
+ lastGestureDragOffset,
+ currentGestureDragOffset,
+ fractionalBreakpointPos,
+ )
+ }
+
+ // No guarantee to enforce.
+ is Guarantee.None -> return GuaranteeState.Inactive
+ }
+ )
+ }
+ }
+
+ // Finally, update the origin state with the current guarantee value.
+ return guaranteeOriginState.withCurrentValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> input
+ is Guarantee.GestureDragDelta -> currentGestureDragOffset
+ is Guarantee.None -> return GuaranteeState.Inactive
+ },
+ segment.direction,
+ )
+ }
+
+ /**
+ * The [DiscontinuityAnimation] in effect for the current frame.
+ *
+ * This describes the starting condition of the spring animation, and is only updated if the
+ * spring animation must restarted: that is, if yet another discontinuity must be animated as a
+ * result of a segment change, or if the [guarantee] requires the spring to be tightened.
+ *
+ * See [currentSpringState] for the continuously updated, animated spring values.
+ */
+ private fun computeAnimation(
+ segment: SegmentData,
+ guarantee: GuaranteeState,
+ segmentChange: SegmentChangeType,
+ spec: MotionSpec,
+ input: Float,
+ animationTimeNanos: Long,
+ ): DiscontinuityAnimation {
+ return when (segmentChange) {
+ SegmentChangeType.Same -> {
+ if (lastSpringState == SpringState.AtRest) {
+ // Nothing to update if no animation is ongoing
+ DiscontinuityAnimation.None
+ } else if (lastGuaranteeState == guarantee) {
+ // Nothing to update if the spring must not be tightened.
+ lastAnimation
+ } else {
+ // Compute the updated spring parameters
+ val tightenedSpringParameters =
+ guarantee.updatedSpringParameters(segment.entryBreakpoint)
+
+ lastAnimation.copy(
+ springStartState = lastSpringState,
+ springParameters = tightenedSpringParameters,
+ springStartTimeNanos = lastFrameTimeNanos,
+ )
+ }
+ }
+
+ SegmentChangeType.SameOppositeDirection,
+ SegmentChangeType.Direction,
+ SegmentChangeType.Spec -> {
+ // Determine the delta in the output, as produced by the old and new mapping.
+ val currentMapping = segment.mapping.map(input)
+ val lastMapping = lastSegment.mapping.map(input)
+ val delta = currentMapping - lastMapping
+
+ val deltaIsFinite = delta.fastIsFinite()
+ if (!deltaIsFinite) {
+ Log.wtf(
+ TAG,
+ "Delta between mappings is undefined!\n" +
+ " MotionValue: $label\n" +
+ " input: $input\n" +
+ " lastMapping: $lastMapping (lastSegment: $lastSegment)\n" +
+ " currentMapping: $currentMapping (currentSegment: $segment)",
+ )
+ }
+
+ if (delta == 0f || !deltaIsFinite) {
+ // Nothing new to animate.
+ lastAnimation
+ } else {
+ val springParameters =
+ if (segmentChange == SegmentChangeType.Direction) {
+ segment.entryBreakpoint.spring
+ } else {
+ spec.resetSpring
+ }
+
+ val newTarget = delta - lastSpringState.displacement
+ DiscontinuityAnimation(
+ SpringState(-newTarget, lastSpringState.velocity + directMappedVelocity),
+ springParameters,
+ lastFrameTimeNanos,
+ )
+ }
+ }
+
+ SegmentChangeType.Traverse -> {
+ // Process all breakpoints traversed, in order.
+ // This is involved due to the guarantees - they have to be applied, one after the
+ // other, before crossing the next breakpoint.
+ val currentDirection = segment.direction
+
+ with(spec[currentDirection]) {
+ val targetIndex = findSegmentIndex(segment.key)
+ val sourceIndex = findSegmentIndex(lastSegment.key)
+ check(targetIndex != sourceIndex)
+
+ val directionOffset = if (targetIndex > sourceIndex) 1 else -1
+
+ var lastBreakpoint = lastSegment.entryBreakpoint
+ var lastAnimationTime = lastFrameTimeNanos
+ var guaranteeState = lastGuaranteeState
+ var springState = lastSpringState
+ var springParameters = lastAnimation.springParameters
+ var initialSpringVelocity = directMappedVelocity
+
+ var segmentIndex = sourceIndex
+ while (segmentIndex != targetIndex) {
+ val nextBreakpoint =
+ breakpoints[segmentIndex + directionOffset.fastCoerceAtLeast(0)]
+
+ val nextBreakpointFrameFraction =
+ lastFrameFractionOfPosition(nextBreakpoint.position, lastInput, input)
+
+ val nextBreakpointCrossTime =
+ lerp(
+ lastFrameTimeNanos,
+ animationTimeNanos,
+ nextBreakpointFrameFraction,
+ )
+ if (
+ guaranteeState != GuaranteeState.Inactive &&
+ springState != SpringState.AtRest
+ ) {
+ val guaranteeValueAtNextBreakpoint =
+ when (lastBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> nextBreakpoint.position
+ is Guarantee.GestureDragDelta ->
+ lerp(
+ lastGestureDragOffset,
+ currentGestureDragOffset,
+ nextBreakpointFrameFraction,
+ )
+
+ is Guarantee.None ->
+ error(
+ "guaranteeState ($guaranteeState) is not Inactive, guarantee is missing"
+ )
+ }
+
+ guaranteeState =
+ guaranteeState.withCurrentValue(
+ guaranteeValueAtNextBreakpoint,
+ currentDirection,
+ )
+
+ springParameters =
+ guaranteeState.updatedSpringParameters(lastBreakpoint)
+ }
+
+ springState =
+ springState.calculateUpdatedState(
+ nextBreakpointCrossTime - lastAnimationTime,
+ springParameters,
+ )
+ lastAnimationTime = nextBreakpointCrossTime
+
+ 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
+ val deltaIsFinite = delta.fastIsFinite()
+ if (deltaIsFinite) {
+ if (delta != 0f) {
+ // There is a discontinuity on this breakpoint, that needs to be
+ // animated. The delta is pushed to the spring, to consume the
+ // discontinuity over time.
+ springState =
+ springState.nudge(
+ displacementDelta = -delta,
+ velocityDelta = initialSpringVelocity,
+ )
+
+ // When *first* crossing a discontinuity in a given frame, the
+ // static mapped velocity observed during previous frame is added as
+ // initial velocity to the spring. This is done ot most once per
+ // frame, and only if there is an actual discontinuity.
+ initialSpringVelocity = 0f
+ }
+ } else {
+ // The before and / or after mapping produced an non-finite number,
+ // which is not allowed. This intentionally crashes eng-builds, since
+ // it's a bug in the Mapping implementation that must be fixed. On
+ // regular builds, it will likely cause a jumpcut.
+ 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)",
+ )
+ }
+
+ segmentIndex += directionOffset
+ lastBreakpoint = nextBreakpoint
+ guaranteeState =
+ when (nextBreakpoint.guarantee) {
+ is Guarantee.InputDelta ->
+ GuaranteeState.withStartValue(nextBreakpoint.position)
+
+ is Guarantee.GestureDragDelta ->
+ GuaranteeState.withStartValue(
+ lerp(
+ lastGestureDragOffset,
+ currentGestureDragOffset,
+ nextBreakpointFrameFraction,
+ )
+ )
+
+ is Guarantee.None -> GuaranteeState.Inactive
+ }
+ }
+
+ val tightened = guarantee.updatedSpringParameters(segment.entryBreakpoint)
+
+ DiscontinuityAnimation(springState, tightened, lastAnimationTime)
+ }
+ }
+ }
+ }
+
+ private fun computeSpringState(
+ animation: DiscontinuityAnimation,
+ timeNanos: Long,
+ ): SpringState {
+ with(animation) {
+ if (isAtRest) return SpringState.AtRest
+
+ val nanosSinceAnimationStart = timeNanos - springStartTimeNanos
+ val updatedSpringState =
+ springStartState.calculateUpdatedState(nanosSinceAnimationStart, springParameters)
+
+ return if (updatedSpringState.isStable(springParameters, stableThreshold)) {
+ SpringState.AtRest
+ } else {
+ updatedSpringState
+ }
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt
new file mode 100644
index 0000000..b0deb75
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.impl
+
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+
+/**
+ * Captures the start-state of a spring-animation to smooth over a discontinuity.
+ *
+ * Discontinuities are caused by segment changes, where the new and old segment produce different
+ * output values for the same input.
+ */
+internal data class DiscontinuityAnimation(
+ val springStartState: SpringState,
+ val springParameters: SpringParameters,
+ val springStartTimeNanos: Long,
+) {
+ val isAtRest: Boolean
+ get() = springStartState == SpringState.AtRest
+
+ companion object {
+ val None =
+ DiscontinuityAnimation(
+ springStartState = SpringState.AtRest,
+ springParameters = SpringParameters.Snap,
+ springStartTimeNanos = 0L,
+ )
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt
new file mode 100644
index 0000000..0c4f291
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.impl
+
+import androidx.compose.ui.util.fastCoerceAtLeast
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spring.SpringParameters
+import kotlin.math.max
+
+/**
+ * Captures the origin of a guarantee, and the maximal distance the input has been away from the
+ * origin at most.
+ */
+@JvmInline
+internal value class GuaranteeState(val packedValue: Long) {
+ private val start: Float
+ get() = unpackFloat1(packedValue)
+
+ private val maxDelta: Float
+ get() = unpackFloat2(packedValue)
+
+ private val isInactive: Boolean
+ get() = this == Inactive
+
+ fun withCurrentValue(value: Float, direction: InputDirection): GuaranteeState {
+ if (isInactive) return Inactive
+
+ val delta = ((value - start) * direction.sign).fastCoerceAtLeast(0f)
+ return GuaranteeState(start, max(delta, maxDelta))
+ }
+
+ fun updatedSpringParameters(breakpoint: Breakpoint): SpringParameters {
+ if (isInactive) return breakpoint.spring
+
+ val denominator =
+ when (val guarantee = breakpoint.guarantee) {
+ is Guarantee.None -> return breakpoint.spring
+ is Guarantee.InputDelta -> guarantee.delta
+ is Guarantee.GestureDragDelta -> guarantee.delta
+ }
+
+ val springTighteningFraction = maxDelta / denominator
+ return com.android.mechanics.spring.lerp(
+ breakpoint.spring,
+ SpringParameters.Snap,
+ springTighteningFraction,
+ )
+ }
+
+ companion object {
+ val Inactive = GuaranteeState(packFloats(Float.NaN, Float.NaN))
+
+ fun withStartValue(start: Float) = GuaranteeState(packFloats(start, 0f))
+ }
+}
+
+internal fun GuaranteeState(start: Float, maxDelta: Float) =
+ GuaranteeState(packFloats(start, maxDelta))
diff --git a/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt
new file mode 100644
index 0000000..b8c68bc
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.impl
+
+/**
+ * Describes how the [currentSegment] is different from last frame's [lastSegment].
+ *
+ * This affects how the discontinuities are animated and [Guarantee]s applied.
+ */
+internal enum class SegmentChangeType {
+ /**
+ * The segment has the same key, this is considered equivalent.
+ *
+ * Only the [GuaranteeState] needs to be kept updated.
+ */
+ Same,
+
+ /**
+ * The segment's direction changed, however the min / max breakpoints remain the same: This is a
+ * direction change within a segment.
+ *
+ * The delta between the mapping must be animated with the reset spring, and there is no
+ * guarantee associated with the change.
+ */
+ SameOppositeDirection,
+
+ /**
+ * The segment and its direction change. This is a direction change that happened over a segment
+ * boundary.
+ *
+ * The direction change might have happened outside the [lastSegment] already, since a segment
+ * can't be exited at the entry side.
+ */
+ Direction,
+
+ /**
+ * The segment changed, due to the [currentInput] advancing in the [currentDirection], crossing
+ * one or more breakpoints.
+ *
+ * The guarantees of all crossed breakpoints have to be applied. The [GuaranteeState] must be
+ * reset, and a new [DiscontinuityAnimation] is started.
+ */
+ Traverse,
+
+ /**
+ * The spec was changed and added or removed the previous and/or current segment.
+ *
+ * The [MotionValue] does not have a semantic understanding of this change, hence the difference
+ * output produced by the previous and current mapping are animated with the
+ * [MotionSpec.resetSpring]
+ */
+ Spec,
+}
diff --git a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt
new file mode 100644
index 0000000..5ff18ed
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2024 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.spec
+
+import androidx.compose.ui.util.fastIsFinite
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Key to identify a breakpoint in a [DirectionalMotionSpec].
+ *
+ * @param debugLabel name of the breakpoint, for tooling and debugging.
+ * @param identity is used to check the equality of two key instances.
+ */
+class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object()) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as BreakpointKey
+
+ return identity == other.identity
+ }
+
+ override fun hashCode(): Int {
+ return identity.hashCode()
+ }
+
+ override fun toString(): String {
+ return "BreakpointKey(${debugLabel ?: ""}" +
+ "@${System.identityHashCode(identity).toString(16).padStart(8,'0')})"
+ }
+
+ internal companion object {
+ val MinLimit = BreakpointKey("built-in::min")
+ val MaxLimit = BreakpointKey("built-in::max")
+ }
+}
+
+/**
+ * Specification of a breakpoint, in the context of a [DirectionalMotionSpec].
+ *
+ * The [spring] and [guarantee] define the physics animation for the discontinuity at this
+ * breakpoint.They are applied in the direction of the containing [DirectionalMotionSpec].
+ *
+ * This [Breakpoint]'s animation definition is valid while the input is within the next segment. If
+ * the animation is still in progress when the input value reaches the next breakpoint, the
+ * remaining animation will be blended with the animation starting at the next breakpoint.
+ *
+ * @param key Identity of the [Breakpoint], unique within a [DirectionalMotionSpec].
+ * @param position The position of the [Breakpoint], in the domain of the `MotionValue`'s input.
+ * @param spring Parameters of the spring used to animate the breakpoints discontinuity.
+ * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on
+ * `MotionValue`'s input or other non-time signals.
+ */
+data class Breakpoint(
+ val key: BreakpointKey,
+ val position: Float,
+ val spring: SpringParameters,
+ val guarantee: Guarantee,
+) : Comparable {
+
+ init {
+ when (key) {
+ BreakpointKey.MinLimit -> require(position == Float.NEGATIVE_INFINITY)
+ BreakpointKey.MaxLimit -> require(position == Float.POSITIVE_INFINITY)
+ else -> require(position.fastIsFinite())
+ }
+ }
+
+ companion object {
+ /** First breakpoint of each spec. */
+ val minLimit =
+ Breakpoint(
+ BreakpointKey.MinLimit,
+ Float.NEGATIVE_INFINITY,
+ SpringParameters.Snap,
+ Guarantee.None,
+ )
+
+ /** Last breakpoint of each spec. */
+ val maxLimit =
+ Breakpoint(
+ BreakpointKey.MaxLimit,
+ Float.POSITIVE_INFINITY,
+ SpringParameters.Snap,
+ Guarantee.None,
+ )
+
+ internal fun create(
+ breakpointKey: BreakpointKey,
+ breakpointPosition: Float,
+ springSpec: SpringParameters,
+ guarantee: Guarantee,
+ ): Breakpoint {
+ return when (breakpointKey) {
+ BreakpointKey.MinLimit -> minLimit
+ BreakpointKey.MaxLimit -> maxLimit
+ else -> Breakpoint(breakpointKey, breakpointPosition, springSpec, guarantee)
+ }
+ }
+ }
+
+ override fun compareTo(other: Breakpoint): Int {
+ return position.compareTo(other.position)
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/spec/Guarantee.kt b/mechanics/src/com/android/mechanics/spec/Guarantee.kt
new file mode 100644
index 0000000..12981cc
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/Guarantee.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2024 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.spec
+
+/**
+ * Describes the condition by which a discontinuity at a breakpoint must have finished animating.
+ *
+ * With a guarantee in effect, the spring parameters will be continuously adjusted, ensuring the
+ * guarantee's target will be met.
+ */
+sealed class Guarantee {
+ /**
+ * No guarantee is provided.
+ *
+ * The spring animation will proceed at its natural pace, regardless of the input or gesture's
+ * progress.
+ */
+ data object None : Guarantee()
+
+ /**
+ * Guarantees that the animation will be complete before the input value is [delta] away from
+ * the [Breakpoint] position.
+ */
+ data class InputDelta(val delta: Float) : Guarantee()
+
+ /**
+ * Guarantees to complete the animation before the gesture is [delta] away from the gesture
+ * position captured when the breakpoint was crossed.
+ */
+ data class GestureDragDelta(val delta: Float) : Guarantee()
+}
diff --git a/mechanics/src/com/android/mechanics/spec/InputDirection.kt b/mechanics/src/com/android/mechanics/spec/InputDirection.kt
new file mode 100644
index 0000000..58fa590
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/InputDirection.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 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.spec
+
+/**
+ * The intrinsic direction of the input value.
+ *
+ * It reflects the user's intent, that is its meant to be derived from a gesture. If the input is
+ * driven by an animation, the direction is expected to not change.
+ *
+ * The directions are labelled [Min] and [Max] to reflect descending and ascending input values
+ * respectively, but it does not imply an spatial direction.
+ */
+enum class InputDirection(val sign: Int) {
+ Min(sign = -1),
+ Max(sign = +1),
+}
diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt
new file mode 100644
index 0000000..4628804
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (C) 2024 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.spec
+
+import androidx.compose.ui.util.fastFirstOrNull
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Specification for the mapping of input values to output values.
+ *
+ * The spec consists of two independent directional spec's, while only one the one matching
+ * `MotionInput`'s `direction` is used at any given time.
+ *
+ * @param maxDirection spec used when the MotionInput's direction is [InputDirection.Max]
+ * @param minDirection spec used when the MotionInput's direction is [InputDirection.Min]
+ * @param resetSpring spring parameters to animate a difference in output, if the difference is
+ * caused by setting this new spec.
+ * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime
+ * would leave the [SegmentKey].
+ */
+data class MotionSpec(
+ val maxDirection: DirectionalMotionSpec,
+ val minDirection: DirectionalMotionSpec = maxDirection,
+ val resetSpring: SpringParameters = DefaultResetSpring,
+ val segmentHandlers: Map = emptyMap(),
+) {
+
+ /** The [DirectionalMotionSpec] for the specified [direction]. */
+ operator fun get(direction: InputDirection): DirectionalMotionSpec {
+ return when (direction) {
+ InputDirection.Min -> minDirection
+ InputDirection.Max -> maxDirection
+ }
+ }
+
+ /** Whether this spec contains a segment with the specified [segmentKey]. */
+ fun containsSegment(segmentKey: SegmentKey): Boolean {
+ return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1
+ }
+
+ /**
+ * The semantic state for [key] at segment with [segmentKey].
+ *
+ * Returns `null` if no semantic value with [key] is defined. Throws [NoSuchElementException] if
+ * [segmentKey] does not exist in this [MotionSpec].
+ */
+ fun semanticState(key: SemanticKey, segmentKey: SegmentKey): T? {
+ with(get(segmentKey.direction)) {
+ val semanticValues = semantics.fastFirstOrNull { it.key == key } ?: return null
+ val segmentIndex = findSegmentIndex(segmentKey)
+ if (segmentIndex < 0) throw NoSuchElementException()
+
+ @Suppress("UNCHECKED_CAST")
+ return semanticValues.values[segmentIndex] as T
+ }
+ }
+
+ /**
+ * All [SemanticValue]s associated with the segment identified with [segmentKey].
+ *
+ * Throws [NoSuchElementException] if [segmentKey] does not exist in this [MotionSpec].
+ */
+ fun semantics(segmentKey: SegmentKey): List> {
+ with(get(segmentKey.direction)) {
+ val segmentIndex = findSegmentIndex(segmentKey)
+ if (segmentIndex < 0) throw NoSuchElementException()
+
+ return semantics.map { it[segmentIndex] }
+ }
+ }
+
+ /**
+ * The [SegmentData] for an input with the specified [position] and [direction].
+ *
+ * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`.
+ */
+ fun segmentAtInput(position: Float, direction: InputDirection): SegmentData {
+ require(position.isFinite())
+
+ return with(get(direction)) {
+ var idx = findBreakpointIndex(position)
+ if (direction == InputDirection.Min && breakpoints[idx].position == position) {
+ // The segment starts at `position`. Since the breakpoints are sorted ascending, no
+ // matter the spec's direction, need to return the previous segment in the min
+ // direction.
+ idx--
+ }
+
+ SegmentData(
+ this@MotionSpec,
+ breakpoints[idx],
+ breakpoints[idx + 1],
+ direction,
+ mappings[idx],
+ )
+ }
+ }
+
+ /**
+ * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with
+ * [newPosition] and [newDirection].
+ *
+ * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key.
+ */
+ internal fun onChangeSegment(
+ currentSegment: SegmentData,
+ newPosition: Float,
+ newDirection: InputDirection,
+ ): SegmentData {
+ val segmentChangeHandler = segmentHandlers[currentSegment.key]
+ return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection)
+ ?: segmentAtInput(newPosition, newDirection)
+ }
+
+ override fun toString() = toDebugString()
+
+ companion object {
+ /**
+ * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the
+ * standard motion scheme.
+ */
+ private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f)
+
+ /* Empty motion spec, the output is the same as the input. */
+ val Empty = MotionSpec(DirectionalMotionSpec.Empty)
+ }
+}
+
+/**
+ * Defines the [breakpoints], as well as the [mappings] in-between adjacent [Breakpoint] pairs.
+ *
+ * This [DirectionalMotionSpec] is applied in the direction defined by the containing [MotionSpec]:
+ * especially the direction in which the `breakpoint` [Guarantee] are applied depend on how this is
+ * used; this type does not have an inherit direction.
+ *
+ * All [breakpoints] are sorted in ascending order by their `position`, with the first and last
+ * breakpoints are guaranteed to be sentinel values for negative and positive infinity respectively.
+ *
+ * @param breakpoints All breakpoints in the spec, must contain [Breakpoint.minLimit] as the first
+ * element, and [Breakpoint.maxLimit] as the last element.
+ * @param mappings All mappings in between the breakpoints, thus must always contain
+ * `breakpoints.size - 1` elements.
+ * @param semantics semantics provided by this spec, must only reference to breakpoint keys included
+ * in [breakpoints].
+ */
+data class DirectionalMotionSpec(
+ val breakpoints: List,
+ val mappings: List,
+ val semantics: List> = emptyList(),
+) {
+ /** Maps all [BreakpointKey]s used in this spec to its index in [breakpoints]. */
+ private val breakpointIndexByKey: Map
+
+ init {
+ require(breakpoints.size >= 2)
+ require(breakpoints.first() == Breakpoint.minLimit)
+ require(breakpoints.last() == Breakpoint.maxLimit)
+ require(breakpoints.zipWithNext { a, b -> a <= b }.all { it }) {
+ "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}"
+ }
+ require(mappings.size == breakpoints.size - 1)
+
+ breakpointIndexByKey =
+ breakpoints.mapIndexed { index, breakpoint -> breakpoint.key to index }.toMap()
+
+ semantics.forEach {
+ require(it.values.size == mappings.size) {
+ "Semantics ${it.key} contains ${it.values.size} values vs ${mappings.size} expected"
+ }
+ }
+ }
+
+ /**
+ * Returns the index of the closest breakpoint where `Breakpoint.position <= position`.
+ *
+ * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor
+ * the last element.
+ *
+ * @param position the position in the input domain.
+ * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2`
+ */
+ fun findBreakpointIndex(position: Float): Int {
+ require(position.isFinite())
+ val breakpointPosition = breakpoints.binarySearchBy(position) { it.position }
+
+ val result =
+ when {
+ // position is between two anchors, return the min one.
+ breakpointPosition < 0 -> -breakpointPosition - 2
+ else -> breakpointPosition
+ }
+
+ check(result >= 0)
+ check(result < breakpoints.size - 1)
+
+ return result
+ }
+
+ /**
+ * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint
+ * exists.
+ */
+ fun findBreakpointIndex(breakpointKey: BreakpointKey): Int {
+ return breakpointIndexByKey[breakpointKey] ?: -1
+ }
+
+ /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */
+ fun findSegmentIndex(segmentKey: SegmentKey): Int {
+ val result = breakpointIndexByKey[segmentKey.minBreakpoint] ?: return -1
+ if (breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1
+
+ return result
+ }
+
+ override fun toString() = toDebugString()
+
+ companion object {
+ /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */
+ val Empty =
+ DirectionalMotionSpec(
+ listOf(Breakpoint.minLimit, Breakpoint.maxLimit),
+ listOf(Mapping.Identity),
+ )
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt
new file mode 100644
index 0000000..9c7f9bd
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt
@@ -0,0 +1,121 @@
+/*
+ * 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.spec
+
+/** Returns a string representation of the [MotionSpec] for debugging by humans. */
+fun MotionSpec.toDebugString(): String {
+ return buildString {
+ if (minDirection == maxDirection) {
+ appendLine("unidirectional:")
+ appendLine(minDirection.toDebugString().prependIndent(" "))
+ } else {
+ appendLine("maxDirection:")
+ appendLine(maxDirection.toDebugString().prependIndent(" "))
+ appendLine("minDirection:")
+ appendLine(minDirection.toDebugString().prependIndent(" "))
+ }
+
+ if (segmentHandlers.isNotEmpty()) {
+ appendLine("segmentHandlers:")
+ segmentHandlers.keys.forEach {
+ appendIndent(2)
+ appendSegmentKey(it)
+ appendLine()
+ }
+ }
+ }
+ .trim()
+}
+
+/** Returns a string representation of the [DirectionalMotionSpec] for debugging by humans. */
+fun DirectionalMotionSpec.toDebugString(): String {
+ return buildString {
+ appendBreakpointLine(breakpoints.first())
+ for (i in mappings.indices) {
+ appendMappingLine(mappings[i], indent = 2)
+ semantics.forEach { appendSemanticsLine(it.key, it.values[i], indent = 4) }
+ appendBreakpointLine(breakpoints[i + 1])
+ }
+ }
+ .trim()
+}
+
+private fun StringBuilder.appendIndent(indent: Int) {
+ repeat(indent) { append(' ') }
+}
+
+private fun StringBuilder.appendBreakpointLine(breakpoint: Breakpoint, indent: Int = 0) {
+ appendIndent(indent)
+ append("@")
+ append(breakpoint.position)
+
+ append(" [")
+ appendBreakpointKey(breakpoint.key)
+ append("]")
+
+ if (breakpoint.guarantee != Guarantee.None) {
+ append(" guarantee=")
+ append(breakpoint.key.debugLabel)
+ }
+
+ if (!breakpoint.spring.isSnapSpring) {
+ append(" spring=")
+ append(breakpoint.spring.stiffness)
+ append("/")
+ append(breakpoint.spring.dampingRatio)
+ }
+
+ appendLine()
+}
+
+private fun StringBuilder.appendBreakpointKey(key: BreakpointKey) {
+ if (key.debugLabel != null) {
+ append(key.debugLabel)
+ append("|")
+ }
+ append("id:0x")
+ append(System.identityHashCode(key.identity).toString(16).padStart(8, '0'))
+}
+
+private fun StringBuilder.appendSegmentKey(key: SegmentKey) {
+ appendBreakpointKey(key.minBreakpoint)
+ if (key.direction == InputDirection.Min) append(" << ") else append(" >> ")
+ appendBreakpointKey(key.maxBreakpoint)
+}
+
+private fun StringBuilder.appendMappingLine(mapping: Mapping, indent: Int = 0) {
+ appendIndent(indent)
+ append(mapping.toString())
+ appendLine()
+}
+
+private fun StringBuilder.appendSemanticsLine(
+ semanticKey: SemanticKey<*>,
+ value: Any?,
+ indent: Int = 0,
+) {
+ appendIndent(indent)
+
+ append(semanticKey.debugLabel)
+ append("[id:0x")
+ append(System.identityHashCode(semanticKey.identity).toString(16).padStart(8, '0'))
+ append("]")
+
+ append("=")
+ append(value)
+ appendLine()
+}
diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt
new file mode 100644
index 0000000..d3bce7b
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/Segment.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2024 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.spec
+
+/**
+ * Identifies a segment in a [MotionSpec].
+ *
+ * A segment only exists between two adjacent [Breakpoint]s; it cannot span multiple breakpoints.
+ * The [direction] indicates to the relevant [DirectionalMotionSpec] of the [MotionSpec].
+ *
+ * The position of the [minBreakpoint] must be less or equal to the position of the [maxBreakpoint].
+ */
+data class SegmentKey(
+ val minBreakpoint: BreakpointKey,
+ val maxBreakpoint: BreakpointKey,
+ val direction: InputDirection,
+) {
+ override fun toString(): String {
+ return "SegmentKey(min=$minBreakpoint, max=$maxBreakpoint, direction=$direction)"
+ }
+}
+
+/**
+ * Captures denormalized segment data from a [MotionSpec].
+ *
+ * Instances are created by the [MotionSpec] and used by the [MotionValue] runtime to compute the
+ * output value. By default, the [SegmentData] is cached while [isValidForInput] returns true.
+ *
+ * The [SegmentData] has an intrinsic direction, thus the segment has an entry and exit side, at the
+ * respective breakpoint.
+ */
+data class SegmentData(
+ val spec: MotionSpec,
+ val minBreakpoint: Breakpoint,
+ val maxBreakpoint: Breakpoint,
+ val direction: InputDirection,
+ val mapping: Mapping,
+) {
+ val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction)
+
+ /**
+ * Whether the given [inputPosition] and [inputDirection] should be handled by this segment.
+ *
+ * The input is considered invalid only if the direction changes or the input is *at or outside*
+ * the segment on the exit-side. The input remains intentionally valid outside the segment on
+ * the entry-side, to avoid flip-flopping.
+ */
+ fun isValidForInput(inputPosition: Float, inputDirection: InputDirection): Boolean {
+ if (inputDirection != direction) return false
+
+ return when (inputDirection) {
+ InputDirection.Max -> inputPosition < maxBreakpoint.position
+ InputDirection.Min -> inputPosition > minBreakpoint.position
+ }
+ }
+
+ /**
+ * The breakpoint at the side of the segment's start.
+ *
+ * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment.
+ */
+ val entryBreakpoint: Breakpoint
+ get() =
+ when (direction) {
+ InputDirection.Max -> minBreakpoint
+ InputDirection.Min -> maxBreakpoint
+ }
+
+ /** Semantic value for the given [semanticKey]. */
+ fun semantic(semanticKey: SemanticKey): T? {
+ return spec.semanticState(semanticKey, key)
+ }
+
+ val range: ClosedFloatingPointRange
+ get() = minBreakpoint.position..maxBreakpoint.position
+
+ override fun toString(): String {
+ return "SegmentData(key=$key, range=$range, mapping=$mapping)"
+ }
+}
+
+/**
+ * Maps the `input` of a [MotionValue] to the desired output value.
+ *
+ * The mapping implementation can be arbitrary, but must not produce discontinuities.
+ */
+fun interface Mapping {
+ /** Computes the [MotionValue]'s target output, given the input. */
+ fun map(input: Float): Float
+
+ /** `f(x) = x` */
+ object Identity : Mapping {
+ override fun map(input: Float): Float {
+ return input
+ }
+
+ override fun toString(): String {
+ return "Identity"
+ }
+ }
+
+ /** `f(x) = value` */
+ data class Fixed(val value: Float) : Mapping {
+ init {
+ require(value.isFinite())
+ }
+
+ override fun map(input: Float): Float {
+ return value
+ }
+ }
+
+ /** `f(x) = factor*x + offset` */
+ data class Linear(val factor: Float, val offset: Float = 0f) : Mapping {
+ init {
+ require(factor.isFinite())
+ require(offset.isFinite())
+ }
+
+ override fun map(input: Float): Float {
+ return input * factor + offset
+ }
+ }
+
+ companion object {
+ val Zero = Fixed(0f)
+ val One = Fixed(1f)
+ val Two = Fixed(2f)
+
+ /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */
+ fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear {
+ require(in0 != in1) {
+ "Cannot define a linear function with both inputs being the same ($in0)."
+ }
+
+ val factor = (out1 - out0) / (in1 - in0)
+ val offset = out0 - factor * in0
+ return Linear(factor, offset)
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt
new file mode 100644
index 0000000..b6ce6ab
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.spec
+
+/**
+ * Handler to allow for custom segment-change logic.
+ *
+ * This handler is called whenever the new input (position or direction) does not match
+ * [currentSegment] anymore (see [SegmentData.isValidForInput]).
+ *
+ * This is intended to implement custom effects on direction-change.
+ *
+ * Implementations can return:
+ * 1. [currentSegment] to delay/suppress segment change.
+ * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection]
+ * 3. manually looking up segments on this [MotionSpec]
+ * 4. create a [SegmentData] that is not in the spec.
+ */
+typealias OnChangeSegmentHandler =
+ MotionSpec.(
+ currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection,
+ ) -> SegmentData?
+
+/** Generic change segment handlers. */
+object ChangeSegmentHandlers {
+ /** Prevents direction changes, as long as the input is still valid on the current segment. */
+ val PreventDirectionChangeWithinCurrentSegment: OnChangeSegmentHandler =
+ { currentSegment, newInput, newDirection ->
+ currentSegment.takeIf {
+ newDirection != currentSegment.direction &&
+ it.isValidForInput(newInput, currentSegment.direction)
+ }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/spec/SemanticValue.kt b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt
new file mode 100644
index 0000000..8adf61a
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.spec
+
+/**
+ * Identifies a "semantic state" of a [MotionValue].
+ *
+ * Semantic states can be supplied by a [MotionSpec], and allows expose semantic information on the
+ * logical state a [MotionValue] is in.
+ */
+class SemanticKey(val type: Class, val debugLabel: String, val identity: Any = Object()) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as SemanticKey<*>
+
+ return identity == other.identity
+ }
+
+ override fun hashCode(): Int {
+ return identity.hashCode()
+ }
+
+ override fun toString(): String {
+ return "Semantics($debugLabel)"
+ }
+}
+
+/** Creates a new semantic key of type [T], identified by [identity]. */
+inline fun SemanticKey(
+ debugLabel: String = T::class.java.simpleName,
+ identity: Any = Object(),
+) = SemanticKey(T::class.java, debugLabel, identity)
+
+/** Pair of semantic [key] and [value]. */
+data class SemanticValue(val key: SemanticKey, val value: T)
+
+/**
+ * Creates a [SemanticValue] tuple from [SemanticKey] `this` with [value].
+ *
+ * This can be useful for creating [SemanticValue] literals with less noise.
+ */
+infix fun SemanticKey.with(value: T) = SemanticValue(this, value)
+
+/**
+ * Defines semantics values for [key], one per segment.
+ *
+ * This [values] are required to align with the segments of the [DirectionalMotionSpec] the instance
+ * will be passed to. The class has no particular value outside of a [DirectionalMotionSpec].
+ */
+class SegmentSemanticValues(val key: SemanticKey, val values: List) {
+
+ /** Retrieves the [SemanticValue] at [segmentIndex]. */
+ operator fun get(segmentIndex: Int): SemanticValue {
+ return SemanticValue(key, values[segmentIndex])
+ }
+
+ override fun toString() = "Semantics($key): [$values]"
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt
new file mode 100644
index 0000000..994927f
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt
@@ -0,0 +1,388 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.SegmentSemanticValues
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Internal, reusable implementation of the [DirectionalBuilderScope].
+ *
+ * Clients must use [directionalMotionSpec] instead.
+ */
+internal open class DirectionalBuilderImpl(
+ override val defaultSpring: SpringParameters,
+ baseSemantics: List>,
+) : DirectionalBuilderScope {
+ internal val breakpoints = mutableListOf(Breakpoint.minLimit)
+ internal val semantics = mutableListOf>()
+ internal val mappings = mutableListOf()
+ private var sourceValue: Float = Float.NaN
+ private var targetValue: Float = Float.NaN
+ private var fractionalMapping: Float = Float.NaN
+ private var breakpointPosition: Float = Float.NaN
+ private var breakpointKey: BreakpointKey? = null
+
+ init {
+ baseSemantics.forEach { getSemantics(it.key).apply { set(0, it.value) } }
+ }
+
+ /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */
+ fun prepareBuilderFn(
+ initialMapping: Mapping = Mapping.Identity,
+ initialSemantics: List> = emptyList(),
+ ) {
+ check(mappings.size == breakpoints.size - 1)
+
+ mappings.add(initialMapping)
+ val semanticIndex = mappings.size - 1
+ initialSemantics.forEach { semantic ->
+ getSemantics(semantic.key).apply { set(semanticIndex, semantic.value) }
+ }
+ }
+
+ internal fun getSemantics(key: SemanticKey): SegmentSemanticValuesBuilder {
+ @Suppress("UNCHECKED_CAST")
+ var builder = semantics.firstOrNull { it.key == key } as SegmentSemanticValuesBuilder?
+ if (builder == null) {
+ builder = SegmentSemanticValuesBuilder(key).also { semantics.add(it) }
+ }
+ return builder
+ }
+
+ /**
+ * Finalizes open segments, after invoking a [DirectionalBuilderFn].
+ *
+ * Afterwards, either [build] or another pair of {[prepareBuilderFn], [finalizeBuilderFn]} calls
+ * can be done.
+ */
+ fun finalizeBuilderFn(
+ atPosition: Float,
+ key: BreakpointKey,
+ springSpec: SpringParameters,
+ guarantee: Guarantee,
+ semantics: List>,
+ ) {
+ if (!(targetValue.isNaN() && fractionalMapping.isNaN())) {
+ // Finalizing will produce the mapping and breakpoint
+ check(mappings.size == breakpoints.size - 1)
+ } else {
+ // Mapping is already added, this will add the breakpoint
+ check(mappings.size == breakpoints.size)
+ }
+
+ if (key == BreakpointKey.MaxLimit) {
+ check(targetValue.isNaN()) { "cant specify target value for last segment" }
+ check(semantics.isEmpty()) { "cant specify semantics for last breakpoint" }
+ } else {
+ check(atPosition.isFinite())
+ check(atPosition > breakpoints.last().position) {
+ "Breakpoint ${breakpoints.last()} placed after partial sequence (end=$atPosition)"
+ }
+ }
+
+ toBreakpointImpl(atPosition, key, semantics)
+ doAddBreakpointImpl(springSpec, guarantee)
+ }
+
+ fun finalizeBuilderFn(breakpoint: Breakpoint) =
+ finalizeBuilderFn(
+ breakpoint.position,
+ breakpoint.key,
+ breakpoint.spring,
+ breakpoint.guarantee,
+ emptyList(),
+ )
+
+ /* Creates the [DirectionalMotionSpec] from the current builder state. */
+ fun build(): DirectionalMotionSpec {
+ require(mappings.size == breakpoints.size - 1)
+ check(breakpoints.last() == Breakpoint.maxLimit)
+
+ val segmentCount = mappings.size
+
+ val semantics = semantics.map { builder -> with(builder) { build(segmentCount) } }
+
+ return DirectionalMotionSpec(breakpoints.toList(), mappings.toList(), semantics)
+ }
+
+ override fun target(
+ breakpoint: Float,
+ from: Float,
+ to: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ) {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpToImpl(from, spring, guarantee)
+ continueWithTargetValueImpl(to)
+ }
+
+ override fun targetFromCurrent(
+ breakpoint: Float,
+ to: Float,
+ delta: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ) {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpByImpl(delta, spring, guarantee)
+ continueWithTargetValueImpl(to)
+ }
+
+ override fun fractionalInput(
+ breakpoint: Float,
+ from: Float,
+ fraction: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ): CanBeLastSegment {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpToImpl(from, spring, guarantee)
+ continueWithFractionalInputImpl(fraction)
+ return CanBeLastSegmentImpl
+ }
+
+ override fun fractionalInputFromCurrent(
+ breakpoint: Float,
+ fraction: Float,
+ delta: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ): CanBeLastSegment {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpByImpl(delta, spring, guarantee)
+ continueWithFractionalInputImpl(fraction)
+ return CanBeLastSegmentImpl
+ }
+
+ override fun fixedValue(
+ breakpoint: Float,
+ value: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ): CanBeLastSegment {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpToImpl(value, spring, guarantee)
+ continueWithFixedValueImpl()
+ return CanBeLastSegmentImpl
+ }
+
+ override fun fixedValueFromCurrent(
+ breakpoint: Float,
+ delta: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ ): CanBeLastSegment {
+ toBreakpointImpl(breakpoint, key, semantics)
+ jumpByImpl(delta, spring, guarantee)
+ continueWithFixedValueImpl()
+ return CanBeLastSegmentImpl
+ }
+
+ override fun mapping(
+ breakpoint: Float,
+ spring: SpringParameters,
+ guarantee: Guarantee,
+ key: BreakpointKey,
+ semantics: List>,
+ mapping: Mapping,
+ ): CanBeLastSegment {
+ toBreakpointImpl(breakpoint, key, semantics)
+ continueWithImpl(mapping, spring, guarantee)
+ return CanBeLastSegmentImpl
+ }
+
+ private fun continueWithTargetValueImpl(target: Float) {
+ check(sourceValue.isFinite())
+
+ targetValue = target
+ }
+
+ private fun continueWithFractionalInputImpl(fraction: Float) {
+ check(sourceValue.isFinite())
+
+ fractionalMapping = fraction
+ }
+
+ private fun continueWithFixedValueImpl() {
+ check(sourceValue.isFinite())
+
+ mappings.add(Mapping.Fixed(sourceValue))
+ sourceValue = Float.NaN
+ }
+
+ private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) {
+ check(sourceValue.isNaN())
+
+ doAddBreakpointImpl(spring, guarantee)
+ sourceValue = value
+ }
+
+ private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) {
+ check(sourceValue.isNaN())
+
+ val breakpoint = doAddBreakpointImpl(spring, guarantee)
+ sourceValue = mappings.last().map(breakpoint.position) + delta
+ }
+
+ private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) {
+ check(sourceValue.isNaN())
+
+ doAddBreakpointImpl(spring, guarantee)
+ mappings.add(mapping)
+ }
+
+ private fun toBreakpointImpl(
+ atPosition: Float,
+ key: BreakpointKey,
+ semantics: List>,
+ ) {
+ check(breakpointPosition.isNaN())
+ check(breakpointKey == null)
+
+ check(atPosition >= breakpoints.last().position) {
+ "Breakpoint position specified is before last breakpoint"
+ }
+
+ if (!targetValue.isNaN() || !fractionalMapping.isNaN()) {
+ check(!sourceValue.isNaN())
+
+ val sourcePosition = breakpoints.last().position
+ val breakpointDistance = atPosition - sourcePosition
+ val mapping =
+ if (breakpointDistance == 0f) {
+ Mapping.Fixed(sourceValue)
+ } else {
+
+ if (fractionalMapping.isNaN()) {
+ val delta = targetValue - sourceValue
+ fractionalMapping = delta / (atPosition - sourcePosition)
+ } else {
+ val delta = (atPosition - sourcePosition) * fractionalMapping
+ targetValue = sourceValue + delta
+ }
+
+ val offset = sourceValue - (sourcePosition * fractionalMapping)
+ Mapping.Linear(fractionalMapping, offset)
+ }
+
+ mappings.add(mapping)
+ targetValue = Float.NaN
+ sourceValue = Float.NaN
+ fractionalMapping = Float.NaN
+ }
+
+ breakpointPosition = atPosition
+ breakpointKey = key
+
+ semantics.forEach { (key, value) ->
+ getSemantics(key).apply {
+ // Last segment is guaranteed to be completed
+ set(mappings.size, value)
+ }
+ }
+ }
+
+ private fun doAddBreakpointImpl(
+ springSpec: SpringParameters,
+ guarantee: Guarantee,
+ ): Breakpoint {
+ val breakpoint =
+ Breakpoint.create(
+ checkNotNull(breakpointKey),
+ breakpointPosition,
+ springSpec,
+ guarantee,
+ )
+
+ breakpoints.add(breakpoint)
+ breakpointPosition = Float.NaN
+ breakpointKey = null
+
+ return breakpoint
+ }
+}
+
+internal class SegmentSemanticValuesBuilder(val key: SemanticKey) {
+ private val values = mutableListOf>()
+ private val unspecified = SemanticValueHolder.Unspecified()
+
+ @Suppress("UNCHECKED_CAST")
+ fun set(segmentIndex: Int, value: V) {
+ if (segmentIndex < values.size) {
+ values[segmentIndex] = SemanticValueHolder.Specified(value as T)
+ } else {
+ backfill(segmentCount = segmentIndex)
+ values.add(SemanticValueHolder.Specified(value as T))
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun updateBefore(segmentIndex: Int, value: V) {
+ require(segmentIndex < values.size)
+
+ val specified = SemanticValueHolder.Specified(value as T)
+
+ for (i in segmentIndex downTo 0) {
+ if (values[i] is SemanticValueHolder.Specified) break
+ values[i] = specified
+ }
+ }
+
+ fun build(segmentCount: Int): SegmentSemanticValues {
+ backfill(segmentCount)
+ val firstValue = values.firstNotNullOf { it as? SemanticValueHolder.Specified }.value
+ return SegmentSemanticValues(
+ key,
+ values.drop(1).runningFold(firstValue) { lastValue, thisHolder ->
+ if (thisHolder is SemanticValueHolder.Specified) thisHolder.value else lastValue
+ },
+ )
+ }
+
+ private fun backfill(segmentCount: Int) {
+ repeat(segmentCount - values.size) { values.add(unspecified) }
+ }
+}
+
+internal sealed interface SemanticValueHolder {
+ class Specified(val value: T) : SemanticValueHolder
+
+ class Unspecified() : SemanticValueHolder
+}
+
+private data object CanBeLastSegmentImpl : CanBeLastSegment
diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt
new file mode 100644
index 0000000..9eacd8f
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+/** Builder function signature. */
+typealias DirectionalBuilderFn = DirectionalBuilderScope.() -> CanBeLastSegment
+
+/**
+ * Defines the contract for building a [DirectionalMotionSpec].
+ *
+ * Provides methods to define breakpoints and mappings for the motion specification.
+ */
+interface DirectionalBuilderScope {
+ /** The default [SpringParameters] used for breakpoints. */
+ val defaultSpring: SpringParameters
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to
+ * linearly interpolate from a starting value ([from]) to the desired target value ([to]).
+ *
+ * Note: This segment cannot be used as the last segment in the specification, as it requires a
+ * subsequent breakpoint to define the target value.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param from The output value at the previous breakpoint, explicitly setting the starting
+ * point for the linear mapping.
+ * @param to The desired output value at the new breakpoint.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun target(
+ breakpoint: Float,
+ from: Float,
+ to: Float,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ )
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to
+ * linearly interpolate from the current output value (optionally with an offset of [delta]) to
+ * the desired target value ([to]).
+ *
+ * Note: This segment cannot be used as the last segment in the specification, as it requires a
+ * subsequent breakpoint to define the target value.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param to The desired output value at the new breakpoint.
+ * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun targetFromCurrent(
+ breakpoint: Float,
+ to: Float,
+ delta: Float = 0f,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ )
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to
+ * linearly interpolate from a starting value ([from]) and then continue with a fractional input
+ * ([fraction]).
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param from The output value at the previous breakpoint, explicitly setting the starting
+ * point for the linear mapping.
+ * @param fraction The fractional multiplier applied to the input difference between
+ * breakpoints.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun fractionalInput(
+ breakpoint: Float,
+ from: Float,
+ fraction: Float,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ ): CanBeLastSegment
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to
+ * linearly interpolate from the current output value (optionally with an offset of [delta]) and
+ * then continue with a fractional input ([fraction]).
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param fraction The fractional multiplier applied to the input difference between
+ * breakpoints.
+ * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun fractionalInputFromCurrent(
+ breakpoint: Float,
+ fraction: Float,
+ delta: Float = 0f,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ ): CanBeLastSegment
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to output
+ * a fixed value ([value]).
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param value The constant output value for this segment.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun fixedValue(
+ breakpoint: Float,
+ value: Float,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ ): CanBeLastSegment
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to output
+ * a constant value derived from the current output value (optionally with an offset of
+ * [delta]).
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param delta An optional offset to apply to the mapped value to determine the fixed value.
+ * Defaults to 0f.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun fixedValueFromCurrent(
+ breakpoint: Float,
+ delta: Float = 0f,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ ): CanBeLastSegment
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment using the
+ * provided [mapping].
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to
+ * [defaultSpring].
+ * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None].
+ * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ * @param mapping The custom [Mapping] to use.
+ */
+ fun mapping(
+ breakpoint: Float,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ mapping: Mapping,
+ ): CanBeLastSegment
+
+ /**
+ * Ends the current segment at the [breakpoint] position and defines the next segment to produce
+ * the input value as output (optionally with an offset of [delta]).
+ *
+ * Note: This segment can be used as the last segment in the specification.
+ *
+ * @param breakpoint The breakpoint defining the end of the current segment and the start of the
+ * next.
+ * @param delta An optional offset to apply to the mapped value to determine the fixed value.
+ * @param spring The [SpringParameters] for the transition to this breakpoint.
+ * @param guarantee The animation guarantee for this transition.
+ * @param key A unique [BreakpointKey] for this breakpoint.
+ * @param semantics Updated semantics values to be applied. Must be a subset of the
+ * [SemanticKey]s used when first creating this builder.
+ */
+ fun identity(
+ breakpoint: Float,
+ delta: Float = 0f,
+ spring: SpringParameters = defaultSpring,
+ guarantee: Guarantee = Guarantee.None,
+ key: BreakpointKey = BreakpointKey(),
+ semantics: List> = emptyList(),
+ ): CanBeLastSegment {
+ return if (delta == 0f) {
+ mapping(breakpoint, spring, guarantee, key, semantics, Mapping.Identity)
+ } else {
+ fractionalInput(
+ breakpoint,
+ fraction = 1f,
+ from = breakpoint + delta,
+ spring = spring,
+ guarantee = guarantee,
+ key = key,
+ semantics = semantics,
+ )
+ }
+ }
+}
+
+/** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */
+sealed interface CanBeLastSegment
diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt
new file mode 100644
index 0000000..b4483b7
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.SegmentSemanticValues
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Builds a [DirectionalMotionSpec] for spatial values by defining a sequence of ([Breakpoint],
+ * [Mapping]) pairs
+ *
+ * The [initialMapping] is [Mapping.Identity], and the Material spatial.default spring is used,
+ * unless otherwise specified.
+ *
+ * @see directionalMotionSpec
+ */
+fun MotionBuilderContext.spatialDirectionalMotionSpec(
+ initialMapping: Mapping = Mapping.Identity,
+ semantics: List> = emptyList(),
+ defaultSpring: SpringParameters = this.spatial.default,
+ init: DirectionalBuilderFn,
+) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init)
+
+/**
+ * Builds a [DirectionalMotionSpec] for effects values by defining a sequence of ([Breakpoint],
+ * [Mapping]) pairs
+ *
+ * The [initialMapping] is [Mapping.Zero], and the Material effects.default spring is used, unless
+ * otherwise specified.
+ *
+ * @see directionalMotionSpec
+ */
+fun MotionBuilderContext.effectsDirectionalMotionSpec(
+ initialMapping: Mapping = Mapping.Zero,
+ semantics: List> = emptyList(),
+ defaultSpring: SpringParameters = this.effects.default,
+ init: DirectionalBuilderFn,
+) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init)
+
+/**
+ * Builds a [DirectionalMotionSpec] by defining a sequence of ([Breakpoint], [Mapping]) pairs.
+ *
+ * This function simplifies the creation of complex motion specifications. It allows you to define a
+ * series of motion segments, each with its own behavior, separated by breakpoints. The breakpoints
+ * and their corresponding segments will always be ordered from min to max value, regardless of how
+ * the `DirectionalMotionSpec` is applied.
+ *
+ * Example Usage:
+ * ```kotlin
+ * val motionSpec = directionalMotionSpec(
+ * defaultSpring = materialSpatial,
+ *
+ * // Start as a constant transition, always 0.
+ * initialMapping = Mapping.Zero
+ * ) {
+ * // At breakpoint 10: Linear transition from 0 to 50.
+ * target(breakpoint = 10f, from = 0f, to = 50f)
+ *
+ * // At breakpoint 20: Jump +5, and constant value 55.
+ * fixedValueFromCurrent(breakpoint = 20f, delta = 5f)
+ *
+ * // At breakpoint 30: Jump to 40. Linear mapping using: progress_since_breakpoint * fraction.
+ * fractionalInput(breakpoint = 30f, from = 40f, fraction = 2f)
+ * }
+ * ```
+ *
+ * @param defaultSpring The default [SpringParameters] to use for all breakpoints.
+ * @param initialMapping The initial [Mapping] for the first segment (defaults to
+ * [Mapping.Identity]).
+ * @param init A lambda function that configures the spec using the [DirectionalBuilderScope]. The
+ * lambda should return a [CanBeLastSegment] to indicate the end of the spec.
+ * @param semantics Semantics specified in this spec, including the initial value applied for
+ * [initialMapping].
+ * @return The constructed [DirectionalMotionSpec].
+ */
+fun directionalMotionSpec(
+ defaultSpring: SpringParameters,
+ initialMapping: Mapping = Mapping.Identity,
+ semantics: List> = emptyList(),
+ init: DirectionalBuilderFn,
+): DirectionalMotionSpec {
+ return DirectionalBuilderImpl(defaultSpring, semantics)
+ .apply {
+ prepareBuilderFn(initialMapping)
+ init()
+ finalizeBuilderFn(Breakpoint.maxLimit)
+ }
+ .build()
+}
+
+/**
+ * Builds a simple [DirectionalMotionSpec] with a single segment.
+ *
+ * @param mapping The [Mapping] to apply to the segment. Defaults to [Mapping.Identity].
+ * @param semantics Semantics values for this spec.
+ * @return A new [DirectionalMotionSpec] instance configured with the provided parameters.
+ */
+fun directionalMotionSpec(
+ mapping: Mapping = Mapping.Identity,
+ semantics: List> = emptyList(),
+): DirectionalMotionSpec {
+ fun toSegmentSemanticValues(semanticValue: SemanticValue) =
+ SegmentSemanticValues(semanticValue.key, listOf(semanticValue.value))
+
+ return DirectionalMotionSpec(
+ listOf(Breakpoint.minLimit, Breakpoint.maxLimit),
+ listOf(mapping),
+ semantics.map { toSegmentSemanticValues(it) },
+ )
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/Effect.kt b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt
new file mode 100644
index 0000000..93314c0
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt
@@ -0,0 +1,68 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.BreakpointKey
+
+/**
+ * Blueprint for a reusable behavior in a [MotionSpec].
+ *
+ * [Effect] instances are reusable for building multiple
+ */
+sealed interface Effect {
+
+ /**
+ * Applies the effect to the motion spec.
+ *
+ * The boundaries of the effect are defined by the [minLimit] and [maxLimit] properties, and
+ * extend in both, the min and max direction by the same amount.
+ *
+ * Implementations must invoke either [EffectApplyScope.unidirectional] or both,
+ * [EffectApplyScope.forward] and [EffectApplyScope.backward]. The motion spec builder will
+ * throw if neither is called.
+ */
+ fun EffectApplyScope.createSpec(
+ minLimit: Float,
+ minLimitKey: BreakpointKey,
+ maxLimit: Float,
+ maxLimitKey: BreakpointKey,
+ placement: EffectPlacement,
+ )
+
+ interface PlaceableAfter : Effect {
+ fun MotionBuilderContext.intrinsicSize(): Float
+ }
+
+ interface PlaceableBefore : Effect {
+ fun MotionBuilderContext.intrinsicSize(): Float
+ }
+
+ interface PlaceableBetween : Effect
+
+ interface PlaceableAt : Effect {
+ fun MotionBuilderContext.minExtent(): Float
+
+ fun MotionBuilderContext.maxExtent(): Float
+ }
+}
+
+/**
+ * Handle for an [Effect] that was placed within a [MotionSpecBuilderScope].
+ *
+ * Used to place effects relative to each other.
+ */
+@JvmInline value class PlacedEffect internal constructor(internal val id: Int)
diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt
new file mode 100644
index 0000000..920b58b
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt
@@ -0,0 +1,182 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.OnChangeSegmentHandler
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Defines the contract for applying [Effect]s within a [MotionSpecBuilder]
+ *
+ * Provides methods to define breakpoints and mappings for the motion specification.
+ *
+ * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters.
+ */
+interface EffectApplyScope : MotionBuilderContext {
+ /** Default spring in use when not otherwise specified. */
+ val defaultSpring: SpringParameters
+
+ /** Mapping used outside of the defined effects. */
+ val baseMapping: Mapping
+
+ /**
+ * Defines spec simultaneously for both, the min and max direction.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
+ * outside of this range will throw.
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param initialMapping [Mapping] for the first segment after [minLimit].
+ * @param semantics Initial semantics for the effect.
+ * @param init Configures the effect's spec using [DirectionalBuilderScope].
+ * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation.
+ */
+ fun unidirectional(
+ initialMapping: Mapping,
+ semantics: List> = emptyList(),
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ )
+
+ /**
+ * Defines spec simultaneously for both, the min and max direction, using a single segment only.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit].
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
+ * @param semantics Initial semantics for the effect.
+ * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation.
+ */
+ fun unidirectional(mapping: Mapping, semantics: List> = emptyList())
+
+ /**
+ * Defines the spec for max direction.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
+ * outside of this range will throw.
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param initialMapping [Mapping] for the first segment after [minLimit].
+ * @param semantics Initial semantics for the effect.
+ * @param init Configures the effect's spec using [DirectionalBuilderScope].
+ * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation.
+ */
+ fun forward(
+ initialMapping: Mapping,
+ semantics: List> = emptyList(),
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ )
+
+ /**
+ * Defines the spec for max direction, using a single segment only.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit].
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
+ * @param semantics Initial semantics for the effect.
+ * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation.
+ */
+ fun forward(mapping: Mapping, semantics: List> = emptyList())
+
+ /**
+ * Defines the spec for min direction.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints
+ * outside of this range will throw.
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param initialMapping [Mapping] for the first segment after [minLimit].
+ * @param semantics Initial semantics for the effect.
+ * @param init Configures the effect's spec using [DirectionalBuilderScope].
+ * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation.
+ */
+ fun backward(
+ initialMapping: Mapping,
+ semantics: List> = emptyList(),
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ )
+
+ /**
+ * Defines the spec for min direction, using a single segment only.
+ *
+ * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the
+ * spec to be defined is confined within [minLimit] and [maxLimit].
+ *
+ * Will throw if [forward] or [unidirectional] has been called in this scope before.
+ *
+ * The first / last semantic value will implicitly extend to the start / end of the resulting
+ * spec, unless redefined in another spec.
+ *
+ * @param mapping [Mapping] to be used between [minLimit] and [maxLimit].
+ * @param semantics Initial semantics for the effect.
+ * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation.
+ */
+ fun backward(mapping: Mapping, semantics: List> = emptyList())
+
+ /** Adds a segment handler to the resulting [MotionSpec]. */
+ fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler)
+
+ /** Returns the value of [baseValue] at [position]. */
+ fun baseValue(position: Float): Float
+}
+
+interface DirectionalEffectBuilderScope : DirectionalBuilderScope {
+
+ fun before(
+ spring: SpringParameters? = null,
+ guarantee: Guarantee? = null,
+ semantics: List>? = null,
+ mapping: Mapping? = null,
+ )
+
+ fun after(
+ spring: SpringParameters? = null,
+ guarantee: Guarantee? = null,
+ semantics: List>? = null,
+ mapping: Mapping? = null,
+ )
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt
new file mode 100644
index 0000000..00f4f10
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt
@@ -0,0 +1,111 @@
+/*
+ * 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.spec.builder
+
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.nextDown
+import kotlin.math.nextUp
+
+/**
+ * Describes the desired placement of an effect within the input domain of a [MotionSpec].
+ *
+ * [start] is always finite, and denotes a specific position in the input where the effects starts.
+ *
+ * [end] is either finite, describing a specific range in the input where the [Effect] applies.
+ * Alternatively, the [end] can be either [Float.NEGATIVE_INFINITY] or [Float.POSITIVE_INFINITY],
+ * indicating that the effect extends either
+ * - for the effects intrinsic extent
+ * - the boundaries of the next placed effect
+ * - the specs' min/max limit
+ *
+ * Thus, [start] and [end] define an implicit direction of the effect. If not [isForward], the
+ * [Effect] will be reversed when applied.
+ */
+@JvmInline
+value class EffectPlacement internal constructor(val value: Long) {
+
+ init {
+ require(start.isFinite())
+ }
+
+ val start: Float
+ get() = unpackFloat1(value)
+
+ val end: Float
+ get() = unpackFloat2(value)
+
+ val type: EffectPlacemenType
+ get() {
+ return when {
+ end.isNaN() -> EffectPlacemenType.At
+ end == Float.NEGATIVE_INFINITY -> EffectPlacemenType.Before
+ end == Float.POSITIVE_INFINITY -> EffectPlacemenType.After
+ else -> EffectPlacemenType.Between
+ }
+ }
+
+ val isForward: Boolean
+ get() {
+ return when (type) {
+ EffectPlacemenType.At -> true
+ EffectPlacemenType.Before -> false
+ EffectPlacemenType.After -> true
+ EffectPlacemenType.Between -> end >= start
+ }
+ }
+
+ internal val sortOrder: Float
+ get() {
+ return when (type) {
+ EffectPlacemenType.At -> start
+ EffectPlacemenType.Before -> start.nextDown()
+ EffectPlacemenType.After -> start.nextUp()
+ EffectPlacemenType.Between -> (start + end) / 2
+ }
+ }
+
+ internal val min: Float
+ get() = min(start, end)
+
+ internal val max: Float
+ get() = max(start, end)
+
+ override fun toString(): String {
+ return "EffectPlacement(start=$start, end=$end)"
+ }
+
+ companion object {
+ fun at(position: Float) = EffectPlacement(packFloats(position, Float.NaN))
+
+ fun after(position: Float) = EffectPlacement(packFloats(position, Float.POSITIVE_INFINITY))
+
+ fun before(position: Float) = EffectPlacement(packFloats(position, Float.NEGATIVE_INFINITY))
+
+ fun between(start: Float, end: Float) = EffectPlacement(packFloats(start, end))
+ }
+}
+
+enum class EffectPlacemenType {
+ At,
+ Before,
+ After,
+ Between,
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt
new file mode 100644
index 0000000..989d481
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.spec.builder
+
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MotionScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Device / scheme specific context for building motion specs.
+ *
+ * See go/motion-system.
+ *
+ * @see rememberMotionBuilderContext for Compose
+ * @see standardViewMotionBuilderContext for Views
+ * @see expressiveViewMotionBuilderContext for Views
+ */
+interface MotionBuilderContext : Density {
+ /**
+ * Spatial spring tokens.
+ *
+ * Used for animations that move something on screen, for example the x and y position,
+ * rotation, size, rounded corners.
+ *
+ * See go/motion-system#b99b0d12-e9c8-4605-96dd-e3f17bfe9538
+ */
+ val spatial: MaterialSprings
+
+ /**
+ * Effects spring tokens.
+ *
+ * Used to animate properties such as color and opacity animations.
+ *
+ * See go/motion-system#142c8835-7474-4f74-b2eb-e1187051ec1f
+ */
+ val effects: MaterialSprings
+
+ companion object {
+ /** Default threshold for effect springs. */
+ const val StableThresholdEffects = 0.01f
+ /**
+ * Default threshold for spatial springs.
+ *
+ * Cuts off when remaining oscillations are below 1px
+ */
+ const val StableThresholdSpatial = 1f
+ }
+}
+
+/** Material spring tokens, see go/motion-system##63b14c00-d049-4d3e-b8b6-83d8f524a8db for usage. */
+data class MaterialSprings(
+ val default: SpringParameters,
+ val fast: SpringParameters,
+ val slow: SpringParameters,
+ val stabilityThreshold: Float,
+)
+
+/** [MotionBuilderContext] based on the current [Density] and [MotionScheme]. */
+@Composable
+fun rememberMotionBuilderContext(): MotionBuilderContext {
+ val density = LocalDensity.current
+ val motionScheme = MaterialTheme.motionScheme
+ return remember(density, motionScheme) { ComposeMotionBuilderContext(motionScheme, density) }
+}
+
+class ComposeMotionBuilderContext(motionScheme: MotionScheme, density: Density) :
+ MotionBuilderContext, Density by density {
+
+ override val spatial =
+ MaterialSprings(
+ SpringParameters(motionScheme.defaultSpatialSpec()),
+ SpringParameters(motionScheme.fastSpatialSpec()),
+ SpringParameters(motionScheme.slowSpatialSpec()),
+ MotionBuilderContext.StableThresholdSpatial,
+ )
+ override val effects =
+ MaterialSprings(
+ SpringParameters(motionScheme.defaultEffectsSpec()),
+ SpringParameters(motionScheme.fastEffectsSpec()),
+ SpringParameters(motionScheme.slowEffectsSpec()),
+ MotionBuilderContext.StableThresholdEffects,
+ )
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt
new file mode 100644
index 0000000..de62c44
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt
@@ -0,0 +1,162 @@
+/*
+ * 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.spec.builder
+
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * Creates a [MotionSpec] for a spatial value.
+ *
+ * The [baseMapping] is [Mapping.Identity], and the Material spatial.default spring is used unless
+ * otherwise specified.
+ *
+ * @see motionSpec
+ */
+fun MotionBuilderContext.spatialMotionSpec(
+ baseMapping: Mapping = Mapping.Identity,
+ defaultSpring: SpringParameters = this.spatial.default,
+ resetSpring: SpringParameters = defaultSpring,
+ baseSemantics: List> = emptyList(),
+ init: MotionSpecBuilderScope.() -> Unit,
+) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init)
+
+/**
+ * Creates a [MotionSpec] for an effects value.
+ *
+ * The [baseMapping] is [Mapping.Zero], and the Material effects.default spring is used unless
+ * otherwise specified.
+ *
+ * @see motionSpec
+ */
+fun MotionBuilderContext.effectsMotionSpec(
+ baseMapping: Mapping = Mapping.Zero,
+ defaultSpring: SpringParameters = this.effects.default,
+ resetSpring: SpringParameters = defaultSpring,
+ baseSemantics: List> = emptyList(),
+ init: MotionSpecBuilderScope.() -> Unit,
+) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init)
+
+/**
+ * Creates a [MotionSpec], based on reusable effects.
+ *
+ * @param baseMapping The mapping in used for segments where no [Effect] is specified.
+ * @param defaultSpring The [DirectionalBuilderScope.defaultSpring], used for all discontinuities
+ * unless otherwise specified.
+ * @param resetSpring spring parameters to animate a difference in output, if the difference is
+ * caused by setting this new spec.
+ * @param baseSemantics initial semantics that apply before of effects override them.
+ * @param init
+ */
+fun MotionBuilderContext.motionSpec(
+ baseMapping: Mapping,
+ defaultSpring: SpringParameters,
+ resetSpring: SpringParameters = defaultSpring,
+ baseSemantics: List> = emptyList(),
+ init: MotionSpecBuilderScope.() -> Unit,
+): MotionSpec {
+ return MotionSpecBuilderImpl(
+ baseMapping,
+ defaultSpring,
+ resetSpring,
+ baseSemantics,
+ motionBuilderContext = this,
+ )
+ .apply(init)
+ .build()
+}
+
+/**
+ * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input.
+ *
+ * The Material spatial.default spring is used to animate to the fixed output value.
+ *
+ * @see fixedValueSpec
+ */
+fun MotionBuilderContext.fixedSpatialValueSpec(
+ value: Float,
+ resetSpring: SpringParameters = this.spatial.default,
+ semantics: List> = emptyList(),
+) = fixedValueSpec(value, resetSpring, semantics)
+
+/**
+ * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input.
+ *
+ * The Material effects.default spring is used to animate to the fixed output value.
+ *
+ * @see fixedValueSpec
+ */
+fun MotionBuilderContext.fixedEffectsValueSpec(
+ value: Float,
+ resetSpring: SpringParameters = this.effects.default,
+ semantics: List> = emptyList(),
+) = fixedValueSpec(value, resetSpring, semantics)
+
+/**
+ * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input.
+ *
+ * @param value The fixed output value.
+ * @param resetSpring spring parameters to animate to the fixed output value.
+ * @param semantics for this spec.
+ */
+fun MotionBuilderContext.fixedValueSpec(
+ value: Float,
+ resetSpring: SpringParameters,
+ semantics: List> = emptyList(),
+): MotionSpec {
+ return MotionSpec(
+ directionalMotionSpec(Mapping.Fixed(value), semantics),
+ resetSpring = resetSpring,
+ )
+}
+
+/** Defines the contract placing [Effect]s within a [MotionSpecBuilder] */
+interface MotionSpecBuilderScope : MotionBuilderContext {
+
+ /**
+ * Places [effect] between [start] and [end].
+ *
+ * If `start > end`, the effect will be reversed when applied. The [effect] can overrule the
+ * `end` position with [Effect.measure].
+ */
+ fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect
+
+ /**
+ * Places [effect] at position, extending backwards.
+ *
+ * The effect will be reversed when applied.
+ */
+ fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect
+
+ /** Places [effect] at position, extending forward. */
+ fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect
+
+ /**
+ * Places [effect] at [otherEffect]'s min position, extending backwards.
+ *
+ * The effect will be reversed when applied.
+ */
+ fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect
+
+ /** Places [effect] after the end of [otherEffect], extending forward. */
+ fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect
+
+ /** Places [effect] at position. */
+ fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect
+}
diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt
new file mode 100644
index 0000000..75b9953
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt
@@ -0,0 +1,589 @@
+/*
+ * 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.spec.builder
+
+import androidx.collection.MutableIntIntMap
+import androidx.collection.MutableIntList
+import androidx.collection.MutableIntLongMap
+import androidx.collection.MutableIntObjectMap
+import androidx.collection.MutableLongList
+import androidx.collection.ObjectList
+import androidx.collection.mutableObjectListOf
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.OnChangeSegmentHandler
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticValue
+import com.android.mechanics.spring.SpringParameters
+
+internal class MotionSpecBuilderImpl(
+ override val baseMapping: Mapping,
+ override val defaultSpring: SpringParameters,
+ private val resetSpring: SpringParameters,
+ private val baseSemantics: List>,
+ motionBuilderContext: MotionBuilderContext,
+) : MotionSpecBuilderScope, MotionBuilderContext by motionBuilderContext, EffectApplyScope {
+
+ private val placedEffects = MutableIntObjectMap()
+ private val absoluteEffectPlacements = MutableIntLongMap()
+ private val relativeEffectPlacements = MutableIntIntMap()
+
+ private lateinit var builders: ObjectList
+ private val forwardBuilder: DirectionalEffectBuilderScopeImpl
+ get() = builders[0]
+
+ private val reverseBuilder: DirectionalEffectBuilderScopeImpl
+ get() = builders[1]
+
+ private lateinit var segmentHandlers: MutableMap
+
+ fun build(): MotionSpec {
+ if (placedEffects.isEmpty()) {
+ return MotionSpec(directionalMotionSpec(baseMapping), resetSpring = resetSpring)
+ }
+
+ builders =
+ mutableObjectListOf(
+ DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics),
+ DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics),
+ )
+ segmentHandlers = mutableMapOf()
+
+ val capacity = placedEffects.size * 2 + 1
+ val sortedEffects = MutableIntList(capacity)
+ val specifiedPlacements = MutablePlacementList(MutableLongList(capacity))
+ val actualPlacements = MutablePlacementList(MutableLongList(capacity))
+
+ placeEffects(sortedEffects, specifiedPlacements, actualPlacements)
+ check(sortedEffects.size >= 2)
+
+ var minLimitKey = BreakpointKey.MinLimit
+ lateinit var maxLimitKey: BreakpointKey
+
+ for (i in 0 until sortedEffects.lastIndex) {
+ maxLimitKey = BreakpointKey()
+ applyEffect(
+ sortedEffects[i],
+ specifiedPlacements[i],
+ actualPlacements[i],
+ minLimitKey,
+ maxLimitKey,
+ )
+ minLimitKey = maxLimitKey
+ }
+
+ maxLimitKey = BreakpointKey.MaxLimit
+
+ applyEffect(
+ sortedEffects.last(),
+ specifiedPlacements.last(),
+ actualPlacements.last(),
+ minLimitKey,
+ maxLimitKey,
+ )
+
+ return MotionSpec(
+ builders[0].build(),
+ builders[1].build(),
+ resetSpring,
+ segmentHandlers.toMap(),
+ )
+ }
+
+ private fun placeEffects(
+ sortedEffects: MutableIntList,
+ specifiedPlacements: MutablePlacementList,
+ actualPlacements: MutablePlacementList,
+ ) {
+
+ // To place the effects, do the following
+ // - sort all `absoluteEffectPlacements` in ascending order
+ // - use the sorted absolutely placed effects as seeds. For each of them, do the following:
+ // - measure the effect
+ // - recursively walk the relatively effects placed before, tracking the min boundary
+ // (this requires effects that have a defined extend to the min side)
+ // - upon reaching the beginning, start placing the effects in the forward direction.
+ // continue up to the seed effects, t
+ // - recursively continue placing effects relatively placed afterwards.
+
+ fun appendEffect(
+ effectId: Int,
+ specifiedPlacement: EffectPlacement,
+ measuredPlacement: EffectPlacement,
+ ) {
+ var actualPlacement = measuredPlacement
+ var prependNoPlaceholderEffect = false
+
+ if (actualPlacements.isEmpty()) {
+ // placing first effect.
+ if (measuredPlacement.min.isFinite()) {
+ prependNoPlaceholderEffect = true
+ }
+ } else {
+
+ val previousPlacement = actualPlacements.last()
+ if (previousPlacement.max.isFinite()) {
+ // The previous effect has a defined end-point.
+
+ if (measuredPlacement.min == Float.NEGATIVE_INFINITY) {
+ // The current effect wants to extend to the end of the previous effect.
+ require(measuredPlacement.max.isFinite())
+ actualPlacement =
+ EffectPlacement.between(previousPlacement.max, measuredPlacement.max)
+ } else if (measuredPlacement.min > previousPlacement.max) {
+ // There's a gap between the last and the current effect, will need to
+ // insert a placeholder
+ require(measuredPlacement.min.isFinite())
+ prependNoPlaceholderEffect = true
+ } else {
+ // In all other cases, the previous end has to match the current start.
+ // In all other cases, effects are overlapping, which is not supported.
+ require(measuredPlacement.min == previousPlacement.max) {
+ "Effects are overlapping"
+ }
+ }
+ } else {
+ // The previous effect wants to extend to the beginning of the next effect
+ assert(previousPlacement.max == Float.POSITIVE_INFINITY)
+
+ // Therefore the current effect is required to have a defined start-point
+ require(measuredPlacement.min.isFinite()) {
+ "Only one of the effects can extend to the boundary, not both:\n" +
+ " this: $actualPlacement (${placedEffects[effectId]})\n" +
+ " previous: $previousPlacement (${placedEffects[effectId]}])\n"
+ }
+
+ actualPlacements[actualPlacements.lastIndex] =
+ EffectPlacement.between(previousPlacement.min, measuredPlacement.min)
+ }
+ }
+
+ if (prependNoPlaceholderEffect) {
+ assert(actualPlacement.min.isFinite())
+ // Adding a placeholder that will be skipped, but simplifies the algorithm by
+ // ensuring all effects are back-to-back. The NoEffectPlaceholderId is used to
+
+ sortedEffects.add(NoEffectPlaceholderId)
+ val placeholderPlacement = EffectPlacement.before(actualPlacement.min)
+ specifiedPlacements.add(placeholderPlacement)
+ actualPlacements.add(placeholderPlacement)
+ }
+
+ sortedEffects.add(effectId)
+ specifiedPlacements.add(specifiedPlacement)
+
+ actualPlacements.add(actualPlacement)
+ }
+
+ fun processEffectsPlacedBefore(
+ anchorEffectId: Int,
+ anchorEffectPlacement: EffectPlacement,
+ ) {
+ val beforeEffectKey = -anchorEffectId
+ if (relativeEffectPlacements.containsKey(beforeEffectKey)) {
+ val effectId = relativeEffectPlacements[beforeEffectKey]
+ val effect = checkNotNull(placedEffects[effectId])
+
+ require(anchorEffectPlacement.min.isFinite())
+ val specifiedPlacement = EffectPlacement.before(anchorEffectPlacement.min)
+
+ val measuredPlacement = measureEffect(effect, specifiedPlacement)
+ processEffectsPlacedBefore(effectId, measuredPlacement)
+ appendEffect(effectId, specifiedPlacement, measuredPlacement)
+ }
+ }
+
+ fun processEffectsPlacedAfter(anchorEffectId: Int, anchorEffectPlacement: EffectPlacement) {
+ val afterEffectKey = anchorEffectId
+ if (relativeEffectPlacements.containsKey(afterEffectKey)) {
+ val effectId = relativeEffectPlacements[afterEffectKey]
+ val effect = checkNotNull(placedEffects[effectId])
+
+ require(anchorEffectPlacement.max.isFinite())
+ val specifiedPlacement = EffectPlacement.after(anchorEffectPlacement.max)
+
+ val measuredPlacement = measureEffect(effect, specifiedPlacement)
+ appendEffect(effectId, specifiedPlacement, measuredPlacement)
+ processEffectsPlacedAfter(effectId, measuredPlacement)
+ }
+ }
+
+ check(absoluteEffectPlacements.isNotEmpty())
+ // Implementation note: sortedAbsolutePlacedEffects should be an IntArray, but that cannot
+ // be sorted with a custom comparator, hence using a typed array.
+ val sortedAbsolutePlacedEffects =
+ Array(absoluteEffectPlacements.size) { 0 }
+ .also { array ->
+ var index = 0
+ absoluteEffectPlacements.forEachKey { array[index++] = it }
+ array.sortBy { EffectPlacement(absoluteEffectPlacements[it]).sortOrder }
+ }
+
+ sortedAbsolutePlacedEffects.forEach { effectId ->
+ val effect = checkNotNull(placedEffects[effectId])
+ val specifiedPlacement = EffectPlacement(absoluteEffectPlacements[effectId])
+ val measuredPlacement = measureEffect(effect, specifiedPlacement)
+ processEffectsPlacedBefore(effectId, measuredPlacement)
+ appendEffect(effectId, specifiedPlacement, measuredPlacement)
+ processEffectsPlacedAfter(effectId, measuredPlacement)
+ }
+
+ if (actualPlacements.last().max != Float.POSITIVE_INFINITY) {
+ sortedEffects.add(NoEffectPlaceholderId)
+ val placeholderPlacement = EffectPlacement.after(actualPlacements.last().max)
+ specifiedPlacements.add(placeholderPlacement)
+ actualPlacements.add(placeholderPlacement)
+ }
+ }
+
+ // ---- MotionSpecBuilderScope implementation --------------------------------------------------
+
+ override fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect {
+ return addEffect(effect).also {
+ absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value
+ }
+ }
+
+ override fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect {
+ return addEffect(effect).also {
+ absoluteEffectPlacements[it.id] = EffectPlacement.between(start, end).value
+ }
+ }
+
+ override fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect {
+ return addEffect(effect).also {
+ absoluteEffectPlacements[it.id] = EffectPlacement.before(position).value
+ }
+ }
+
+ override fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect {
+ require(placedEffects.containsKey(otherEffect.id))
+ require(!relativeEffectPlacements.containsKey(-otherEffect.id))
+ return addEffect(effect).also { relativeEffectPlacements[-otherEffect.id] = it.id }
+ }
+
+ override fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect {
+ return addEffect(effect).also {
+ absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value
+ }
+ }
+
+ override fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect {
+ require(placedEffects.containsKey(otherEffect.id))
+ require(!relativeEffectPlacements.containsKey(otherEffect.id))
+
+ relativeEffectPlacements.forEach { key, value ->
+ if (value == otherEffect.id) {
+ require(key > 0) {
+ val other = placedEffects[otherEffect.id]
+ "Cannot place effect [$effect] *after* [$other], since the latter was placed" +
+ "*before* an effect"
+ }
+ }
+ }
+
+ require(!relativeEffectPlacements.containsKey(otherEffect.id))
+ return addEffect(effect).also { relativeEffectPlacements[otherEffect.id] = it.id }
+ }
+
+ private fun addEffect(effect: Effect): PlacedEffect {
+ return PlacedEffect(placedEffects.size + 1).also { placedEffects[it.id] = effect }
+ }
+
+ // ----- EffectApplyScope implementation -------------------------------------------------------
+
+ override fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler) {
+ require(!segmentHandlers.containsKey(key))
+ segmentHandlers[key] = handler
+ }
+
+ override fun baseValue(position: Float): Float {
+ return baseMapping.map(position)
+ }
+
+ override fun unidirectional(
+ initialMapping: Mapping,
+ semantics: List>,
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ ) {
+ forward(initialMapping, semantics, init)
+ backward(initialMapping, semantics, init)
+ }
+
+ override fun unidirectional(mapping: Mapping, semantics: List>) {
+ forward(mapping, semantics)
+ backward(mapping, semantics)
+ }
+
+ override fun forward(
+ initialMapping: Mapping,
+ semantics: List>,
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ ) {
+ check(!forwardInvoked) { "Cannot define forward spec more than once" }
+ forwardInvoked = true
+
+ forwardBuilder.prepareBuilderFn(initialMapping, semantics)
+ forwardBuilder.init()
+ }
+
+ override fun forward(mapping: Mapping, semantics: List>) {
+ check(!forwardInvoked) { "Cannot define forward spec more than once" }
+ forwardInvoked = true
+
+ forwardBuilder.prepareBuilderFn(mapping, semantics)
+ }
+
+ override fun backward(
+ initialMapping: Mapping,
+ semantics: List>,
+ init: DirectionalEffectBuilderScope.() -> Unit,
+ ) {
+ check(!backwardInvoked) { "Cannot define backward spec more than once" }
+ backwardInvoked = true
+
+ reverseBuilder.prepareBuilderFn(initialMapping, semantics)
+ reverseBuilder.init()
+ }
+
+ override fun backward(mapping: Mapping, semantics: List>) {
+ check(!backwardInvoked) { "Cannot define backward spec more than once" }
+ backwardInvoked = true
+
+ reverseBuilder.prepareBuilderFn(mapping, semantics)
+ }
+
+ private var forwardInvoked = false
+ private var backwardInvoked = false
+
+ private fun applyEffect(
+ effectId: Int,
+ specifiedPlacement: EffectPlacement,
+ actualPlacement: EffectPlacement,
+ minLimitKey: BreakpointKey,
+ maxLimitKey: BreakpointKey,
+ ) {
+ require(minLimitKey != maxLimitKey)
+
+ if (effectId == NoEffectPlaceholderId) {
+ val maxBreakpoint =
+ Breakpoint.create(maxLimitKey, actualPlacement.max, defaultSpring, Guarantee.None)
+ builders.forEach { builder ->
+ builder.mappings += builder.afterMapping ?: baseMapping
+ builder.breakpoints += maxBreakpoint
+ }
+ return
+ }
+
+ val initialForwardSize = forwardBuilder.breakpoints.size
+ val initialReverseSize = reverseBuilder.breakpoints.size
+
+ val effect = checkNotNull(placedEffects[effectId])
+
+ forwardInvoked = false
+ backwardInvoked = false
+
+ builders.forEach { it.resetBeforeAfter() }
+ with(effect) {
+ createSpec(
+ actualPlacement.min,
+ minLimitKey,
+ actualPlacement.max,
+ maxLimitKey,
+ specifiedPlacement,
+ )
+ }
+
+ check(forwardInvoked) { "forward() spec not defined during createSpec()" }
+ check(backwardInvoked) { "backward() spec not defined during createSpec()" }
+
+ builders.forEachIndexed { index, builder ->
+ val initialSize = if (index == 0) initialForwardSize else initialReverseSize
+
+ require(builder.breakpoints[initialSize - 1].key == minLimitKey)
+
+ builder.finalizeBuilderFn(
+ actualPlacement.max,
+ maxLimitKey,
+ builder.afterSpring ?: defaultSpring,
+ builder.afterGuarantee ?: Guarantee.None,
+ builder.afterSemantics ?: emptyList(),
+ )
+ check(builder.breakpoints.size > initialSize)
+
+ if (builder.beforeSpring != null || builder.beforeGuarantee != null) {
+ val oldMinBreakpoint = builder.breakpoints[initialSize - 1]
+ builder.breakpoints[initialSize - 1] =
+ oldMinBreakpoint.copy(
+ spring = builder.beforeSpring ?: oldMinBreakpoint.spring,
+ guarantee = builder.beforeGuarantee ?: oldMinBreakpoint.guarantee,
+ )
+ }
+
+ builder.beforeMapping
+ ?.takeIf { initialSize >= 2 && builder.mappings[initialSize - 2] === baseMapping }
+ ?.also { builder.mappings[initialSize - 2] = it }
+
+ builder.beforeSemantics?.forEach {
+ builder.getSemantics(it.key).updateBefore(initialSize - 2, it.value)
+ }
+ }
+ }
+
+ companion object {
+ private val NoEffectPlaceholderId = -1
+ }
+}
+
+private class DirectionalEffectBuilderScopeImpl(
+ defaultSpring: SpringParameters,
+ baseSemantics: List>,
+) : DirectionalBuilderImpl(defaultSpring, baseSemantics), DirectionalEffectBuilderScope {
+
+ var beforeGuarantee: Guarantee? = null
+ var beforeSpring: SpringParameters? = null
+ var beforeSemantics: List>? = null
+ var beforeMapping: Mapping? = null
+
+ override fun before(
+ spring: SpringParameters?,
+ guarantee: Guarantee?,
+ semantics: List>?,
+ mapping: Mapping?,
+ ) {
+ beforeGuarantee = guarantee
+ beforeSpring = spring
+ beforeSemantics = semantics
+ beforeMapping = mapping
+ }
+
+ var afterGuarantee: Guarantee? = null
+ var afterSpring: SpringParameters? = null
+ var afterSemantics: List>? = null
+ var afterMapping: Mapping? = null
+
+ override fun after(
+ spring: SpringParameters?,
+ guarantee: Guarantee?,
+ semantics: List>?,
+ mapping: Mapping?,
+ ) {
+ afterGuarantee = guarantee
+ afterSpring = spring
+ afterSemantics = semantics
+ afterMapping = mapping
+ }
+
+ fun resetBeforeAfter() {
+ beforeGuarantee = null
+ beforeSpring = null
+ beforeSemantics = null
+ beforeMapping = null
+ afterGuarantee = null
+ afterSpring = null
+ afterSemantics = null
+ afterMapping = null
+ }
+}
+
+private fun MotionBuilderContext.measureEffect(
+ effect: Effect,
+ specifiedPlacement: EffectPlacement,
+): EffectPlacement {
+ return when (specifiedPlacement.type) {
+ EffectPlacemenType.At -> {
+ require(effect is Effect.PlaceableAt)
+ with(effect) {
+ val minExtend = minExtent()
+ require(minExtend.isFinite() && minExtend >= 0)
+ val maxExtend = maxExtent()
+ require(maxExtend.isFinite() && maxExtend >= 0)
+
+ EffectPlacement.between(
+ specifiedPlacement.start - minExtend,
+ specifiedPlacement.start + maxExtend,
+ )
+ }
+ }
+
+ EffectPlacemenType.Before -> {
+ require(effect is Effect.PlaceableBefore)
+ with(effect) {
+ val intrinsicSize = intrinsicSize()
+ if (intrinsicSize.isFinite()) {
+ require(intrinsicSize >= 0)
+
+ EffectPlacement.between(
+ specifiedPlacement.start,
+ specifiedPlacement.start - intrinsicSize,
+ )
+ } else {
+ specifiedPlacement
+ }
+ }
+ }
+
+ EffectPlacemenType.After -> {
+ require(effect is Effect.PlaceableAfter)
+ with(effect) {
+ val intrinsicSize = intrinsicSize()
+ if (intrinsicSize.isFinite()) {
+
+ require(intrinsicSize >= 0)
+
+ EffectPlacement.between(
+ specifiedPlacement.start,
+ specifiedPlacement.start + intrinsicSize,
+ )
+ } else {
+ specifiedPlacement
+ }
+ }
+ }
+
+ EffectPlacemenType.Between -> specifiedPlacement
+ }
+}
+
+@JvmInline
+value class MutablePlacementList(val storage: MutableLongList) {
+
+ val size: Int
+ get() = storage.size
+
+ val lastIndex: Int
+ get() = storage.lastIndex
+
+ val indices: IntRange
+ get() = storage.indices
+
+ fun isEmpty() = storage.isEmpty()
+
+ fun isNotEmpty() = storage.isNotEmpty()
+
+ operator fun get(index: Int) = EffectPlacement(storage.get(index))
+
+ fun last() = EffectPlacement(storage.last())
+
+ fun add(element: EffectPlacement) = storage.add(element.value)
+
+ operator fun set(index: Int, element: EffectPlacement) =
+ EffectPlacement(storage.set(index, element.value))
+}
diff --git a/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt
new file mode 100644
index 0000000..81af8a4
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.spring
+
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.SpringSpec
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+
+/** Converts a [SpringSpec] into its [SpringParameters] equivalent. */
+fun SpringParameters(springSpec: SpringSpec) =
+ with(springSpec) { SpringParameters(stiffness, dampingRatio) }
+
+/**
+ * Converts a [FiniteAnimationSpec] from the [MotionScheme] into its [SpringParameters] equivalent.
+ */
+@ExperimentalMaterial3ExpressiveApi
+fun SpringParameters(animationSpec: FiniteAnimationSpec): SpringParameters {
+ check(animationSpec is SpringSpec) {
+ "animationSpec is expected to be a SpringSpec, but is $animationSpec"
+ }
+ return SpringParameters(animationSpec)
+}
+
+@Composable
+fun defaultSpatialSpring(): SpringParameters {
+ return SpringParameters(MaterialTheme.motionScheme.defaultSpatialSpec())
+}
+
+@Composable
+fun defaultEffectSpring(): SpringParameters {
+ return SpringParameters(MaterialTheme.motionScheme.defaultEffectsSpec())
+}
diff --git a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
new file mode 100644
index 0000000..828527a
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2024 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.spring
+
+import androidx.compose.ui.util.fastCoerceIn
+import androidx.compose.ui.util.lerp
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import kotlin.math.pow
+
+/**
+ * Describes the parameters of a spring.
+ *
+ * Note: This is conceptually compatible with the Compose [SpringSpec]. In contrast to the compose
+ * implementation, these [SpringParameters] are intended to be continuously updated.
+ *
+ * @see SpringParameters function to create this value.
+ */
+@JvmInline
+value class SpringParameters(val packedValue: Long) {
+ val stiffness: Float
+ get() = unpackFloat1(packedValue)
+
+ val dampingRatio: Float
+ get() = unpackFloat2(packedValue)
+
+ /** Whether the spring is expected to immediately end movement. */
+ val isSnapSpring: Boolean
+ get() = stiffness >= snapStiffness && dampingRatio == snapDamping
+
+ override fun toString(): String {
+ return "MechanicsSpringSpec(stiffness=$stiffness, dampingRatio=$dampingRatio)"
+ }
+
+ companion object {
+ private val snapStiffness = 100_000f
+ private val snapDamping = 1f
+
+ /** A spring so stiff it completes the motion almost immediately. */
+ val Snap = SpringParameters(snapStiffness, snapDamping)
+ }
+}
+
+/** Creates a [SpringParameters] with the given [stiffness] and [dampingRatio]. */
+fun SpringParameters(stiffness: Float, dampingRatio: Float): SpringParameters {
+ require(stiffness > 0) { "Spring stiffness constant must be positive." }
+ require(dampingRatio >= 0) { "Spring damping constant must be positive." }
+ return SpringParameters(packFloats(stiffness, dampingRatio))
+}
+
+/**
+ * Return interpolated [SpringParameters], based on the [fraction] between [start] and [stop].
+ *
+ * The [SpringParameters.dampingRatio] is interpolated linearly, the [SpringParameters.stiffness] is
+ * interpolated logarithmically.
+ *
+ * The [fraction] is clamped to a `0..1` range.
+ */
+fun lerp(start: SpringParameters, stop: SpringParameters, fraction: Float): SpringParameters {
+ val f = fraction.fastCoerceIn(0f, 1f)
+ val stiffness = start.stiffness.pow(1 - f) * stop.stiffness.pow(f)
+ val dampingRatio = lerp(start.dampingRatio, stop.dampingRatio, f)
+ return SpringParameters(packFloats(stiffness, dampingRatio))
+}
diff --git a/mechanics/src/com/android/mechanics/spring/SpringState.kt b/mechanics/src/com/android/mechanics/spring/SpringState.kt
new file mode 100644
index 0000000..bdf7c33
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/spring/SpringState.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2024 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.spring
+
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import kotlin.math.cos
+import kotlin.math.exp
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Describes the motion state of a spring.
+ *
+ * @see calculateUpdatedState to simulate the springs movement
+ * @see SpringState function to create this value.
+ */
+@JvmInline
+value class SpringState(val packedValue: Long) {
+ val displacement: Float
+ get() = unpackFloat1(packedValue)
+
+ val velocity: Float
+ get() = unpackFloat2(packedValue)
+
+ /**
+ * Whether the state is considered stable.
+ *
+ * The amplitude of the remaining movement, for a spring with [parameters] is less than
+ * [stableThreshold]
+ */
+ fun isStable(parameters: SpringParameters, stableThreshold: Float): Boolean {
+ if (this == AtRest) return true
+ val currentEnergy = parameters.stiffness * displacement * displacement + velocity * velocity
+ val maxStableEnergy = parameters.stiffness * stableThreshold * stableThreshold
+ return currentEnergy <= maxStableEnergy
+ }
+
+ /** Adds the specified [displacementDelta] and [velocityDelta] to the returned state. */
+ fun nudge(displacementDelta: Float = 0f, velocityDelta: Float = 0f): SpringState {
+ return SpringState(displacement + displacementDelta, velocity + velocityDelta)
+ }
+
+ override fun toString(): String {
+ return "MechanicsSpringState(displacement=$displacement, velocity=$velocity)"
+ }
+
+ companion object {
+ /** Spring at rest. */
+ val AtRest = SpringState(displacement = 0f, velocity = 0f)
+ }
+}
+
+/** Creates a [SpringState] given [displacement] and [velocity] */
+fun SpringState(displacement: Float, velocity: Float = 0f) =
+ SpringState(packFloats(displacement, velocity))
+
+/**
+ * Computes the updated [SpringState], after letting the spring with the specified [parameters]
+ * settle for [elapsedNanos].
+ *
+ * This implementation is based on Compose's [SpringSimulation].
+ */
+fun SpringState.calculateUpdatedState(
+ elapsedNanos: Long,
+ parameters: SpringParameters,
+): SpringState {
+ if (parameters.isSnapSpring || this == SpringState.AtRest) {
+ return SpringState.AtRest
+ }
+
+ val stiffness = parameters.stiffness.toDouble()
+ val naturalFreq = sqrt(stiffness)
+
+ val dampingRatio = parameters.dampingRatio
+ val displacement = displacement
+ val velocity = velocity
+ val deltaT = elapsedNanos / 1_000_000_000.0 // unit: seconds
+ val dampingRatioSquared = dampingRatio * dampingRatio.toDouble()
+ val r = -dampingRatio * naturalFreq
+
+ val currentDisplacement: Double
+ val currentVelocity: Double
+
+ if (dampingRatio > 1) {
+ // Over damping
+ val s = naturalFreq * sqrt(dampingRatioSquared - 1)
+ val gammaPlus = r + s
+ val gammaMinus = r - s
+
+ val coeffB = (gammaMinus * displacement - velocity) / (gammaMinus - gammaPlus)
+ val coeffA = displacement - coeffB
+ currentDisplacement = (coeffA * exp(gammaMinus * deltaT) + coeffB * exp(gammaPlus * deltaT))
+ currentVelocity =
+ (coeffA * gammaMinus * exp(gammaMinus * deltaT) +
+ coeffB * gammaPlus * exp(gammaPlus * deltaT))
+ } else if (dampingRatio == 1.0f) {
+ // Critically damped
+ val coeffA = displacement
+ val coeffB = velocity + naturalFreq * displacement
+ val nFdT = -naturalFreq * deltaT
+ currentDisplacement = (coeffA + coeffB * deltaT) * exp(nFdT)
+ currentVelocity =
+ (((coeffA + coeffB * deltaT) * exp(nFdT) * (-naturalFreq)) + coeffB * exp(nFdT))
+ } else {
+ // Underdamped
+ val dampedFreq = naturalFreq * sqrt(1 - dampingRatioSquared)
+ val cosCoeff = displacement
+ val sinCoeff = ((1 / dampedFreq) * (((-r * displacement) + velocity)))
+ val dFdT = dampedFreq * deltaT
+ currentDisplacement = (exp(r * deltaT) * ((cosCoeff * cos(dFdT) + sinCoeff * sin(dFdT))))
+ currentVelocity =
+ (currentDisplacement * r +
+ (exp(r * deltaT) *
+ ((-dampedFreq * cosCoeff * sin(dFdT) + dampedFreq * sinCoeff * cos(dFdT)))))
+ }
+
+ return SpringState(currentDisplacement.toFloat(), currentVelocity.toFloat())
+}
diff --git a/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt
new file mode 100644
index 0000000..140fe75
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.view
+
+import android.content.Context
+import android.view.ViewConfiguration
+import androidx.compose.ui.util.fastForEach
+import com.android.mechanics.spec.InputDirection
+import kotlin.math.max
+import kotlin.math.min
+
+fun interface GestureContextUpdateListener {
+ fun onGestureContextUpdated()
+}
+
+interface ViewGestureContext {
+ val direction: InputDirection
+ val dragOffset: Float
+
+ fun addUpdateCallback(listener: GestureContextUpdateListener)
+
+ fun removeUpdateCallback(listener: GestureContextUpdateListener)
+}
+
+/**
+ * [ViewGestureContext] driven by a gesture distance.
+ *
+ * The direction is determined from the gesture input, where going further than
+ * [directionChangeSlop] in the opposite direction toggles the direction.
+ *
+ * @param initialDragOffset The initial [dragOffset] of the [ViewGestureContext]
+ * @param initialDirection The initial [direction] of the [ViewGestureContext]
+ * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for
+ * the [direction] to flip.
+ */
+class DistanceGestureContext(
+ initialDragOffset: Float,
+ initialDirection: InputDirection,
+ private val directionChangeSlop: Float,
+) : ViewGestureContext {
+ init {
+ require(directionChangeSlop > 0) {
+ "directionChangeSlop must be greater than 0, was $directionChangeSlop"
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun create(
+ context: Context,
+ initialDragOffset: Float = 0f,
+ initialDirection: InputDirection = InputDirection.Max,
+ ): DistanceGestureContext {
+ val directionChangeSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
+ return DistanceGestureContext(initialDragOffset, initialDirection, directionChangeSlop)
+ }
+ }
+
+ private val callbacks = mutableListOf()
+
+ override var dragOffset: Float = initialDragOffset
+ set(value) {
+ if (field == value) return
+
+ field = value
+ direction =
+ when (direction) {
+ InputDirection.Max -> {
+ if (furthestDragOffset - value > directionChangeSlop) {
+ furthestDragOffset = value
+ InputDirection.Min
+ } else {
+ furthestDragOffset = max(value, furthestDragOffset)
+ InputDirection.Max
+ }
+ }
+
+ InputDirection.Min -> {
+ if (value - furthestDragOffset > directionChangeSlop) {
+ furthestDragOffset = value
+ InputDirection.Max
+ } else {
+ furthestDragOffset = min(value, furthestDragOffset)
+ InputDirection.Min
+ }
+ }
+ }
+ invokeCallbacks()
+ }
+
+ override var direction = initialDirection
+ private set
+
+ private var furthestDragOffset = initialDragOffset
+
+ /**
+ * Sets [dragOffset] and [direction] to the specified values.
+ *
+ * This also resets memoized [furthestDragOffset], which is used to determine the direction
+ * change.
+ */
+ fun reset(dragOffset: Float, direction: InputDirection) {
+ this.dragOffset = dragOffset
+ this.direction = direction
+ this.furthestDragOffset = dragOffset
+
+ invokeCallbacks()
+ }
+
+ override fun addUpdateCallback(listener: GestureContextUpdateListener) {
+ callbacks.add(listener)
+ }
+
+ override fun removeUpdateCallback(listener: GestureContextUpdateListener) {
+ callbacks.remove(listener)
+ }
+
+ private fun invokeCallbacks() {
+ callbacks.fastForEach { it.onGestureContextUpdated() }
+ }
+}
diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt
new file mode 100644
index 0000000..5d1a21a
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.view
+
+import android.content.Context
+import androidx.compose.ui.unit.Density
+import com.android.mechanics.spec.builder.MaterialSprings
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.view.ViewMaterialSprings.Default
+
+/**
+ * Creates a [MotionBuilderContext] using the **standard** motion spec.
+ *
+ * See go/motion-system.
+ *
+ * @param context The context to derive the density from.
+ */
+fun standardViewMotionBuilderContext(context: Context): MotionBuilderContext {
+ return standardViewMotionBuilderContext(context.resources.displayMetrics.density)
+}
+
+/**
+ * Creates a [MotionBuilderContext] using the **standard** motion spec.
+ *
+ * See go/motion-system.
+ *
+ * @param density The density of the display, as a scaling factor for the dp to px conversion.
+ */
+fun standardViewMotionBuilderContext(density: Float): MotionBuilderContext {
+ return with(ViewMaterialSprings.Default) {
+ ViewMotionBuilderContext(Spatial, Effects, Density(density))
+ }
+}
+
+/**
+ * Creates a [MotionBuilderContext] using the **expressive** motion spec.
+ *
+ * See go/motion-system.
+ *
+ * @param context The context to derive the density from.
+ */
+fun expressiveViewMotionBuilderContext(context: Context): MotionBuilderContext {
+ return expressiveViewMotionBuilderContext(context.resources.displayMetrics.density)
+}
+
+/**
+ * Creates a [MotionBuilderContext] using the **expressive** motion spec.
+ *
+ * See go/motion-system.
+ *
+ * @param density The density of the display, as a scaling factor for the dp to px conversion.
+ */
+fun expressiveViewMotionBuilderContext(density: Float): MotionBuilderContext {
+ return with(ViewMaterialSprings.Expressive) {
+ ViewMotionBuilderContext(Spatial, Effects, Density(density))
+ }
+}
+
+/**
+ * Material motion system spring definitions.
+ *
+ * See go/motion-system.
+ *
+ * NOTE: These are only defined here since material spring parameters are not available for View
+ * based APIs. There might be a delay in updating these values, should the material tokens be
+ * updated in the future.
+ *
+ * @see rememberMotionBuilderContext for Compose
+ */
+object ViewMaterialSprings {
+ object Default {
+ val Spatial =
+ MaterialSprings(
+ SpringParameters(700.0f, 0.9f),
+ SpringParameters(1400.0f, 0.9f),
+ SpringParameters(300.0f, 0.9f),
+ MotionBuilderContext.StableThresholdSpatial,
+ )
+
+ val Effects =
+ MaterialSprings(
+ SpringParameters(1600.0f, 1.0f),
+ SpringParameters(3800.0f, 1.0f),
+ SpringParameters(800.0f, 1.0f),
+ MotionBuilderContext.StableThresholdEffects,
+ )
+ }
+
+ object Expressive {
+ val Spatial =
+ MaterialSprings(
+ SpringParameters(380.0f, 0.8f),
+ SpringParameters(800.0f, 0.6f),
+ SpringParameters(200.0f, 0.8f),
+ MotionBuilderContext.StableThresholdSpatial,
+ )
+
+ val Effects =
+ MaterialSprings(
+ SpringParameters(1600.0f, 1.0f),
+ SpringParameters(3800.0f, 1.0f),
+ SpringParameters(800.0f, 1.0f),
+ MotionBuilderContext.StableThresholdEffects,
+ )
+ }
+}
+
+internal class ViewMotionBuilderContext(
+ override val spatial: MaterialSprings,
+ override val effects: MaterialSprings,
+ density: Density,
+) : MotionBuilderContext, Density by density
diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt
new file mode 100644
index 0000000..617e363
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt
@@ -0,0 +1,342 @@
+/*
+ * 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.view
+
+import android.animation.ValueAnimator
+import androidx.compose.ui.util.fastForEach
+import com.android.mechanics.MotionValue.Companion.StableThresholdEffect
+import com.android.mechanics.debug.DebugInspector
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.impl.Computations
+import com.android.mechanics.impl.DiscontinuityAnimation
+import com.android.mechanics.impl.GuaranteeState
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spring.SpringState
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.DisposableHandle
+
+/** Observe MotionValue output changes. */
+fun interface ViewMotionValueListener {
+ /** Invoked whenever the ViewMotionValue computed a new output. */
+ fun onMotionValueUpdated(motionValue: ViewMotionValue)
+}
+
+/**
+ * [MotionValue] implementation for View-based UIs.
+ *
+ * See the documentation of [MotionValue].
+ */
+class ViewMotionValue
+@JvmOverloads
+constructor(
+ initialInput: Float,
+ gestureContext: ViewGestureContext,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ label: String? = null,
+ stableThreshold: Float = StableThresholdEffect,
+) : DisposableHandle {
+
+ private val impl =
+ ImperativeComputations(
+ this,
+ initialInput,
+ gestureContext,
+ initialSpec,
+ stableThreshold,
+ label,
+ )
+
+ var input: Float by impl::currentInput
+
+ var spec: MotionSpec by impl::spec
+
+ /** Animated [output] value. */
+ val output: Float by impl::output
+
+ /**
+ * [output] value, but without animations.
+ *
+ * This value always reports the target value, even before a animation is finished.
+ *
+ * While [isStable], [outputTarget] and [output] are the same value.
+ */
+ val outputTarget: Float by impl::outputTarget
+
+ /** Whether an animation is currently running. */
+ val isStable: Boolean by impl::isStable
+
+ /**
+ * The current value for the [SemanticKey].
+ *
+ * `null` if not defined in the spec.
+ */
+ operator fun get(key: SemanticKey): T? {
+ return impl.semanticState(key)
+ }
+
+ /** The current segment used to compute the output. */
+ val segmentKey: SegmentKey
+ get() = impl.currentComputedValues.segment.key
+
+ val label: String? by impl::label
+
+ fun addUpdateCallback(listener: ViewMotionValueListener) {
+ check(impl.isActive)
+ impl.listeners.add(listener)
+ }
+
+ fun removeUpdateCallback(listener: ViewMotionValueListener) {
+ impl.listeners.remove(listener)
+ }
+
+ override fun dispose() {
+ impl.dispose()
+ }
+
+ companion object {
+ internal const val TAG = "ViewMotionValue"
+ }
+
+ private var debugInspectorRefCount = AtomicInteger(0)
+
+ private fun onDisposeDebugInspector() {
+ if (debugInspectorRefCount.decrementAndGet() == 0) {
+ impl.debugInspector = null
+ }
+ }
+
+ /**
+ * Provides access to internal state for debug tooling and tests.
+ *
+ * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
+ */
+ fun debugInspector(): DebugInspector {
+ if (debugInspectorRefCount.getAndIncrement() == 0) {
+ impl.debugInspector =
+ DebugInspector(
+ FrameData(
+ impl.lastInput,
+ impl.lastSegment.direction,
+ impl.lastGestureDragOffset,
+ impl.lastFrameTimeNanos,
+ impl.lastSpringState,
+ impl.lastSegment,
+ impl.lastAnimation,
+ ),
+ impl.isActive,
+ impl.animationFrameDriver.isRunning,
+ ::onDisposeDebugInspector,
+ )
+ }
+
+ return checkNotNull(impl.debugInspector)
+ }
+}
+
+private class ImperativeComputations(
+ private val motionValue: ViewMotionValue,
+ initialInput: Float,
+ val gestureContext: ViewGestureContext,
+ initialSpec: MotionSpec,
+ override val stableThreshold: Float,
+ override val label: String?,
+) : Computations(), GestureContextUpdateListener {
+
+ init {
+ gestureContext.addUpdateCallback(this)
+ }
+
+ override fun onGestureContextUpdated() {
+ ensureFrameRequested()
+ }
+
+ // ---- CurrentFrameInput ---------------------------------------------------------------------
+
+ override var spec: MotionSpec = initialSpec
+ set(value) {
+ if (field != value) {
+ field = value
+ ensureFrameRequested()
+ }
+ }
+
+ override var currentInput: Float = initialInput
+ set(value) {
+ if (field != value) {
+ field = value
+ ensureFrameRequested()
+ }
+ }
+
+ override val currentDirection: InputDirection
+ get() = gestureContext.direction
+
+ override val currentGestureDragOffset: Float
+ get() = gestureContext.dragOffset
+
+ override var currentAnimationTimeNanos: Long = -1L
+
+ // ---- LastFrameState ---------------------------------------------------------------------
+
+ override var lastSegment: SegmentData = spec.segmentAtInput(currentInput, currentDirection)
+ override var lastGuaranteeState: GuaranteeState = GuaranteeState.Inactive
+ override var lastAnimation: DiscontinuityAnimation = DiscontinuityAnimation.None
+ override var lastSpringState: SpringState = lastAnimation.springStartState
+ override var lastFrameTimeNanos: Long = -1L
+ override var lastInput: Float = currentInput
+ override var lastGestureDragOffset: Float = currentGestureDragOffset
+ override var directMappedVelocity: Float = 0f
+ var lastDirection: InputDirection = currentDirection
+
+ // ---- Lifecycle ------------------------------------------------------------------------------
+
+ // HACK: Use a ValueAnimator to listen to animation frames without using Choreographer directly.
+ // This is done solely for testability - because the AnimationHandler is not usable directly[1],
+ // this resumes/pauses a - for all practical purposes - infinite animation.
+ //
+ // [1] the android one is hidden API, the androidx one is package private, and the
+ // dynamicanimation one is not controllable from tests).
+ val animationFrameDriver =
+ ValueAnimator().apply {
+ setFloatValues(Float.MIN_VALUE, Float.MAX_VALUE)
+ duration = Long.MAX_VALUE
+ repeatMode = ValueAnimator.RESTART
+ repeatCount = ValueAnimator.INFINITE
+ start()
+ pause()
+ addUpdateListener {
+ val isAnimationFinished = updateOutputValue(currentPlayTime)
+ if (isAnimationFinished) {
+ pause()
+ }
+ }
+ }
+
+ fun ensureFrameRequested() {
+ if (animationFrameDriver.isPaused) {
+ animationFrameDriver.resume()
+ debugInspector?.isAnimating = true
+ }
+ }
+
+ fun pauseFrameRequests() {
+ if (animationFrameDriver.isRunning) {
+ animationFrameDriver.pause()
+ debugInspector?.isAnimating = false
+ }
+ }
+
+ /** `true` until disposed with [MotionValue.dispose]. */
+ var isActive = true
+ set(value) {
+ field = value
+ debugInspector?.isActive = value
+ }
+
+ var debugInspector: DebugInspector? = null
+
+ val listeners = mutableListOf()
+
+ fun dispose() {
+ check(isActive) { "ViewMotionValue[$label] is already disposed" }
+ pauseFrameRequests()
+ animationFrameDriver.end()
+ isActive = false
+ listeners.clear()
+ }
+
+ // indicates whether doAnimationFrame is called continuously (as opposed to being
+ // suspended for an undetermined amount of time in between frames).
+ var isAnimatingUninterrupted = false
+
+ fun updateOutputValue(frameTimeMillis: Long): Boolean {
+ check(isActive) { "ViewMotionValue($label) is already disposed." }
+
+ currentAnimationTimeNanos = frameTimeMillis * 1_000_000L
+
+ // Read currentComputedValues only once and update it, if necessary
+ val currentValues = currentComputedValues
+
+ debugInspector?.run {
+ frame =
+ FrameData(
+ currentInput,
+ currentDirection,
+ currentGestureDragOffset,
+ currentAnimationTimeNanos,
+ currentSpringState,
+ currentValues.segment,
+ currentValues.animation,
+ )
+ }
+
+ listeners.fastForEach { it.onMotionValueUpdated(motionValue) }
+
+ // Prepare last* state
+ if (isAnimatingUninterrupted) {
+ directMappedVelocity =
+ computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos)
+ } else {
+ directMappedVelocity = 0f
+ }
+
+ var isAnimationFinished = isStable
+ if (lastSegment != currentValues.segment) {
+ lastSegment = currentValues.segment
+ isAnimationFinished = false
+ }
+
+ if (lastGuaranteeState != currentValues.guarantee) {
+ lastGuaranteeState = currentValues.guarantee
+ isAnimationFinished = false
+ }
+
+ if (lastAnimation != currentValues.animation) {
+ lastAnimation = currentValues.animation
+ isAnimationFinished = false
+ }
+
+ if (lastSpringState != currentSpringState) {
+ lastSpringState = currentSpringState
+ isAnimationFinished = false
+ }
+
+ if (lastInput != currentInput) {
+ lastInput = currentInput
+ isAnimationFinished = false
+ }
+
+ if (lastGestureDragOffset != currentGestureDragOffset) {
+ lastGestureDragOffset = currentGestureDragOffset
+ isAnimationFinished = false
+ }
+
+ if (lastDirection != currentDirection) {
+ lastDirection = currentDirection
+ isAnimationFinished = false
+ }
+
+ lastFrameTimeNanos = currentAnimationTimeNanos
+ isAnimatingUninterrupted = !isAnimationFinished
+
+ return isAnimationFinished
+ }
+}
diff --git a/mechanics/testing/Android.bp b/mechanics/testing/Android.bp
new file mode 100644
index 0000000..ac49d16
--- /dev/null
+++ b/mechanics/testing/Android.bp
@@ -0,0 +1,37 @@
+// 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 {
+ default_applicable_licenses: ["Android-Apache-2.0"],
+ default_team: "trendy_team_motion",
+}
+
+android_library {
+ name: "mechanics-testing",
+ manifest: "AndroidManifest.xml",
+ srcs: [
+ "src/**/*.kt",
+ ],
+ static_libs: [
+ "//frameworks/libs/systemui/mechanics:mechanics",
+ "platform-test-annotations",
+ "PlatformMotionTestingCompose",
+ "androidx.compose.runtime_runtime",
+ "androidx.compose.ui_ui-test-junit4",
+ "testables",
+ "truth",
+ ],
+ kotlincflags: ["-Xjvm-default=all"],
+}
diff --git a/mechanics/testing/AndroidManifest.xml b/mechanics/testing/AndroidManifest.xml
new file mode 100644
index 0000000..20c40b0
--- /dev/null
+++ b/mechanics/testing/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt
new file mode 100644
index 0000000..0144a16
--- /dev/null
+++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2024 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(ExperimentalCoroutinesApi::class)
+
+package com.android.mechanics.testing
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.Snapshot
+import com.android.mechanics.DistanceGestureContext
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.MotionSpec
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import platform.test.motion.MotionTestRule
+import platform.test.motion.compose.runMonotonicClockTest
+import platform.test.motion.golden.FrameId
+import platform.test.motion.golden.TimeSeries
+import platform.test.motion.golden.TimestampFrameId
+
+/** Toolkit to support [MotionValue] motion tests. */
+data object ComposeMotionValueToolkit : MotionValueToolkit() {
+
+ override fun goldenTest(
+ motionTestRule: MotionTestRule<*>,
+ spec: MotionSpec,
+ createDerived: (underTest: MotionValue) -> List,
+ initialValue: Float,
+ initialDirection: InputDirection,
+ directionChangeSlop: Float,
+ stableThreshold: Float,
+ verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult,
+ capture: CaptureTimeSeriesFn,
+ testInput: suspend InputScope.() -> Unit,
+ ) = runMonotonicClockTest {
+ val frameEmitter = MutableStateFlow(0)
+
+ val testHarness =
+ ComposeMotionValueTestHarness(
+ initialValue,
+ initialDirection,
+ spec,
+ stableThreshold,
+ directionChangeSlop,
+ frameEmitter.asStateFlow(),
+ createDerived,
+ )
+ val underTest = testHarness.underTest
+ val derived = testHarness.derived
+
+ val motionValueCaptures = buildList {
+ add(MotionValueCapture(underTest.debugInspector()))
+ derived.forEach { add(MotionValueCapture(it.debugInspector(), "${it.label}-")) }
+ }
+
+ val keepRunningJobs = (derived + underTest).map { launch { it.keepRunning() } }
+
+ val recordingJob = launch { testInput.invoke(testHarness) }
+
+ val frameIds = mutableListOf()
+
+ fun recordFrame(frameId: TimestampFrameId) {
+ frameIds.add(frameId)
+ motionValueCaptures.forEach { it.captureCurrentFrame(capture) }
+ }
+ runBlocking(Dispatchers.Main) {
+ val startFrameTime = testScheduler.currentTime
+ while (!recordingJob.isCompleted) {
+ recordFrame(TimestampFrameId(testScheduler.currentTime - startFrameTime))
+
+ // Emulate setting input *before* the frame advances. This ensures the `testInput`
+ // coroutine will continue if needed. The specific value for frameEmitter is
+ // irrelevant, it only requires to be unique per frame.
+ frameEmitter.tryEmit(testScheduler.currentTime)
+ testScheduler.runCurrent()
+ // Whenever keepRunning was suspended, allow the snapshotFlow to wake up
+ Snapshot.sendApplyNotifications()
+
+ // Now advance the test clock
+ testScheduler.advanceTimeBy(FrameDuration)
+ // Since the tests capture the debugInspector output, make sure keepRunning()
+ // was able to complete the frame.
+ testScheduler.runCurrent()
+ }
+ }
+
+ val timeSeries = createTimeSeries(frameIds, motionValueCaptures)
+ motionValueCaptures.forEach { it.debugger.dispose() }
+ keepRunningJobs.forEach { it.cancel() }
+ verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries)
+ }
+}
+
+private class ComposeMotionValueTestHarness(
+ initialInput: Float,
+ initialDirection: InputDirection,
+ spec: MotionSpec,
+ stableThreshold: Float,
+ directionChangeSlop: Float,
+ val onFrame: StateFlow,
+ createDerived: (underTest: MotionValue) -> List,
+) : InputScope {
+
+ override var input by mutableFloatStateOf(initialInput)
+ override val gestureContext: DistanceGestureContext =
+ DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)
+
+ override val underTest =
+ MotionValue(
+ { input },
+ gestureContext,
+ stableThreshold = stableThreshold,
+ initialSpec = spec,
+ )
+
+ val derived = createDerived(underTest)
+
+ override fun updateInput(value: Float) {
+ input = value
+ gestureContext.dragOffset = value
+ }
+
+ override suspend fun awaitStable() {
+ val debugInspectors = buildList {
+ add(underTest.debugInspector())
+ addAll(derived.map { it.debugInspector() })
+ }
+ try {
+
+ onFrame
+ // Since this is a state-flow, the current frame is counted too.
+ .drop(1)
+ .takeWhile { debugInspectors.any { !it.frame.isStable } }
+ .collect {}
+ } finally {
+ debugInspectors.forEach { it.dispose() }
+ }
+ }
+
+ override suspend fun awaitFrames(frames: Int) {
+ onFrame
+ // Since this is a state-flow, the current frame is counted too.
+ .drop(1)
+ .take(frames)
+ .collect {}
+ }
+
+ override fun reset(position: Float, direction: InputDirection) {
+ input = position
+ gestureContext.reset(position, direction)
+ }
+}
diff --git a/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt
new file mode 100644
index 0000000..013a0dd
--- /dev/null
+++ b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2024 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.testing
+
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import com.android.mechanics.testing.DataPointTypes.springParameters
+import com.android.mechanics.testing.DataPointTypes.springState
+import org.json.JSONObject
+import platform.test.motion.golden.DataPointType
+import platform.test.motion.golden.UnknownTypeException
+
+fun SpringParameters.asDataPoint() = springParameters.makeDataPoint(this)
+
+fun SpringState.asDataPoint() = springState.makeDataPoint(this)
+
+object DataPointTypes {
+ val springParameters: DataPointType =
+ DataPointType(
+ "springParameters",
+ jsonToValue = {
+ with(it as? JSONObject ?: throw UnknownTypeException()) {
+ SpringParameters(
+ getDouble("stiffness").toFloat(),
+ getDouble("dampingRatio").toFloat(),
+ )
+ }
+ },
+ valueToJson = {
+ JSONObject().apply {
+ put("stiffness", it.stiffness)
+ put("dampingRatio", it.dampingRatio)
+ }
+ },
+ )
+
+ val springState: DataPointType =
+ DataPointType(
+ "springState",
+ jsonToValue = {
+ with(it as? JSONObject ?: throw UnknownTypeException()) {
+ SpringState(
+ getDouble("displacement").toFloat(),
+ getDouble("velocity").toFloat(),
+ )
+ }
+ },
+ valueToJson = {
+ JSONObject().apply {
+ put("displacement", it.displacement)
+ put("velocity", it.velocity)
+ }
+ },
+ )
+}
diff --git a/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt
new file mode 100644
index 0000000..93855f4
--- /dev/null
+++ b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.testing
+
+import androidx.compose.ui.unit.Density
+import com.android.mechanics.spec.builder.MaterialSprings
+import com.android.mechanics.spec.builder.MotionBuilderContext
+import com.android.mechanics.spring.SpringParameters
+
+/**
+ * [MotionBuilderContext] implementation for unit tests.
+ *
+ * Only use when the specifics of the spring parameters do not matter for the test.
+ *
+ * While the values are copied from the current material motion tokens, this can (and likely will)
+ * get out of sync with the material tokens, and is not intended reflect the up-to-date tokens, but
+ * provide a stable definitions of "some" spring parameters.
+ */
+class FakeMotionSpecBuilderContext(density: Float = 1f) :
+ MotionBuilderContext, Density by Density(density) {
+ override val spatial =
+ MaterialSprings(
+ SpringParameters(700.0f, 0.9f),
+ SpringParameters(1400.0f, 0.9f),
+ SpringParameters(300.0f, 0.9f),
+ MotionBuilderContext.StableThresholdSpatial,
+ )
+
+ override val effects =
+ MaterialSprings(
+ SpringParameters(1600.0f, 1.0f),
+ SpringParameters(3800.0f, 1.0f),
+ SpringParameters(800.0f, 1.0f),
+ MotionBuilderContext.StableThresholdEffects,
+ )
+
+ companion object {
+ val Default = FakeMotionSpecBuilderContext()
+ }
+}
diff --git a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt
new file mode 100644
index 0000000..d8ef1cf
--- /dev/null
+++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.testing
+
+import com.android.mechanics.debug.DebugInspector
+import com.android.mechanics.spec.SemanticKey
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import platform.test.motion.golden.DataPointType
+import platform.test.motion.golden.FeatureCapture
+import platform.test.motion.golden.asDataPoint
+
+/** Feature captures on MotionValue's [DebugInspector] */
+object FeatureCaptures {
+ /** Input value of the current frame. */
+ val input = FeatureCapture("input") { it.frame.input.asDataPoint() }
+
+ /** Gesture direction of the current frame. */
+ val gestureDirection =
+ FeatureCapture("gestureDirection") {
+ it.frame.gestureDirection.name.asDataPoint()
+ }
+
+ /** Animated output value of the current frame. */
+ val output = FeatureCapture("output") { it.frame.output.asDataPoint() }
+
+ /** Output target value of the current frame. */
+ val outputTarget =
+ FeatureCapture("outputTarget") {
+ it.frame.outputTarget.asDataPoint()
+ }
+
+ /** Spring parameters currently in use. */
+ val springParameters =
+ FeatureCapture("springParameters") {
+ it.frame.springParameters.asDataPoint()
+ }
+
+ /** Spring state currently in use. */
+ val springState =
+ FeatureCapture