Add Arc based AnimationSpec
Introduces animation using arcs of a quarter of an Ellipse.
- ArcAnimationSpec for single shot animations
- `using(ArcMode)` in `keyframes` for key-frame based animations
These interpolators are designed to be used with 2-dimensional values.
Particularly, for positional values such as Offset.
The interpolator supports multiple modes:
- ArcAbove: The arc corresponds to the curve of a quarter of an
Ellipse where the curve is above the center of the Ellipse.
- ArcBelow: The arc corresponds to the curve of a quarter of an
Ellipse where the curve is below the center of the Ellipse.
- ArcLinear: At typical linear interpolation. This is only expected
to be used in keyframes, where the user might want to avoid arcs in a
segment of the animation.
While it may be used with other N-dimensional values, its use is
discouraged since it may lead to unpredictable animation curves. The
lint check `ArcAnimationSpecTypeDetector` addresses that. It's an
Informational Lint check that verifies that the calling type of
`ArcAnimationSpec` is one of: Offset, IntOffset
or DpOffset.
See ArcOffsetDemo.kt for a simplified use-case using keyframes using
ArcMode.
Relnote: "You may now use `ArcAnimationSpec` and `using(arcMode:
ArcMode)` (in keyframes) to
interpolate positional values such as Offset using arcs of quarter of an
Ellipse."
Bug: 299477780
Test: ArcAnimationTest, ArcKeyframeAnimationTest
Change-Id: I154c403a5685f1146c5157738b4331967a7d77f7
diff --git a/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/AnimationCoreIssueRegistry.kt b/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/AnimationCoreIssueRegistry.kt
index 7dce8cf..a73e703 100644
--- a/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/AnimationCoreIssueRegistry.kt
+++ b/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/AnimationCoreIssueRegistry.kt
@@ -29,7 +29,8 @@
override val minApi = CURRENT_API
override val issues get() = listOf(
TransitionDetector.UnusedTransitionTargetStateParameter,
- UnrememberedAnimatableDetector.UnrememberedAnimatable
+ UnrememberedAnimatableDetector.UnrememberedAnimatable,
+ ArcAnimationSpecTypeDetector.ArcAnimationSpecTypeIssue
)
override val vendor = Vendor(
vendorName = "Jetpack Compose",
diff --git a/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetector.kt b/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetector.kt
new file mode 100644
index 0000000..cfdaf85
--- /dev/null
+++ b/compose/animation/animation-core-lint/src/main/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetector.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2023 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:Suppress("UnstableApiUsage")
+
+package androidx.compose.animation.core.lint
+
+import com.android.tools.lint.client.api.UElementHandler
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.UastLintUtils.Companion.tryResolveUDeclaration
+import java.util.EnumSet
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.UClass
+private const val ANIMATION_CORE_PACKAGE = "androidx.compose.animation.core"
+private const val GEOMETRY_PACKAGE = "androidx.compose.ui.geometry"
+private const val UNIT_PACKAGE = "androidx.compose.ui.unit"
+private const val ARC_ANIMATION_SPEC_NAME = "ArcAnimationSpec"
+private const val ARC_KEYFRAMES_SPEC_NAME = "keyframesWithArcs"
+private const val OFFSET_NAME = "Offset"
+private const val INT_OFFSET_NAME = "IntOffset"
+private const val DP_OFFSET_NAME = "DpOffset"
+private const val ARC_SPEC_FQ_NAME =
+ "$ANIMATION_CORE_PACKAGE.$ARC_ANIMATION_SPEC_NAME"
+private const val OFFSET_FQ_NAME =
+ "$GEOMETRY_PACKAGE.$OFFSET_NAME"
+private const val INT_OFFSET_FQ_NAME =
+ "$UNIT_PACKAGE.$INT_OFFSET_NAME"
+private const val DP_OFFSET_FQ_NAME =
+ "$UNIT_PACKAGE.$DP_OFFSET_NAME"
+private val preferredArcAnimationTypes by lazy(LazyThreadSafetyMode.NONE) {
+ setOf(
+ OFFSET_FQ_NAME,
+ INT_OFFSET_FQ_NAME,
+ DP_OFFSET_FQ_NAME
+ )
+}
+/**
+ * Lint to inform of the expected usage for `ArcAnimationSpec` (and its derivative)
+ * `keyframesWithArcs`.
+ */
+class ArcAnimationSpecTypeDetector : Detector(), SourceCodeScanner {
+ override fun getApplicableUastTypes() = listOf(UCallExpression::class.java)
+ override fun createUastHandler(context: JavaContext) = object : UElementHandler() {
+ override fun visitCallExpression(node: UCallExpression) {
+ when (node.classReference?.resolvedName) {
+ ARC_ANIMATION_SPEC_NAME -> detectTypeParameterInArcAnimation(node)
+ }
+ }
+ private fun detectTypeParameterInArcAnimation(node: UCallExpression) {
+ val typeArg = node.typeArguments.firstOrNull() ?: return
+ val qualifiedTypeName = typeArg.canonicalText
+ // Check that the given type to the call is one of: Offset, IntOffset, DpOffset
+ if (preferredArcAnimationTypes.contains(qualifiedTypeName)) {
+ return
+ }
+ // Node class resolution might be slower, do last
+ val fqClassName =
+ (node.classReference?.tryResolveUDeclaration() as? UClass)?.qualifiedName
+ // Verify that the method calls are from the expected animation classes, otherwise, skip
+ // check
+ if (fqClassName != ARC_SPEC_FQ_NAME) {
+ return
+ }
+ // Generate Lint
+ context.report(
+ issue = ArcAnimationSpecTypeIssue,
+ scope = node,
+ location = context.getNameLocation(node),
+ message = "Arc animation is intended for 2D values such as Offset, IntOffset or " +
+ "DpOffset.\nOtherwise, the animation might not be what you expect."
+ )
+ }
+ }
+ companion object {
+ val ArcAnimationSpecTypeIssue = Issue.create(
+ id = "ArcAnimationSpecTypeIssue",
+ briefDescription = "$ARC_ANIMATION_SPEC_NAME is " +
+ "designed for 2D values. Particularly, for positional values such as Offset.",
+ explanation = "$ARC_ANIMATION_SPEC_NAME is designed for" +
+ " 2D values. Particularly, for positional values such as Offset.\nTrying to use " +
+ "it for values of different dimensions (Float, Size, Color, etc.) will result " +
+ "in unpredictable animation behavior.",
+ category = Category.CORRECTNESS,
+ priority = 5,
+ severity = Severity.INFORMATIONAL,
+ implementation = Implementation(
+ ArcAnimationSpecTypeDetector::class.java,
+ EnumSet.of(Scope.JAVA_FILE)
+ )
+ )
+ }
+}
diff --git a/compose/animation/animation-core-lint/src/test/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetectorTest.kt b/compose/animation/animation-core-lint/src/test/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetectorTest.kt
new file mode 100644
index 0000000..fda5093
--- /dev/null
+++ b/compose/animation/animation-core-lint/src/test/java/androidx/compose/animation/core/lint/ArcAnimationSpecTypeDetectorTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2023 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 androidx.compose.animation.core.lint
+
+import androidx.compose.lint.test.bytecodeStub
+import com.android.tools.lint.checks.infrastructure.LintDetectorTest
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Issue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Detector to discourage the use of arc-based animations on types other than the specified (known
+ * 2-dimensional types such as Offset, IntOffset, DpOffset).
+ *
+ * TODO(b/299477780): Support detecting usages on keyframes. Note that it would only apply to usages
+ * of `KeyframeEntity<T>.using(arcMode: ArcMode)` where arc mode is ArcAbove/ArcBelow.
+ */
+@RunWith(JUnit4::class)
+class ArcAnimationSpecTypeDetectorTest : LintDetectorTest() {
+ override fun getDetector(): Detector = ArcAnimationSpecTypeDetector()
+
+ override fun getIssues(): MutableList<Issue> =
+ mutableListOf(ArcAnimationSpecTypeDetector.ArcAnimationSpecTypeIssue)
+
+ // Simplified version of Arc animation classes in AnimationSpec.kt
+ private val ArcAnimationSpecStub = bytecodeStub(
+ filename = "AnimationSpec.kt",
+ filepath = "androidx/compose/animation/core",
+ checksum = 0x9d0cdf8f,
+ source = """
+ package androidx.compose.animation.core
+
+ class ArcAnimationSpec<T>(val mode: ArcMode, val durationMillis: Int = 400)
+
+ sealed class ArcMode
+
+ object ArcAbove : ArcMode()
+ """,
+ """
+ META-INF/main.kotlin_module:
+ H4sIAAAAAAAA/2NgYGBmYGBgBGJOBijg4uViTsvPF2ILSS0u8S5RYtBiAACf
+ q36HJwAAAA==
+ """,
+ """
+ androidx/compose/animation/core/ArcAbove.class:
+ H4sIAAAAAAAA/41SS2/TQBD+1knzhqbllVDe5ZH0gJuKWyukEECylORAqkio
+ p42zwDb2brXeRD3mxA/hH1QcKoGEIrjxoxCzJoUDl9jyzM4333yzM/LPX1++
+ AXiGxwwNrsZGy/GpH+r4RCfC50rG3EqtCDHCb5uwPdIzkQdjeLICu6fHRM4w
+ 5A6kkvY5Q6bRHDK0Gt2JtpFU/vEs9qWywige+S/FOz6NbEerxJppaLXpcTMR
+ Zr85rGAN+RKyKDBk7QeZMOx0V73vPkPhIIzSKzihXAkeLhEY9AeH7X7nVQXr
+ KBcJrDJsd7V57x8LOzJcqoQ0lbapaOL3te1Po4j0Ni4G6AnLx9xywrx4lqFd
+ MmeKzoCBTQg/lS7apdO4RQ0W81LJq3npt5gXfnz0aov5nrfLXuQL3vdPOa/q
+ OeoeQ3OVEd2SqXu1fZEZnIjw6cQybL2ZKitjEaiZTOQoEu1/s9AaO1TIsN6V
+ SvSn8UiYQ04chs2uDnk05Ea6eAmWBnpqQvFauqC+FB7+J4sWbTGbjl53SyV/
+ j6Ic+U3yHr1raXTfbYQi5rI77BzFszT/YMkGCtgmW/nDQIm0HFb5W32D2O4p
+ f4X39hyXP2PjLAU8PEztXTxKf22GK9T06hEyAa4FuB5QaY2OqAe4ia0jsAS3
+ cJvyCcoJ7iTI/QY7flqdFwMAAA==
+ """,
+ """
+ androidx/compose/animation/core/ArcAnimationSpec.class:
+ H4sIAAAAAAAA/5VSS28TVxT+7vg1HgyMXUKCCRQID8cujONCH5gipSCkkeyA
+ cJRNurkZ35obj2eiudcRqyo/odtuWbMACUTVRRV12R9V9Vx7EvLowt3c87jf
+ Oec7j7//+eNPAPfRZmjyqJ/Esv/aC+LRTqyExyM54lrGEXkS4a0mweqBp7cj
+ ggIYQ+3R+sPONt/lXsijgfd8a1sEuv34tIvBPekrIMuQfyQjqR8z3K11ZmDQ
+ jfui7S9vMCx14mTgbQu9lXAZKcJGsZ6AlbcW67VxGFLR7IgCbBQZrg5jHcrI
+ 294deTLSIol46PmRTihYBqqAMwxzwSsRDNPoFzzhI0FAhju10w0d8fRMkkF7
+ eaOEszjnoITzDJmasfMoO8ihwrA8c3slFHGhCAtzDOf642QC6cowlIqB+SXM
+ Y8F8X6L29CvjbM2S+9jyaDQ//Y+B+53/mt5T8TMfh/oJjVwn40DHSZcnQ5G0
+ p50XHCJ5laEwENqkYWjUZp8CQ5ninp5on6bq088Bna7QvM81J7Q12s3QLTPz
+ FM0DGtaQ/K+lsZqk9VcY1P5e1bEWLGd/z7Fceow8MG3HsnML+3v1rL2/57KW
+ 1bR+nK/k3UzVamYrtm25OdLyf73JW27hZfnQsim8mrVtt0jOCfiz03HPmNIt
+ YrPODCn32CLuDTXD5ZfjSMuR8KNdqeRWKFY/HzMt+clkeOc7MhJr49GWSNY5
+ YRgqnTjg4QZPpLFT582TuQ7v+FjSsz3Ng2GX76RhxZ4cRFyPE9KdXjxOAvFM
+ mo9Lab6NU8ywQvvN0WwtVMzJUm8tsvIkbZIVc6cks2TTMRDqa7J6JM0+5hoV
+ 53e49U/4ot74iIv1xY+ovp8ku58myVPoA9KvTQNwGYtmraRNixnNlLDwjdm5
+ ldaFa0KvkGXqtSjYcCxfyf3yGwpl9usP9cbiJ3w5rfUtvRkw57Co4VumktdT
+ vp45I5K5+gdcfHeMH1J+pSkg5Xd0BGXcwFJK5Gii6tsZEmXw3QSVwfcTuYKH
+ JJ8T5iZhbm0i4+O2jzs+algmFXUfDXy1CaZwF/c2UVJYVPAUmgpFhQsK8xO9
+ oHBDYUnhmsL1fwEMDsmpAwYAAA==
+ """,
+ """
+ androidx/compose/animation/core/ArcMode.class:
+ H4sIAAAAAAAA/5VRXWsTQRQ9s9lu0jW229aP1O+KYFOx2xbRh4oQK0IgUbCS
+ lzzIZDPqJLszZXY29DH4U/wHfRJ8kNBHf5R4Z5Pia4Xlfpwz596Zs7///PwF
+ 4BkeMjzmami0HJ7Gic5OdC5irmTGrdSKECPilkm6eiiqYAzRiE94nHL1JX4/
+ GInEVlFhCF5KJe0rhsp2s1fHEoIQPqoMvv0qc4Zm55I7Dhn2tztjbVOp4tEk
+ i6Wywiiexm/EZ16k9kir3Joisdp0uRkLc9jshfDcro1HyT/yU1ayDLv/N41h
+ 7ULQFZYPueWEedmkQnYxF5ZdAAMbE34qXbdH1XCf4clsuhJ6DS/0otk0pI/q
+ 2vPGbHrg7bHX1Zp//j3wIu/8G2MVJzlgbtDOZcxpDfTEuRO1LqjjE5Hsji15
+ fETGMax2pBLvimwgzEc+SAlZ7+iEpz1upOsXYHisC5OIt9I1mx8KZWUmejKX
+ xLaU0rYcnvtbZKrvXkrZc3+UbnqXutg9nfLSzg/Uzkr6HsWgBAPcp1ifH8Ay
+ QiBiVF1ZiJ9S9hbi+llpoxPcmINzQVldxUp59EG54A62KL8gZJW4qI9KG2tt
+ rLexgWtU4nqbZtzsg+VoYLMPP0eY41aOIMftv/zlm/jsAgAA
+ """
+ )
+
+ // Simplified version of Offset.kt in geometry package
+ private val GeometryStub = bytecodeStub(
+ filename = "Offset.kt",
+ filepath = "androidx/compose/ui/geometry",
+ checksum = 0x471b639e,
+ source = """
+ package androidx.compose.ui.geometry
+
+ class Offset
+ """,
+ """
+ META-INF/main.kotlin_module:
+ H4sIAAAAAAAA/2NgYGBmYGBgBGIOBijgMuKST8xLKcrPTKnQS87PLcgvTtVL
+ zMvMTSzJzM8DihSlCvE7wvjBBanJ3iVcvFzMafn5QmwhqcUl3iVKDFoMAHnM
+ zO9bAAAA
+ """,
+ """
+ androidx/compose/ui/geometry/Offset.class:
+ H4sIAAAAAAAA/41RzS5DQRg937S9uIr6r9+NSLBwETsiQSJpUiRIN1bT3sFo
+ 74zcmQq7Pos3sJJYSGPpocR3Lw9gc3J+vpk5M/P1/f4BYBdLhBVp4tTq+Clq
+ 2eTBOhV1dXSrbKJ8+hyd39w45QdAhMq9fJRRR5rb6Lx5r1rsFgjBvjbaHxAK
+ a+uNMkoIQhQxQCj6O+0Iq/V/7L9HGK+3re9oE50qL2PpJXsieSxwTcpgMAMQ
+ qM3+k87UFrN4m7Dc74WhqIpQVJj1e9V+b0ds0VHp8yUQFZFN7VC2duj3tM22
+ 53rHNlaEsbo26qybNFV6JZsddibqtiU7DZnqTP+Z4aXtpi11ojMxd9E1Xieq
+ oZ3m9NAY66XX1jhsQ/Dt/5pmj8FYZRXlGihtvGHwlYnAHGOQm0XMM5Z/BzCE
+ MM8XcpzFYv5RhGHOytco1DBSw2gNY6gwxXgNE5i8BjlMYZpzh9BhxiH4AWXo
+ H/7lAQAA
+ """
+ )
+
+ // Simplified classes of ui/unit package
+ private val UnitStub = bytecodeStub(
+ filename = "Units.kt",
+ filepath = "androidx/compose/ui/unit",
+ checksum = 0x137591fb,
+ source = """
+ package androidx.compose.ui.unit
+
+ class IntOffset
+
+ class DpOffset
+ """,
+ """
+ META-INF/main.kotlin_module:
+ H4sIAAAAAAAA/2NgYGBmYGBgBGIOBijgMuKST8xLKcrPTKnQS87PLcgvTtVL
+ zMvMTSzJzM8DihSlCvE7wvjBBanJ3iVcvFzMafn5QmwhqcUl3iVKDFoMAHnM
+ zO9bAAAA
+ """,
+ """
+ androidx/compose/ui/unit/DpOffset.class:
+ H4sIAAAAAAAA/4VRy0oDMRQ9N7VjHavWd32CuFEXjoo7RfCBUKgKPrpxlXZS
+ jW0TaTList/iH7gSXEhx6UeJd0b3bg7ncZOcJF/f7x8AdrFEWJEm7lodP0cN
+ 23m0TkWJjhKjfXTyeNFsOuUHQYTSg3ySUVuau+ii/qAa7OYIwb7myQNCbm29
+ VkQeQYgBDBIG/L12hNXqv7vvEcarLevb2kRnystYesme6DzluCKlUEgBBGqx
+ /6xTtcUs3iYs93thKMoiFCVm/V6539sRW3SU/3wJREmkUzuUri3c8KFus+W5
+ 27GNFWGsqo06Tzp11b2W9TY7E1XbkO2a7OpU/5nhlU26DXWqUzF3mRivO6qm
+ neb00BjrpdfWOGxD8NX/iqYvwVhmFWUayG+8ofDKRGCOMcjMAPOMxd8BDCHM
+ 8oUMZ7GY/RFhmLPiLXIVjFQwWsEYSkwxXsEEJm9BDlOY5twhdJhxCH4AObkh
+ xeABAAA=
+ """,
+ """
+ androidx/compose/ui/unit/IntOffset.class:
+ H4sIAAAAAAAA/4VRTS9rQRh+3ml71FHUd3FZiAUWDmJHJEhucpIiwe3Gatoz
+ ZbSdkc4csexv8Q+sJBbS3KUfJd5z2Ns8eT7emXlm5uPz7R3APlYIa9IkfauT
+ p6hlew/WqSjVUWq0j2LjL9ptp/wIiFC9l48y6kpzG10071WL3QIhONQ8ekQo
+ bGw2KighCFHECKHo77QjrNd/3/6AMFXvWN/VJjpTXibSS/ZE77HAJSmDcgYg
+ UIf9J52pHWbJLmF1OAhDUROhqDIbDmrDwZ7YoZPS/+dAVEU2tUfZ2vI/PtVt
+ dzyXO7WJIkzWtVHnaa+p+tey2WVnum5bstuQfZ3pHzO8smm/pf7qTCxepsbr
+ nmpopzk9NsZ66bU1DrsQfPefotlTMNZYRbkGSluvKL8wEVhkDHKziCXGyvcA
+ RhHm+XKOC/iT/xJhjLPKDQoxxmNMxJhElSmmYkxj5gbkMIs5zh1Ch3mH4Au3
+ DmZN4gEAAA==
+ """
+ )
+
+ @Test
+ fun testPreferredTypeIssue() {
+ lint().files(
+ kotlin("""
+package foo
+
+import androidx.compose.animation.core.ArcAnimationSpec
+import androidx.compose.animation.core.ArcAbove
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.IntOffset
+
+fun test() {
+ ArcAnimationSpec<Offset>(ArcAbove)
+ ArcAnimationSpec<IntOffset>(ArcAbove)
+ ArcAnimationSpec<DpOffset>(ArcAbove)
+ ArcAnimationSpec<Float>(ArcAbove)
+ ArcAnimationSpec<String>(ArcAbove)
+}
+ """),
+ ArcAnimationSpecStub,
+ GeometryStub,
+ UnitStub
+ ).run()
+ .expect("""src/foo/test.kt:14: Information: Arc animation is intended for 2D values such as Offset, IntOffset or DpOffset.
+Otherwise, the animation might not be what you expect. [ArcAnimationSpecTypeIssue]
+ ArcAnimationSpec<Float>(ArcAbove)
+ ~~~~~~~~~~~~~~~~
+src/foo/test.kt:15: Information: Arc animation is intended for 2D values such as Offset, IntOffset or DpOffset.
+Otherwise, the animation might not be what you expect. [ArcAnimationSpecTypeIssue]
+ ArcAnimationSpec<String>(ArcAbove)
+ ~~~~~~~~~~~~~~~~
+0 errors, 0 warnings""")
+ }
+}
diff --git a/compose/animation/animation-core/api/current.txt b/compose/animation/animation-core/api/current.txt
index a753685..d5aec14 100644
--- a/compose/animation/animation-core/api/current.txt
+++ b/compose/animation/animation-core/api/current.txt
@@ -208,6 +208,38 @@
method public static androidx.compose.animation.core.AnimationVector4D AnimationVector(float v1, float v2, float v3, float v4);
}
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi @androidx.compose.runtime.Immutable public final class ArcAnimationSpec<T> implements androidx.compose.animation.core.DurationBasedAnimationSpec<T> {
+ ctor public ArcAnimationSpec(optional androidx.compose.animation.core.ArcMode mode, optional int durationMillis, optional int delayMillis, optional androidx.compose.animation.core.Easing easing);
+ method public int getDelayMillis();
+ method public int getDurationMillis();
+ method public androidx.compose.animation.core.Easing getEasing();
+ method public androidx.compose.animation.core.ArcMode getMode();
+ method public <V extends androidx.compose.animation.core.AnimationVector> androidx.compose.animation.core.VectorizedDurationBasedAnimationSpec<V> vectorize(androidx.compose.animation.core.TwoWayConverter<T,V> converter);
+ property public final int delayMillis;
+ property public final int durationMillis;
+ property public final androidx.compose.animation.core.Easing easing;
+ property public final androidx.compose.animation.core.ArcMode mode;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public abstract sealed class ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion Companion;
+ }
+
+ public static final class ArcMode.Companion {
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcAbove extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcAbove INSTANCE;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcBelow extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcBelow INSTANCE;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcLinear extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcLinear INSTANCE;
+ }
+
@androidx.compose.runtime.Immutable public final class CubicBezierEasing implements androidx.compose.animation.core.Easing {
ctor public CubicBezierEasing(float a, float b, float c, float d);
method public float transform(float fraction);
@@ -457,6 +489,7 @@
public static final class KeyframesSpec.KeyframesSpecConfig<T> extends androidx.compose.animation.core.KeyframesSpecBaseConfig<T,androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>> {
ctor public KeyframesSpec.KeyframesSpecConfig();
+ method @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public infix androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T> using(androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>, androidx.compose.animation.core.ArcMode arcMode);
method @Deprecated public infix void with(androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>, androidx.compose.animation.core.Easing easing);
}
diff --git a/compose/animation/animation-core/api/restricted_current.txt b/compose/animation/animation-core/api/restricted_current.txt
index dd4d257a..f22fd5d 100644
--- a/compose/animation/animation-core/api/restricted_current.txt
+++ b/compose/animation/animation-core/api/restricted_current.txt
@@ -208,6 +208,38 @@
method public static androidx.compose.animation.core.AnimationVector4D AnimationVector(float v1, float v2, float v3, float v4);
}
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi @androidx.compose.runtime.Immutable public final class ArcAnimationSpec<T> implements androidx.compose.animation.core.DurationBasedAnimationSpec<T> {
+ ctor public ArcAnimationSpec(optional androidx.compose.animation.core.ArcMode mode, optional int durationMillis, optional int delayMillis, optional androidx.compose.animation.core.Easing easing);
+ method public int getDelayMillis();
+ method public int getDurationMillis();
+ method public androidx.compose.animation.core.Easing getEasing();
+ method public androidx.compose.animation.core.ArcMode getMode();
+ method public <V extends androidx.compose.animation.core.AnimationVector> androidx.compose.animation.core.VectorizedDurationBasedAnimationSpec<V> vectorize(androidx.compose.animation.core.TwoWayConverter<T,V> converter);
+ property public final int delayMillis;
+ property public final int durationMillis;
+ property public final androidx.compose.animation.core.Easing easing;
+ property public final androidx.compose.animation.core.ArcMode mode;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public abstract sealed class ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion Companion;
+ }
+
+ public static final class ArcMode.Companion {
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcAbove extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcAbove INSTANCE;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcBelow extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcBelow INSTANCE;
+ }
+
+ @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public static final class ArcMode.Companion.ArcLinear extends androidx.compose.animation.core.ArcMode {
+ field public static final androidx.compose.animation.core.ArcMode.Companion.ArcLinear INSTANCE;
+ }
+
@androidx.compose.runtime.Immutable public final class CubicBezierEasing implements androidx.compose.animation.core.Easing {
ctor public CubicBezierEasing(float a, float b, float c, float d);
method public float transform(float fraction);
@@ -457,6 +489,7 @@
public static final class KeyframesSpec.KeyframesSpecConfig<T> extends androidx.compose.animation.core.KeyframesSpecBaseConfig<T,androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>> {
ctor public KeyframesSpec.KeyframesSpecConfig();
+ method @SuppressCompatibility @androidx.compose.animation.core.ExperimentalAnimationSpecApi public infix androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T> using(androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>, androidx.compose.animation.core.ArcMode arcMode);
method @Deprecated public infix void with(androidx.compose.animation.core.KeyframesSpec.KeyframeEntity<T>, androidx.compose.animation.core.Easing easing);
}
diff --git a/compose/animation/animation-core/samples/src/main/java/androidx/compose/animation/core/samples/ArcAnimationSamples.kt b/compose/animation/animation-core/samples/src/main/java/androidx/compose/animation/core/samples/ArcAnimationSamples.kt
new file mode 100644
index 0000000..5c8a79f
--- /dev/null
+++ b/compose/animation/animation-core/samples/src/main/java/androidx/compose/animation/core/samples/ArcAnimationSamples.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2023 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(ExperimentalAnimationSpecApi::class)
+
+package androidx.compose.animation.core.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.animation.core.ArcAnimationSpec
+import androidx.compose.animation.core.ArcMode.Companion.ArcAbove
+import androidx.compose.animation.core.ExperimentalAnimationSpecApi
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.keyframes
+import androidx.compose.ui.geometry.Offset
+
+@Sampled
+fun OffsetArcAnimationSpec() {
+ // Will interpolate the Offset in arcs such that the curve of the quarter of an Ellipse is above
+ // the center.
+ ArcAnimationSpec<Offset>(mode = ArcAbove)
+}
+
+@Sampled
+fun OffsetKeyframesWithArcsBuilder() {
+ keyframes<Offset> {
+ // Animate for 1.2 seconds
+ durationMillis = 1200
+
+ // Animate to Offset(100f, 100f) at 50% of the animation using LinearEasing then, animate
+ // using ArcAbove for the rest of the animation
+ Offset(100f, 100f) atFraction 0.5f using LinearEasing using ArcAbove
+ }
+}
diff --git a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/AnimationTestUtils.kt b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/AnimationTestUtils.kt
index fcb63dc..41e3cb7 100644
--- a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/AnimationTestUtils.kt
+++ b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/AnimationTestUtils.kt
@@ -119,3 +119,58 @@
end: Float,
startVelocity: Float
): Float = getVelocityFromNanos(playTimeMillis * MillisToNanos, start, end, startVelocity)
+
+/**
+ * Creates a TwoWayConverter for FloatArray and the given AnimationVector type.
+ */
+internal inline fun <reified V : AnimationVector> createFloatArrayConverter():
+ TwoWayConverter<FloatArray, V> =
+ object : TwoWayConverter<FloatArray, V> {
+ override val convertToVector: (FloatArray) -> V = {
+ when (V::class) {
+ AnimationVector1D::class -> {
+ AnimationVector(
+ it.getOrElse(0) { 0f }
+ )
+ }
+
+ AnimationVector2D::class -> {
+ AnimationVector(
+ it.getOrElse(0) { 0f },
+ it.getOrElse(1) { 0f },
+ )
+ }
+
+ AnimationVector3D::class -> {
+ AnimationVector(
+ it.getOrElse(0) { 0f },
+ it.getOrElse(1) { 0f },
+ it.getOrElse(2) { 0f }
+ )
+ }
+
+ else -> { // 4D
+ AnimationVector(
+ it.getOrElse(0) { 0f },
+ it.getOrElse(1) { 0f },
+ it.getOrElse(2) { 0f },
+ it.getOrElse(3) { 0f }
+ )
+ }
+ } as V
+ }
+ override val convertFromVector: (V) -> FloatArray = { vector ->
+ FloatArray(vector.size, vector::get)
+ }
+ }
+
+/**
+ * Returns an [AnimationVector] of type [V] filled with the given [value].
+ */
+internal inline fun <reified V : AnimationVector> createFilledVector(value: Float): V =
+ when (V::class) {
+ AnimationVector1D::class -> AnimationVector1D(value)
+ AnimationVector2D::class -> AnimationVector2D(value, value)
+ AnimationVector3D::class -> AnimationVector3D(value, value, value)
+ else -> AnimationVector4D(value, value, value, value)
+ } as V
diff --git a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/ArcAnimationTest.kt b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/ArcAnimationTest.kt
new file mode 100644
index 0000000..d9c38ec
--- /dev/null
+++ b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/ArcAnimationTest.kt
@@ -0,0 +1,386 @@
+/*
+ * Copyright 2023 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 androidx.compose.animation.core
+
+import androidx.compose.animation.core.ArcMode.Companion.ArcAbove
+import androidx.compose.animation.core.ArcMode.Companion.ArcBelow
+import androidx.compose.animation.core.ArcMode.Companion.ArcLinear
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Mostly tests some mathematical assumptions about arcs.
+ */
+@Suppress("JoinDeclarationAndAssignment") // Looks kinda messy
+@OptIn(ExperimentalAnimationSpecApi::class)
+@RunWith(JUnit4::class)
+class ArcAnimationTest {
+ // Animation parameters used in all tests
+ private val timeMillis = 1000
+ private val initialValue = 0f
+ private val targetValue = 300f
+
+ private val error = 0.01f
+
+ @Test
+ fun test2DInterpolation_withArcAbove() {
+ val animation = createArcAnimation<AnimationVector2D>(ArcAbove)
+ var arcValue: AnimationVector2D
+ var linearValue: AnimationVector2D
+
+ // Test values at 25%, 50%, 75%
+ // For arc above Y will always be lower but X will be higher
+ arcValue = animation.valueAt(0.25f)
+ linearValue = linearValueAt(0.25f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ arcValue = animation.valueAt(0.5f)
+ linearValue = linearValueAt(0.5f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ arcValue = animation.valueAt(0.75f)
+ linearValue = linearValueAt(0.75f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ // Test that x at 25% is the complement of y at 75%
+ assertEquals(
+ targetValue - animation.valueAt(0.25f)[0],
+ animation.valueAt(0.75f)[1],
+ error // Bound to have some minor differences :)
+ )
+
+ var arcVelocity: AnimationVector2D
+ // Test that velocity at 50% is equal on both components
+ arcVelocity = animation.velocityAt(0.5f)
+ assertEquals(arcVelocity[0], arcVelocity[1], error)
+
+ // Test that for velocity at 0% only the X component is non-zero
+ arcVelocity = animation.velocityAt(0.0f)
+ assertEquals(0f, arcVelocity[1], error)
+ assertTrue(arcVelocity[0] > error)
+
+ // Test that for velocity at 100% only the X component in non-zero
+ arcVelocity = animation.velocityAt(1f)
+ assertEquals(0f, arcVelocity[0], error)
+ assertTrue(arcVelocity[1] > error)
+ }
+
+ @Test
+ fun test2DInterpolation_withArcBelow() {
+ val animation = createArcAnimation<AnimationVector2D>(ArcBelow)
+ var arcValue: AnimationVector2D
+ var linearValue: AnimationVector2D
+
+ // Test values at 25%, 50%, 75%
+ // For arc below Y will always be higher but X will be lower
+ arcValue = animation.valueAt(0.25f)
+ linearValue = linearValueAt(0.25f)
+ assertTrue(arcValue[0] < linearValue[0])
+ assertTrue(arcValue[1] > linearValue[1])
+
+ arcValue = animation.valueAt(0.5f)
+ linearValue = linearValueAt(0.5f)
+ assertTrue(arcValue[0] < linearValue[0])
+ assertTrue(arcValue[1] > linearValue[1])
+
+ arcValue = animation.valueAt(0.75f)
+ linearValue = linearValueAt(0.75f)
+ assertTrue(arcValue[0] < linearValue[0])
+ assertTrue(arcValue[1] > linearValue[1])
+
+ // Test that Y at 25% is the complement of X at 75%
+ assertEquals(
+ targetValue - animation.valueAt(0.25f)[1],
+ animation.valueAt(0.75f)[0],
+ error // Bound to have some minor differences :)
+ )
+
+ var arcVelocity: AnimationVector2D
+ // Test that velocity at 50% is equal on both components
+ arcVelocity = animation.velocityAt(0.5f)
+ assertEquals(arcVelocity[0], arcVelocity[1], error)
+
+ // Test that for velocity at 0% only the Y component is non-zero
+ arcVelocity = animation.velocityAt(0.0f)
+ assertEquals(0f, arcVelocity[0], error)
+ assertTrue(arcVelocity[1] > error)
+
+ // Test that for velocity at 100% only the Y component in non-zero
+ arcVelocity = animation.velocityAt(1f)
+ assertEquals(0f, arcVelocity[1], error)
+ assertTrue(arcVelocity[0] > error)
+ }
+
+ @Test
+ fun test2DInterpolation_withLinearArc() {
+ val animation = createArcAnimation<AnimationVector2D>(ArcLinear)
+ var arcValue: AnimationVector2D
+ var linearValue: AnimationVector2D
+
+ // Test values at 25%, 50%, 75% should be exactly the same as a linear interpolation
+ arcValue = animation.valueAt(0.25f)
+ linearValue = linearValueAt(0.25f)
+ assertEquals(linearValue, arcValue)
+
+ arcValue = animation.valueAt(0.5f)
+ linearValue = linearValueAt(0.5f)
+ assertEquals(linearValue, arcValue)
+
+ arcValue = animation.valueAt(0.75f)
+ linearValue = linearValueAt(0.75f)
+ assertEquals(linearValue, arcValue)
+
+ var arcVelocity: AnimationVector2D
+ arcVelocity = animation.velocityAt(0.25f)
+ assertEquals(0f, arcVelocity[0] - arcVelocity[1], error)
+
+ arcVelocity = animation.velocityAt(0.5f)
+ assertEquals(0f, arcVelocity[0] - arcVelocity[1], error)
+
+ arcVelocity = animation.velocityAt(0.75f)
+ assertEquals(0f, arcVelocity[0] - arcVelocity[1], error)
+ }
+
+ @Test
+ fun test2DInterpolation_withEasing() {
+ val animation = createArcAnimation<AnimationVector2D>(ArcLinear)
+ val easedAnimation =
+ createArcAnimation<AnimationVector2D>(ArcLinear, FastOutSlowInEasing)
+
+ var arcValue: AnimationVector2D
+ var easedArcValue: AnimationVector2D
+
+ // At 15% of time, the eased animation will lag behind
+ arcValue = animation.valueAt(0.15f)
+ easedArcValue = easedAnimation.valueAt(0.15f)
+ assertTrue(arcValue[0] > easedArcValue[0])
+ assertTrue(arcValue[1] > easedArcValue[1])
+
+ // At 26% of time, both animations will be around the same value
+ arcValue = animation.valueAt(0.26f)
+ easedArcValue = easedAnimation.valueAt(0.26f)
+ // Bigger error here, but still within 1% of the target value
+ assertEquals(arcValue[0], easedArcValue[0], 1f)
+ assertEquals(arcValue[1], easedArcValue[1], 1f)
+
+ // At 50% of time, the eased animation should lead ahead
+ arcValue = animation.valueAt(0.5f)
+ easedArcValue = easedAnimation.valueAt(0.5f)
+ assertTrue(arcValue[0] < easedArcValue[0])
+ assertTrue(arcValue[1] < easedArcValue[1])
+ }
+
+ @Test
+ fun test1DInterpolation_isAlwaysLinear() {
+ // TODO: This behavior might change, to be a forced Arc by repeating the same value on a
+ // fake second dimension
+ fun testArcMode(arcMode: ArcMode) {
+ val animation = createArcAnimation<AnimationVector1D>(arcMode)
+ var arcValue: AnimationVector1D
+
+ arcValue = animation.valueAt(0.25f)
+ assertEquals(arcValue, linearValueAt<AnimationVector1D>(0.25f))
+
+ arcValue = animation.valueAt(0.5f)
+ assertEquals(arcValue, linearValueAt<AnimationVector1D>(0.5f))
+
+ arcValue = animation.valueAt(0.75f)
+ assertEquals(arcValue, linearValueAt<AnimationVector1D>(0.75f))
+ }
+
+ testArcMode(ArcAbove)
+ testArcMode(ArcBelow)
+ testArcMode(ArcLinear)
+ }
+
+ @Test
+ fun test3DInterpolation_firstPairAsArc() {
+ val animation = createArcAnimation<AnimationVector3D>(ArcAbove)
+ var arcValue: AnimationVector3D
+ var linearValue: AnimationVector3D
+
+ // TODO: Test the 3rd dimension, not as important since we don't have any 3-dimensional
+ // values out of the box. Currently, this is the same as `test2DInterpolation_withArcAbove`
+
+ // Test values at 25%, 50%, 75%
+ // For arc above Y will always be lower but X will be higher
+ arcValue = animation.valueAt(0.25f)
+ linearValue = linearValueAt(0.25f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ arcValue = animation.valueAt(0.5f)
+ linearValue = linearValueAt(0.5f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ arcValue = animation.valueAt(0.75f)
+ linearValue = linearValueAt(0.75f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ // Test that x at 25% is the complement of y at 75%
+ assertEquals(
+ targetValue - animation.valueAt(0.25f)[0],
+ animation.valueAt(0.75f)[1],
+ error // Bound to have some minor differences :)
+ )
+
+ var arcVelocity: AnimationVector3D
+ // Test that velocity at 50% is equal on both components
+ arcVelocity = animation.velocityAt(0.5f)
+ assertEquals(arcVelocity[0], arcVelocity[1], error)
+
+ // Test that for velocity at 0% only the X component is non-zero
+ arcVelocity = animation.velocityAt(0.0f)
+ assertEquals(0f, arcVelocity[1], error)
+ assertTrue(arcVelocity[0] > error)
+
+ // Test that for velocity at 100% only the Y component in non-zero
+ arcVelocity = animation.velocityAt(1f)
+ assertEquals(0f, arcVelocity[0], error)
+ assertTrue(arcVelocity[1] > error)
+ }
+
+ @Test
+ fun test4DInterpolation_twoPairsAsArcs() {
+ val animation = createArcAnimation<AnimationVector4D>(ArcAbove)
+ var arcValue: AnimationVector4D
+ var linearValue: AnimationVector4D
+
+ // Test values at 25%, 50%, 75%
+ // For arc below Y will always be higher but X will be lower
+ // Similarly for [3] and [2], the second pair
+ arcValue = animation.valueAt(0.25f)
+ linearValue = linearValueAt(0.25f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ // Second pair
+ assertTrue(arcValue[2] > linearValue[2])
+ assertTrue(arcValue[3] < linearValue[3])
+
+ arcValue = animation.valueAt(0.5f)
+ linearValue = linearValueAt(0.5f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ // Second pair
+ assertTrue(arcValue[2] > linearValue[2])
+ assertTrue(arcValue[3] < linearValue[3])
+
+ arcValue = animation.valueAt(0.75f)
+ linearValue = linearValueAt(0.75f)
+ assertTrue(arcValue[0] > linearValue[0])
+ assertTrue(arcValue[1] < linearValue[1])
+
+ // Second pair
+ assertTrue(arcValue[2] > linearValue[2])
+ assertTrue(arcValue[3] < linearValue[3])
+ }
+
+ @Test
+ fun testEquals() {
+ // Equal mode with defaults
+ var animationA = ArcAnimationSpec<Float>(ArcAbove)
+ var animationB = ArcAnimationSpec<Float>(ArcAbove)
+ assertEquals(animationA, animationB)
+
+ // Equals with custom values
+ animationA = ArcAnimationSpec(
+ mode = ArcBelow,
+ durationMillis = 13,
+ delayMillis = 17,
+ easing = EaseInOut
+ )
+ animationB = ArcAnimationSpec(
+ mode = ArcBelow,
+ durationMillis = 13,
+ delayMillis = 17,
+ easing = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f) // Re-declared EasInOut
+ )
+ assertEquals(animationA, animationB)
+ }
+
+ @Test
+ fun testNotEquals() {
+ // Different modes
+ var animationA = ArcAnimationSpec<Float>(ArcAbove)
+ var animationB = ArcAnimationSpec<Float>(ArcBelow)
+ assertNotEquals(animationA, animationB)
+
+ // Different duration
+ animationA = ArcAnimationSpec(mode = ArcLinear, durationMillis = 5)
+ animationB = ArcAnimationSpec(mode = ArcLinear, durationMillis = 7)
+ assertNotEquals(animationA, animationB)
+
+ // Different delay
+ animationA = ArcAnimationSpec(mode = ArcLinear, delayMillis = 9)
+ animationB = ArcAnimationSpec(mode = ArcLinear, delayMillis = 11)
+ assertNotEquals(animationA, animationB)
+
+ // Different Easing
+ animationA = ArcAnimationSpec(mode = ArcLinear, easing = EaseInOut)
+ animationB = ArcAnimationSpec(mode = ArcLinear, easing = FastOutSlowInEasing)
+ assertNotEquals(animationA, animationB)
+ }
+
+ private inline fun <reified V : AnimationVector>
+ VectorizedDurationBasedAnimationSpec<V>.valueAt(timePercent: Float): V =
+ this.getValueFromNanos(
+ playTimeNanos = (durationMillis * timePercent).toLong() * 1_000_000,
+ initialValue = createFilledVector(initialValue),
+ targetValue = createFilledVector(targetValue),
+ initialVelocity = createFilledVector(0f)
+ )
+
+ private inline fun <reified V : AnimationVector>
+ VectorizedDurationBasedAnimationSpec<V>.velocityAt(timePercent: Float): V =
+ this.getVelocityFromNanos(
+ playTimeNanos = (durationMillis * timePercent).toLong() * 1_000_000,
+ initialValue = createFilledVector(initialValue),
+ targetValue = createFilledVector(targetValue),
+ initialVelocity = createFilledVector(0f)
+ )
+
+ private inline fun <reified V : AnimationVector> linearValueAt(timePercent: Float): V {
+ val value = timePercent * targetValue
+ return createFilledVector<V>(value)
+ }
+
+ /**
+ * Creates an [ArcAnimationSpec] for the given [AnimationVector] type.
+ */
+ private inline fun <reified V : AnimationVector> createArcAnimation(
+ mode: ArcMode,
+ easing: Easing = LinearEasing
+ ): VectorizedDurationBasedAnimationSpec<V> {
+ val spec = ArcAnimationSpec<FloatArray>(
+ mode = mode,
+ durationMillis = timeMillis,
+ easing = easing
+ )
+ return spec.vectorize(createFloatArrayConverter())
+ }
+}
diff --git a/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/KeyframeArcAnimationTest.kt b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/KeyframeArcAnimationTest.kt
new file mode 100644
index 0000000..1976ee8
--- /dev/null
+++ b/compose/animation/animation-core/src/androidUnitTest/kotlin/androidx/compose/animation/core/KeyframeArcAnimationTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2023 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 androidx.compose.animation.core
+
+import androidx.compose.animation.core.ArcMode.Companion.ArcAbove
+import androidx.compose.animation.core.ArcMode.Companion.ArcBelow
+import androidx.compose.animation.core.ArcMode.Companion.ArcLinear
+import androidx.compose.ui.geometry.Offset
+import junit.framework.TestCase.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@Suppress("JoinDeclarationAndAssignment") // Looks kinda messy
+@OptIn(ExperimentalAnimationSpecApi::class)
+@RunWith(JUnit4::class)
+class KeyframeArcAnimationTest {
+ private val timeMillis = 3000
+ private val initialValue = 0f
+ private val targetValue = 600f
+ private val error = 0.0001f
+
+ @Test
+ fun test2DArcKeyFrame_interpolatedValues() {
+ var arcVector: AnimationVector2D
+ var linearVector: AnimationVector2D
+
+ // Test above, below, linear keyframes
+ val keyframeAnimation = keyframes {
+ durationMillis = timeMillis
+
+ Offset(initialValue, initialValue) at 0 using LinearEasing using ArcAbove
+ Offset(200f, 200f) at 1000 using LinearEasing using ArcBelow
+ Offset(400f, 400f) atFraction 2f / 3f using LinearEasing using ArcLinear
+ }.vectorize(Offset.VectorConverter)
+
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(1f / 6f)
+ assertTrue(arcVector[0] > linearVector[0]) // X is higher for ArcAbove (in this scenario)
+ assertTrue(arcVector[1] < linearVector[1]) // Y is lower for ArcAbove (in this scenario)
+
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (1500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(3f / 6f)
+ assertTrue(arcVector[0] < linearVector[0]) // X is lower for ArcBelow
+ assertTrue(arcVector[1] > linearVector[1]) // Y is higher for ArcBelow
+
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (2500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(5f / 6f)
+ assertEquals(linearVector[0], arcVector[0], error) // X is equals for ArcLinear
+ assertEquals(linearVector[1], arcVector[1], error) // Y is equals for ArcLinear
+ }
+
+ @Test
+ fun test2DArcKeyFrame_multipleEasing() {
+ var arcVector: AnimationVector2D
+ var linearVector: AnimationVector2D
+
+ // We test different Easing curves using Linear arc mode
+ val keyframeAnimation = keyframes {
+ durationMillis = timeMillis
+
+ Offset.Zero at 0 using EaseInCubic using ArcLinear
+ Offset(200f, 200f) at 1000 using LinearEasing using ArcLinear
+ Offset(400f, 400f) atFraction 2f / 3f using EaseOutCubic using ArcLinear
+ }.vectorize(Offset.VectorConverter)
+
+ // Start with EaseInCubic, which is always a lower value
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(1f / 6f)
+ // X & Y are lower for EaseInCubic
+ assertTrue(arcVector[0] < linearVector[0])
+ assertTrue(arcVector[1] < linearVector[1])
+
+ // Then, LinearEasing, which is always equals
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (1500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(3f / 6f)
+ assertEquals(linearVector[0], arcVector[0], error) // X is equals with LinearEasing
+ assertEquals(linearVector[1], arcVector[1], error) // Y is equals with LinearEasing
+
+ // Then, EaseOutCubic, which is always a higher value
+ arcVector = keyframeAnimation.getValueFromNanos(
+ (2500).toLong() * 1_000_000,
+ createFilledVector(initialValue),
+ createFilledVector(targetValue),
+ createFilledVector(0f)
+ )
+ linearVector = linearValueAt(5f / 6f)
+ // X & Y are higher for EaseOutCubic
+ assertTrue(arcVector[0] > linearVector[0])
+ assertTrue(arcVector[1] > linearVector[1])
+ }
+
+ private inline fun <reified V : AnimationVector> linearValueAt(timePercent: Float): V {
+ val value = timePercent * targetValue
+ return createFilledVector<V>(value)
+ }
+}
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/AnimationSpec.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/AnimationSpec.kt
index a84bb06..ec76ac5 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/AnimationSpec.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/AnimationSpec.kt
@@ -19,8 +19,12 @@
import androidx.annotation.IntRange
import androidx.collection.MutableIntList
import androidx.collection.MutableIntObjectMap
+import androidx.collection.emptyIntObjectMap
+import androidx.collection.intListOf
import androidx.collection.mutableIntObjectMapOf
import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
+import androidx.compose.animation.core.ArcMode.Companion.ArcBelow
+import androidx.compose.animation.core.ArcMode.Companion.ArcLinear
import androidx.compose.animation.core.KeyframesSpec.KeyframesSpecConfig
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
@@ -168,6 +172,75 @@
}
/**
+ * [DurationBasedAnimationSpec] that interpolates 2-dimensional values using arcs of quarter of an
+ * Ellipse.
+ *
+ * To interpolate with [keyframes] use [KeyframesSpecConfig.using] with an [ArcMode].
+ *
+ *
+ *
+ * As such, it's recommended that [ArcAnimationSpec] is only used for positional values such as:
+ * [Offset], [IntOffset] or [androidx.compose.ui.unit.DpOffset].
+ *
+ *
+ *
+ * The orientation of the arc is indicated by the given [mode].
+ *
+ * Do note, that if the target value being animated only changes in one dimension, you'll only be
+ * able to get a linear curve.
+ *
+ * Similarly, one-dimensional values will always only interpolate on a linear curve.
+ *
+ * @param mode Orientation of the arc.
+ * @param durationMillis Duration of the animation. [DefaultDurationMillis] by default.
+ * @param delayMillis Time the animation waits before starting. 0 by default.
+ * @param easing [Easing] applied on the animation curve. [FastOutSlowInEasing] by default.
+ *
+ * @see ArcMode
+ * @see keyframes
+ *
+ * @sample androidx.compose.animation.core.samples.OffsetArcAnimationSpec
+ */
+@ExperimentalAnimationSpecApi
+@Immutable
+class ArcAnimationSpec<T>(
+ val mode: ArcMode = ArcBelow,
+ val durationMillis: Int = DefaultDurationMillis,
+ val delayMillis: Int = 0,
+ val easing: Easing = FastOutSlowInEasing // Same default as tween()
+) : DurationBasedAnimationSpec<T> {
+ override fun <V : AnimationVector> vectorize(
+ converter: TwoWayConverter<T, V>
+ ): VectorizedDurationBasedAnimationSpec<V> =
+ VectorizedKeyframesSpec(
+ timestamps = intListOf(0, durationMillis),
+ keyframes = emptyIntObjectMap(),
+ durationMillis = durationMillis,
+ delayMillis = delayMillis,
+ defaultEasing = easing,
+ initialArcMode = mode
+ )
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is ArcAnimationSpec<*>) return false
+
+ if (mode != other.mode) return false
+ if (durationMillis != other.durationMillis) return false
+ if (delayMillis != other.delayMillis) return false
+ return easing == other.easing
+ }
+
+ override fun hashCode(): Int {
+ var result = mode.hashCode()
+ result = 31 * result + durationMillis
+ result = 31 * result + delayMillis
+ result = 31 * result + easing.hashCode()
+ return result
+ }
+}
+
+/**
* This class defines the two types of [StartOffset]: [StartOffsetType.Delay] and
* [StartOffsetType.FastForward].
* [StartOffsetType.Delay] delays the start of the animation, whereas [StartOffsetType.FastForward]
@@ -487,7 +560,11 @@
* You can also provide a custom [Easing] for the interval with use of [with] function applied
* for the interval starting keyframe.
* @sample androidx.compose.animation.core.samples.KeyframesBuilderWithEasing
-
+ *
+ * Values can be animated using arcs of quarter of an Ellipse with [KeyframesSpecConfig.using] and
+ * [ArcMode]:
+ *
+ * @sample androidx.compose.animation.core.samples.OffsetKeyframesWithArcsBuilder
*/
@Immutable
class KeyframesSpec<T>(val config: KeyframesSpecConfig<T>) : DurationBasedAnimationSpec<T> {
@@ -501,6 +578,7 @@
* @see keyframes
*/
class KeyframesSpecConfig<T> : KeyframesSpecBaseConfig<T, KeyframeEntity<T>>() {
+ @OptIn(ExperimentalAnimationSpecApi::class)
override fun createEntityFor(value: T): KeyframeEntity<T> = KeyframeEntity(value)
/**
@@ -520,37 +598,86 @@
infix fun KeyframeEntity<T>.with(easing: Easing) {
this.easing = easing
}
+
+ /**
+ * [ArcMode] applied from this keyframe to the next.
+ *
+ * Note that arc modes are meant for objects with even dimensions (such as [Offset] and its
+ * variants). Where each value pair is animated as an arc. So, if the object has odd
+ * dimensions the last value will always animate linearly.
+ *
+ *
+ *
+ * The order of each value in an object with multiple dimensions is given by the applied
+ * vector converter in [KeyframesSpec.vectorize].
+ *
+ * E.g.: [RectToVector] assigns its values as `[left, top, right, bottom]` so the pairs of
+ * dimensions animated as arcs are: `[left, top]` and `[right, bottom]`.
+ */
+ @ExperimentalAnimationSpecApi
+ infix fun KeyframeEntity<T>.using(arcMode: ArcMode): KeyframeEntity<T> {
+ this.arcMode = arcMode
+ return this
+ }
}
+ @OptIn(ExperimentalAnimationSpecApi::class)
override fun <V : AnimationVector> vectorize(
converter: TwoWayConverter<T, V>
): VectorizedKeyframesSpec<V> {
- @SuppressWarnings("PrimitiveInCollection") // Consumed by stable public API
- val vectorizedKeyframes = mutableMapOf<Int, Pair<V, Easing>>()
+ // Max capacity is +2 to account for when the start/end timestamps are not included
+ val timestamps = MutableIntList(config.keyframes.size + 2)
+ val timeToInfoMap =
+ MutableIntObjectMap<VectorizedKeyframeSpecElementInfo<V>>(config.keyframes.size)
config.keyframes.forEach { key, value ->
- vectorizedKeyframes[key] = value.toPair(converter.convertToVector)
+ timestamps.add(key)
+ timeToInfoMap[key] = VectorizedKeyframeSpecElementInfo(
+ vectorValue = converter.convertToVector(value.value),
+ easing = value.easing,
+ arcMode = value.arcMode
+ )
}
+
+ if (!config.keyframes.contains(0)) {
+ timestamps.add(0, 0)
+ }
+ if (!config.keyframes.contains(config.durationMillis)) {
+ timestamps.add(config.durationMillis)
+ }
+ timestamps.sort()
+
return VectorizedKeyframesSpec(
- keyframes = vectorizedKeyframes,
+ timestamps = timestamps,
+ keyframes = timeToInfoMap,
durationMillis = config.durationMillis,
- delayMillis = config.delayMillis
+ delayMillis = config.delayMillis,
+ defaultEasing = LinearEasing,
+ initialArcMode = ArcLinear
)
}
/**
* Holder class for building a keyframes animation.
*/
+ @OptIn(ExperimentalAnimationSpecApi::class)
class KeyframeEntity<T> internal constructor(
value: T,
- easing: Easing = LinearEasing
+ easing: Easing = LinearEasing,
+ internal var arcMode: ArcMode = ArcMode.Companion.ArcLinear
) : KeyframeBaseEntity<T>(value = value, easing = easing) {
override fun equals(other: Any?): Boolean {
- return other is KeyframeEntity<*> && other.value == value && other.easing == easing
+ if (other === this) return true
+ if (other !is KeyframeEntity<*>) return false
+
+ return other.value == value && other.easing == easing && other.arcMode == arcMode
}
override fun hashCode(): Int {
- return value.hashCode() * 31 + easing.hashCode()
+ var result = value?.hashCode() ?: 0
+ result = 31 * result + arcMode.hashCode()
+ result = 31 * result + easing.hashCode()
+ return result
}
}
}
@@ -645,6 +772,11 @@
*
* @sample androidx.compose.animation.core.samples.KeyframesBuilderWithEasing
*
+ * Values can be animated using arcs of quarter of an Ellipse with [KeyframesSpecConfig.using] and
+ * [ArcMode]:
+ *
+ * @sample androidx.compose.animation.core.samples.OffsetKeyframesWithArcsBuilder
+ *
* @param init Initialization function for the [KeyframesSpec] animation
* @see KeyframesSpec.KeyframesSpecConfig
*/
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/ArcSpline.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/ArcSpline.kt
new file mode 100644
index 0000000..1ede230
--- /dev/null
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/ArcSpline.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2023 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 androidx.compose.animation.core
+
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.sin
+
+/**
+ * This provides a curve fit system that stitches the x,y path together with
+ * quarter ellipses.
+ *
+ * @param arcModes Array of arc mode values. Expected to be of size n - 1.
+ * @param timePoints Array of timestamps. Expected to be of size n. Seconds preferred.
+ * @param y Array of values (of size n), where each value is spread on a [FloatArray] for each of
+ * its dimensions, expected to be of even size since two values are needed to interpolate arcs.
+ */
+@ExperimentalAnimationSpecApi
+internal class ArcSpline(
+ arcModes: IntArray,
+ timePoints: FloatArray,
+ y: Array<FloatArray>
+) {
+ private val arcs: Array<Array<Arc>>
+ private val isExtrapolate = true
+
+ init {
+ var mode = StartVertical
+ var last = StartVertical
+
+ arcs = Array(timePoints.size - 1) { i ->
+ when (arcModes[i]) {
+ ArcStartVertical -> {
+ mode = StartVertical
+ last = mode
+ }
+
+ ArcStartHorizontal -> {
+ mode = StartHorizontal
+ last = mode
+ }
+
+ ArcStartFlip -> {
+ mode = if (last == StartVertical) StartHorizontal else StartVertical
+ last = mode
+ }
+
+ ArcStartLinear -> mode = StartLinear
+ ArcAbove -> mode = UpArc
+ ArcBelow -> mode = DownArc
+ }
+ val dim = y[i].size / 2 + y[i].size % 2
+ Array(dim) { j ->
+ val k = j * 2
+ Arc(
+ mode = mode,
+ time1 = timePoints[i],
+ time2 = timePoints[i + 1],
+ x1 = y[i][k],
+ y1 = y[i][k + 1],
+ x2 = y[i + 1][k],
+ y2 = y[i + 1][k + 1]
+ )
+ }
+ }
+ }
+
+ /**
+ * get the values of the at t point in time.
+ */
+ fun getPos(time: Float, v: FloatArray) {
+ var t = time
+ if (isExtrapolate) {
+ if (t < arcs[0][0].time1 || t > arcs[arcs.size - 1][0].time2) {
+ val p: Int
+ val t0: Float
+ if (t > arcs[arcs.size - 1][0].time2) {
+ p = arcs.size - 1
+ t0 = arcs[arcs.size - 1][0].time2
+ } else {
+ p = 0
+ t0 = arcs[0][0].time1
+ }
+ val dt = t - t0
+
+ var i = 0
+ var j = 0
+ while (i < v.size) {
+ if (arcs[p][j].isLinear) {
+ v[i] = arcs[p][j].getLinearX(t0) + dt * arcs[p][j].getLinearDX()
+ v[i + 1] = arcs[p][j].getLinearY(t0) + dt * arcs[p][j].getLinearDY()
+ } else {
+ arcs[p][j].setPoint(t0)
+ v[i] = arcs[p][j].calcX() + dt * arcs[p][j].calcDX()
+ v[i + 1] = arcs[p][j].calcY() + dt * arcs[p][j].calcDY()
+ }
+ i += 2
+ j++
+ }
+ return
+ }
+ } else {
+ if (t < arcs[0][0].time1) {
+ t = arcs[0][0].time1
+ }
+ if (t > arcs[arcs.size - 1][0].time2) {
+ t = arcs[arcs.size - 1][0].time2
+ }
+ }
+
+ // TODO: Consider passing the index from the caller to improve performance
+ var populated = false
+ for (i in arcs.indices) {
+ var k = 0
+ var j = 0
+ while (j < v.size) {
+ if (t <= arcs[i][k].time2) {
+ if (arcs[i][k].isLinear) {
+ v[j] = arcs[i][k].getLinearX(t)
+ v[j + 1] = arcs[i][k].getLinearY(t)
+ populated = true
+ } else {
+ arcs[i][k].setPoint(t)
+ v[j] = arcs[i][k].calcX()
+ v[j + 1] = arcs[i][k].calcY()
+ populated = true
+ }
+ }
+ j += 2
+ k++
+ }
+ if (populated) {
+ return
+ }
+ }
+ }
+
+ /**
+ * Get the differential which of the curves at point t
+ */
+ fun getSlope(time: Float, v: FloatArray) {
+ var t = time
+ if (t < arcs[0][0].time1) {
+ t = arcs[0][0].time1
+ } else if (t > arcs[arcs.size - 1][0].time2) {
+ t = arcs[arcs.size - 1][0].time2
+ }
+ var populated = false
+ // TODO: Consider passing the index from the caller to improve performance
+ for (i in arcs.indices) {
+ var j = 0
+ var k = 0
+ while (j < v.size) {
+ if (t <= arcs[i][k].time2) {
+ if (arcs[i][k].isLinear) {
+ v[j] = arcs[i][k].getLinearDX()
+ v[j + 1] = arcs[i][k].getLinearDY()
+ populated = true
+ } else {
+ arcs[i][k].setPoint(t)
+ v[j] = arcs[i][k].calcDX()
+ v[j + 1] = arcs[i][k].calcDY()
+ populated = true
+ }
+ }
+ j += 2
+ k++
+ }
+ if (populated) {
+ return
+ }
+ }
+ }
+
+ class Arc internal constructor(
+ mode: Int,
+ val time1: Float,
+ val time2: Float,
+ private val x1: Float,
+ private val y1: Float,
+ private val x2: Float,
+ private val y2: Float
+ ) {
+ private var arcDistance = 0f
+ private var tmpSinAngle = 0f
+ private var tmpCosAngle = 0f
+
+ private val lut: FloatArray
+ private val oneOverDeltaTime: Float
+ private val ellipseA: Float
+ private val ellipseB: Float
+ private val ellipseCenterX: Float // also used to cache the slope in the unused center
+ private val ellipseCenterY: Float // also used to cache the slope in the unused center
+ private val arcVelocity: Float
+ private val isVertical: Boolean
+
+ val isLinear: Boolean
+
+ init {
+ val dx = x2 - x1
+ val dy = y2 - y1
+ isVertical = when (mode) {
+ StartVertical -> true
+ UpArc -> dy < 0
+ DownArc -> dy > 0
+ else -> false
+ }
+ / (this.time2 - this.time1)
+
+ var isLinear = false
+ if (StartLinear == mode) {
+ isLinear = true
+ }
+ if (isLinear || abs(dx) < Epsilon || abs(dy) < Epsilon) {
+ isLinear = true
+ arcDistance = hypot(dy, dx)
+ arcVelocity = arcDistance * oneOverDeltaTime
+ ellipseCenterX =
+ dx / (this.time2 - this.time1) // cache the slope in the unused center
+ ellipseCenterY =
+ dy / (this.time2 - this.time1) // cache the slope in the unused center
+ lut = FloatArray(101)
+ ellipseA = Float.NaN
+ ellipseB = Float.NaN
+ } else {
+ lut = FloatArray(101)
+ ellipseA = dx * if (isVertical) -1 else 1
+ ellipseB = dy * if (isVertical) 1 else -1
+ ellipseCenterX = if (isVertical) x2 else x1
+ ellipseCenterY = if (isVertical) y1 else y2
+ buildTable(x1, y1, x2, y2)
+ arcVelocity = arcDistance * oneOverDeltaTime
+ }
+ this.isLinear = isLinear
+ }
+
+ fun setPoint(time: Float) {
+ val percent = (if (isVertical) time2 - time else time - time1) * oneOverDeltaTime
+ val angle = Math.PI.toFloat() * 0.5f * lookup(percent)
+ tmpSinAngle = sin(angle)
+ tmpCosAngle = cos(angle)
+ }
+
+ fun calcX(): Float {
+ return ellipseCenterX + ellipseA * tmpSinAngle
+ }
+
+ fun calcY(): Float {
+ return ellipseCenterY + ellipseB * tmpCosAngle
+ }
+
+ fun calcDX(): Float {
+ val vx = ellipseA * tmpCosAngle
+ val vy = -ellipseB * tmpSinAngle
+ val norm = arcVelocity / hypot(vx, vy)
+ return if (isVertical) -vx * norm else vx * norm
+ }
+
+ fun calcDY(): Float {
+ val vx = ellipseA * tmpCosAngle
+ val vy = -ellipseB * tmpSinAngle
+ val norm = arcVelocity / hypot(vx, vy)
+ return if (isVertical) -vy * norm else vy * norm
+ }
+
+ fun getLinearX(time: Float): Float {
+ var t = time
+ t = (t - time1) * oneOverDeltaTime
+ return x1 + t * (x2 - x1)
+ }
+
+ fun getLinearY(time: Float): Float {
+ var t = time
+ t = (t - time1) * oneOverDeltaTime
+ return y1 + t * (y2 - y1)
+ }
+
+ fun getLinearDX(): Float {
+ return ellipseCenterX
+ }
+
+ fun getLinearDY(): Float {
+ return ellipseCenterY
+ }
+
+ private fun lookup(v: Float): Float {
+ if (v <= 0) {
+ return 0.0f
+ }
+ if (v >= 1) {
+ return 1.0f
+ }
+ val pos = v * (lut.size - 1)
+ val iv = pos.toInt()
+ val off = pos - pos.toInt()
+ return lut[iv] + off * (lut[iv + 1] - lut[iv])
+ }
+
+ private fun buildTable(x1: Float, y1: Float, x2: Float, y2: Float) {
+ val a = x2 - x1
+ val b = y1 - y2
+ var lx = 0f
+ var ly = 0f
+ var dist = 0f
+ for (i in ourPercent.indices) {
+ val angle = Math.toRadians(90.0 * i / (ourPercent.size - 1)).toFloat()
+ val s = sin(angle)
+ val c = cos(angle)
+ val px = a * s
+ val py = b * c
+ if (i > 0) {
+ dist += hypot((px - lx), (py - ly))
+ ourPercent[i] = dist
+ }
+ lx = px
+ ly = py
+ }
+ arcDistance = dist
+ for (i in ourPercent.indices) {
+ ourPercent[i] /= dist
+ }
+ for (i in lut.indices) {
+ val pos = i / (lut.size - 1).toFloat()
+ val index = ourPercent.binarySearch(pos)
+ if (index >= 0) {
+ lut[i] = index / (ourPercent.size - 1).toFloat()
+ } else if (index == -1) {
+ lut[i] = 0f
+ } else {
+ val p1 = -index - 2
+ val p2 = -index - 1
+ val ans =
+ (p1 + (pos - ourPercent[p1]) / (ourPercent[p2] - ourPercent[p1])) /
+ (ourPercent.size - 1)
+ lut[i] = ans
+ }
+ }
+ }
+
+ companion object {
+ private var _ourPercent: FloatArray? = null
+ private val ourPercent: FloatArray
+ get() {
+ if (_ourPercent != null) {
+ return _ourPercent!!
+ }
+ _ourPercent = FloatArray(91)
+ return _ourPercent!!
+ }
+ private const val Epsilon = 0.001f
+ }
+ }
+
+ companion object {
+ const val ArcStartVertical = 1
+ const val ArcStartHorizontal = 2
+ const val ArcStartFlip = 3
+ const val ArcBelow = 4
+ const val ArcAbove = 5
+ const val ArcStartLinear = 0
+ private const val StartVertical = 1
+ private const val StartHorizontal = 2
+ private const val StartLinear = 3
+ private const val DownArc = 4
+ private const val UpArc = 5
+ }
+}
diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt
index 3fd4de1..ddcaa2c 100644
--- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt
+++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt
@@ -16,8 +16,11 @@
package androidx.compose.animation.core
+import androidx.collection.IntList
+import androidx.collection.IntObjectMap
+import androidx.collection.MutableIntList
+import androidx.collection.MutableIntObjectMap
import androidx.compose.animation.core.AnimationConstants.DefaultDurationMillis
-import androidx.compose.animation.core.internal.JvmDefaultWithCompatibility
import kotlin.math.min
/**
@@ -214,24 +217,162 @@
* delayMillis = delay
* )
*
- * @param keyframes a map from time to a value/easing function pair. The value in each entry
- * defines the animation value at that time, and the easing curve is used in the
- * interval starting from that time.
- * @param durationMillis total duration of the animation
- * @param delayMillis the amount of the time the animation should wait before it starts. Defaults to
- * 0.
+ * The interpolation between each value is dictated by [VectorizedKeyframeSpecElementInfo.arcMode] on each
+ * keyframe. If no keyframe information is provided, [initialArcMode] is used.
*
* @see [KeyframesSpec]
*/
-class VectorizedKeyframesSpec<V : AnimationVector>(
- private val keyframes: Map<Int, Pair<V, Easing>>,
+@OptIn(ExperimentalAnimationSpecApi::class)
+class VectorizedKeyframesSpec<V : AnimationVector> internal constructor(
+ // List of all timestamps. Must include start (time = 0), end (time = durationMillis) and all
+ // other timestamps found in [keyframes].
+ private val timestamps: IntList,
+ private val keyframes: IntObjectMap<VectorizedKeyframeSpecElementInfo<V>>,
override val durationMillis: Int,
- override val delayMillis: Int = 0
+ override val delayMillis: Int,
+ // Easing used for any segment of time not covered by [keyframes].
+ private val defaultEasing: Easing,
+ // The [ArcMode] used from time `0` until the first keyframe. So, it applies
+ // for the entire duration if [keyframes] is empty.
+ private val initialArcMode: ArcMode
) : VectorizedDurationBasedAnimationSpec<V> {
+ /**
+ * @param keyframes a map from time to a value/easing function pair. The value in each entry
+ * defines the animation value at that time, and the easing curve is used in
+ * the interval starting from that time.
+ * @param durationMillis total duration of the animation
+ * @param delayMillis the amount of the time the animation should wait before it starts.
+ * Defaults to 0.
+ */
+ constructor(
+ keyframes: Map<Int, Pair<V, Easing>>,
+ durationMillis: Int,
+ delayMillis: Int = 0
+ ) : this(
+ timestamps = kotlin.run {
+ val times = MutableIntList(keyframes.size + 2)
+ keyframes.forEach { (t, _) ->
+ times.add(t)
+ }
+ if (!keyframes.containsKey(0)) {
+ times.add(0, 0)
+ }
+ if (!keyframes.containsKey(durationMillis)) {
+ times.add(durationMillis)
+ }
+ times.sort()
+ return@run times
+ },
+ keyframes = kotlin.run {
+ val timeToInfoMap = MutableIntObjectMap<VectorizedKeyframeSpecElementInfo<V>>()
+ keyframes.forEach { (time, valueEasing) ->
+ timeToInfoMap[time] = VectorizedKeyframeSpecElementInfo(
+ vectorValue = valueEasing.first,
+ easing = valueEasing.second,
+ arcMode = ArcMode.Companion.ArcLinear
+ )
+ }
+ return@run timeToInfoMap
+ },
+ durationMillis = durationMillis,
+ delayMillis = delayMillis,
+ defaultEasing = LinearEasing,
+ initialArcMode = ArcMode.Companion.ArcLinear
+ )
+
+ /**
+ * List of time range for the given keyframes.
+ *
+ * This will be used to do a faster lookup for the corresponding Easing curves.
+ */
+ private lateinit var modes: IntArray
+ private lateinit var times: FloatArray
private lateinit var valueVector: V
private lateinit var velocityVector: V
+ // Objects for ArcSpline
+ private lateinit var lastInitialValue: V
+ private lateinit var lastTargetValue: V
+ private lateinit var posArray: FloatArray
+ private lateinit var slopeArray: FloatArray
+ private lateinit var arcSpline: ArcSpline
+
+ private fun init(initialValue: V, targetValue: V, initialVelocity: V) {
+ var requiresArcSpline = ::arcSpline.isInitialized
+
+ // Only need to initialize once
+ if (!::valueVector.isInitialized) {
+ valueVector = initialValue.newInstance()
+ velocityVector = initialVelocity.newInstance()
+
+ times = FloatArray(timestamps.size) {
+ timestamps[it].toFloat() / SecondsToMillis
+ }
+
+ modes = IntArray(timestamps.size) {
+ val mode = (keyframes[timestamps[it]]?.arcMode ?: initialArcMode)
+ if (mode != ArcMode.Companion.ArcLinear) {
+ requiresArcSpline = true
+ }
+
+ mode.value
+ }
+ }
+
+ if (!requiresArcSpline) {
+ return
+ }
+
+ // Initialize variables dependent on initial and/or target value
+ if (!::arcSpline.isInitialized ||
+ lastInitialValue != initialValue || lastTargetValue != targetValue
+ ) {
+ lastInitialValue = initialValue
+ lastTargetValue = targetValue
+
+ // Force to the next even dimension
+ val dimensionCount = initialValue.size % 2 + initialValue.size
+ posArray = FloatArray(dimensionCount)
+ slopeArray = FloatArray(dimensionCount)
+
+ // TODO(b/299477780): Re-use objects, after the first pass, only the initial and target
+ // may change, and only if the keyframes does not overwrite it
+ val values = Array(timestamps.size) {
+ when (val timestamp = timestamps[it]) {
+ // Start (zero) and end (durationMillis) may not have been declared in keyframes
+ 0 -> {
+ if (!keyframes.contains(timestamp)) {
+ FloatArray(dimensionCount, initialValue::get)
+ } else {
+ FloatArray(dimensionCount, keyframes[timestamp]!!.vectorValue::get)
+ }
+ }
+
+ durationMillis -> {
+ if (!keyframes.contains(timestamp)) {
+ FloatArray(dimensionCount, targetValue::get)
+ } else {
+ FloatArray(dimensionCount, keyframes[timestamp]!!.vectorValue::get)
+ }
+ }
+
+ // All other values are guaranteed to exist
+ else -> FloatArray(dimensionCount, keyframes[timestamp]!!.vectorValue::get)
+ }
+ }
+ arcSpline = ArcSpline(
+ arcModes = modes,
+ timePoints = times,
+ y = values
+ )
+ }
+ }
+
+ /**
+ * @Throws IllegalStateException When the initial or final value to animate within a keyframe is
+ * missing.
+ */
override fun getValueFromNanos(
playTimeNanos: Long,
initialValue: V,
@@ -240,49 +381,65 @@
): V {
val playTimeMillis = playTimeNanos / MillisToNanos
val clampedPlayTime = clampPlayTime(playTimeMillis).toInt()
+
// If there is a key frame defined with the given time stamp, return that value
- if (keyframes.containsKey(clampedPlayTime)) {
- return keyframes.getValue(clampedPlayTime).first
+ if (keyframes.contains(clampedPlayTime)) {
+ return keyframes[clampedPlayTime]!!.vectorValue
}
if (clampedPlayTime >= durationMillis) {
return targetValue
} else if (clampedPlayTime <= 0) return initialValue
- var startTime = 0
- var startVal = initialValue
- var endVal = targetValue
- var endTime: Int = durationMillis
- var easing: Easing = LinearEasing
- for ((timestamp, value) in keyframes) {
- if (clampedPlayTime > timestamp && timestamp >= startTime) {
- startTime = timestamp
- startVal = value.first
- easing = value.second
- } else if (clampedPlayTime < timestamp && timestamp <= endTime) {
- endTime = timestamp
- endVal = value.first
+ init(initialValue, targetValue, initialVelocity)
+
+ // ArcSpline is only initialized when necessary
+ if (::arcSpline.isInitialized) {
+ // ArcSpline requires eased play time in seconds
+ val easedTime = getEasedTime(clampedPlayTime)
+
+ arcSpline.getPos(
+ time = easedTime,
+ v = posArray
+ )
+ for (i in posArray.indices) {
+ valueVector[i] = posArray[i]
}
+ return valueVector
}
- // Now interpolate
- val fraction = easing.transform(
- (clampedPlayTime - startTime) / (endTime - startTime).toFloat()
- )
- init(initialValue)
- for (i in 0 until startVal.size) {
- valueVector[i] = lerp(startVal[i], endVal[i], fraction)
+ // If ArcSpline is not required we do a simple linear interpolation
+ val index = findEntryForTimeMillis(clampedPlayTime)
+
+ // For the `lerp` method we need the eased time as a fraction
+ val easedTime = getEasedTimeFromIndex(index, clampedPlayTime, true)
+
+ val timestampStart = timestamps[index]
+ val startValue: V = if (keyframes.contains(timestampStart)) {
+ keyframes[timestampStart]!!.vectorValue
+ } else if (index == 0) {
+ // Use initial value if it wasn't overwritten by the user
+ initialValue
+ } else {
+ throw IllegalStateException("No value to animate from at $clampedPlayTime millis")
+ }
+
+ val timestampEnd = timestamps[index + 1]
+ val endValue = if (keyframes.contains(timestampEnd)) {
+ keyframes[timestampEnd]!!.vectorValue
+ } else if (index + 1 == timestamps.size - 1) {
+ // Use target value if it wasn't overwritten by the user
+ targetValue
+ } else {
+ throw IllegalStateException("No value to animate to at $clampedPlayTime millis")
+ }
+
+ for (i in 0 until valueVector.size) {
+ valueVector[i] = lerp(startValue[i], endValue[i], easedTime)
}
return valueVector
}
- private fun init(value: V) {
- if (!::valueVector.isInitialized) {
- valueVector = value.newInstance()
- velocityVector = value.newInstance()
- }
- }
-
override fun getVelocityFromNanos(
playTimeNanos: Long,
initialValue: V,
@@ -291,9 +448,26 @@
): V {
val playTimeMillis = playTimeNanos / MillisToNanos
val clampedPlayTime = clampPlayTime(playTimeMillis)
- if (clampedPlayTime <= 0L) {
+ if (clampedPlayTime < 0L) {
return initialVelocity
}
+
+ init(initialValue, targetValue, initialVelocity)
+
+ // ArcSpline is only initialized when necessary
+ if (::arcSpline.isInitialized) {
+ val easedTime = getEasedTime(clampedPlayTime.toInt())
+ arcSpline.getSlope(
+ time = easedTime,
+ v = slopeArray
+ )
+ for (i in slopeArray.indices) {
+ velocityVector[i] = slopeArray[i]
+ }
+ return velocityVector
+ }
+
+ // Velocity calculation when ArcSpline is not used
val startNum = getValueFromMillis(
clampedPlayTime - 1,
initialValue,
@@ -306,13 +480,113 @@
targetValue,
initialVelocity
)
-
- init(initialValue)
for (i in 0 until startNum.size) {
velocityVector[i] = (startNum[i] - endNum[i]) * 1000f
}
return velocityVector
}
+
+ private fun getEasedTime(timeMillis: Int): Float {
+ // There's no promise on the nature of the given time, so we need to search for the correct
+ // time range at every call
+ val index = findEntryForTimeMillis(timeMillis)
+ return getEasedTimeFromIndex(index, timeMillis, false)
+ }
+
+ private fun getEasedTimeFromIndex(
+ index: Int,
+ timeMillis: Int,
+ asFraction: Boolean
+ ): Float {
+ if (index >= timestamps.lastIndex) {
+ // Return the same value. This may only happen at the end of the animation.
+ return timeMillis.toFloat() / SecondsToMillis
+ }
+ val timeMin = timestamps[index]
+ val timeMax = timestamps[index + 1]
+
+ if (timeMillis == timeMin) {
+ return timeMin.toFloat() / SecondsToMillis
+ }
+
+ val timeRange = timeMax - timeMin
+ val easing = keyframes[timeMin]?.easing ?: defaultEasing
+ val rawFraction = (timeMillis - timeMin).toFloat() / timeRange
+ val easedFraction = easing.transform(rawFraction)
+
+ if (asFraction) {
+ return easedFraction
+ }
+ return (timeRange * easedFraction + timeMin) / SecondsToMillis
+ }
+
+ /**
+ * Returns the entry index such that:
+ *
+ * [timeMillis] >= Entry(i).key && [timeMillis] < Entry(i+1).key
+ */
+ private fun findEntryForTimeMillis(timeMillis: Int): Int {
+ val index = timestamps.binarySearch(timeMillis)
+ return if (index < -1) -(index + 2) else index
+ }
+
+ @Suppress("unused")
+ private val ArcMode.value: Int
+ get() = when (this) {
+ ArcMode.Companion.ArcAbove -> ArcSpline.ArcAbove
+ ArcMode.Companion.ArcBelow -> ArcSpline.ArcBelow
+ ArcMode.Companion.ArcLinear -> ArcSpline.ArcStartLinear
+ else -> ArcSpline.ArcStartLinear // Unknown mode, fallback to linear
+ }
+}
+
+@OptIn(ExperimentalAnimationSpecApi::class)
+internal data class VectorizedKeyframeSpecElementInfo<V : AnimationVector>(
+ val vectorValue: V,
+ val easing: Easing,
+ val arcMode: ArcMode
+)
+
+/**
+ * Interpolation mode for Arc-based animation spec.
+ *
+ * @see ArcAbove
+ * @see ArcBelow
+ * @see ArcLinear
+ *
+ * @see ArcAnimationSpec
+ */
+@ExperimentalAnimationSpecApi
+sealed class ArcMode {
+ companion object {
+ /**
+ * Interpolates using a quarter of an Ellipse where the curve is "above" the center of the
+ * Ellipse.
+ */
+ @ExperimentalAnimationSpecApi
+ object ArcAbove : ArcMode()
+
+ /**
+ * Interpolates using a quarter of an Ellipse where the curve is "below" the center of the
+ * Ellipse.
+ */
+ @ExperimentalAnimationSpecApi
+ object ArcBelow : ArcMode()
+
+ /**
+ * An [ArcMode] that forces linear interpolation.
+ *
+ * You'll likely only use this mode within a keyframe.
+ */
+ @ExperimentalAnimationSpecApi
+ object ArcLinear : ArcMode()
+
+ /**
+ * Unused [ArcMode] to prevent exhaustive `when` usage.
+ */
+ @Suppress("unused")
+ private object UnexpectedArc : ArcMode()
+ }
}
/**
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
index c138d13..40b35d5 100644
--- a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/AnimationDemos.kt
@@ -60,6 +60,7 @@
import androidx.compose.animation.demos.statetransition.LoadingAnimationDemo
import androidx.compose.animation.demos.statetransition.MultiDimensionalAnimationDemo
import androidx.compose.animation.demos.statetransition.RepeatedRotationDemo
+import androidx.compose.animation.demos.suspendfun.ArcOffsetDemo
import androidx.compose.animation.demos.suspendfun.InfiniteAnimationDemo
import androidx.compose.animation.demos.suspendfun.OffsetKeyframeSplinePlaygroundDemo
import androidx.compose.animation.demos.suspendfun.OffsetKeyframeWithSplineDemo
@@ -166,6 +167,7 @@
ComposableDemo("Spline Keyframes Playground") {
OffsetKeyframeSplinePlaygroundDemo()
},
+ ComposableDemo("Arc Offset Demo") { ArcOffsetDemo() },
)
),
DemoCategory(
diff --git a/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/suspendfun/ArcOffsetDemo.kt b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/suspendfun/ArcOffsetDemo.kt
new file mode 100644
index 0000000..ea765b4
--- /dev/null
+++ b/compose/animation/animation/integration-tests/animation-demos/src/main/java/androidx/compose/animation/demos/suspendfun/ArcOffsetDemo.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023 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 androidx.compose.animation.demos.suspendfun
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.ArcMode.Companion.ArcAbove
+import androidx.compose.animation.core.ArcMode.Companion.ArcBelow
+import androidx.compose.animation.core.ArcMode.Companion.ArcLinear
+import androidx.compose.animation.core.ExperimentalAnimationSpecApi
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.VectorConverter
+import androidx.compose.animation.core.keyframes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+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.PathEffect
+import androidx.compose.ui.graphics.PointMode
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.round
+import androidx.compose.ui.unit.toSize
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+
+@SuppressWarnings("PrimitiveInCollection")
+@OptIn(ExperimentalAnimationSpecApi::class)
+@Preview
+@Composable
+fun ArcOffsetDemo() {
+ val animOffset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
+ val points = remember { mutableStateListOf<Offset>() }
+ val target = remember { MutableStateFlow(Offset.Unspecified) }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .pointerInput(Unit) {
+ val halfSize = size.toSize() * 0.5f
+ detectTapGestures {
+ target.value = Offset(
+ x = it.x - halfSize.width,
+ y = it.y - halfSize.height
+ )
+ }
+ }
+ .drawBehind {
+ val halfSize = size * 0.5f
+ translate(halfSize.width, halfSize.height) {
+ drawPoints(
+ points = points,
+ pointMode = PointMode.Lines,
+ color = Color(0xFFFFC107),
+ strokeWidth = 4f,
+ pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 2f)),
+ cap = StrokeCap.Round
+ )
+ }
+ }
+ ) {
+ Text("Tap anywhere to animate")
+ Box(
+ Modifier
+ .size(50.dp)
+ .align(Alignment.Center)
+ .offset {
+ animOffset.value.round()
+ }
+ .background(Color.Red, RoundedCornerShape(50))
+ )
+ }
+
+ LaunchedEffect(Unit) {
+ target.collectLatest { target ->
+ if (target != Offset.Unspecified) {
+ points.clear()
+ val current = animOffset.value
+ val diffOff = target - current
+ val halfDiff = diffOff * 0.5f
+ val midOffset = current + halfDiff
+ val mode = if (diffOff.y > 0f) ArcBelow else ArcAbove
+ animOffset.animateTo(
+ targetValue = target,
+ animationSpec = keyframes {
+ durationMillis = 1400
+
+ current atFraction 0f using LinearEasing using ArcLinear
+ midOffset atFraction 0.5f using FastOutSlowInEasing using mode
+ }
+ ) {
+ points.add(value)
+ }
+ }
+ }
+ }
+}