Adds back gesture recognition to Sandbox.
This is used to step through the "happy path" of the existing
back tutorial steps (right edge and left edge). Unlike in the
Pixel Tips app, the tutorial only continues if you perform the
gesture from the correct edge of the screen.
This also lays the groundwork to provide helpful tips if the
expected gesture is performed incorrectly, although currently
such gestures are just ignored.
Demo: https://drive.google.com/open?id=12S42mZQITGzIWnj7mP1L__PCOgAQcjgp
Bug: 148542211
Change-Id: Ib2e0b2ff7c021db48c96d58e1370fa2e93330912
diff --git a/quickstep/res/layout/back_gesture_tutorial_fragment.xml b/quickstep/res/layout/back_gesture_tutorial_fragment.xml
index 294e46e..d8c25bd 100644
--- a/quickstep/res/layout/back_gesture_tutorial_fragment.xml
+++ b/quickstep/res/layout/back_gesture_tutorial_fragment.xml
@@ -16,9 +16,7 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:layerType="software"
android:background="@color/back_gesture_tutorial_background_color">
- <!--The layout is rendered on the software layer to avoid b/136158117-->
<ImageView
android:id="@+id/back_gesture_tutorial_fragment_hand_coaching"
diff --git a/quickstep/res/values/colors.xml b/quickstep/res/values/colors.xml
new file mode 100644
index 0000000..3583676
--- /dev/null
+++ b/quickstep/res/values/colors.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+<resources>
+ <color name="back_arrow_color_dark">#99000000</color>
+</resources>
\ No newline at end of file
diff --git a/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialController.java b/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialController.java
index 3fe91a3..5c2e992 100644
--- a/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialController.java
+++ b/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialController.java
@@ -25,6 +25,7 @@
import com.android.launcher3.R;
import com.android.quickstep.interaction.BackGestureTutorialFragment.TutorialStep;
import com.android.quickstep.interaction.BackGestureTutorialFragment.TutorialType;
+import com.android.quickstep.interaction.EdgeBackGestureHandler.BackGestureResult;
import java.util.Optional;
@@ -79,21 +80,33 @@
mHandCoachingAnimation.stop();
}
- void onGestureDetected() {
- hideHandCoachingAnimation();
-
- if (mTutorialStep == TutorialStep.CONFIRM) {
+ void onGestureAttempted(BackGestureResult result) {
+ if (mTutorialStep == TutorialStep.CONFIRM
+ && (result == BackGestureResult.BACK_COMPLETED_FROM_LEFT
+ || result == BackGestureResult.BACK_COMPLETED_FROM_RIGHT)) {
mFragment.closeTutorial();
return;
}
- if (mTutorialTypeInfo.get().getTutorialType() == TutorialType.RIGHT_EDGE_BACK_NAVIGATION) {
- mFragment.changeController(TutorialStep.ENGAGED,
- TutorialType.LEFT_EDGE_BACK_NAVIGATION);
+ if (!mTutorialTypeInfo.isPresent()) {
return;
}
- mFragment.changeController(TutorialStep.CONFIRM);
+ switch (mTutorialTypeInfo.get().getTutorialType()) {
+ case RIGHT_EDGE_BACK_NAVIGATION:
+ if (result == BackGestureResult.BACK_COMPLETED_FROM_RIGHT) {
+ hideHandCoachingAnimation();
+ mFragment.changeController(
+ TutorialStep.ENGAGED, TutorialType.LEFT_EDGE_BACK_NAVIGATION);
+ }
+ break;
+ case LEFT_EDGE_BACK_NAVIGATION:
+ if (result == BackGestureResult.BACK_COMPLETED_FROM_LEFT) {
+ hideHandCoachingAnimation();
+ mFragment.changeController(TutorialStep.CONFIRM);
+ }
+ break;
+ }
}
abstract Optional<Integer> getTitleStringId();
diff --git a/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialFragment.java b/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialFragment.java
index 54408ce..593b695 100644
--- a/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialFragment.java
+++ b/quickstep/src/com/android/quickstep/interaction/BackGestureTutorialFragment.java
@@ -17,21 +17,26 @@
import android.content.ActivityNotFoundException;
import android.content.Intent;
+import android.graphics.Insets;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
+import android.view.WindowInsets;
+import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import com.android.launcher3.R;
+import com.android.quickstep.interaction.EdgeBackGestureHandler.BackGestureAttemptCallback;
+import com.android.quickstep.interaction.EdgeBackGestureHandler.BackGestureResult;
import java.net.URISyntaxException;
import java.util.Optional;
/** Shows the Back gesture interactive tutorial. */
-public class BackGestureTutorialFragment extends Fragment {
+public class BackGestureTutorialFragment extends Fragment implements BackGestureAttemptCallback {
private static final String LOG_TAG = "TutorialFragment";
private static final String KEY_TUTORIAL_STEP = "tutorialStep";
@@ -47,6 +52,7 @@
private Optional<BackGestureTutorialController> mTutorialController = Optional.empty();
private View mRootView;
private BackGestureTutorialHandAnimation mHandCoachingAnimation;
+ private EdgeBackGestureHandler mEdgeBackGestureHandler;
public static BackGestureTutorialFragment newInstance(
TutorialStep tutorialStep, TutorialType tutorialType) {
@@ -64,17 +70,25 @@
Bundle args = savedInstanceState != null ? savedInstanceState : getArguments();
mTutorialStep = (TutorialStep) args.getSerializable(KEY_TUTORIAL_STEP);
mTutorialType = (TutorialType) args.getSerializable(KEY_TUTORIAL_TYPE);
+ mEdgeBackGestureHandler = new EdgeBackGestureHandler(getContext());
+ mEdgeBackGestureHandler.registerBackGestureAttemptCallback(this);
}
@Override
public View onCreateView(
- LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ @NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
mRootView = inflater.inflate(R.layout.back_gesture_tutorial_fragment,
container, /* attachToRoot= */ false);
mRootView.findViewById(R.id.back_gesture_tutorial_fragment_close_button)
.setOnClickListener(this::onCloseButtonClicked);
+ mRootView.setOnApplyWindowInsetsListener((view, insets) -> {
+ Insets systemInsets = insets.getInsets(WindowInsets.Type.systemBars());
+ mEdgeBackGestureHandler.setInsets(systemInsets.left, systemInsets.right);
+ return insets;
+ });
+ mRootView.setOnTouchListener(mEdgeBackGestureHandler);
mHandCoachingAnimation = new BackGestureTutorialHandAnimation(getContext(), mRootView);
return mRootView;
@@ -92,6 +106,14 @@
mHandCoachingAnimation.stop();
}
+ void onAttachedToWindow() {
+ mEdgeBackGestureHandler.setIsEnabled(true);
+ }
+
+ void onDetachedFromWindow() {
+ mEdgeBackGestureHandler.setIsEnabled(false);
+ }
+
@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
savedInstanceState.putSerializable(KEY_TUTORIAL_STEP, mTutorialStep);
@@ -125,10 +147,9 @@
this.mTutorialType = tutorialType;
}
- void onBackPressed() {
- if (mTutorialController.isPresent()) {
- mTutorialController.get().onGestureDetected();
- }
+ @Override
+ public void onBackGestureAttempted(BackGestureResult result) {
+ mTutorialController.ifPresent(controller -> controller.onGestureAttempted(result));
}
void closeTutorial() {
diff --git a/quickstep/src/com/android/quickstep/interaction/EdgeBackGestureHandler.java b/quickstep/src/com/android/quickstep/interaction/EdgeBackGestureHandler.java
new file mode 100644
index 0000000..04cd2f4
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/interaction/EdgeBackGestureHandler.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2020 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.quickstep.interaction;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.PixelFormat;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.DisplayManager.DisplayListener;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemProperties;
+import android.view.Display;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnTouchListener;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+import android.view.WindowManager.LayoutParams;
+
+import com.android.launcher3.ResourceUtils;
+
+/**
+ * Utility class to handle edge swipes for back gestures.
+ *
+ * Forked from platform/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/EdgeBackGestureHandler.java.
+ */
+public class EdgeBackGestureHandler implements DisplayListener, OnTouchListener {
+
+ private static final String TAG = "EdgeBackGestureHandler";
+ private static final int MAX_LONG_PRESS_TIMEOUT = SystemProperties.getInt(
+ "gestures.back_timeout", 250);
+
+ private final Context mContext;
+
+ private final Point mDisplaySize = new Point();
+ private final int mDisplayId;
+
+ // The edge width where touch down is allowed
+ private int mEdgeWidth;
+ // The bottom gesture area height
+ private int mBottomGestureHeight;
+ // The slop to distinguish between horizontal and vertical motion
+ private final float mTouchSlop;
+ // Duration after which we consider the event as longpress.
+ private final int mLongPressTimeout;
+
+ private final PointF mDownPoint = new PointF();
+ private boolean mThresholdCrossed = false;
+ private boolean mAllowGesture = false;
+ private boolean mIsEnabled;
+ private int mLeftInset;
+ private int mRightInset;
+
+ private EdgeBackGesturePanel mEdgeBackPanel;
+ private BackGestureAttemptCallback mGestureCallback;
+
+ private final EdgeBackGesturePanel.BackCallback mBackCallback =
+ new EdgeBackGesturePanel.BackCallback() {
+ @Override
+ public void triggerBack() {
+ if (mGestureCallback != null) {
+ mGestureCallback.onBackGestureAttempted(mEdgeBackPanel.getIsLeftPanel()
+ ? BackGestureResult.BACK_COMPLETED_FROM_LEFT
+ : BackGestureResult.BACK_COMPLETED_FROM_RIGHT);
+ }
+ }
+
+ @Override
+ public void cancelBack() {
+ if (mGestureCallback != null) {
+ mGestureCallback.onBackGestureAttempted(mEdgeBackPanel.getIsLeftPanel()
+ ? BackGestureResult.BACK_CANCELLED_FROM_LEFT
+ : BackGestureResult.BACK_CANCELLED_FROM_RIGHT);
+ }
+ }
+ };
+
+ EdgeBackGestureHandler(Context context) {
+ final Resources res = context.getResources();
+ mContext = context;
+ mDisplayId = context.getDisplay() == null
+ ? Display.DEFAULT_DISPLAY : context.getDisplay().getDisplayId();
+
+ mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
+ mLongPressTimeout = Math.min(MAX_LONG_PRESS_TIMEOUT,
+ ViewConfiguration.getLongPressTimeout());
+
+ mBottomGestureHeight =
+ ResourceUtils.getNavbarSize(ResourceUtils.NAVBAR_BOTTOM_GESTURE_SIZE, res);
+ mEdgeWidth = ResourceUtils.getNavbarSize("config_backGestureInset", res);
+ }
+
+ void setIsEnabled(boolean isEnabled) {
+ if (isEnabled == mIsEnabled) {
+ return;
+ }
+ mIsEnabled = isEnabled;
+
+ if (mEdgeBackPanel != null) {
+ mEdgeBackPanel.onDestroy();
+ mEdgeBackPanel = null;
+ }
+
+ if (!mIsEnabled) {
+ mContext.getSystemService(DisplayManager.class).unregisterDisplayListener(this);
+ } else {
+ updateDisplaySize();
+ mContext.getSystemService(DisplayManager.class).registerDisplayListener(this,
+ new Handler(Looper.getMainLooper()));
+
+ // Add a nav bar panel window.
+ mEdgeBackPanel = new EdgeBackGesturePanel(mContext);
+ mEdgeBackPanel.setBackCallback(mBackCallback);
+ mEdgeBackPanel.setLayoutParams(createLayoutParams());
+ updateDisplaySize();
+ }
+ }
+
+ void registerBackGestureAttemptCallback(BackGestureAttemptCallback callback) {
+ mGestureCallback = callback;
+ }
+
+ private WindowManager.LayoutParams createLayoutParams() {
+ Resources resources = mContext.getResources();
+ WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
+ ResourceUtils.getNavbarSize("navigation_edge_panel_width", resources),
+ ResourceUtils.getNavbarSize("navigation_edge_panel_height", resources),
+ LayoutParams.TYPE_APPLICATION_PANEL,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
+ | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+ | WindowManager.LayoutParams.FLAG_SPLIT_TOUCH
+ | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+ PixelFormat.TRANSLUCENT);
+ layoutParams.setTitle(TAG + mDisplayId);
+ layoutParams.windowAnimations = 0;
+ layoutParams.setFitInsetsTypes(0 /* types */);
+ return layoutParams;
+ }
+
+ @Override
+ public boolean onTouch(View view, MotionEvent motionEvent) {
+ if (mIsEnabled) {
+ onMotionEvent(motionEvent);
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isWithinTouchRegion(int x, int y) {
+ // Disallow if too far from the edge
+ if (x > mEdgeWidth + mLeftInset && x < (mDisplaySize.x - mEdgeWidth - mRightInset)) {
+ return false;
+ }
+
+ // Disallow if we are in the bottom gesture area
+ if (y >= (mDisplaySize.y - mBottomGestureHeight)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private void cancelGesture(MotionEvent ev) {
+ // Send action cancel to reset all the touch events
+ mAllowGesture = false;
+ MotionEvent cancelEv = MotionEvent.obtain(ev);
+ cancelEv.setAction(MotionEvent.ACTION_CANCEL);
+ mEdgeBackPanel.onMotionEvent(cancelEv);
+ cancelEv.recycle();
+ }
+
+ private void onMotionEvent(MotionEvent ev) {
+ int action = ev.getActionMasked();
+ if (action == MotionEvent.ACTION_DOWN) {
+ boolean isOnLeftEdge = ev.getX() <= mEdgeWidth + mLeftInset;
+ mAllowGesture = isWithinTouchRegion((int) ev.getX(), (int) ev.getY());
+ if (mAllowGesture) {
+ mEdgeBackPanel.setIsLeftPanel(isOnLeftEdge);
+ mEdgeBackPanel.onMotionEvent(ev);
+
+ mDownPoint.set(ev.getX(), ev.getY());
+ mThresholdCrossed = false;
+ }
+ } else if (mAllowGesture) {
+ if (!mThresholdCrossed) {
+ if (action == MotionEvent.ACTION_POINTER_DOWN) {
+ // We do not support multi touch for back gesture
+ cancelGesture(ev);
+ return;
+ } else if (action == MotionEvent.ACTION_MOVE) {
+ if ((ev.getEventTime() - ev.getDownTime()) > mLongPressTimeout) {
+ cancelGesture(ev);
+ return;
+ }
+ float dx = Math.abs(ev.getX() - mDownPoint.x);
+ float dy = Math.abs(ev.getY() - mDownPoint.y);
+ if (dy > dx && dy > mTouchSlop) {
+ cancelGesture(ev);
+ return;
+
+ } else if (dx > dy && dx > mTouchSlop) {
+ mThresholdCrossed = true;
+ }
+ }
+
+ }
+
+ // forward touch
+ mEdgeBackPanel.onMotionEvent(ev);
+ }
+
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ if (!mAllowGesture && mGestureCallback != null) {
+ mGestureCallback.onBackGestureAttempted(BackGestureResult.BACK_NOT_STARTED);
+ }
+ }
+ }
+
+ @Override
+ public void onDisplayAdded(int displayId) { }
+
+ @Override
+ public void onDisplayRemoved(int displayId) { }
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ if (displayId == mDisplayId) {
+ updateDisplaySize();
+ }
+ }
+
+ private void updateDisplaySize() {
+ mContext.getDisplay().getRealSize(mDisplaySize);
+ if (mEdgeBackPanel != null) {
+ mEdgeBackPanel.setDisplaySize(mDisplaySize);
+ }
+ }
+
+ void setInsets(int leftInset, int rightInset) {
+ mLeftInset = leftInset;
+ mRightInset = rightInset;
+ }
+
+ enum BackGestureResult {
+ UNKNOWN,
+ BACK_COMPLETED_FROM_LEFT,
+ BACK_COMPLETED_FROM_RIGHT,
+ BACK_CANCELLED_FROM_LEFT,
+ BACK_CANCELLED_FROM_RIGHT,
+ BACK_NOT_STARTED,
+ }
+
+ /** Callback to let the UI react to attempted back gestures. */
+ interface BackGestureAttemptCallback {
+ /** Called whenever any touch is completed. */
+ void onBackGestureAttempted(BackGestureResult result);
+ }
+}
diff --git a/quickstep/src/com/android/quickstep/interaction/EdgeBackGesturePanel.java b/quickstep/src/com/android/quickstep/interaction/EdgeBackGesturePanel.java
new file mode 100644
index 0000000..34eeafc
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/interaction/EdgeBackGesturePanel.java
@@ -0,0 +1,701 @@
+/*
+ * 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.quickstep.interaction;
+
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.Point;
+import android.os.SystemClock;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.View;
+import android.view.WindowManager;
+import android.view.animation.Interpolator;
+import android.view.animation.PathInterpolator;
+
+import androidx.core.math.MathUtils;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.launcher3.R;
+import com.android.launcher3.ResourceUtils;
+import com.android.launcher3.anim.Interpolators;
+import com.android.launcher3.util.VibratorWrapper;
+
+/** Forked from platform/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/NavigationBarEdgePanel.java. */
+public class EdgeBackGesturePanel extends View {
+
+ private static final String LOG_TAG = "EdgeBackGesturePanel";
+
+ private static final long DISAPPEAR_FADE_ANIMATION_DURATION_MS = 80;
+ private static final long DISAPPEAR_ARROW_ANIMATION_DURATION_MS = 100;
+
+ /**
+ * The time required since the first vibration effect to automatically trigger a click
+ */
+ private static final int GESTURE_DURATION_FOR_CLICK_MS = 400;
+
+ /**
+ * The basic translation in dp where the arrow resides
+ */
+ private static final int BASE_TRANSLATION_DP = 32;
+
+ /**
+ * The length of the arrow leg measured from the center to the end
+ */
+ private static final int ARROW_LENGTH_DP = 18;
+
+ /**
+ * The angle measured from the xAxis, where the leg is when the arrow rests
+ */
+ private static final int ARROW_ANGLE_WHEN_EXTENDED_DEGREES = 56;
+
+ /**
+ * The angle that is added per 1000 px speed to the angle of the leg
+ */
+ private static final int ARROW_ANGLE_ADDED_PER_1000_SPEED = 4;
+
+ /**
+ * The maximum angle offset allowed due to speed
+ */
+ private static final int ARROW_MAX_ANGLE_SPEED_OFFSET_DEGREES = 4;
+
+ /**
+ * The thickness of the arrow. Adjusted to match the home handle (approximately)
+ */
+ private static final float ARROW_THICKNESS_DP = 2.5f;
+
+ /**
+ * The amount of rubber banding we do for the vertical translation
+ */
+ private static final int RUBBER_BAND_AMOUNT = 15;
+
+ /**
+ * The interpolator used to rubberband
+ */
+ private static final Interpolator RUBBER_BAND_INTERPOLATOR =
+ new PathInterpolator(1.0f / 5.0f, 1.0f, 1.0f, 1.0f);
+
+ /**
+ * The amount of rubber banding we do for the translation before base translation
+ */
+ private static final int RUBBER_BAND_AMOUNT_APPEAR = 4;
+
+ /**
+ * The interpolator used to rubberband the appearing of the arrow.
+ */
+ private static final Interpolator RUBBER_BAND_INTERPOLATOR_APPEAR =
+ new PathInterpolator(1.0f / RUBBER_BAND_AMOUNT_APPEAR, 1.0f, 1.0f, 1.0f);
+
+ private final WindowManager mWindowManager;
+ private BackCallback mBackCallback;
+
+ /**
+ * The paint the arrow is drawn with
+ */
+ private final Paint mPaint = new Paint();
+
+ private final float mDensity;
+ private final float mBaseTranslation;
+ private final float mArrowLength;
+ private final float mArrowThickness;
+
+ /**
+ * The minimum delta needed in movement for the arrow to change direction / stop triggering back
+ */
+ private final float mMinDeltaForSwitch;
+ // The closest to y = 0 that the arrow will be displayed.
+ private int mMinArrowPosition;
+ // The amount the arrow is shifted to avoid the finger.
+ private int mFingerOffset;
+
+ private final float mSwipeThreshold;
+ private final Path mArrowPath = new Path();
+ private final Point mDisplaySize = new Point();
+
+ private final SpringAnimation mAngleAnimation;
+ private final SpringAnimation mTranslationAnimation;
+ private final SpringAnimation mVerticalTranslationAnimation;
+ private final SpringForce mAngleAppearForce;
+ private final SpringForce mAngleDisappearForce;
+ private final ValueAnimator mArrowDisappearAnimation;
+ private final SpringForce mRegularTranslationSpring;
+ private final SpringForce mTriggerBackSpring;
+
+ private VelocityTracker mVelocityTracker;
+ private int mArrowPaddingEnd;
+ private WindowManager.LayoutParams mLayoutParams;
+
+ /**
+ * True if the panel is currently on the left of the screen
+ */
+ private boolean mIsLeftPanel;
+
+ private float mStartX;
+ private float mStartY;
+ private float mCurrentAngle;
+ /**
+ * The current translation of the arrow
+ */
+ private float mCurrentTranslation;
+ /**
+ * Where the arrow will be in the resting position.
+ */
+ private float mDesiredTranslation;
+
+ private boolean mDragSlopPassed;
+ private boolean mArrowsPointLeft;
+ private float mMaxTranslation;
+ private boolean mTriggerBack;
+ private float mPreviousTouchTranslation;
+ private float mTotalTouchDelta;
+ private float mVerticalTranslation;
+ private float mDesiredVerticalTranslation;
+ private float mDesiredAngle;
+ private float mAngleOffset;
+ private float mDisappearAmount;
+ private long mVibrationTime;
+ private int mScreenSize;
+
+ private final DynamicAnimation.OnAnimationEndListener mSetGoneEndListener =
+ new DynamicAnimation.OnAnimationEndListener() {
+ @Override
+ public void onAnimationEnd(
+ DynamicAnimation animation, boolean canceled, float value, float velocity) {
+ animation.removeEndListener(this);
+ if (!canceled) {
+ setVisibility(GONE);
+ }
+ }
+ };
+
+ private static final FloatPropertyCompat<EdgeBackGesturePanel> CURRENT_ANGLE =
+ new FloatPropertyCompat<EdgeBackGesturePanel>("currentAngle") {
+ @Override
+ public void setValue(EdgeBackGesturePanel object, float value) {
+ object.setCurrentAngle(value);
+ }
+
+ @Override
+ public float getValue(EdgeBackGesturePanel object) {
+ return object.getCurrentAngle();
+ }
+ };
+
+ private static final FloatPropertyCompat<EdgeBackGesturePanel> CURRENT_TRANSLATION =
+ new FloatPropertyCompat<EdgeBackGesturePanel>("currentTranslation") {
+ @Override
+ public void setValue(EdgeBackGesturePanel object, float value) {
+ object.setCurrentTranslation(value);
+ }
+
+ @Override
+ public float getValue(EdgeBackGesturePanel object) {
+ return object.getCurrentTranslation();
+ }
+ };
+
+ private static final FloatPropertyCompat<EdgeBackGesturePanel> CURRENT_VERTICAL_TRANSLATION =
+ new FloatPropertyCompat<EdgeBackGesturePanel>("verticalTranslation") {
+
+ @Override
+ public void setValue(EdgeBackGesturePanel object, float value) {
+ object.setVerticalTranslation(value);
+ }
+
+ @Override
+ public float getValue(EdgeBackGesturePanel object) {
+ return object.getVerticalTranslation();
+ }
+ };
+
+ public EdgeBackGesturePanel(Context context) {
+ super(context);
+
+ mWindowManager = context.getSystemService(WindowManager.class);
+
+ mDensity = context.getResources().getDisplayMetrics().density;
+
+ mBaseTranslation = dp(BASE_TRANSLATION_DP);
+ mArrowLength = dp(ARROW_LENGTH_DP);
+ mArrowThickness = dp(ARROW_THICKNESS_DP);
+ mMinDeltaForSwitch = dp(32);
+
+ mPaint.setStrokeWidth(mArrowThickness);
+ mPaint.setStrokeCap(Paint.Cap.ROUND);
+ mPaint.setAntiAlias(true);
+ mPaint.setStyle(Paint.Style.STROKE);
+ mPaint.setStrokeJoin(Paint.Join.ROUND);
+
+ mArrowDisappearAnimation = ValueAnimator.ofFloat(0.0f, 1.0f);
+ mArrowDisappearAnimation.setDuration(DISAPPEAR_ARROW_ANIMATION_DURATION_MS);
+ mArrowDisappearAnimation.setInterpolator(Interpolators.FAST_OUT_SLOW_IN);
+ mArrowDisappearAnimation.addUpdateListener(animation -> {
+ mDisappearAmount = (float) animation.getAnimatedValue();
+ invalidate();
+ });
+
+ mAngleAnimation =
+ new SpringAnimation(this, CURRENT_ANGLE);
+ mAngleAppearForce = new SpringForce()
+ .setStiffness(500)
+ .setDampingRatio(0.5f);
+ mAngleDisappearForce = new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_MEDIUM)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
+ .setFinalPosition(90);
+ mAngleAnimation.setSpring(mAngleAppearForce).setMaxValue(90);
+
+ mTranslationAnimation =
+ new SpringAnimation(this, CURRENT_TRANSLATION);
+ mRegularTranslationSpring = new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_MEDIUM)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+ mTriggerBackSpring = new SpringForce()
+ .setStiffness(450)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+ mTranslationAnimation.setSpring(mRegularTranslationSpring);
+ mVerticalTranslationAnimation =
+ new SpringAnimation(this, CURRENT_VERTICAL_TRANSLATION);
+ mVerticalTranslationAnimation.setSpring(
+ new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_MEDIUM)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
+ mPaint.setColor(context.getColor(R.color.back_arrow_color_dark));
+ loadDimens();
+ updateArrowDirection();
+
+ mSwipeThreshold = ResourceUtils.getDimenByName(
+ "navigation_edge_action_drag_threshold", context.getResources(), 16 /* defaultValue */);
+ setVisibility(GONE);
+ }
+
+ void onDestroy() {
+ mWindowManager.removeView(this);
+ }
+
+ @Override
+ public boolean hasOverlappingRendering() {
+ return false;
+ }
+
+ @SuppressLint("RtlHardcoded")
+ void setIsLeftPanel(boolean isLeftPanel) {
+ mIsLeftPanel = isLeftPanel;
+ mLayoutParams.gravity = mIsLeftPanel
+ ? (Gravity.LEFT | Gravity.TOP)
+ : (Gravity.RIGHT | Gravity.TOP);
+ }
+
+ boolean getIsLeftPanel() {
+ return mIsLeftPanel;
+ }
+
+ void setDisplaySize(Point displaySize) {
+ mDisplaySize.set(displaySize.x, displaySize.y);
+ mScreenSize = Math.min(mDisplaySize.x, mDisplaySize.y);
+ }
+
+ void setBackCallback(BackCallback callback) {
+ mBackCallback = callback;
+ }
+
+ void setLayoutParams(WindowManager.LayoutParams layoutParams) {
+ mLayoutParams = layoutParams;
+ mWindowManager.addView(this, mLayoutParams);
+ }
+
+ private float getCurrentAngle() {
+ return mCurrentAngle;
+ }
+
+ private float getCurrentTranslation() {
+ return mCurrentTranslation;
+ }
+
+ void onMotionEvent(MotionEvent event) {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ mVelocityTracker.addMovement(event);
+ switch (event.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ mDragSlopPassed = false;
+ resetOnDown();
+ mStartX = event.getX();
+ mStartY = event.getY();
+ setVisibility(VISIBLE);
+ updatePosition(event.getY());
+ mWindowManager.updateViewLayout(this, mLayoutParams);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ handleMoveEvent(event);
+ break;
+ case MotionEvent.ACTION_UP:
+ if (mTriggerBack) {
+ triggerBack();
+ } else {
+ cancelBack();
+ }
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ cancelBack();
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ break;
+ }
+ }
+
+ @Override
+ protected void onConfigurationChanged(Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+ updateArrowDirection();
+ loadDimens();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ float pointerPosition = mCurrentTranslation - mArrowThickness / 2.0f;
+ canvas.save();
+ canvas.translate(
+ mIsLeftPanel ? pointerPosition : getWidth() - pointerPosition,
+ (getHeight() * 0.5f) + mVerticalTranslation);
+
+ // Let's calculate the position of the end based on the angle
+ float x = (polarToCartX(mCurrentAngle) * mArrowLength);
+ float y = (polarToCartY(mCurrentAngle) * mArrowLength);
+ Path arrowPath = calculatePath(x, y);
+
+ canvas.drawPath(arrowPath, mPaint);
+ canvas.restore();
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ mMaxTranslation = getWidth() - mArrowPaddingEnd;
+ }
+
+ private void loadDimens() {
+ Resources res = getResources();
+ mArrowPaddingEnd = ResourceUtils.getDimenByName("navigation_edge_panel_padding", res, 8);
+ mMinArrowPosition = ResourceUtils.getDimenByName("navigation_edge_arrow_min_y", res, 64);
+ mFingerOffset = ResourceUtils.getDimenByName("navigation_edge_finger_offset", res, 48);
+ }
+
+ private void updateArrowDirection() {
+ // Both panels arrow point the same way
+ mArrowsPointLeft = getLayoutDirection() == LAYOUT_DIRECTION_LTR;
+ invalidate();
+ }
+
+ private float getStaticArrowWidth() {
+ return polarToCartX(ARROW_ANGLE_WHEN_EXTENDED_DEGREES) * mArrowLength;
+ }
+
+ private float polarToCartX(float angleInDegrees) {
+ return (float) Math.cos(Math.toRadians(angleInDegrees));
+ }
+
+ private float polarToCartY(float angleInDegrees) {
+ return (float) Math.sin(Math.toRadians(angleInDegrees));
+ }
+
+ private Path calculatePath(float x, float y) {
+ if (!mArrowsPointLeft) {
+ x = -x;
+ }
+ float extent = lerp(1.0f, 0.75f, mDisappearAmount);
+ x = x * extent;
+ y = y * extent;
+ mArrowPath.reset();
+ mArrowPath.moveTo(x, y);
+ mArrowPath.lineTo(0, 0);
+ mArrowPath.lineTo(x, -y);
+ return mArrowPath;
+ }
+
+ private static float lerp(float start, float stop, float amount) {
+ return start + (stop - start) * amount;
+ }
+
+ private void triggerBack() {
+ if (mBackCallback != null) {
+ mBackCallback.triggerBack();
+ }
+
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+ mVelocityTracker.computeCurrentVelocity(1000);
+ // Only do the extra translation if we're not already flinging
+ boolean isSlow = Math.abs(mVelocityTracker.getXVelocity()) < 500;
+ if (isSlow
+ || SystemClock.uptimeMillis() - mVibrationTime >= GESTURE_DURATION_FOR_CLICK_MS) {
+ VibratorWrapper.INSTANCE.get(getContext()).vibrate(VibratorWrapper.EFFECT_CLICK);
+ }
+
+ // Let's also snap the angle a bit
+ if (mAngleOffset > -4) {
+ mAngleOffset = Math.max(-8, mAngleOffset - 8);
+ updateAngle(true /* animated */);
+ }
+
+ // Finally, after the translation, animate back and disappear the arrow
+ Runnable translationEnd = () -> {
+ // let's snap it back
+ mAngleOffset = Math.max(0, mAngleOffset + 8);
+ updateAngle(true /* animated */);
+
+ mTranslationAnimation.setSpring(mTriggerBackSpring);
+ // Translate the arrow back a bit to make for a nice transition
+ setDesiredTranslation(mDesiredTranslation - dp(32), true /* animated */);
+ animate().alpha(0f).setDuration(DISAPPEAR_FADE_ANIMATION_DURATION_MS)
+ .withEndAction(() -> setVisibility(GONE));
+ mArrowDisappearAnimation.start();
+ };
+ if (mTranslationAnimation.isRunning()) {
+ mTranslationAnimation.addEndListener(new DynamicAnimation.OnAnimationEndListener() {
+ @Override
+ public void onAnimationEnd(DynamicAnimation animation, boolean canceled,
+ float value,
+ float velocity) {
+ animation.removeEndListener(this);
+ if (!canceled) {
+ translationEnd.run();
+ }
+ }
+ });
+ } else {
+ translationEnd.run();
+ }
+ }
+
+ private void cancelBack() {
+ if (mBackCallback != null) {
+ mBackCallback.cancelBack();
+ }
+
+ if (mTranslationAnimation.isRunning()) {
+ mTranslationAnimation.addEndListener(mSetGoneEndListener);
+ } else {
+ setVisibility(GONE);
+ }
+ }
+
+ private void resetOnDown() {
+ animate().cancel();
+ mAngleAnimation.cancel();
+ mTranslationAnimation.cancel();
+ mVerticalTranslationAnimation.cancel();
+ mArrowDisappearAnimation.cancel();
+ mAngleOffset = 0;
+ mTranslationAnimation.setSpring(mRegularTranslationSpring);
+ // Reset the arrow to the side
+ setTriggerBack(false /* triggerBack */, false /* animated */);
+ setDesiredTranslation(0, false /* animated */);
+ setCurrentTranslation(0);
+ updateAngle(false /* animate */);
+ mPreviousTouchTranslation = 0;
+ mTotalTouchDelta = 0;
+ mVibrationTime = 0;
+ setDesiredVerticalTransition(0, false /* animated */);
+ }
+
+ private void handleMoveEvent(MotionEvent event) {
+ float x = event.getX();
+ float y = event.getY();
+ float touchTranslation = Math.abs(x - mStartX);
+ float yOffset = y - mStartY;
+ float delta = touchTranslation - mPreviousTouchTranslation;
+ if (Math.abs(delta) > 0) {
+ if (Math.signum(delta) == Math.signum(mTotalTouchDelta)) {
+ mTotalTouchDelta += delta;
+ } else {
+ mTotalTouchDelta = delta;
+ }
+ }
+ mPreviousTouchTranslation = touchTranslation;
+
+ // Apply a haptic on drag slop passed
+ if (!mDragSlopPassed && touchTranslation > mSwipeThreshold) {
+ mDragSlopPassed = true;
+ VibratorWrapper.INSTANCE.get(getContext()).vibrate(VibratorWrapper.EFFECT_CLICK);
+ mVibrationTime = SystemClock.uptimeMillis();
+
+ // Let's show the arrow and animate it in!
+ mDisappearAmount = 0.0f;
+ setAlpha(1f);
+ // And animate it go to back by default!
+ setTriggerBack(true /* triggerBack */, true /* animated */);
+ }
+
+ // Let's make sure we only go to the baseextend and apply rubberbanding afterwards
+ if (touchTranslation > mBaseTranslation) {
+ float diff = touchTranslation - mBaseTranslation;
+ float progress = MathUtils.clamp(diff / (mScreenSize - mBaseTranslation), 0, 1);
+ progress = RUBBER_BAND_INTERPOLATOR.getInterpolation(progress)
+ * (mMaxTranslation - mBaseTranslation);
+ touchTranslation = mBaseTranslation + progress;
+ } else {
+ float diff = mBaseTranslation - touchTranslation;
+ float progress = MathUtils.clamp(diff / mBaseTranslation, 0, 1);
+ progress = RUBBER_BAND_INTERPOLATOR_APPEAR.getInterpolation(progress)
+ * (mBaseTranslation / RUBBER_BAND_AMOUNT_APPEAR);
+ touchTranslation = mBaseTranslation - progress;
+ }
+ // By default we just assume the current direction is kept
+ boolean triggerBack = mTriggerBack;
+
+ // First lets see if we had continuous motion in one direction for a while
+ if (Math.abs(mTotalTouchDelta) > mMinDeltaForSwitch) {
+ triggerBack = mTotalTouchDelta > 0;
+ }
+
+ // Then, let's see if our velocity tells us to change direction
+ mVelocityTracker.computeCurrentVelocity(1000);
+ float xVelocity = mVelocityTracker.getXVelocity();
+ float yVelocity = mVelocityTracker.getYVelocity();
+ float velocity = (float) Math.hypot(xVelocity, yVelocity);
+ mAngleOffset = Math.min(velocity / 1000 * ARROW_ANGLE_ADDED_PER_1000_SPEED,
+ ARROW_MAX_ANGLE_SPEED_OFFSET_DEGREES) * Math.signum(xVelocity);
+ if (mIsLeftPanel && mArrowsPointLeft || !mIsLeftPanel && !mArrowsPointLeft) {
+ mAngleOffset *= -1;
+ }
+
+ // Last if the direction in Y is bigger than X * 2 we also abort
+ if (Math.abs(yOffset) > Math.abs(x - mStartX) * 2) {
+ triggerBack = false;
+ }
+ setTriggerBack(triggerBack, true /* animated */);
+
+ if (!mTriggerBack) {
+ touchTranslation = 0;
+ } else if (mIsLeftPanel && mArrowsPointLeft
+ || (!mIsLeftPanel && !mArrowsPointLeft)) {
+ // If we're on the left we should move less, because the arrow is facing the other
+ // direction
+ touchTranslation -= getStaticArrowWidth();
+ }
+ setDesiredTranslation(touchTranslation, true /* animated */);
+ updateAngle(true /* animated */);
+
+ float maxYOffset = getHeight() / 2.0f - mArrowLength;
+ float progress =
+ MathUtils.clamp(Math.abs(yOffset) / (maxYOffset * RUBBER_BAND_AMOUNT), 0, 1);
+ float verticalTranslation = RUBBER_BAND_INTERPOLATOR.getInterpolation(progress)
+ * maxYOffset * Math.signum(yOffset);
+ setDesiredVerticalTransition(verticalTranslation, true /* animated */);
+ }
+
+ private void updatePosition(float touchY) {
+ float position = touchY - mFingerOffset;
+ position = Math.max(position, mMinArrowPosition);
+ position -= mLayoutParams.height / 2.0f;
+ mLayoutParams.y = MathUtils.clamp((int) position, 0, mDisplaySize.y);
+ }
+
+ private void setDesiredVerticalTransition(float verticalTranslation, boolean animated) {
+ if (mDesiredVerticalTranslation != verticalTranslation) {
+ mDesiredVerticalTranslation = verticalTranslation;
+ if (!animated) {
+ setVerticalTranslation(verticalTranslation);
+ } else {
+ mVerticalTranslationAnimation.animateToFinalPosition(verticalTranslation);
+ }
+ invalidate();
+ }
+ }
+
+ private void setVerticalTranslation(float verticalTranslation) {
+ mVerticalTranslation = verticalTranslation;
+ invalidate();
+ }
+
+ private float getVerticalTranslation() {
+ return mVerticalTranslation;
+ }
+
+ private void setDesiredTranslation(float desiredTranslation, boolean animated) {
+ if (mDesiredTranslation != desiredTranslation) {
+ mDesiredTranslation = desiredTranslation;
+ if (!animated) {
+ setCurrentTranslation(desiredTranslation);
+ } else {
+ mTranslationAnimation.animateToFinalPosition(desiredTranslation);
+ }
+ }
+ }
+
+ private void setCurrentTranslation(float currentTranslation) {
+ mCurrentTranslation = currentTranslation;
+ invalidate();
+ }
+
+ private void setTriggerBack(boolean triggerBack, boolean animated) {
+ if (mTriggerBack != triggerBack) {
+ mTriggerBack = triggerBack;
+ mAngleAnimation.cancel();
+ updateAngle(animated);
+ // Whenever the trigger back state changes the existing translation animation should be
+ // cancelled
+ mTranslationAnimation.cancel();
+ }
+ }
+
+ private void updateAngle(boolean animated) {
+ float newAngle = mTriggerBack ? ARROW_ANGLE_WHEN_EXTENDED_DEGREES + mAngleOffset : 90;
+ if (newAngle != mDesiredAngle) {
+ if (!animated) {
+ setCurrentAngle(newAngle);
+ } else {
+ mAngleAnimation.setSpring(mTriggerBack ? mAngleAppearForce : mAngleDisappearForce);
+ mAngleAnimation.animateToFinalPosition(newAngle);
+ }
+ mDesiredAngle = newAngle;
+ }
+ }
+
+ private void setCurrentAngle(float currentAngle) {
+ mCurrentAngle = currentAngle;
+ invalidate();
+ }
+
+ private float dp(float dp) {
+ return mDensity * dp;
+ }
+
+ /** Callback to let the gesture handler react to the detected back gestures. */
+ interface BackCallback {
+ /** Indicates that a Back gesture was recognized. */
+ void triggerBack();
+
+ /** Indicates that the gesture was cancelled. */
+ void cancelBack();
+ }
+}
diff --git a/quickstep/src/com/android/quickstep/interaction/GestureSandboxActivity.java b/quickstep/src/com/android/quickstep/interaction/GestureSandboxActivity.java
index 8081ad7..4815366 100644
--- a/quickstep/src/com/android/quickstep/interaction/GestureSandboxActivity.java
+++ b/quickstep/src/com/android/quickstep/interaction/GestureSandboxActivity.java
@@ -30,12 +30,11 @@
import com.android.quickstep.interaction.BackGestureTutorialFragment.TutorialType;
import java.util.List;
-import java.util.Optional;
/** Shows the gesture interactive sandbox in full screen mode. */
public class GestureSandboxActivity extends FragmentActivity {
- Optional<BackGestureTutorialFragment> mFragment = Optional.empty();
+ private BackGestureTutorialFragment mFragment;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -43,10 +42,10 @@
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.back_gesture_tutorial_activity);
- mFragment = Optional.of(BackGestureTutorialFragment.newInstance(TutorialStep.ENGAGED,
- TutorialType.RIGHT_EDGE_BACK_NAVIGATION));
+ mFragment = BackGestureTutorialFragment.newInstance(
+ TutorialStep.ENGAGED, TutorialType.RIGHT_EDGE_BACK_NAVIGATION);
getSupportFragmentManager().beginTransaction()
- .add(R.id.back_gesture_tutorial_fragment_container, mFragment.get())
+ .add(R.id.back_gesture_tutorial_fragment_container, mFragment)
.commit();
}
@@ -54,6 +53,13 @@
public void onAttachedToWindow() {
super.onAttachedToWindow();
disableSystemGestures();
+ mFragment.onAttachedToWindow();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ mFragment.onDetachedFromWindow();
}
@Override
@@ -64,13 +70,6 @@
}
}
- @Override
- public void onBackPressed() {
- if (mFragment.isPresent()) {
- mFragment.get().onBackPressed();
- }
- }
-
private void hideSystemUI() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
diff --git a/src/com/android/launcher3/util/VibratorWrapper.java b/src/com/android/launcher3/util/VibratorWrapper.java
index 04741a1..b0defd4 100644
--- a/src/com/android/launcher3/util/VibratorWrapper.java
+++ b/src/com/android/launcher3/util/VibratorWrapper.java
@@ -39,7 +39,7 @@
public static final MainThreadInitializedObject<VibratorWrapper> INSTANCE =
new MainThreadInitializedObject<>(VibratorWrapper::new);
- private static final VibrationEffect EFFECT_CLICK =
+ public static final VibrationEffect EFFECT_CLICK =
createPredefined(VibrationEffect.EFFECT_CLICK);
/**