[go: nahoru, domu]

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].
+ *
+ * &nbsp;
+ *
+ * As such, it's recommended that [ArcAnimationSpec] is only used for positional values such as:
+ * [Offset], [IntOffset] or [androidx.compose.ui.unit.DpOffset].
+ *
+ * &nbsp;
+ *
+ * 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.
+         *
+         * &nbsp;
+         *
+         * 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)
+                }
+            }
+        }
+    }
+}