Quick launch: an app opens on a page of its own, with the home screen blurring away

When a swipe past the first or the last home screen page is set to open an
app, the home screen no longer stretches. It moves aside with the finger, as
for a real page, an empty black page comes in from that edge, and the home
screen (icons and wallpaper) blurs and darkens the further it goes. Let go
past 40% of the width, or flick, and the page settles over the whole screen
and the app comes up on it; let go earlier and the home screen comes back.
Swipes set to other actions keep the stretch.

The home screen is moved with the drag layer's animation matrix, which does
not move touch coordinates, so the pages keep reading the same touch. The
blur is a second index on the folder blur's property in the depth controller.
The status bar icons turn light over the black page. Once the app is in
front the launcher stops and the page resets; if nothing comes within 1.5 s
the home screen slides back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TMCandClaude Opus 5.5 committed 2026-11-13 11:02:10 +01:00
1 parent 0fbefd2ca6
commit 900071ccac
6 files changed
+404 -26

No files matched your search

+107 -22
View File
@@ -3,6 +3,7 @@ package app.flexera.home
import android.app.ActivityOptions
import android.content.Context
import android.content.pm.LauncherApps
import android.graphics.Canvas
import android.os.Build
import android.util.Log
import android.view.HapticFeedbackConstants
@@ -24,7 +25,9 @@ import com.android.launcher3.util.EdgeEffectCompat
*
* The home screen already answers such a swipe by stretching and springing back. That stretch is
* an edge effect, one per side, and quick launch is an edge effect that also keeps count of how
* far the finger has pulled: far enough, or a flick, and lifting the finger does the action.
* far the finger has pulled. When the choice is an app, there is no stretch: the pull brings in
* a page of the app's own ([QuickLaunchPage]), and the app opens on it. For the other actions
* the stretch stays, and far enough, or a flick, does the action when the finger lifts.
* Launcher3 uses the same place for the Google feed on other phones (OverlayEdgeEffect).
*/
object QuickLaunch {
@@ -56,11 +59,26 @@ object QuickLaunch {
}
}
/** The app [choice] opens, if it opens one. */
private fun appOf(choice: GestureHandlerConfig): OpenAppTarget.App? =
(choice as? GestureHandlerConfig.OpenApp)?.target as? OpenAppTarget.App
/** Opens [target]; false if it cannot be opened. */
private fun startApp(launcher: Launcher, target: OpenAppTarget.App, options: ActivityOptions): Boolean = try {
val apps = launcher.getSystemService(LauncherApps::class.java)
apps?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
apps != null
} catch (e: RuntimeException) {
// The chosen app is gone or cannot be started; nothing to open.
Log.w(TAG, "The quick launch app could not be opened", e)
false
}
private fun launch(launcher: Launcher, firstPage: Boolean, fromLeft: Boolean) {
val choice = choice(launcher, firstPage)
if (choice is GestureHandlerConfig.NoOp) return
val target = (choice as? GestureHandlerConfig.OpenApp)?.target
if (target is OpenAppTarget.App) {
val target = appOf(choice)
if (target != null) {
// The app opens out of the screen edge the finger pulled from, as near to a page
// sliding in as Android lets an ordinary app come: it plays a sideways slide of
// the launcher's own making only for the phone's built-in launcher.
@@ -72,18 +90,26 @@ object QuickLaunch {
1,
root.height,
)
try {
launcher.getSystemService(LauncherApps::class.java)
?.startMainActivity(target.key.componentName, target.key.user, null, options.toBundle())
} catch (e: RuntimeException) {
// The chosen app is gone or cannot be started; nothing to open.
Log.w(TAG, "The quick launch app could not be opened", e)
}
startApp(launcher, target, options)
return
}
(launcher as? LawnchairLauncher)?.gestureController?.handle(choice)
}
/** The page is all the way in: the app comes up on it, or the home screen comes back. */
private fun launchOnPage(launcher: Launcher, firstPage: Boolean, page: QuickLaunchPage) {
val target = appOf(choice(launcher, firstPage))
if (target == null) {
page.close()
return
}
// Scaled up from the whole screen to the whole screen, which leaves a fade: the app
// appears on the black page rather than flying in over it.
val root = launcher.rootView
val options = ActivityOptions.makeScaleUpAnimation(root, 0, 0, root.width, root.height)
if (!startApp(launcher, target, options)) page.close()
}
private class Effect(
private val workspace: View,
private val launcher: Launcher,
@@ -99,33 +125,80 @@ object QuickLaunch {
private var armed = false
private var flicked = false
/** The page this pull brings in, when the choice is an app; then there is no stretch. */
private var paging: QuickLaunchPage? = null
/** How fast the finger was moving towards the missing page when it lifted, in px/s. */
private var outwardVelocity = 0f
private val flickVelocity = FLICK_DP_PER_SECOND * workspace.resources.displayMetrics.density
// While paging, the pull is the page's, not a stretch's: the pages ask these whether
// there is a pull to give back before they scroll the other way.
override fun getDistance(): Float = if (paging != null) pulled else super.getDistance()
override fun isFinished(): Boolean = if (paging != null) pulled <= 0f else super.isFinished()
override fun draw(canvas: Canvas): Boolean = if (paging != null) false else super.draw(canvas)
override fun onPullDistance(deltaDistance: Float, displacement: Float, ev: MotionEvent): Float {
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
if (pulled == 0f && deltaDistance > 0f) {
active = choice(workspace.context, firstPage) !is GestureHandlerConfig.NoOp &&
val choice = choice(workspace.context, firstPage)
active = choice !is GestureHandlerConfig.NoOp &&
launcher.isInState(LauncherState.NORMAL) &&
!launcher.dragController.isDragging
paging = if (active && appOf(choice) != null) {
QuickLaunchPage.of(launcher)?.takeUnless { it.isOpening }
} else {
null
}
// A page still on its way back is caught where it is.
pulled = paging?.begin(fromLeft = atLeftEdge) ?: 0f
}
val page = paging
if (page != null) {
val before = pulled
pulled = (pulled + deltaDistance).coerceIn(0f, 1f)
page.show(pulled)
arm(pulled >= QuickLaunchPage.OPEN_DISTANCE)
return pulled - before
}
val consumed = super.onPullDistance(deltaDistance, displacement, ev)
pulled = (pulled + deltaDistance).coerceAtLeast(0f)
val nowArmed = active && pulled >= ARM_DISTANCE
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
arm(active && pulled >= ARM_DISTANCE)
return consumed
}
override fun onFlingVelocity(velocity: Int) {
// A pull at the left side is a finger moving right, which is a positive speed.
val outward = if (atLeftEdge) velocity else -velocity
flicked = active && pulled >= FLICK_DISTANCE && outward >= flickVelocity
outwardVelocity = (if (atLeftEdge) velocity else -velocity).toFloat()
flicked = active && pulled >= FLICK_DISTANCE && outwardVelocity >= flickVelocity
}
override fun onAbsorb(velocity: Int) {
if (paging == null) super.onAbsorb(velocity)
}
override fun onRelease(ev: MotionEvent) {
val lifted = ev.actionMasked == MotionEvent.ACTION_UP
val page = paging
if (page != null) {
// As with the pages themselves: a flick decides, else how far the page is in.
val open = lifted && when {
flicked -> true
outwardVelocity <= -flickVelocity -> false
else -> armed
}
val tickNow = open && !armed
val speed = if (open) outwardVelocity else -outwardVelocity
reset()
if (tickNow) tick()
page.settle(open, speed) { launchOnPage(launcher, firstPage, page) }
return
}
val fire = lifted && active && (armed || flicked)
val tickNow = fire && !armed
reset()
@@ -138,13 +211,23 @@ object QuickLaunch {
}
override fun onRelease() {
val page = paging
reset()
super.onRelease()
if (page != null) page.close() else super.onRelease()
}
override fun finish() {
val page = paging
reset()
super.finish()
if (page != null) page.close() else super.finish()
}
private fun arm(nowArmed: Boolean) {
if (nowArmed != armed) {
armed = nowArmed
// A tick when the pull is far enough, and again when it is taken back.
tick()
}
}
private fun reset() {
@@ -152,6 +235,8 @@ object QuickLaunch {
active = false
armed = false
flicked = false
paging = null
outwardVelocity = 0f
}
private fun tick() {
@@ -0,0 +1,266 @@
package app.flexera.home
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import com.android.launcher3.BaseActivity
import com.android.launcher3.InsettableFrameLayout
import com.android.launcher3.Launcher
import com.android.launcher3.util.SystemUiController
import java.util.WeakHashMap
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Quick launch's page: when a swipe past the first or the last home screen page opens an app,
* the home screen does not stretch. It moves aside with the finger, as it does for a page that
* is there, and an empty black page comes in behind it. The home screen blurs and darkens the
* further it goes. Let go far enough, or flick, and the page settles over the whole screen and
* the app comes up on it; let go early, and the home screen comes back.
*
* The home screen is moved by its drawing only ([View.setAnimationMatrix] on the drag layer),
* not by its position: the touch that moves it is still being read by the pages inside it, and
* moving their position would move the finger's position in them as well.
*
* [QuickLaunch]'s edge effect drives this with the finger, from 0 (home screen) to 1 (page).
*/
class QuickLaunchPage private constructor(private val launcher: Launcher) {
companion object {
/** How far the page has to come in for letting go to open it, as a share of the width. */
const val OPEN_DISTANCE = 0.4f
/** How dark the home screen gets beside a page that is all the way in. */
private const val HOME_DIM = 0.35f
/** The page settles at least this fast, in dp per second, and within these times. */
private const val MIN_SETTLE_DP_PER_SECOND = 1500f
private const val MIN_SETTLE_MS = 150L
private const val MAX_SETTLE_MS = 300L
/**
* Once the page is across, the launcher is paused as soon as the app starts. Still in
* the foreground after this long, it never will be: nothing came, and the home screen
* comes back.
*/
private const val LAUNCH_TIMEOUT_MS = 1500L
private val pages = WeakHashMap<Launcher, QuickLaunchPage>()
/** [launcher]'s page, made when first wanted; null while the launcher has no views. */
fun of(launcher: Launcher): QuickLaunchPage? {
pages[launcher]?.let { return it }
if (launcher.rootView == null || launcher.dragLayer == null) return null
return QuickLaunchPage(launcher).also { pages[launcher] = it }
}
/** Whether [launcher]'s page is on the screen, all or part of it. */
@JvmStatic
fun isShowing(launcher: Launcher): Boolean = (pages[launcher]?.progress ?: 0f) > 0f
/** The blur for how far the page is in: quick at first, so the home screen soon recedes. */
private fun blurFor(progress: Float): Float {
val left = 1f - progress
return 1f - left * left
}
}
/** How far the page is in, from 0 (not at all) to 1 (it fills the screen). */
var progress = 0f
private set
/** Whether the page comes in from the left edge of the screen, rather than the right. */
private var fromLeft = false
/** The page is on its way all the way in, or there, with the app to follow. */
private var opening = false
/** Changes whenever what an earlier opening waits for no longer counts. */
private var generation = 0
private var animator: ValueAnimator? = null
private val shift = Matrix()
private val view = PageView(launcher)
private val minSettleSpeed = MIN_SETTLE_DP_PER_SECOND * launcher.resources.displayMetrics.density
init {
view.visibility = View.INVISIBLE
// Over the whole window, the status and navigation bars included.
val params = InsettableFrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
).apply { ignoreInsets = true }
launcher.rootView.addView(view, params)
}
/** Whether the page is busy opening an app; it then takes no new pull. */
val isOpening: Boolean
get() = opening
/**
* A pull begins at the left or the right edge. A page still on its way back from the same
* edge is caught where it is; that is returned, as how far the page is in.
*/
fun begin(fromLeft: Boolean): Float {
cancelAnimation()
if (this.fromLeft != fromLeft) {
show(0f)
this.fromLeft = fromLeft
}
return progress
}
/** Puts the page [progress] of the way in, from 0 to 1, with the home screen beside it. */
fun show(progress: Float) {
this.progress = progress.coerceIn(0f, 1f)
apply()
}
/**
* The finger has let go: the page goes all the way in ([open]) or back out, at least as fast
* as the finger was moving that way ([speed], pixels per second). All the way in, [launch]
* runs; it starts the app, or calls [close] if there is none to start.
*/
fun settle(open: Boolean, speed: Float, launch: () -> Unit) {
cancelAnimation()
opening = open
val target = if (open) 1f else 0f
if (progress == target) {
if (open) opened(launch) else apply()
return
}
val remaining = abs(target - progress) * view.rootWidth()
// A decelerating move starts at twice its average speed.
val duration = (2000f * remaining / max(speed, minSettleSpeed)).toLong()
val anim = ValueAnimator.ofFloat(progress, target)
anim.duration = duration.coerceIn(MIN_SETTLE_MS, MAX_SETTLE_MS)
anim.interpolator = DecelerateInterpolator()
anim.addUpdateListener { show(it.animatedValue as Float) }
anim.addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(animation: Animator) {
cancelled = true
}
override fun onAnimationEnd(animation: Animator) {
if (animator === anim) animator = null
if (!cancelled && open) opened(launch)
}
})
animator = anim
anim.start()
}
/** The home screen comes back, as after letting go too early. */
fun close() {
generation++
settle(open = false, speed = 0f) {}
}
/** The home screen is back at once, with nothing to see on the way. */
fun reset() {
generation++
cancelAnimation()
opening = false
show(0f)
}
private fun opened(launch: () -> Unit) {
val token = ++generation
// Once the app is in front, the launcher stops; the next time it is seen, it is as it was.
launcher.addEventCallback(BaseActivity.EVENT_STOPPED) {
if (token == generation) reset()
}
// Starting an app pauses the launcher, resumes it for a moment and pauses it again, so
// being resumed only means something once the start has had its time.
view.postDelayed({
if (token != generation) return@postDelayed
if (launcher.hasBeenResumed()) {
// Still in front: nothing came.
close()
} else {
// Something is in front without hiding the launcher, or is still starting.
// Should the launcher come back without having stopped, the home screen does too.
launcher.addEventCallback(BaseActivity.EVENT_RESUMED) {
if (token == generation) close()
}
}
}, LAUNCH_TIMEOUT_MS)
launch()
}
private fun cancelAnimation() {
animator?.let {
animator = null
it.cancel()
}
}
private fun apply() {
val p = progress
view.visibility = if (p > 0f) View.VISIBLE else View.INVISIBLE
view.invalidate()
val dragLayer = launcher.dragLayer
if (p > 0f) {
shift.setTranslate((if (fromLeft) 1f else -1f) * p * view.rootWidth(), 0f)
dragLayer.setAnimationMatrix(shift)
} else {
dragLayer.setAnimationMatrix(null)
}
launcher.depthController?.quickLaunchBlur?.value = blurFor(p)
// Light status bar icons once the page is most of what is seen.
launcher.systemUiController?.updateUiState(
SystemUiController.UI_STATE_QUICK_LAUNCH_PAGE,
if (p >= 0.5f) SystemUiController.FLAG_DARK_STATUS or SystemUiController.FLAG_DARK_NAV else 0,
)
}
/** The width the page and the home screen move across; known before this view's first layout. */
private fun View.rootWidth(): Int = if (width > 0) width else launcher.dragLayer.width
private inner class PageView(context: Context) : View(context) {
private val pagePaint = Paint().apply { color = Color.BLACK }
private val dimPaint = Paint().apply { color = Color.BLACK }
init {
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
}
/**
* While the page opens and the app is on its way, a touch would land on the home screen
* behind it, where nothing is where it is drawn; the page keeps it. On its way back it
* lets touches through, so that a new swipe can catch it.
*/
override fun onTouchEvent(event: MotionEvent): Boolean = opening
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
val edge = progress * w
dimPaint.alpha = (HOME_DIM * progress * 255).roundToInt()
if (fromLeft) {
canvas.drawRect(0f, 0f, edge, h, pagePaint)
canvas.drawRect(edge, 0f, w, h, dimPaint)
} else {
canvas.drawRect(w - edge, 0f, w, h, pagePaint)
canvas.drawRect(0f, 0f, w - edge, h, dimPaint)
}
}
}
}
@@ -227,6 +227,11 @@ public class DepthController extends BaseDepthControllerImpl implements StateHan
mLauncher, AbstractFloatingView.TYPE_FOLDER) == null) {
folderBlur.setValue(0f);
}
// Flexera Launcher: likewise quick launch's blur with its page gone.
if (quickLaunchBlur.getValue() > 0f
&& !app.flexera.home.QuickLaunchPage.isShowing(mLauncher)) {
quickLaunchBlur.setValue(0f);
}
if (mLauncher.getStateManager().getState() == LauncherState.NORMAL
&& !mLauncher.getStateManager().isInTransition()) {
clearWorkspaceRenderEffects();
@@ -95,6 +95,12 @@ public class BaseDepthControllerImpl implements BaseDepthController {
* depth it comes with no zoom, and it reaches the icons as well as the wallpaper.
*/
public final MultiProperty folderBlur;
/**
* Flexera Launcher: property to set the blur of the home screen while quick launch's page
* slides in over it (app.flexera.home.QuickLaunchPage), from 0 to 1. It shares the folder
* blur's machinery; the stronger of the two wins.
*/
public final MultiProperty quickLaunchBlur;
/**
* Blur radius when completely zoomed out, in pixels.
@@ -111,7 +117,8 @@ public class BaseDepthControllerImpl implements BaseDepthController {
private float mDepth;
// Used just for animating wallpaper zoom without affecting blur.
private final AnimatedFloat mWallpaperZoomOnly = new AnimatedFloat(this::applyDepthAndBlur);
// Flexera Launcher: the blur behind an open folder, see folderBlur.
// Flexera Launcher: the blur behind an open folder, see folderBlur, or behind quick
// launch's page, see quickLaunchBlur.
private final AnimatedFloat mFolderBlur = new AnimatedFloat(this::applyDepthAndBlur);
// Whether a folder blur is on the launcher's surface, and so has to come off again.
private boolean mFolderBlurApplied;
@@ -174,8 +181,10 @@ public class BaseDepthControllerImpl implements BaseDepthController {
folderZoom = null;
}
// Flexera Launcher
folderBlur = new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 1, Float::max)
.get(0);
MultiPropertyFactory<AnimatedFloat> backdropBlur =
new MultiPropertyFactory<>(mFolderBlur, AnimatedFloat.VALUE, 2, Float::max);
folderBlur = backdropBlur.get(0);
quickLaunchBlur = backdropBlur.get(1);
// mEarlyWakeupInfo.token = new Binder();
// mEarlyWakeupInfo.trace = BaseDepthControllerImpl.class.getName();
@@ -201,6 +210,11 @@ public class BaseDepthControllerImpl implements BaseDepthController {
return folderBlur;
}
@Override
public MultiProperty getQuickLaunchBlur() {
return quickLaunchBlur;
}
/** Flexera Launcher: the blur behind an open folder as it is now, from 0 to 1. */
private float folderBlurAmount() {
return Utilities.boundToRange(mFolderBlur.value, 0f, 1f);
@@ -46,4 +46,10 @@ interface BaseDepthController {
* (none) to 1 (as strong as it gets).
*/
val folderBlur: MultiPropertyFactory<*>.MultiProperty?
/**
* Flexera Launcher: property for the blur of the home screen while quick launch's page
* slides in over it, from 0 (none) to 1 (as strong as it gets).
*/
val quickLaunchBlur: MultiPropertyFactory<*>.MultiProperty?
}
@@ -35,6 +35,8 @@ public class SystemUiController {
public static final int UI_STATE_WIDGET_BOTTOM_SHEET = 2;
public static final int UI_STATE_FULLSCREEN_TASK = 3;
public static final int UI_STATE_ALL_APPS = 4;
// Flexera Launcher: quick launch's black page (app.flexera.home.QuickLaunchPage).
public static final int UI_STATE_QUICK_LAUNCH_PAGE = 5;
public static final int FLAG_LIGHT_NAV = 1 << 0;
public static final int FLAG_DARK_NAV = 1 << 1;
@@ -54,7 +56,7 @@ public class SystemUiController {
public @interface SystemUiControllerFlags {}
private final View mView;
private final int[] mStates = new int[5];
private final int[] mStates = new int[6];
public SystemUiController(View view) {
mView = view;