diff --git a/flexera/src/app/flexera/home/QuickLaunch.kt b/flexera/src/app/flexera/home/QuickLaunch.kt index ed37d0ad3e..7472122ab2 100644 --- a/flexera/src/app/flexera/home/QuickLaunch.kt +++ b/flexera/src/app/flexera/home/QuickLaunch.kt @@ -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() { diff --git a/flexera/src/app/flexera/home/QuickLaunchPage.kt b/flexera/src/app/flexera/home/QuickLaunchPage.kt new file mode 100644 index 0000000000..c312518dac --- /dev/null +++ b/flexera/src/app/flexera/home/QuickLaunchPage.kt @@ -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]'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) + } + } + } +} diff --git a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java index 81b2dce929..6a723d33e9 100644 --- a/quickstep/src/com/android/launcher3/statehandlers/DepthController.java +++ b/quickstep/src/com/android/launcher3/statehandlers/DepthController.java @@ -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(); diff --git a/quickstep/src/com/android/quickstep/util/BaseDepthControllerImpl.java b/quickstep/src/com/android/quickstep/util/BaseDepthControllerImpl.java index bbd3adbbb2..391243b0b6 100644 --- a/quickstep/src/com/android/quickstep/util/BaseDepthControllerImpl.java +++ b/quickstep/src/com/android/quickstep/util/BaseDepthControllerImpl.java @@ -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 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); diff --git a/src/com/android/launcher3/statehandlers/BaseDepthController.kt b/src/com/android/launcher3/statehandlers/BaseDepthController.kt index 88d2bcdd5c..a44df53a3e 100644 --- a/src/com/android/launcher3/statehandlers/BaseDepthController.kt +++ b/src/com/android/launcher3/statehandlers/BaseDepthController.kt @@ -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? } \ No newline at end of file diff --git a/src/com/android/launcher3/util/SystemUiController.java b/src/com/android/launcher3/util/SystemUiController.java index 368b267ab6..9fa6ce8b4a 100644 --- a/src/com/android/launcher3/util/SystemUiController.java +++ b/src/com/android/launcher3/util/SystemUiController.java @@ -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;