Files
Flexera-Launcher/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/NoButtonNavbarToOverviewTouchController.java
T
TMCandClaude Fable 5.2 8aba905d9c Thumb-friendly drawer: compact list, Niagara-style alphabet, dark backdrop; QR code on About
Requests from the owner on 12 November 2026, after using 0.3.1 on the Pixel 9a, with a
photo of Niagara Launcher's alphabet as the reference.

Drawer list
- Rows are 50dp with 34dp icons and 15sp names (were 64dp, 46dp, 17sp); letter headers are
  34dp. A row grows with a large system font so a notification preview still fits.

Alphabet
- Letters sit 20dp apart at most (was 30dp). The strip keeps a stretch of edge as tall as
  26 letters, low on the edge with its end 40dp above the navigation bar, and centres the
  letters that exist in it. It used to spread over the whole edge, which put "A" out of a
  thumb's reach.
- The wave follows Niagara's: letters keep their size, the wave is wide (104dp) so about
  five letters on each side of the finger curve around the thumb, and the selected letter
  is shown large in a light bubble just beyond the wave's peak. This replaces the enlarged
  letter on a dark spot from 0.3.1, which collided with its neighbours at this spacing.
- The list dims a little during any scrub, more where the wave lies over it.

Backdrop
- The drawer's scrim is black at 78% in every theme (AllAppsState). Lawnchair's grey at 40%
  left a light grey behind white names over a light wallpaper. LawnchairScrimView judges the
  status bar icons by the scrim's own colour again, since that colour is now always dark.

Scrub from the home screen (PRD AD-10)
- On by default, the owner's decision; this closes the PRD's open question.
- It opens the drawer in 120 ms instead of the state manager's 600 ms, like swipe up and hold.
- A touch that lifts within 200 ms was a flick and opens the plain list.

Fix: undoing a scrub that was not meant
- 0.3.1 put the list back to "where it was", read from the list's rows. A closed drawer has
  been told to return to the top but lays its rows out only when it opens, so the rows
  still reported the place the drawer was last left at, and a quick swipe reopened it
  there. The list now goes where a plainly opened drawer stands.

About
- A QR code for the release page, generated on the phone with ZXing core (new dependency),
  black on white in every theme, with the address under it.

Checked on the Android 17 emulator with timed touches, a thumb-sized shape over the
screenshots, a 1.6 font scale, and by decoding the QR code from a screenshot.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 09:00:22 +01:00

442 lines
21 KiB
Java

/*
* 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.launcher3.uioverrides.touchcontrollers;
import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE;
import static com.android.app.animation.Interpolators.DECELERATE;
import static com.android.launcher3.LauncherAnimUtils.SCRIM_COLORS;
import static com.android.launcher3.LauncherAnimUtils.newSingleUseCancelListener;
import static com.android.launcher3.LauncherState.ALL_APPS;
import static com.android.launcher3.LauncherState.HINT_STATE;
import static com.android.launcher3.LauncherState.NORMAL;
import static com.android.launcher3.LauncherState.OVERVIEW;
import static com.android.launcher3.MotionEventsUtils.isTrackpadMotionEvent;
import static com.android.launcher3.Utilities.EDGE_NAV_BAR;
import static com.android.launcher3.anim.AnimatorListeners.forSuccessCallback;
import static com.android.launcher3.util.NavigationMode.THREE_BUTTONS;
import static com.android.launcher3.util.VibratorWrapper.OVERVIEW_HAPTIC;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_ONE_HANDED_ACTIVE;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_OVERVIEW_DISABLED;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.graphics.PointF;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import com.android.internal.jank.Cuj;
import com.android.launcher3.LauncherState;
import com.android.launcher3.Utilities;
import com.android.launcher3.anim.AnimatorPlaybackController;
import com.android.launcher3.states.StateAnimationConfig;
import com.android.launcher3.taskbar.LauncherTaskbarUIController;
import com.android.launcher3.uioverrides.QuickstepLauncher;
import com.android.launcher3.util.DisplayController;
import com.android.launcher3.util.VibratorWrapper;
import com.android.launcher3.views.ScrimColorsEvaluator;
import com.android.quickstep.SystemUiProxy;
import com.android.quickstep.util.AnimatorControllerWithResistance;
import com.android.quickstep.util.MotionPauseDetector;
import com.android.quickstep.util.OverviewToHomeAnim;
import com.android.quickstep.views.RecentsView;
import com.android.systemui.shared.system.InteractionJankMonitorWrapper;
import java.util.function.BiConsumer;
/**
* Touch controller which handles swipe and hold from the nav bar to go to Overview. Swiping above
* the nav bar falls back to go to All Apps. Swiping from the nav bar without holding goes to the
* first home screen instead of to Overview.
*/
public class NoButtonNavbarToOverviewTouchController extends PortraitStatesTouchController {
public static final String TAG = "NoButtonNavbarToOverviewTouchController";
private static final float ONE_HANDED_ACTIVATED_SLOP_MULTIPLIER = 2.5f;
// How much of the movement to use for translating overview after swipe and hold.
private static final float OVERVIEW_MOVEMENT_FACTOR = 0.25f;
private static final long TRANSLATION_ANIM_MIN_DURATION_MS = 80;
private static final float TRANSLATION_ANIM_VELOCITY_DP_PER_MS = 0.8f;
private final VibratorWrapper mVibratorWrapper;
private final BiConsumer<AnimatorSet, Long> mCancelSplitRunnable;
private final RecentsView mRecentsView;
private final MotionPauseDetector mMotionPauseDetector;
private final float mMotionPauseMinDisplacement;
private boolean mDidTouchStartInNavBar;
// Flexera Launcher: swipe up and hold hands the same touch to the wave alphabet (PRD AD-17).
// "Started" is set when the pause is detected: the alphabet takes the finger at once, while
// the drawer finishes opening under it. "Drawer open" is set when that has finished.
private boolean mFlexeraScrubStarted;
private boolean mFlexeraDrawerOpen;
// Where the finger was at the last drag event, in the drag layer's coordinates.
private float mFlexeraLastX;
private float mFlexeraLastY;
// How long the drawer takes to finish opening once a hold is detected. Short, because the
// alphabet is already following the finger and the list has to be readable behind it.
private static final long FLEXERA_HOLD_OPEN_MS = 120;
// A finger that has not moved for this long is holding. Swipe to recents waits twice as
// long; here a false alarm only opens the drawer the swipe was opening anyway.
private static final long FLEXERA_HOLD_STILL_MS = 150;
// A finger that lifts sooner than this after the hold was noticed was not holding; see
// onDragEnd. Too short a time to have read the letter and meant it.
private static final long FLEXERA_MIN_HOLD_MS = app.flexera.drawer.FlexeraDrawer.MIN_SCRUB_MS;
private long mFlexeraScrubStartTime;
private boolean mStartedOverview;
private boolean mReachedOverview;
// The last recorded displacement before we reached overview.
private PointF mStartDisplacement = new PointF();
private float mStartY;
private AnimatorPlaybackController mOverviewResistYAnim;
// Normal to Hint animation has flag SKIP_OVERVIEW, so we update this scrim with this animator.
private ValueAnimator mNormalToHintOverviewScrimAnimator;
private final QuickstepLauncher mLauncher;
private boolean mIsTrackpadSwipe;
/**
* @param cancelSplitRunnable Called when split placeholder view needs to be cancelled.
* Animation should be added to the provided AnimatorSet
*/
public NoButtonNavbarToOverviewTouchController(QuickstepLauncher l,
BiConsumer<AnimatorSet, Long> cancelSplitRunnable) {
super(l);
mLauncher = l;
mRecentsView = l.getOverviewPanel();
mMotionPauseDetector = new MotionPauseDetector(l);
mMotionPauseMinDisplacement = ViewConfiguration.get(l).getScaledTouchSlop();
mVibratorWrapper = VibratorWrapper.INSTANCE.get(l.getApplicationContext());
mCancelSplitRunnable = cancelSplitRunnable;
}
@Override
protected boolean canInterceptTouch(MotionEvent ev) {
mIsTrackpadSwipe = isTrackpadMotionEvent(ev);
mLauncher.setCanShowAllAppsEducationView(!mIsTrackpadSwipe);
boolean threeButtons = !mIsTrackpadSwipe
&& DisplayController.getNavigationMode(mLauncher) == THREE_BUTTONS;
// Flexera Launcher: with three-button navigation this controller used to stand aside
// completely. It now still takes the ordinary swipe up from the home screen, so that
// swipe up and hold (PRD AD-17) works in that mode too. Everything else is left to the
// controllers after it, as before.
if (threeButtons && !mLauncher.isInState(NORMAL)) {
return false;
}
// The buttons own the navigation bar, so a touch never counts as starting on it.
mDidTouchStartInNavBar = !threeButtons && (ev.getEdgeFlags() & EDGE_NAV_BAR) != 0;
boolean isOneHandedModeActive = (SystemUiProxy.INSTANCE.get(mLauncher)
.getLastSystemUiStateFlags() & SYSUI_STATE_ONE_HANDED_ACTIVE) != 0;
// Reset touch slop multiplier to default 1.0f if one-handed-mode is not active
mDetector.setTouchSlopMultiplier(
isOneHandedModeActive ? ONE_HANDED_ACTIVATED_SLOP_MULTIPLIER : 1f /* default */);
return super.canInterceptTouch(ev) && !mLauncher.isInState(HINT_STATE);
}
@Override
protected LauncherState getTargetState(LauncherState fromState, boolean isDragTowardPositive) {
if (fromState == NORMAL && mDidTouchStartInNavBar) {
return HINT_STATE;
}
return super.getTargetState(fromState, isDragTowardPositive);
}
@Override
protected float initCurrentAnimation() {
float progressMultiplier = super.initCurrentAnimation();
if (mToState == HINT_STATE) {
// Track the drag across the entire height of the screen.
progressMultiplier = -1f / mLauncher.getDeviceProfile().getDeviceProperties().getHeightPx();
}
return progressMultiplier;
}
@Override
public void onDragStart(boolean start, float startDisplacement) {
if (mLauncher.isInState(ALL_APPS)) {
LauncherTaskbarUIController controller =
((QuickstepLauncher) mLauncher).getTaskbarUIController();
if (controller != null) {
controller.setShouldDelayLauncherStateAnim(true);
}
}
super.onDragStart(start, startDisplacement);
mMotionPauseDetector.clear();
mMotionPauseDetector.setIsTrackpadGesture(mIsTrackpadSwipe);
if (handlingOverviewAnim()) {
InteractionJankMonitorWrapper.begin(mRecentsView, Cuj.CUJ_LAUNCHER_APP_SWIPE_TO_RECENTS,
"Home");
mMotionPauseDetector.setOnMotionPauseListener(this::onMotionPauseDetected);
}
// Flexera Launcher: a pause during an ordinary swipe up to the drawer starts a scrub.
mFlexeraScrubStarted = false;
mFlexeraDrawerOpen = false;
if (flexeraHoldToScrub()) {
mMotionPauseDetector.setOnMotionPauseListener(this::onFlexeraHoldDetected);
mMotionPauseDetector.setForcePauseTimeout(FLEXERA_HOLD_STILL_MS);
}
if (mFromState == NORMAL && mToState == HINT_STATE) {
mNormalToHintOverviewScrimAnimator = ObjectAnimator.ofObject(
mLauncher.getScrimView(),
SCRIM_COLORS,
ScrimColorsEvaluator.INSTANCE,
mFromState.getWorkspaceScrimColor(mLauncher),
mToState.getWorkspaceScrimColor(mLauncher));
}
mStartedOverview = false;
mReachedOverview = false;
mOverviewResistYAnim = null;
}
@Override
public String dump() {
return TAG;
}
@Override
protected void updateProgress(float fraction) {
super.updateProgress(fraction);
if (mNormalToHintOverviewScrimAnimator != null) {
mNormalToHintOverviewScrimAnimator.setCurrentFraction(fraction);
}
}
@Override
public void onDragEnd(float velocity) {
LauncherTaskbarUIController controller =
((QuickstepLauncher) mLauncher).getTaskbarUIController();
if (controller != null) {
controller.setShouldDelayLauncherStateAnim(false);
}
if (mFlexeraScrubStarted) {
// Flexera Launcher: lifting the finger ends the scrub and leaves the drawer open at
// the chosen letter. If the drawer is still opening, its end callback finishes up.
if (SystemClock.uptimeMillis() - mFlexeraScrubStartTime < FLEXERA_MIN_HOLD_MS) {
// The finger left almost as soon as the hold was noticed, so it was not
// holding: an ordinary swipe slowed down at its end. Open the drawer the way
// that swipe would have, with the list where it was.
app.flexera.drawer.FlexeraDrawer.cancelScrubFromSwipe(mLauncher.getAppsView());
} else {
app.flexera.drawer.FlexeraDrawer.endScrubFromSwipe(mLauncher.getAppsView());
}
if (mFlexeraDrawerOpen) {
onSwipeInteractionCompleted(ALL_APPS);
}
} else if (mStartedOverview) {
goToOverviewOrHomeOnDragEnd(velocity);
} else {
InteractionJankMonitorWrapper.cancel(Cuj.CUJ_LAUNCHER_APP_SWIPE_TO_RECENTS);
super.onDragEnd(velocity);
}
mMotionPauseDetector.clear();
mIsTrackpadSwipe = false;
mNormalToHintOverviewScrimAnimator = null;
if (mLauncher.isInState(OVERVIEW)) {
// Normally we would cleanup the state based on mCurrentAnimation, but since we stop
// using that when we pause to go to Overview, we need to clean up ourselves.
clearState();
}
}
@Override
protected void updateSwipeCompleteAnimation(ValueAnimator animator, long expectedDuration,
LauncherState targetState, float velocity, boolean isFling) {
super.updateSwipeCompleteAnimation(animator, expectedDuration, targetState, velocity,
isFling);
if (targetState == HINT_STATE) {
// Normally we compute the duration based on the velocity and distance to the given
// state, but since the hint state tracks the entire screen without a clear endpoint, we
// need to manually set the duration to a reasonable value.
long duration = HINT_STATE.getTransitionDuration(mLauncher, true /* isToState */);
animator.setDuration(duration);
AnimatorSet animatorSet = new AnimatorSet();
mCancelSplitRunnable.accept(animatorSet, duration);
animatorSet.start();
}
}
private void onMotionPauseDetected() {
if (mCurrentAnimation == null) {
return;
}
mNormalToHintOverviewScrimAnimator = null;
mCurrentAnimation.getTarget().addListener(newSingleUseCancelListener(() ->
mLauncher.getStateManager().goToState(OVERVIEW, true, forSuccessCallback(() -> {
mOverviewResistYAnim = AnimatorControllerWithResistance
.createRecentsResistanceFromOverviewAnim(mLauncher, null)
.createPlaybackController();
mReachedOverview = true;
maybeSwipeInteractionToOverviewComplete();
}))));
mCurrentAnimation.getTarget().removeListener(mClearStateOnCancelListener);
mCurrentAnimation.dispatchOnCancel();
mStartedOverview = true;
VibratorWrapper.INSTANCE.get(mLauncher).vibrate(OVERVIEW_HAPTIC);
}
/**
* Flexera Launcher: true while this gesture is an ordinary swipe up from the home screen to
* the drawer, which is the one gesture where holding still should start an alphabet scrub.
* A swipe that starts on the navigation bar keeps its stock meaning (recents).
*/
private boolean flexeraHoldToScrub() {
return !mIsTrackpadSwipe && !mDidTouchStartInNavBar
&& mStartState == NORMAL && mToState == ALL_APPS;
}
/**
* Flexera Launcher: the finger paused part-way through a swipe up. From this moment the same
* touch scrubs the wave alphabet, with no wait: the alphabet takes the finger where it is,
* and the drawer finishes opening under it.
*/
private void onFlexeraHoldDetected() {
if (mCurrentAnimation == null || mFlexeraScrubStarted || !flexeraHoldToScrub()) {
return;
}
mFlexeraScrubStarted = true;
mFlexeraScrubStartTime = SystemClock.uptimeMillis();
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
// Play the swipe's own animation to its end, as lifting the finger would, only faster.
// Going through the state manager instead would take its full 600 ms opening time.
mCurrentAnimation.setEndAction(() -> {
mFlexeraDrawerOpen = true;
if (!mDetector.isDraggingState()) {
// The finger lifted while the drawer was still opening.
onSwipeInteractionCompleted(ALL_APPS);
}
});
ValueAnimator anim = mCurrentAnimation.getAnimationPlayer();
anim.setFloatValues(
Utilities.boundToRange(mCurrentAnimation.getProgressFraction(), 0f, 1f), 1f);
anim.setDuration(FLEXERA_HOLD_OPEN_MS).setInterpolator(DECELERATE);
mCurrentAnimation.dispatchOnStart();
anim.start();
VibratorWrapper.INSTANCE.get(mLauncher).vibrate(OVERVIEW_HAPTIC);
}
private void maybeSwipeInteractionToOverviewComplete() {
if (mReachedOverview && !mDetector.isDraggingState()) {
InteractionJankMonitorWrapper.end(Cuj.CUJ_LAUNCHER_APP_SWIPE_TO_RECENTS);
onSwipeInteractionCompleted(OVERVIEW);
}
}
private boolean handlingOverviewAnim() {
long stateFlags = SystemUiProxy.INSTANCE.get(mLauncher).getLastSystemUiStateFlags();
return mDidTouchStartInNavBar && mStartState == NORMAL
&& (stateFlags & SYSUI_STATE_OVERVIEW_DISABLED) == 0;
}
@Override
public boolean onDrag(float yDisplacement, float xDisplacement, MotionEvent event) {
// Flexera Launcher: remembered so that a hold, which can be noticed between two touch
// events, knows where the finger is.
mFlexeraLastX = event.getX();
mFlexeraLastY = event.getY();
if (mFlexeraScrubStarted) {
// Flexera Launcher: the swipe has become a scrub; the finger now moves the alphabet,
// not the drawer.
app.flexera.drawer.FlexeraDrawer.scrubFromSwipe(
mLauncher.getAppsView(), mLauncher.getDragLayer(), mFlexeraLastX, mFlexeraLastY);
return true;
}
if (mStartedOverview) {
if (!mReachedOverview) {
mStartDisplacement.set(xDisplacement, yDisplacement);
mStartY = event.getY();
} else {
mRecentsView.setTranslationX((xDisplacement - mStartDisplacement.x)
* OVERVIEW_MOVEMENT_FACTOR);
float yProgress = (mStartDisplacement.y - yDisplacement) / mStartY;
if (yProgress > 0 && mOverviewResistYAnim != null) {
mOverviewResistYAnim.setPlayFraction(yProgress);
} else {
mRecentsView.setTranslationY((yDisplacement - mStartDisplacement.y)
* OVERVIEW_MOVEMENT_FACTOR);
}
}
}
float upDisplacement = -yDisplacement;
// Flexera Launcher: a pause also matters on an ordinary swipe up to the drawer, but only
// after the swipe has travelled far enough that it is clearly not a slow start.
boolean holdToScrub = flexeraHoldToScrub();
float minDisplacement = holdToScrub
? app.flexera.drawer.FlexeraDrawer.holdMinSwipePx(mLauncher)
: mMotionPauseMinDisplacement;
mMotionPauseDetector.setDisallowPause(!(handlingOverviewAnim() || holdToScrub)
|| upDisplacement < minDisplacement);
mMotionPauseDetector.addPosition(event);
// Stay in Overview.
// Flexera Launcher: likewise, once this very event has triggered the hold hand-over (inside
// addPosition above), it must not also reach the normal drag code. That code would move
// the drawer back to the finger's position and leave it stuck there.
return mStartedOverview || mFlexeraScrubStarted
|| super.onDrag(yDisplacement, xDisplacement, event);
}
private void goToOverviewOrHomeOnDragEnd(float velocity) {
boolean goToHomeInsteadOfOverview = !mMotionPauseDetector.isPaused();
if (goToHomeInsteadOfOverview) {
new OverviewToHomeAnim(mLauncher, () -> onSwipeInteractionCompleted(NORMAL), null)
.animateWithVelocity(velocity);
InteractionJankMonitorWrapper.cancel(Cuj.CUJ_LAUNCHER_APP_SWIPE_TO_RECENTS);
}
if (mReachedOverview) {
float distanceDp = dpiFromPx(Math.max(
Math.abs(mRecentsView.getTranslationX()),
Math.abs(mRecentsView.getTranslationY())));
long duration = (long) Math.max(TRANSLATION_ANIM_MIN_DURATION_MS,
distanceDp / TRANSLATION_ANIM_VELOCITY_DP_PER_MS);
mRecentsView.animate()
.translationX(0)
.translationY(0)
.setInterpolator(ACCELERATE_DECELERATE)
.setDuration(duration)
.withEndAction(goToHomeInsteadOfOverview
? null
: this::maybeSwipeInteractionToOverviewComplete);
if (!goToHomeInsteadOfOverview) {
// Return to normal properties for the overview state.
StateAnimationConfig config = new StateAnimationConfig();
config.duration = duration;
LauncherState state = mLauncher.getStateManager().getState();
mLauncher.getStateManager().createAtomicAnimation(state, state, config).start();
}
}
}
private float dpiFromPx(float pixels) {
return Utilities.dpiFromPx(pixels, mLauncher.getResources().getDisplayMetrics().densityDpi);
}
}