diff --git a/lib/java/com/google/android/material/motion/MotionUtils.java b/lib/java/com/google/android/material/motion/MotionUtils.java
index d3d2842fc42..e6b2aca6b9a 100644
--- a/lib/java/com/google/android/material/motion/MotionUtils.java
+++ b/lib/java/com/google/android/material/motion/MotionUtils.java
@@ -15,24 +15,17 @@
*/
package com.google.android.material.motion;
-import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
-
import android.animation.TimeInterpolator;
import android.content.Context;
import android.util.TypedValue;
+import android.view.animation.AnimationUtils;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
-import androidx.annotation.RestrictTo;
import androidx.core.graphics.PathParser;
import androidx.core.view.animation.PathInterpolatorCompat;
import com.google.android.material.resources.MaterialAttributes;
-/**
- * A utility class for motion system functions.
- *
- * @hide
- */
-@RestrictTo(LIBRARY_GROUP)
+/** A utility class for motion system functions. */
public class MotionUtils {
// Constants corresponding to motionEasing* theme attr values.
@@ -43,61 +36,96 @@ public class MotionUtils {
private MotionUtils() {}
+ /**
+ * Resolve a duration from a material duration theme attribute.
+ *
+ * @param context the context from where the theme attribute will be resolved.
+ * @param attrResId the {@code motionDuration*} theme attribute to resolve
+ * @param defaultDuration the duration to be returned if unable to resolve {@code attrResId}
+ * @return the resolved {@code int} duration which {@code attrResId} points to or the {@code
+ * defaultDuration} if resolution was unsuccessful.
+ */
public static int resolveThemeDuration(
@NonNull Context context, @AttrRes int attrResId, int defaultDuration) {
return MaterialAttributes.resolveInteger(context, attrResId, defaultDuration);
}
+ /**
+ * Load an interpolator from a material easing theme attribute.
+ *
+ * @param context context from where the theme attribute will be resolved
+ * @param attrResId the {@code motionEasing*} theme attribute to resolve
+ * @param defaultInterpolator the interpolator to be returned if unable to resolve {@code
+ * attrResId}.
+ * @return the resolved {@link TimeInterpolator} which {@code attrResId} points to or the {@code
+ * defaultInterpolator} if resolution was unsuccessful.
+ */
@NonNull
public static TimeInterpolator resolveThemeInterpolator(
@NonNull Context context,
@AttrRes int attrResId,
@NonNull TimeInterpolator defaultInterpolator) {
TypedValue easingValue = new TypedValue();
- if (context.getTheme().resolveAttribute(attrResId, easingValue, true)) {
- if (easingValue.type != TypedValue.TYPE_STRING) {
- throw new IllegalArgumentException("Motion easing theme attribute must be a string");
- }
+ if (!context.getTheme().resolveAttribute(attrResId, easingValue, true)) {
+ return defaultInterpolator;
+ }
- String easingString = String.valueOf(easingValue.string);
+ if (easingValue.type != TypedValue.TYPE_STRING) {
+ throw new IllegalArgumentException(
+ "Motion easing theme attribute must be an @interpolator resource for"
+ + " ?attr/motionEasing*Interpolator attributes or a string for"
+ + " ?attr/motionEasing* attributes.");
+ }
- if (isEasingType(easingString, EASING_TYPE_CUBIC_BEZIER)) {
- String controlPointsString = getEasingContent(easingString, EASING_TYPE_CUBIC_BEZIER);
- String[] controlPoints = controlPointsString.split(",");
- if (controlPoints.length != 4) {
- throw new IllegalArgumentException(
- "Motion easing theme attribute must have 4 control points if using bezier curve"
- + " format; instead got: "
- + controlPoints.length);
- }
+ String easingString = String.valueOf(easingValue.string);
+ if (isLegacyEasingAttribute(easingString)) {
+ return getLegacyThemeInterpolator(easingString);
+ }
+
+ return AnimationUtils.loadInterpolator(context, easingValue.resourceId);
+ }
- float controlX1 = getControlPoint(controlPoints, 0);
- float controlY1 = getControlPoint(controlPoints, 1);
- float controlX2 = getControlPoint(controlPoints, 2);
- float controlY2 = getControlPoint(controlPoints, 3);
- return PathInterpolatorCompat.create(controlX1, controlY1, controlX2, controlY2);
- } else if (isEasingType(easingString, EASING_TYPE_PATH)) {
- String path = getEasingContent(easingString, EASING_TYPE_PATH);
- return PathInterpolatorCompat.create(PathParser.createPathFromPathData(path));
- } else {
- throw new IllegalArgumentException("Invalid motion easing type: " + easingString);
+ private static TimeInterpolator getLegacyThemeInterpolator(String easingString) {
+ if (isLegacyEasingType(easingString, EASING_TYPE_CUBIC_BEZIER)) {
+ String controlPointsString = getLegacyEasingContent(easingString, EASING_TYPE_CUBIC_BEZIER);
+ String[] controlPoints = controlPointsString.split(",");
+ if (controlPoints.length != 4) {
+ throw new IllegalArgumentException(
+ "Motion easing theme attribute must have 4 control points if using bezier curve"
+ + " format; instead got: "
+ + controlPoints.length);
}
+
+ float controlX1 = getLegacyControlPoint(controlPoints, 0);
+ float controlY1 = getLegacyControlPoint(controlPoints, 1);
+ float controlX2 = getLegacyControlPoint(controlPoints, 2);
+ float controlY2 = getLegacyControlPoint(controlPoints, 3);
+ return PathInterpolatorCompat.create(controlX1, controlY1, controlX2, controlY2);
+ } else if (isLegacyEasingType(easingString, EASING_TYPE_PATH)) {
+ String path = getLegacyEasingContent(easingString, EASING_TYPE_PATH);
+ return PathInterpolatorCompat.create(PathParser.createPathFromPathData(path));
+ } else {
+ throw new IllegalArgumentException("Invalid motion easing type: " + easingString);
}
- return defaultInterpolator;
}
- private static boolean isEasingType(String easingString, String easingType) {
+ private static boolean isLegacyEasingAttribute(String easingString) {
+ return isLegacyEasingType(easingString, EASING_TYPE_CUBIC_BEZIER)
+ || isLegacyEasingType(easingString, EASING_TYPE_PATH);
+ }
+
+ private static boolean isLegacyEasingType(String easingString, String easingType) {
return easingString.startsWith(easingType + EASING_TYPE_FORMAT_START)
&& easingString.endsWith(EASING_TYPE_FORMAT_END);
}
- private static String getEasingContent(String easingString, String easingType) {
+ private static String getLegacyEasingContent(String easingString, String easingType) {
return easingString.substring(
easingType.length() + EASING_TYPE_FORMAT_START.length(),
easingString.length() - EASING_TYPE_FORMAT_END.length());
}
- private static float getControlPoint(String[] controlPoints, int index) {
+ private static float getLegacyControlPoint(String[] controlPoints, int index) {
float controlPoint = Float.parseFloat(controlPoints[index]);
if (controlPoint < 0 || controlPoint > 1) {
throw new IllegalArgumentException(
diff --git a/lib/javatests/com/google/android/material/motion/AndroidManifest.xml b/lib/javatests/com/google/android/material/motion/AndroidManifest.xml
new file mode 100644
index 00000000000..270cd8a07d8
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/lib/javatests/com/google/android/material/motion/MotionUtilsTest.java b/lib/javatests/com/google/android/material/motion/MotionUtilsTest.java
new file mode 100644
index 00000000000..dd8adf441b8
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/MotionUtilsTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.material.motion;
+
+import com.google.android.material.R;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.animation.TimeInterpolator;
+import android.os.Build.VERSION_CODES;
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.animation.Interpolator;
+import android.view.animation.LinearInterpolator;
+import android.view.animation.PathInterpolator;
+import androidx.annotation.RequiresApi;
+import androidx.test.core.app.ApplicationProvider;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.android.controller.ActivityController;
+import org.robolectric.annotation.Config;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = VERSION_CODES.LOLLIPOP)
+@DoNotInstrument
+@RequiresApi(api = VERSION_CODES.LOLLIPOP)
+public class MotionUtilsTest {
+
+ private ActivityController activityController;
+
+ @Rule public final ExpectedException thrown = ExpectedException.none();
+
+ @Test
+ public void testResolvesThemeInterpolator() {
+ assertThemeInterpolatorIsInstanceOf(
+ R.style.Theme_Material3_DayNight,
+ R.attr.motionEasingStandardInterpolator,
+ LinearInterpolator.class);
+ }
+
+ @Test
+ public void testCustomInterpolator_resolvesThemeInterpolator() {
+ assertThemeInterpolatorIsInstanceOf(
+ R.style.Theme_Material3_DayNight_CustomInterpolator,
+ R.attr.motionEasingStandardInterpolator,
+ LinearInterpolator.class);
+ }
+
+ @Test
+ public void testCustomAnimInterpolator_resolvesThemeInterpolator() {
+ assertThemeInterpolatorIsInstanceOf(
+ R.style.Theme_Material3_DayNight_CustomAnimInterpolator,
+ R.attr.motionEasingStandardInterpolator,
+ LinearInterpolator.class);
+ }
+
+ @Test
+ public void testResolvesLegacyInterpolator() {
+ assertThemeInterpolatorIsInstanceOf(
+ R.style.Theme_Material3_DayNight, R.attr.motionEasingStandard, PathInterpolator.class);
+ }
+
+ @Test
+ public void testMaterialComponentsTheme_resolveUnavailableInterpolatorReturnsDefault() {
+ createActivityAndSetTheme(R.style.Theme_MaterialComponents_DayNight);
+
+ DefaultDummyInterpolator defaultInterpolator = new DefaultDummyInterpolator();
+ TimeInterpolator standardInterpolator =
+ MotionUtils.resolveThemeInterpolator(
+ activityController.get().getApplicationContext(),
+ R.attr.motionEasingStandardInterpolator,
+ defaultInterpolator);
+ assertThat(standardInterpolator).isEqualTo(defaultInterpolator);
+ }
+
+ @Test
+ public void testMaterialComponentsTheme_resolvesLegacyInterpolator() {
+ assertThemeInterpolatorIsInstanceOf(
+ R.style.Theme_MaterialComponents_DayNight,
+ R.attr.motionEasingStandard,
+ PathInterpolator.class);
+ }
+
+ @Test
+ public void testMaterialComponentsThemeIncorrectLegacyAttrType_shouldThrowException() {
+ createActivityAndSetTheme(
+ R.style.Theme_MaterialComponents_DayNight_IncorrectLegacyEasingAttrType);
+
+ // ThrowingRunnable used by assertThrows is not available until gradle 4.13
+ thrown.expect(IllegalArgumentException.class);
+ MotionUtils.resolveThemeInterpolator(
+ activityController.get().getApplicationContext(),
+ R.attr.motionEasingStandard,
+ new DefaultDummyInterpolator());
+ }
+
+ @Test
+ public void testMaterialComponentsThemeIncorrectLegacyFormatting_shouldThrowException() {
+ createActivityAndSetTheme(
+ R.style.Theme_MaterialComponents_DayNight_IncorrectLegacyEasingFormat);
+
+ // ThrowingRunnable used by assertThrows is not available until gradle 4.13
+ thrown.expect(IllegalArgumentException.class);
+ MotionUtils.resolveThemeInterpolator(
+ activityController.get().getApplicationContext(),
+ R.attr.motionEasingStandard,
+ new DefaultDummyInterpolator());
+ }
+
+ private void createActivityAndSetTheme(int themeId) {
+ ApplicationProvider.getApplicationContext().setTheme(themeId);
+ activityController = Robolectric.buildActivity(AppCompatActivity.class).create();
+ }
+
+ private void assertThemeInterpolatorIsInstanceOf(int theme, int attrId, Class> expectedClass) {
+ createActivityAndSetTheme(theme);
+ DefaultDummyInterpolator defaultInterpolator = new DefaultDummyInterpolator();
+ TimeInterpolator themeInterpolator =
+ MotionUtils.resolveThemeInterpolator(
+ activityController.get().getApplicationContext(), attrId, defaultInterpolator);
+ assertThat(themeInterpolator).isNotEqualTo(defaultInterpolator);
+ // Robolectric's shadow of Android's AnimationUtils always returns a LinearInterpolator.
+ assertThat(themeInterpolator).isInstanceOf(expectedClass);
+ }
+
+ private static class DefaultDummyInterpolator implements Interpolator {
+ @Override
+ public float getInterpolation(float input) {
+ return 0;
+ }
+ }
+}
diff --git a/lib/javatests/com/google/android/material/motion/res/anim/custom_anim_interpolator.xml b/lib/javatests/com/google/android/material/motion/res/anim/custom_anim_interpolator.xml
new file mode 100644
index 00000000000..ceba7c70aee
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/res/anim/custom_anim_interpolator.xml
@@ -0,0 +1,19 @@
+
+
+
diff --git a/lib/javatests/com/google/android/material/motion/res/interpolator/custom_interpolator.xml b/lib/javatests/com/google/android/material/motion/res/interpolator/custom_interpolator.xml
new file mode 100644
index 00000000000..3881b33f617
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/res/interpolator/custom_interpolator.xml
@@ -0,0 +1,21 @@
+
+
+
diff --git a/lib/javatests/com/google/android/material/motion/res/values/colors.xml b/lib/javatests/com/google/android/material/motion/res/values/colors.xml
new file mode 100644
index 00000000000..6acfd7b07d9
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/res/values/colors.xml
@@ -0,0 +1,19 @@
+
+
+
+ #FFFFFF
+
diff --git a/lib/javatests/com/google/android/material/motion/res/values/strings.xml b/lib/javatests/com/google/android/material/motion/res/values/strings.xml
new file mode 100644
index 00000000000..3cb9d84321d
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/res/values/strings.xml
@@ -0,0 +1,19 @@
+
+
+
+ cubic-bezier(0.2, 0.0, 0.0)
+
diff --git a/lib/javatests/com/google/android/material/motion/res/values/themes.xml b/lib/javatests/com/google/android/material/motion/res/values/themes.xml
new file mode 100644
index 00000000000..c96035710bc
--- /dev/null
+++ b/lib/javatests/com/google/android/material/motion/res/values/themes.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+