From 433c81175520df94a19096c2d476db112bfe0466 Mon Sep 17 00:00:00 2001 From: Miranda Kephart Date: Wed, 22 May 2019 12:25:51 -0400 Subject: [PATCH] Add white edgelights to AOSP Moves most of the invocation code (as well as PerimeterPathGuide, CornerPathRenderer, and EdgeLight) into base SystemUI. Shows white edge lights coming in from the corners into the center, from gesture or squeeze. Bug: 132984557 Test: manual Change-Id: Icbe611c3513f24f0ac13b68bd4d65f7cb4d402d6 --- .../SystemUI/res/layout/invocation_lights.xml | 23 ++ packages/SystemUI/res/values/colors.xml | 9 +- .../systemui/assist/AssistManager.java | 89 +++- .../assist/ui/CircularCornerPathRenderer.java | 65 +++ .../assist/ui/CornerPathRenderer.java | 140 +++++++ .../assist/ui/DefaultUiController.java | 178 ++++++++ .../systemui/assist/ui/DisplayUtils.java | 128 ++++++ .../android/systemui/assist/ui/EdgeLight.java | 99 +++++ .../assist/ui/InvocationLightsView.java | 194 +++++++++ .../assist/ui/PerimeterPathGuide.java | 389 ++++++++++++++++++ 10 files changed, 1295 insertions(+), 19 deletions(-) create mode 100644 packages/SystemUI/res/layout/invocation_lights.xml create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/CircularCornerPathRenderer.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/CornerPathRenderer.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/DefaultUiController.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/DisplayUtils.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/EdgeLight.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/InvocationLightsView.java create mode 100644 packages/SystemUI/src/com/android/systemui/assist/ui/PerimeterPathGuide.java diff --git a/packages/SystemUI/res/layout/invocation_lights.xml b/packages/SystemUI/res/layout/invocation_lights.xml new file mode 100644 index 0000000000000..ff78670d07196 --- /dev/null +++ b/packages/SystemUI/res/layout/invocation_lights.xml @@ -0,0 +1,23 @@ + + + + diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml index 3f84b32ee0c27..abf4fdf03b93a 100644 --- a/packages/SystemUI/res/values/colors.xml +++ b/packages/SystemUI/res/values/colors.xml @@ -166,14 +166,17 @@ #ffdadce0 - #80000000 + #80000000 #ff757575 - #ff008577 - #ffd93025 + #ff008577 + #ffd93025 #ccffffff + + #ffffffff + #F8F9FA #F1F3F4 diff --git a/packages/SystemUI/src/com/android/systemui/assist/AssistManager.java b/packages/SystemUI/src/com/android/systemui/assist/AssistManager.java index 2c38e513d7de5..bca96623a4b6c 100644 --- a/packages/SystemUI/src/com/android/systemui/assist/AssistManager.java +++ b/packages/SystemUI/src/com/android/systemui/assist/AssistManager.java @@ -40,8 +40,11 @@ import com.android.internal.logging.nano.MetricsProto.MetricsEvent; import com.android.keyguard.KeyguardUpdateMonitor; import com.android.settingslib.applications.InterestingConfigChanges; import com.android.systemui.ConfigurationChangedReceiver; +import com.android.systemui.Dependency; import com.android.systemui.R; import com.android.systemui.SysUiServiceProvider; +import com.android.systemui.assist.ui.DefaultUiController; +import com.android.systemui.recents.OverviewProxyService; import com.android.systemui.statusbar.CommandQueue; import com.android.systemui.statusbar.policy.DeviceProvisionedController; @@ -50,6 +53,40 @@ import com.android.systemui.statusbar.policy.DeviceProvisionedController; */ public class AssistManager implements ConfigurationChangedReceiver { + /** + * Controls the UI for showing Assistant invocation progress. + */ + public interface UiController { + /** + * Updates the invocation progress. + * + * @param type one of INVOCATION_TYPE_GESTURE, INVOCATION_TYPE_ACTIVE_EDGE, + * INVOCATION_TYPE_VOICE, INVOCATION_TYPE_QUICK_SEARCH_BAR, + * INVOCATION_HOME_BUTTON_LONG_PRESS + * @param progress a float between 0 and 1 inclusive. 0 represents the beginning of the + * gesture; 1 represents the end. + */ + void onInvocationProgress(int type, float progress); + + /** + * Called when an invocation gesture completes. + * + * @param velocity the speed of the invocation gesture, in pixels per millisecond. For + * drags, this is 0. + */ + void onGestureCompletion(float velocity); + + /** + * Called with the Bundle from VoiceInteractionSessionListener.onSetUiHints. + */ + void processBundle(Bundle hints); + + /** + * Hides the UI. + */ + void hide(); + } + private static final String TAG = "AssistManager"; // Note that VERBOSE logging may leak PII (e.g. transcription contents). @@ -76,6 +113,7 @@ public class AssistManager implements ConfigurationChangedReceiver { private final InterestingConfigChanges mInterestingConfigChanges; private final PhoneStateMonitor mPhoneStateMonitor; private final AssistHandleBehaviorController mHandleController; + private final UiController mUiController; private AssistOrbContainer mView; private final DeviceProvisionedController mDeviceProvisionedController; @@ -85,16 +123,16 @@ public class AssistManager implements ConfigurationChangedReceiver { private IVoiceInteractionSessionShowCallback mShowCallback = new IVoiceInteractionSessionShowCallback.Stub() { - @Override - public void onFailed() throws RemoteException { - mView.post(mHideRunnable); - } + @Override + public void onFailed() throws RemoteException { + mView.post(mHideRunnable); + } - @Override - public void onShown() throws RemoteException { - mView.post(mHideRunnable); - } - }; + @Override + public void onShown() throws RemoteException { + mView.post(mHideRunnable); + } + }; private Runnable mHideRunnable = new Runnable() { @Override @@ -119,6 +157,23 @@ public class AssistManager implements ConfigurationChangedReceiver { | ActivityInfo.CONFIG_SCREEN_LAYOUT | ActivityInfo.CONFIG_ASSETS_PATHS); onConfigurationChanged(context.getResources().getConfiguration()); mShouldEnableOrb = !ActivityManager.isLowRamDeviceStatic(); + + mUiController = new DefaultUiController(mContext); + + OverviewProxyService overviewProxy = Dependency.get(OverviewProxyService.class); + overviewProxy.addCallback(new OverviewProxyService.OverviewProxyListener() { + @Override + public void onAssistantProgress(float progress) { + // Progress goes from 0 to 1 to indicate how close the assist gesture is to + // completion. + onInvocationProgress(INVOCATION_TYPE_GESTURE, progress); + } + + @Override + public void onAssistantGestureCompletion(float velocity) { + onGestureCompletion(velocity); + } + }); } protected void registerVoiceInteractionSessionListener() { @@ -196,21 +251,23 @@ public class AssistManager implements ConfigurationChangedReceiver { // Logs assistant start with invocation type. MetricsLogger.action( new LogMaker(MetricsEvent.ASSISTANT) - .setType(MetricsEvent.TYPE_OPEN).setSubtype(args.getInt(INVOCATION_TYPE_KEY))); + .setType(MetricsEvent.TYPE_OPEN).setSubtype( + args.getInt(INVOCATION_TYPE_KEY))); startAssistInternal(args, assistComponent, isService); } /** Called when the user is performing an assistant invocation action (e.g. Active Edge) */ public void onInvocationProgress(int type, float progress) { - // intentional no-op, vendor's AssistManager implementation should override if needed. + mUiController.onInvocationProgress(type, progress); } - /** Called when the user has invoked the assistant with the incoming velocity, in pixels per + /** + * Called when the user has invoked the assistant with the incoming velocity, in pixels per * millisecond. For invocations without a velocity (e.g. slow drag), the velocity is set to * zero. */ - public void onAssistantGestureCompletion(float velocity) { - // intentional no-op, vendor's AssistManager implementation should override if needed. + public void onGestureCompletion(float velocity) { + mUiController.onGestureCompletion(velocity); } public void hideAssist() { @@ -264,7 +321,7 @@ public class AssistManager implements ConfigurationChangedReceiver { Settings.Secure.ASSIST_STRUCTURE_ENABLED, 1, UserHandle.USER_CURRENT) != 0; final SearchManager searchManager = - (SearchManager) mContext.getSystemService(Context.SEARCH_SERVICE); + (SearchManager) mContext.getSystemService(Context.SEARCH_SERVICE); if (searchManager == null) { return; } @@ -329,7 +386,7 @@ public class AssistManager implements ConfigurationChangedReceiver { // Look for the search icon specified in the activity meta-data Bundle metaData = isService ? packageManager.getServiceInfo( - component, PackageManager.GET_META_DATA).metaData + component, PackageManager.GET_META_DATA).metaData : packageManager.getActivityInfo( component, PackageManager.GET_META_DATA).metaData; if (metaData != null) { diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/CircularCornerPathRenderer.java b/packages/SystemUI/src/com/android/systemui/assist/ui/CircularCornerPathRenderer.java new file mode 100644 index 0000000000000..162e09e4d23dc --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/CircularCornerPathRenderer.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import android.graphics.Path; + +/** + * Describes paths for circular rounded device corners. + */ +public final class CircularCornerPathRenderer extends CornerPathRenderer { + + private final int mCornerRadiusBottom; + private final int mCornerRadiusTop; + private final int mHeight; + private final int mWidth; + private final Path mPath = new Path(); + + public CircularCornerPathRenderer(int cornerRadiusBottom, int cornerRadiusTop, + int width, int height) { + mCornerRadiusBottom = cornerRadiusBottom; + mCornerRadiusTop = cornerRadiusTop; + mHeight = height; + mWidth = width; + } + + @Override // CornerPathRenderer + public Path getCornerPath(Corner corner) { + mPath.reset(); + switch (corner) { + case BOTTOM_LEFT: + mPath.moveTo(0, mHeight - mCornerRadiusBottom); + mPath.arcTo(0, mHeight - mCornerRadiusBottom * 2, mCornerRadiusBottom * 2, mHeight, + 180, -90, true); + break; + case BOTTOM_RIGHT: + mPath.moveTo(mWidth - mCornerRadiusBottom, mHeight); + mPath.arcTo(mWidth - mCornerRadiusBottom * 2, mHeight - mCornerRadiusBottom * 2, + mWidth, mHeight, 90, -90, true); + break; + case TOP_RIGHT: + mPath.moveTo(mWidth, mCornerRadiusTop); + mPath.arcTo(mWidth - mCornerRadiusTop, 0, mWidth, mCornerRadiusTop, 0, -90, true); + break; + case TOP_LEFT: + mPath.moveTo(mCornerRadiusTop, 0); + mPath.arcTo(0, 0, mCornerRadiusTop, mCornerRadiusTop, 270, -90, true); + break; + } + return mPath; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/CornerPathRenderer.java b/packages/SystemUI/src/com/android/systemui/assist/ui/CornerPathRenderer.java new file mode 100644 index 0000000000000..2b40e6501fdd0 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/CornerPathRenderer.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import android.graphics.Path; +import android.graphics.PointF; + +import java.util.ArrayList; +import java.util.List; + +/** + * Handles paths along device corners. + */ +public abstract class CornerPathRenderer { + + // The maximum delta between the corner curve and points approximating the corner curve. + private static final float ACCEPTABLE_ERROR = 0.1f; + + /** + * For convenience, labels the four device corners. + * + * Corners must be listed in CCW order, otherwise we'll break rotation. + */ + public enum Corner { + BOTTOM_LEFT, + BOTTOM_RIGHT, + TOP_RIGHT, + TOP_LEFT + } + + /** + * Returns the path along the inside of a corner (centered insetAmountPx from the corner's + * edge). + */ + public Path getInsetPath(Corner corner, float insetAmountPx) { + return approximateInnerPath(getCornerPath(corner), -insetAmountPx); + } + + /** + * Returns the path of a corner (centered on the exact corner). Must be implemented by extending + * classes, based on the device-specific rounded corners. A default implementation for circular + * corners is provided by CircularCornerPathRenderer. + */ + public abstract Path getCornerPath(Corner corner); + + private Path approximateInnerPath(Path input, float delta) { + List points = shiftBy(getApproximatePoints(input), delta); + return toPath(points); + } + + private ArrayList getApproximatePoints(Path path) { + float[] rawInput = path.approximate(ACCEPTABLE_ERROR); + + ArrayList output = new ArrayList<>(); + + for (int i = 0; i < rawInput.length; i = i + 3) { + output.add(new PointF(rawInput[i + 1], rawInput[i + 2])); + } + + return output; + } + + private ArrayList shiftBy(ArrayList input, float delta) { + ArrayList output = new ArrayList<>(); + + for (int i = 0; i < input.size(); i++) { + PointF point = input.get(i); + PointF normal = normalAt(input, i); + PointF shifted = + new PointF(point.x + (normal.x * delta), point.y + (normal.y * delta)); + output.add(shifted); + } + return output; + } + + private Path toPath(List points) { + Path path = new Path(); + if (points.size() > 0) { + path.moveTo(points.get(0).x, points.get(0).y); + for (PointF point : points.subList(1, points.size())) { + path.lineTo(point.x, point.y); + } + } + return path; + } + + private PointF normalAt(List points, int index) { + PointF d1; + if (index == 0) { + d1 = new PointF(0, 0); + } else { + PointF point = points.get(index); + PointF previousPoint = points.get(index - 1); + d1 = new PointF((point.x - previousPoint.x), (point.y - previousPoint.y)); + } + + PointF d2; + if (index == (points.size() - 1)) { + d2 = new PointF(0, 0); + } else { + PointF point = points.get(index); + PointF nextPoint = points.get(index + 1); + d2 = new PointF((nextPoint.x - point.x), (nextPoint.y - point.y)); + } + + return rotate90Ccw(normalize(new PointF(d1.x + d2.x, d1.y + d2.y))); + } + + private PointF rotate90Ccw(PointF input) { + return new PointF(-input.y, input.x); + } + + private float magnitude(PointF point) { + return (float) Math.sqrt((point.x * point.x) + (point.y * point.y)); + } + + private PointF normalize(PointF point) { + float magnitude = magnitude(point); + if (magnitude == 0.f) { + return point; + } + + float normal = 1 / magnitude; + return new PointF((point.x * normal), (point.y * normal)); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/DefaultUiController.java b/packages/SystemUI/src/com/android/systemui/assist/ui/DefaultUiController.java new file mode 100644 index 0000000000000..7ad6dfd2672b8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/DefaultUiController.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.ColorInt; +import android.content.Context; +import android.graphics.PixelFormat; +import android.metrics.LogMaker; +import android.os.Bundle; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.WindowManager; +import android.view.animation.PathInterpolator; +import android.widget.FrameLayout; + +import com.android.internal.logging.MetricsLogger; +import com.android.internal.logging.nano.MetricsProto.MetricsEvent; +import com.android.systemui.Dependency; +import com.android.systemui.R; +import com.android.systemui.assist.AssistManager; + +/** + * Default UiController implementation. Shows white edge lights along the bottom of the phone, + * expanding from the corners to meet in the center. + */ +public class DefaultUiController implements AssistManager.UiController { + + private static final String TAG = "DefaultUiController"; + + private static final long ANIM_DURATION_MS = 200; + + protected final FrameLayout mRoot; + + private final WindowManager mWindowManager; + private final WindowManager.LayoutParams mLayoutParams; + private final PathInterpolator mProgressInterpolator = new PathInterpolator(.83f, 0, .84f, 1); + + private boolean mAttached = false; + private boolean mInvocationInProgress = false; + private float mLastInvocationProgress = 0; + + private ValueAnimator mInvocationAnimator = new ValueAnimator(); + private InvocationLightsView mInvocationLightsView; + + public DefaultUiController(Context context) { + mRoot = new FrameLayout(context); + mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + + mLayoutParams = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, 0, 0, + WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL, + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT); + mLayoutParams.privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_NO_MOVE_ANIMATION; + mLayoutParams.gravity = Gravity.BOTTOM; + mLayoutParams.setTitle("Assist"); + + mInvocationLightsView = (InvocationLightsView) + LayoutInflater.from(context).inflate(R.layout.invocation_lights, mRoot, false); + mRoot.addView(mInvocationLightsView); + } + + @Override // AssistManager.UiController + public void processBundle(Bundle bundle) { + Log.e(TAG, "Bundle received but handling is not implemented; ignoring"); + } + + @Override // AssistManager.UiController + public void onInvocationProgress(int type, float progress) { + if (progress == 1) { + animateInvocationCompletion(type, 0); + } else if (progress == 0) { + mInvocationInProgress = false; + hide(); + } else { + if (!mInvocationInProgress) { + attach(); + mInvocationInProgress = true; + } + setProgressInternal(type, progress); + } + mLastInvocationProgress = progress; + + // Logs assistant invocation start. + if (!mInvocationInProgress && progress > 0.f) { + MetricsLogger.action(new LogMaker(MetricsEvent.ASSISTANT) + .setType(MetricsEvent.TYPE_ACTION)); + } + // Logs assistant invocation cancelled. + if (mInvocationInProgress && progress == 0f) { + MetricsLogger.action(new LogMaker(MetricsEvent.ASSISTANT) + .setType(MetricsEvent.TYPE_DISMISS).setSubtype(0)); + } + } + + @Override // AssistManager.UiController + public void onGestureCompletion(float velocity) { + animateInvocationCompletion(AssistManager.INVOCATION_TYPE_GESTURE, velocity); + } + + @Override // AssistManager.UiController + public void hide() { + Dependency.get(AssistManager.class).hideAssist(); + detach(); + if (mInvocationAnimator.isRunning()) { + mInvocationAnimator.cancel(); + } + mInvocationLightsView.hide(); + mInvocationInProgress = false; + } + + /** + * Sets the colors of the four invocation lights, from left to right. + */ + public void setInvocationColors(@ColorInt int color1, @ColorInt int color2, + @ColorInt int color3, @ColorInt int color4) { + mInvocationLightsView.setColors(color1, color2, color3, color4); + } + + private void attach() { + if (!mAttached) { + mWindowManager.addView(mRoot, mLayoutParams); + mAttached = true; + } + } + + private void detach() { + if (mAttached) { + mWindowManager.removeViewImmediate(mRoot); + mAttached = false; + } + } + + private void setProgressInternal(int type, float progress) { + mInvocationLightsView.onInvocationProgress( + mProgressInterpolator.getInterpolation(progress)); + } + + private void animateInvocationCompletion(int type, float velocity) { + mInvocationAnimator = ValueAnimator.ofFloat(mLastInvocationProgress, 1); + mInvocationAnimator.setStartDelay(1); + mInvocationAnimator.setDuration(ANIM_DURATION_MS); + mInvocationAnimator.addUpdateListener( + animation -> setProgressInternal(type, (float) animation.getAnimatedValue())); + mInvocationAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + super.onAnimationEnd(animation); + mInvocationInProgress = false; + mLastInvocationProgress = 0; + hide(); + } + }); + mInvocationAnimator.start(); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/DisplayUtils.java b/packages/SystemUI/src/com/android/systemui/assist/ui/DisplayUtils.java new file mode 100644 index 0000000000000..251229f42da3d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/DisplayUtils.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import android.content.Context; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.Surface; + +/** + * Utility class for determining screen and corner dimensions. + */ +public class DisplayUtils { + /** + * Converts given distance from dp to pixels. + */ + public static int convertDpToPx(float dp, Context context) { + Display d = context.getDisplay(); + + DisplayMetrics dm = new DisplayMetrics(); + d.getRealMetrics(dm); + + return (int) Math.ceil(dp * dm.density); + } + + /** + * The width of the display. + * + * - Not affected by rotation. + * - Includes system decor. + */ + public static int getWidth(Context context) { + Display d = context.getDisplay(); + + DisplayMetrics dm = new DisplayMetrics(); + d.getRealMetrics(dm); + + int rotation = d.getRotation(); + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) { + return dm.widthPixels; + } else { + return dm.heightPixels; + } + } + + /** + * The height of the display. + * + * - Not affected by rotation. + * - Includes system decor. + */ + public static int getHeight(Context context) { + Display d = context.getDisplay(); + + DisplayMetrics dm = new DisplayMetrics(); + d.getRealMetrics(dm); + + int rotation = d.getRotation(); + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) { + return dm.heightPixels; + } else { + return dm.widthPixels; + } + } + + /** + * Returns the radius of the bottom corners (the distance from the true corner to the point + * where the curve ends), in pixels. + */ + public static int getCornerRadiusBottom(Context context) { + int radius = 0; + + int resourceId = context.getResources().getIdentifier("rounded_corner_radius_bottom", + "dimen", "android"); + if (resourceId > 0) { + radius = context.getResources().getDimensionPixelSize(resourceId); + } + + if (radius == 0) { + radius = getCornerRadiusDefault(context); + } + return radius; + } + + /** + * Returns the radius of the top corners (the distance from the true corner to the point where + * the curve ends), in pixels. + */ + public static int getCornerRadiusTop(Context context) { + int radius = 0; + + int resourceId = context.getResources().getIdentifier("rounded_corner_radius_top", + "dimen", "android"); + if (resourceId > 0) { + radius = context.getResources().getDimensionPixelSize(resourceId); + } + + if (radius == 0) { + radius = getCornerRadiusDefault(context); + } + return radius; + } + + private static int getCornerRadiusDefault(Context context) { + int radius = 0; + + int resourceId = context.getResources().getIdentifier("rounded_corner_radius", "dimen", + "android"); + if (resourceId > 0) { + radius = context.getResources().getDimensionPixelSize(resourceId); + } + return radius; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/EdgeLight.java b/packages/SystemUI/src/com/android/systemui/assist/ui/EdgeLight.java new file mode 100644 index 0000000000000..9ae02c5e3104b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/EdgeLight.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import androidx.annotation.ColorInt; + +/** + * Represents a line drawn on the perimeter of the display. + * + * Offsets and lengths are both normalized to the perimeter of the display – ex. a length of 1 + * is equal to the perimeter of the display. Positions move counter-clockwise as values increase. + * + * If there is no bottom corner radius, the origin is the bottom-left corner. + * If there is a bottom corner radius, the origin is immediately after the bottom corner radius, + * counter-clockwise. + */ +public final class EdgeLight { + @ColorInt + private int mColor; + private float mOffset; + private float mLength; + + /** Copies a list of EdgeLights. */ + public static EdgeLight[] copy(EdgeLight[] array) { + EdgeLight[] copy = new EdgeLight[array.length]; + for (int i = 0; i < array.length; i++) { + copy[i] = new EdgeLight(array[i]); + } + return copy; + } + + public EdgeLight(@ColorInt int color, float offset, float length) { + mColor = color; + mOffset = offset; + mLength = length; + } + + public EdgeLight(EdgeLight sourceLight) { + mColor = sourceLight.getColor(); + mOffset = sourceLight.getOffset(); + mLength = sourceLight.getLength(); + } + + /** Returns the current edge light color. */ + @ColorInt + public int getColor() { + return mColor; + } + + /** Sets the edge light color. */ + public void setColor(@ColorInt int color) { + mColor = color; + } + + /** Returns the edge light length, in units of the total device perimeter. */ + public float getLength() { + return mLength; + } + + /** Sets the edge light length, in units of the total device perimeter. */ + public void setLength(float length) { + mLength = length; + } + + /** + * Returns the current offset, in units of the total device perimeter and measured from the + * bottom-left corner (see class description). + */ + public float getOffset() { + return mOffset; + } + + /** + * Sets the current offset, in units of the total device perimeter and measured from the + * bottom-left corner (see class description). + */ + public void setOffset(float offset) { + mOffset = offset; + } + + /** Returns the center, measured from the bottom-left corner (see class description). */ + public float getCenter() { + return mOffset + (mLength / 2.f); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/InvocationLightsView.java b/packages/SystemUI/src/com/android/systemui/assist/ui/InvocationLightsView.java new file mode 100644 index 0000000000000..de1d7c8c0a04a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/InvocationLightsView.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import android.annotation.ColorInt; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.util.AttributeSet; +import android.util.Log; +import android.util.MathUtils; +import android.view.View; + +import com.android.systemui.R; + +import java.util.ArrayList; + +/** + * Shows lights at the bottom of the phone, marking the invocation progress. + */ +public class InvocationLightsView extends View { + + private static final String TAG = "InvocationLightsView"; + + private static final int LIGHT_HEIGHT_DP = 3; + // minimum light length as a fraction of the corner length + private static final float MINIMUM_CORNER_RATIO = .6f; + + protected final ArrayList mAssistInvocationLights = new ArrayList<>(); + protected final PerimeterPathGuide mGuide; + + private final Paint mPaint = new Paint(); + // Path used to render lights. One instance is used to draw all lights and is cached to avoid + // allocation on each frame. + private final Path mPath = new Path(); + private final int mViewHeight; + + // Allocate variable for screen location lookup to avoid memory alloc onDraw() + private int[] mScreenLocation = new int[2]; + + public InvocationLightsView(Context context) { + this(context, null); + } + + public InvocationLightsView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InvocationLightsView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public InvocationLightsView(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + + int strokeWidth = DisplayUtils.convertDpToPx(LIGHT_HEIGHT_DP, context); + mPaint.setStrokeWidth(strokeWidth); + mPaint.setStyle(Paint.Style.STROKE); + mPaint.setStrokeJoin(Paint.Join.MITER); + mPaint.setAntiAlias(true); + + int cornerRadiusBottom = DisplayUtils.getCornerRadiusBottom(context); + int cornerRadiusTop = DisplayUtils.getCornerRadiusTop(context); + int displayWidth = DisplayUtils.getWidth(context); + int displayHeight = DisplayUtils.getHeight(context); + CircularCornerPathRenderer cornerPathRenderer = new CircularCornerPathRenderer( + cornerRadiusBottom, cornerRadiusTop, displayWidth, displayHeight); + mGuide = new PerimeterPathGuide(context, cornerPathRenderer, + strokeWidth / 2, displayWidth, displayHeight); + + mViewHeight = Math.max(cornerRadiusBottom, cornerRadiusTop); + + @ColorInt int lightColor = getResources().getColor(R.color.default_invocation_lights_color); + for (int i = 0; i < 4; i++) { + mAssistInvocationLights.add(new EdgeLight(lightColor, 0, 0)); + } + } + + /** + * Updates positions of the invocation lights based on the progress (a float between 0 and 1). + * The lights begin at the device corners and expand inward until they meet at the center. + */ + public void onInvocationProgress(float progress) { + if (progress == 0) { + setVisibility(View.GONE); + } else { + float cornerLengthNormalized = + mGuide.getRegionWidth(PerimeterPathGuide.Region.BOTTOM_LEFT); + float arcLengthNormalized = cornerLengthNormalized * MINIMUM_CORNER_RATIO; + float arcOffsetNormalized = (cornerLengthNormalized - arcLengthNormalized) / 2f; + + float minLightLength = arcLengthNormalized / 2; + float maxLightLength = mGuide.getRegionWidth(PerimeterPathGuide.Region.BOTTOM) / 4f; + + float lightLength = MathUtils.lerp(minLightLength, maxLightLength, progress); + + float leftStart = (-cornerLengthNormalized + arcOffsetNormalized) * (1 - progress); + float rightStart = mGuide.getRegionWidth(PerimeterPathGuide.Region.BOTTOM) + + (cornerLengthNormalized - arcOffsetNormalized) * (1 - progress); + + setLight(0, leftStart, lightLength); + setLight(1, leftStart + lightLength, lightLength); + setLight(2, rightStart - (lightLength * 2), lightLength); + setLight(3, rightStart - lightLength, lightLength); + setVisibility(View.VISIBLE); + } + invalidate(); + } + + /** + * Hides and resets the invocation lights. + */ + public void hide() { + setVisibility(GONE); + for (EdgeLight light : mAssistInvocationLights) { + light.setLength(0); + } + } + + /** + * Sets the invocation light colors, from left to right. + */ + public void setColors(@ColorInt int color1, @ColorInt int color2, + @ColorInt int color3, @ColorInt int color4) { + mAssistInvocationLights.get(0).setColor(color1); + mAssistInvocationLights.get(1).setColor(color2); + mAssistInvocationLights.get(2).setColor(color3); + mAssistInvocationLights.get(3).setColor(color4); + } + + @Override + protected void onFinishInflate() { + getLayoutParams().height = mViewHeight; + requestLayout(); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + + int rotation = getContext().getDisplay().getRotation(); + mGuide.setRotation(rotation); + } + + @Override + protected void onDraw(Canvas canvas) { + // If the view doesn't take up the whole screen, offset the canvas by its translation + // distance such that PerimeterPathGuide's paths are drawn properly based upon the actual + // screen edges. + getLocationOnScreen(mScreenLocation); + canvas.translate(-mScreenLocation[0], -mScreenLocation[1]); + + // if the lights are different colors, the inner ones need to be drawn last and with a + // square cap so that the join between lights is straight + mPaint.setStrokeCap(Paint.Cap.ROUND); + renderLight(mAssistInvocationLights.get(0), canvas); + renderLight(mAssistInvocationLights.get(3), canvas); + + mPaint.setStrokeCap(Paint.Cap.SQUARE); + renderLight(mAssistInvocationLights.get(1), canvas); + renderLight(mAssistInvocationLights.get(2), canvas); + } + + protected void setLight(int index, float offset, float length) { + if (index < 0 || index >= 4) { + Log.w(TAG, "invalid invocation light index: " + index); + } + mAssistInvocationLights.get(index).setOffset(offset); + mAssistInvocationLights.get(index).setLength(length); + } + + private void renderLight(EdgeLight light, Canvas canvas) { + mGuide.strokeSegment(mPath, light.getOffset(), light.getOffset() + light.getLength()); + mPaint.setColor(light.getColor()); + canvas.drawPath(mPath, mPaint); + } + +} diff --git a/packages/SystemUI/src/com/android/systemui/assist/ui/PerimeterPathGuide.java b/packages/SystemUI/src/com/android/systemui/assist/ui/PerimeterPathGuide.java new file mode 100644 index 0000000000000..8eea36892aa75 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/assist/ui/PerimeterPathGuide.java @@ -0,0 +1,389 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.assist.ui; + +import static android.view.Surface.ROTATION_0; +import static android.view.Surface.ROTATION_180; +import static android.view.Surface.ROTATION_270; +import static android.view.Surface.ROTATION_90; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Path; +import android.graphics.PathMeasure; +import android.util.Log; +import android.util.Pair; +import android.view.Surface; + +import androidx.core.math.MathUtils; + +/** + * PerimeterPathGuide establishes a coordinate system for drawing paths along the perimeter of the + * screen. All positions around the perimeter have a coordinate [0, 1). The origin is the bottom + * left corner of the screen, to the right of the curved corner, if any. Coordinates increase + * counter-clockwise around the screen. + * + * Non-square screens require PerimeterPathGuide to be notified when the rotation changes, such that + * it can recompute the edge lengths for the coordinate system. + */ +public class PerimeterPathGuide { + + private static final String TAG = "PerimeterPathGuide"; + + /** + * For convenience, labels sections of the device perimeter. + * + * Must be listed in CCW order. + */ + public enum Region { + BOTTOM, + BOTTOM_RIGHT, + RIGHT, + TOP_RIGHT, + TOP, + TOP_LEFT, + LEFT, + BOTTOM_LEFT + } + + private final int mDeviceWidthPx; + private final int mDeviceHeightPx; + private final int mTopCornerRadiusPx; + private final int mBottomCornerRadiusPx; + + private class RegionAttributes { + public float absoluteLength; + public float normalizedLength; + public float endCoordinate; + public Path path; + } + + // Allocate a Path and PathMeasure for use by intermediate operations that would otherwise have + // to allocate. reset() must be called before using this path, this ensures state from previous + // operations is cleared. + private final Path mScratchPath = new Path(); + private final CornerPathRenderer mCornerPathRenderer; + private final PathMeasure mScratchPathMeasure = new PathMeasure(mScratchPath, false); + private RegionAttributes[] mRegions; + private final int mEdgeInset; + private int mRotation = ROTATION_0; + + public PerimeterPathGuide(Context context, CornerPathRenderer cornerPathRenderer, + int edgeInset, int screenWidth, int screenHeight) { + mCornerPathRenderer = cornerPathRenderer; + mDeviceWidthPx = screenWidth; + mDeviceHeightPx = screenHeight; + mTopCornerRadiusPx = DisplayUtils.getCornerRadiusTop(context); + mBottomCornerRadiusPx = DisplayUtils.getCornerRadiusBottom(context); + mEdgeInset = edgeInset; + + mRegions = new RegionAttributes[8]; + for (int i = 0; i < mRegions.length; i++) { + mRegions[i] = new RegionAttributes(); + } + computeRegions(); + } + + /** + * Sets the rotation. + * + * @param rotation one of Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, + * Surface.ROTATION_270 + */ + public void setRotation(int rotation) { + if (rotation != mRotation) { + switch (rotation) { + case ROTATION_0: + case ROTATION_90: + case ROTATION_180: + case ROTATION_270: + mRotation = rotation; + computeRegions(); + break; + default: + Log.e(TAG, "Invalid rotation provided: " + rotation); + } + } + } + + /** + * Sets path to the section of the perimeter between startCoord and endCoord (measured + * counter-clockwise from the bottom left). + */ + public void strokeSegment(Path path, float startCoord, float endCoord) { + path.reset(); + + startCoord = ((startCoord % 1) + 1) % 1; // Wrap to the range [0, 1). + endCoord = ((endCoord % 1) + 1) % 1; // Wrap to the range [0, 1). + boolean outOfOrder = startCoord > endCoord; + + if (outOfOrder) { + strokeSegmentInternal(path, startCoord, 1f); + startCoord = 0; + } + strokeSegmentInternal(path, startCoord, endCoord); + } + + /** + * Returns the device perimeter in pixels. + */ + public float getPerimeterPx() { + float total = 0; + for (RegionAttributes region : mRegions) { + total += region.absoluteLength; + } + return total; + } + + /** + * Returns the bottom corner radius in pixels. + */ + public float getBottomCornerRadiusPx() { + return mBottomCornerRadiusPx; + } + + /** + * Given a region and a progress value [0,1] indicating the counter-clockwise progress within + * that region, compute the global [0,1) coordinate. + */ + public float getCoord(Region region, float progress) { + RegionAttributes regionAttributes = mRegions[region.ordinal()]; + progress = MathUtils.clamp(progress, 0, 1); + return regionAttributes.endCoordinate - (1 - progress) * regionAttributes.normalizedLength; + } + + /** + * Returns the center of the provided region, relative to the entire perimeter. + */ + public float getRegionCenter(Region region) { + return getCoord(region, 0.5f); + } + + /** + * Returns the width of the provided region, in units relative to the entire perimeter. + */ + public float getRegionWidth(Region region) { + return mRegions[region.ordinal()].normalizedLength; + } + + /** + * Points are expressed in terms of their relative position on the perimeter of the display, + * moving counter-clockwise. This method converts a point to clockwise, assisting use cases + * such as animating to a point clockwise instead of counter-clockwise. + * + * @param point A point in the range from 0 to 1. + * @return A point in the range of -1 to 0 that represents the same location as {@code point}. + */ + public static float makeClockwise(float point) { + return point - 1; + } + + private int getPhysicalCornerRadius(CircularCornerPathRenderer.Corner corner) { + if (corner == CircularCornerPathRenderer.Corner.BOTTOM_LEFT + || corner == CircularCornerPathRenderer.Corner.BOTTOM_RIGHT) { + return mBottomCornerRadiusPx; + } + return mTopCornerRadiusPx; + } + + // Populate mRegions based upon the current rotation value. + private void computeRegions() { + int screenWidth = mDeviceWidthPx; + int screenHeight = mDeviceHeightPx; + + int rotateMatrix = 0; + + switch (mRotation) { + case ROTATION_90: + rotateMatrix = -90; + break; + case ROTATION_180: + rotateMatrix = -180; + break; + case Surface.ROTATION_270: + rotateMatrix = -270; + break; + } + + Matrix matrix = new Matrix(); + matrix.postRotate(rotateMatrix, mDeviceWidthPx / 2, mDeviceHeightPx / 2); + + if (mRotation == ROTATION_90 || mRotation == Surface.ROTATION_270) { + screenHeight = mDeviceWidthPx; + screenWidth = mDeviceHeightPx; + matrix.postTranslate((mDeviceHeightPx + - mDeviceWidthPx) / 2, (mDeviceWidthPx - mDeviceHeightPx) / 2); + } + + CircularCornerPathRenderer.Corner screenBottomLeft = getRotatedCorner( + CircularCornerPathRenderer.Corner.BOTTOM_LEFT); + CircularCornerPathRenderer.Corner screenBottomRight = getRotatedCorner( + CircularCornerPathRenderer.Corner.BOTTOM_RIGHT); + CircularCornerPathRenderer.Corner screenTopLeft = getRotatedCorner( + CircularCornerPathRenderer.Corner.TOP_LEFT); + CircularCornerPathRenderer.Corner screenTopRight = getRotatedCorner( + CircularCornerPathRenderer.Corner.TOP_RIGHT); + + mRegions[Region.BOTTOM_LEFT.ordinal()].path = + mCornerPathRenderer.getInsetPath(screenBottomLeft, mEdgeInset); + mRegions[Region.BOTTOM_RIGHT.ordinal()].path = + mCornerPathRenderer.getInsetPath(screenBottomRight, mEdgeInset); + mRegions[Region.TOP_RIGHT.ordinal()].path = + mCornerPathRenderer.getInsetPath(screenTopRight, mEdgeInset); + mRegions[Region.TOP_LEFT.ordinal()].path = + mCornerPathRenderer.getInsetPath(screenTopLeft, mEdgeInset); + + mRegions[Region.BOTTOM_LEFT.ordinal()].path.transform(matrix); + mRegions[Region.BOTTOM_RIGHT.ordinal()].path.transform(matrix); + mRegions[Region.TOP_RIGHT.ordinal()].path.transform(matrix); + mRegions[Region.TOP_LEFT.ordinal()].path.transform(matrix); + + + Path bottomPath = new Path(); + bottomPath.moveTo(getPhysicalCornerRadius(screenBottomLeft), screenHeight - mEdgeInset); + bottomPath.lineTo(screenWidth - getPhysicalCornerRadius(screenBottomRight), + screenHeight - mEdgeInset); + mRegions[Region.BOTTOM.ordinal()].path = bottomPath; + + Path topPath = new Path(); + topPath.moveTo(screenWidth - getPhysicalCornerRadius(screenTopRight), mEdgeInset); + topPath.lineTo(getPhysicalCornerRadius(screenTopLeft), mEdgeInset); + mRegions[Region.TOP.ordinal()].path = topPath; + + Path rightPath = new Path(); + rightPath.moveTo(screenWidth - mEdgeInset, + screenHeight - getPhysicalCornerRadius(screenBottomRight)); + rightPath.lineTo(screenWidth - mEdgeInset, getPhysicalCornerRadius(screenTopRight)); + mRegions[Region.RIGHT.ordinal()].path = rightPath; + + Path leftPath = new Path(); + leftPath.moveTo(mEdgeInset, + getPhysicalCornerRadius(screenTopLeft)); + leftPath.lineTo(mEdgeInset, screenHeight - getPhysicalCornerRadius(screenBottomLeft)); + mRegions[Region.LEFT.ordinal()].path = leftPath; + + float perimeterLength = 0; + PathMeasure pathMeasure = new PathMeasure(); + for (int i = 0; i < mRegions.length; i++) { + pathMeasure.setPath(mRegions[i].path, false); + mRegions[i].absoluteLength = pathMeasure.getLength(); + perimeterLength += mRegions[i].absoluteLength; + } + + float accum = 0; + for (int i = 0; i < mRegions.length; i++) { + mRegions[i].normalizedLength = mRegions[i].absoluteLength / perimeterLength; + accum += mRegions[i].normalizedLength; + mRegions[i].endCoordinate = accum; + } + } + + private CircularCornerPathRenderer.Corner getRotatedCorner( + CircularCornerPathRenderer.Corner screenCorner) { + int corner = screenCorner.ordinal(); + switch (mRotation) { + case ROTATION_90: + corner += 3; + break; + case ROTATION_180: + corner += 2; + break; + case Surface.ROTATION_270: + corner += 1; + break; + } + return CircularCornerPathRenderer.Corner.values()[corner % 4]; + } + + private void strokeSegmentInternal(Path path, float startCoord, float endCoord) { + Pair startPoint = placePoint(startCoord); + Pair endPoint = placePoint(endCoord); + + if (startPoint.first.equals(endPoint.first)) { + strokeRegion(path, startPoint.first, startPoint.second, endPoint.second); + } else { + strokeRegion(path, startPoint.first, startPoint.second, 1f); + boolean hitStart = false; + for (Region r : Region.values()) { + if (r.equals(startPoint.first)) { + hitStart = true; + continue; + } + if (hitStart) { + if (!r.equals(endPoint.first)) { + strokeRegion(path, r, 0f, 1f); + } else { + strokeRegion(path, r, 0f, endPoint.second); + break; + } + } + } + } + } + + private void strokeRegion(Path path, Region r, float relativeStart, float relativeEnd) { + if (relativeStart == relativeEnd) { + return; + } + + mScratchPathMeasure.setPath(mRegions[r.ordinal()].path, false); + mScratchPathMeasure.getSegment(relativeStart * mScratchPathMeasure.getLength(), + relativeEnd * mScratchPathMeasure.getLength(), path, true); + } + + /** + * Return the Region where the point is located, and its relative position within that region + * (from 0 to 1). + * Note that we move counterclockwise around the perimeter; for example, a relative position of + * 0 in + * the BOTTOM region is on the left side of the screen, but in the TOP region it’s on the + * right. + */ + private Pair placePoint(float coord) { + if (0 > coord || coord > 1) { + coord = ((coord % 1) + 1) + % 1; // Wrap to the range [0, 1). Inputs of exactly 1 are preserved. + } + + Region r = getRegionForPoint(coord); + if (r.equals(Region.BOTTOM)) { + return Pair.create(r, coord / mRegions[r.ordinal()].normalizedLength); + } else { + float coordOffsetInRegion = coord - mRegions[r.ordinal() - 1].endCoordinate; + float coordRelativeToRegion = + coordOffsetInRegion / mRegions[r.ordinal()].normalizedLength; + return Pair.create(r, coordRelativeToRegion); + } + } + + private Region getRegionForPoint(float coord) { + // If coord is outside of [0,1], wrap to [0,1). + if (coord < 0 || coord > 1) { + coord = ((coord % 1) + 1) % 1; + } + + for (Region region : Region.values()) { + if (coord <= mRegions[region.ordinal()].endCoordinate) { + return region; + } + } + + // Should never happen. + Log.e(TAG, "Fell out of getRegionForPoint"); + return Region.BOTTOM; + } +}