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 @@ + + + + + + + + + +