feat: implementation scroll dock page (#7120)

- Closes: #3491
This commit is contained in:
John Andrew Camu authored and GitHub committed 2026-08-08 18:04:55 +08:00
1 parent 142cbea797
commit 99a4e77713
21 files changed
+783 -188

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 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.
Modifications copyright 2026 Lawnchair
-->
<com.android.launcher3.CellLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:launcher="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:hapticFeedbackEnabled="false"
launcher:containerType="hotseat" />
+1
View File
@@ -782,6 +782,7 @@
<string name="dock_icons">Dock icons</string>
<string name="dock_rows">Dock rows</string>
<string name="dock_pages">Dock pages</string>
<string name="state_folded">%1$s (folded)</string>
<string name="state_unfolded">%1$s (unfolded)</string>
<string name="folded_label">Folded</string>
@@ -103,6 +103,7 @@ class DeviceProfileOverrides @Inject constructor(
val enableTaskbarOnPhone: Boolean,
val numHotseatRows: Int = 1,
val numDockPages: Int = 1,
// Foldable overrides (-1 means don't override)
val foldableShownHotseatIcons: Int = -1,
@@ -132,6 +133,7 @@ class DeviceProfileOverrides @Inject constructor(
enableTaskbarOnPhone = prefs2.enableTaskbarOnPhone.firstCached(),
numHotseatRows = prefs.hotseatRows.get().coerceIn(1, 2),
numDockPages = prefs.dockPages.get().coerceIn(1, 5),
foldableShownHotseatIcons = if (deviceType == InvariantDeviceProfile.TYPE_MULTI_DISPLAY) {
val folded = prefs.hotseatColumns.get()
@@ -172,8 +174,8 @@ class DeviceProfileOverrides @Inject constructor(
idp.numDatabaseAllAppsColumns = foldableDatabaseAllAppsColumns
}
// Ensure database can hold enough icons for multi-row dock
val requiredSlots = idp.numShownHotseatIcons * numHotseatRows
// Ensure database can hold enough icons for multi-row / multi-page dock
val requiredSlots = idp.numShownHotseatIcons * numHotseatRows * numDockPages
if (idp.numDatabaseHotseatIcons < requiredSlots) {
idp.numDatabaseHotseatIcons = requiredSlots
}
@@ -0,0 +1,136 @@
package app.lawnchair.hotseat
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.ViewGroup
import com.android.launcher3.CellLayout
import com.android.launcher3.DeviceProfile
import com.android.launcher3.PagedView
import com.android.launcher3.R
import com.android.launcher3.Workspace
import com.android.launcher3.pageindicators.PageIndicatorDots
/**
* Horizontally pageable container for dock [CellLayout] pages.
*/
class HotseatPagedView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : PagedView<PageIndicatorDots>(context, attrs, defStyle) {
fun interface OnDockPageChangeListener {
fun onDockPageChanged(page: Int)
}
var isPagingEnabled: Boolean = false
private set(value) {
field = value
applyPageIndicatorVisibility()
}
private var onDockPageChangeListener: OnDockPageChangeListener? = null
init {
importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_NO
}
fun setPageIndicator(indicator: PageIndicatorDots?) {
mPageIndicator = indicator
mPageIndicator?.let {
it.setMarkersCount(childCount)
it.setActiveMarker(nextPage)
}
applyPageIndicatorVisibility()
}
private fun applyPageIndicatorVisibility() {
val indicator = mPageIndicator ?: return
if (isPagingEnabled) {
indicator.visibility = VISIBLE
// Same as workspace: show while paging, fade out when idle.
indicator.setShouldAutoHide(true)
} else {
indicator.setShouldAutoHide(false)
indicator.visibility = GONE
}
}
override fun getPageAt(index: Int): CellLayout? = getChildAt(index) as? CellLayout
fun getCurrentCellLayout(): CellLayout? = getPageAt(nextPage)
fun resetPages(hasVerticalHotseat: Boolean, workspace: Workspace<*>?, dp: DeviceProfile) {
removeAllViews()
val pageCount = if (hasVerticalHotseat) 1 else maxOf(1, dp.numHotseatPages)
isPagingEnabled = pageCount > 1
val inflater = LayoutInflater.from(context)
for (i in 0 until pageCount) {
val page = inflater.inflate(R.layout.hotseat_page, this, false) as CellLayout
page.setHotseatPageIndex(i)
if (workspace != null) {
page.setCellLayoutContainer(workspace)
}
page.resetCellSize(dp)
page.isLongClickable = false
page.isHapticFeedbackEnabled = false
if (hasVerticalHotseat) {
page.setGridSize(1, dp.numShownHotseatIcons)
} else {
page.setGridSize(dp.numShownHotseatIcons, dp.numHotseatRows)
}
addView(page, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
mPageIndicator?.let {
it.setMarkersCount(pageCount)
it.setActiveMarker(0)
}
applyPageIndicatorVisibility()
setCurrentPage(0)
requestLayout()
}
fun setOnDockPageChangeListener(listener: OnDockPageChangeListener?) {
onDockPageChangeListener = listener
}
override fun onPageEndTransition() {
super.onPageEndTransition()
onDockPageChangeListener?.onDockPageChanged(nextPage)
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
if (mMaxScroll > 0) {
mPageIndicator?.setScroll(l, mMaxScroll)
}
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (!isPagingEnabled) return false
return super.onInterceptTouchEvent(ev)
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
if (!isPagingEnabled) return false
return super.onTouchEvent(ev)
}
/** Returns the page CellLayout under the given x in this view's coordinates. */
fun findPageAtLocalX(x: Float): CellLayout? {
val width = measuredWidth
if (width <= 0 || childCount == 0) {
return getCurrentCellLayout()
}
var raw = (scrollX + x).toInt() / width
if (mIsRtl) {
raw = childCount - 1 - raw
}
val page = raw.coerceIn(0, childCount - 1)
return getPageAt(page)
}
}
@@ -101,6 +101,7 @@ class PreferenceManager @Inject constructor(
val hotseatColumns = IntPref("pref_hotseatColumns", calculatedGridSpec.hotseatColumns, reloadGrid)
val hotseatColumnsUnfolded = IntPref("pref_hotseatColumnsUnfolded", calculatedGridSpec.hotseatColumnsUnfolded, reloadGrid)
val hotseatRows = IntPref("pref_hotseatRows", 1, reloadGrid)
val dockPages = IntPref("pref_dockPages", 1, reloadGrid)
val workspaceColumns = IntPref("pref_workspaceColumns", calculatedGridSpec.workspaceColumns)
val workspaceRows = IntPref("pref_workspaceRows", calculatedGridSpec.workspaceRows)
val workspaceIncreaseMaxGridSize = BoolPref("pref_workspace_increase_max_grid_size", false)
@@ -139,6 +139,7 @@ fun GridSettings(prefs: PreferenceManager, prefs2: PreferenceManager2) {
val hotseatColumnsAdapter = prefs.hotseatColumns.getAdapter()
val hotseatColumnsUnfoldedAdapter = prefs.hotseatColumnsUnfolded.getAdapter()
val hotseatRowsAdapter = prefs.hotseatRows.getAdapter()
val dockPagesAdapter = prefs.dockPages.getAdapter()
PreferenceGroup(heading = stringResource(id = R.string.grid)) {
if (isFoldable) {
@@ -175,6 +176,12 @@ fun GridSettings(prefs: PreferenceManager, prefs2: PreferenceManager2) {
step = 1,
valueRange = 1..2,
)
SliderPreference(
label = stringResource(id = R.string.dock_pages),
adapter = dockPagesAdapter,
step = 1,
valueRange = 1..5,
)
SliderPreference(
adapter = prefs2.hotseatBottomFactor.getAdapter(),
label = stringResource(id = R.string.hotseat_bottom_space_label),
@@ -199,12 +206,14 @@ fun ColumnScope.DockPreferencesPreview(modifier: Modifier = Modifier) {
val prefs2 = preferenceManager2()
val hotseatRows = prefs.hotseatRows
val dockPages = prefs.dockPages
val adapters = listOf(
prefs2.hotseatMode.getAdapter(),
prefs.hotseatColumns.getAdapter(),
prefs.hotseatColumnsUnfolded.getAdapter(),
hotseatRows.getAdapter(),
dockPages.getAdapter(),
prefs2.themedHotseatQsb.getAdapter(),
prefs.hotseatQsbCornerRadius.getAdapter(),
prefs.hotseatQsbAlpha.getAdapter(),
@@ -96,23 +96,53 @@ public class HotseatEduController {
pageId = mLauncher.getModel().getModelDbController().getNewScreenId();
}
boolean isPortrait = !mLauncher.getDeviceProfile().isVerticalBarLayout();
int hotseatItemsNum = mLauncher.getDeviceProfile().numShownHotseatIcons;
for (int i = 0; i < hotseatItemsNum; i++) {
int x = isPortrait ? i : 0;
int y = isPortrait ? 0 : hotseatItemsNum - i - 1;
View child = mHotseat.getChildAt(x, y);
if (child == null || child.getTag() == null) continue;
ItemInfo tag = (ItemInfo) child.getTag();
if (tag.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) continue;
mLauncher.getModelWriter().moveItemInDatabase(tag,
LauncherSettings.Favorites.CONTAINER_DESKTOP, pageId, i, toRow);
mNewItems.add(tag);
int numColumns = mLauncher.getDeviceProfile().inv.numColumns;
int destIndex = 0;
for (CellLayout page : mHotseat.getPageLayouts()) {
int countX = page.getCountX();
int countY = page.getCountY();
if (isPortrait) {
for (int y = 0; y < countY; y++) {
for (int x = 0; x < countX; x++) {
destIndex += migrateHotseatChild(page.getChildAt(x, y), pageId, toRow,
destIndex, numColumns);
}
}
} else {
for (int i = 0; i < countY; i++) {
destIndex += migrateHotseatChild(page.getChildAt(0, countY - i - 1), pageId,
toRow, destIndex, numColumns);
}
}
}
return pageId;
}
/** @return 1 if the item was migrated, otherwise 0 */
private int migrateHotseatChild(View child, int pageId, int toRow, int destIndex,
int numColumns) {
if (child == null || child.getTag() == null) {
return 0;
}
ItemInfo tag = (ItemInfo) child.getTag();
if (tag.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
return 0;
}
int destX = numColumns > 0 ? destIndex % numColumns : destIndex;
int destY = numColumns > 0 ? toRow - destIndex / numColumns : toRow;
if (destY < 0) {
destY = 0;
}
mLauncher.getModelWriter().moveItemInDatabase(tag,
LauncherSettings.Favorites.CONTAINER_DESKTOP, pageId, destX, destY);
mNewItems.add(tag);
return 1;
}
void moveHotseatItems() {
mHotseat.removeAllViewsInLayout();
for (CellLayout page : mHotseat.getPageLayouts()) {
page.removeAllViewsInLayout();
}
if (!mNewItems.isEmpty()) {
mLauncher.bindItemsAdded(mNewItems);
}
@@ -123,8 +153,9 @@ public class HotseatEduController {
}
void showDimissTip() {
if (mHotseat.getShortcutsAndWidgets().getChildCount()
< mLauncher.getDeviceProfile().numShownHotseatIcons) {
CellLayout page = mHotseat.getCurrentPageLayout();
int childCount = page != null ? page.getShortcutsAndWidgets().getChildCount() : 0;
if (childCount < mLauncher.getDeviceProfile().numShownHotseatIcons) {
Snackbar.show(mLauncher, R.string.hotseat_tip_gaps_filled,
R.string.hotseat_prediction_settings, null,
() -> mLauncher.startActivity(getSettingsIntent()));
@@ -138,11 +169,12 @@ public class HotseatEduController {
}
void showEdu() {
int childCount = mHotseat.getShortcutsAndWidgets().getChildCount();
CellLayout page = mHotseat.getCurrentPageLayout();
int childCount = page != null ? page.getShortcutsAndWidgets().getChildCount() : 0;
CellLayout cellLayout = mLauncher.getWorkspace().getScreenWithId(Workspace.FIRST_SCREEN_ID);
// hotseat is already empty and does not require migration. show edu tip
boolean requiresMigration = IntStream.range(0, childCount).anyMatch(i -> {
View v = mHotseat.getShortcutsAndWidgets().getChildAt(i);
View v = page.getShortcutsAndWidgets().getChildAt(i);
return v != null && v.getTag() != null && ((ItemInfo) v.getTag()).container
!= LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
});
@@ -168,14 +200,19 @@ public class HotseatEduController {
* @return whether suitable child was found and tip was shown
*/
private boolean showHotseatArrowTip(boolean usePinned, String message) {
int childCount = mHotseat.getShortcutsAndWidgets().getChildCount();
CellLayout page = mHotseat.getCurrentPageLayout();
if (page == null) {
Log.e(TAG, "Unable to find suitable view for ArrowTip");
return false;
}
int childCount = page.getShortcutsAndWidgets().getChildCount();
boolean isPortrait = !mLauncher.getDeviceProfile().isVerticalBarLayout();
BubbleTextView tipTargetView = null;
for (int i = childCount - 1; i > -1; i--) {
int x = isPortrait ? i : 0;
int y = isPortrait ? 0 : i;
View v = mHotseat.getShortcutsAndWidgets().getChildAt(x, y);
View v = page.getShortcutsAndWidgets().getChildAt(x, y);
if (v instanceof BubbleTextView && v.getTag() instanceof WorkspaceItemInfo) {
ItemInfo info = (ItemInfo) v.getTag();
boolean isPinned = info.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT;
@@ -42,6 +42,7 @@ import com.android.launcher3.DeviceProfile;
import com.android.launcher3.DragSource;
import com.android.launcher3.DropTarget;
import com.android.launcher3.Flags;
import com.android.launcher3.CellLayout;
import com.android.launcher3.Hotseat;
import com.android.launcher3.LauncherPrefs;
import com.android.launcher3.LauncherSettings;
@@ -98,7 +99,7 @@ public class HotseatPredictionController implements DragController.DragListener,
private AnimatorSet mIconRemoveAnimators;
private int mPauseFlags = 0;
private List<PredictedAppIcon.PredictedIconOutlineDrawing> mOutlineDrawings = new ArrayList<>();
private final List<OutlineOnPage> mOutlineDrawings = new ArrayList<>();
private boolean mEnableHotseatLongPressTipForTesting = true;
@@ -133,7 +134,40 @@ public class HotseatPredictionController implements DragController.DragListener,
mLauncher.getDragController().addDragListener(this);
launcher.addOnDeviceProfileChangeListener(this);
mHotseat.getShortcutsAndWidgets().setOnHierarchyChangeListener(this);
attachPageListeners();
mHotseat.getPagedView().setOnDockPageChangeListener(page -> {
if (mPauseFlags == 0 && !mLauncher.isWorkspaceLoading()) {
fillGapsWithPrediction(true);
}
});
}
private void attachPageListeners() {
for (CellLayout page : mHotseat.getPageLayouts()) {
page.getShortcutsAndWidgets().setOnHierarchyChangeListener(this);
}
}
@Nullable
private CellLayout getPageForRank(int rank) {
return mHotseat.getPageAt(mHotseat.getPageFromOrder(rank));
}
@Nullable
private View getChildAtRank(int rank) {
CellLayout page = getPageForRank(rank);
if (page == null) {
return null;
}
return page.getChildAt(
mHotseat.getCellXFromOrder(rank),
mHotseat.getCellYFromOrder(rank));
}
/** Visible-page slot count (columns × rows). Predictions only fill the current page. */
private int getVisiblePageStartRank() {
int page = mHotseat.getPagedView().getNextPage();
return Math.max(0, page) * mHotSeatItemsCount;
}
@Override
@@ -211,10 +245,10 @@ public class HotseatPredictionController implements DragController.DragListener,
}
mPauseFlags |= FLAG_FILL_IN_PROGRESS;
for (int rank = 0; rank < mHotSeatItemsCount; rank++) {
View child = mHotseat.getChildAt(
mHotseat.getCellXFromOrder(rank),
mHotseat.getCellYFromOrder(rank));
int startRank = getVisiblePageStartRank();
for (int localRank = 0; localRank < mHotSeatItemsCount; localRank++) {
int rank = startRank + localRank;
View child = getChildAtRank(rank);
if (child != null && !isPredictedIcon(child)) {
continue;
@@ -222,7 +256,10 @@ public class HotseatPredictionController implements DragController.DragListener,
if (mPredictedItems.size() <= predictionIndex) {
// Remove predicted apps from the past
if (isPredictedIcon(child)) {
mHotseat.removeView(child);
CellLayout page = getPageForRank(rank);
if (page != null) {
page.removeView(child);
}
}
continue;
}
@@ -270,10 +307,10 @@ public class HotseatPredictionController implements DragController.DragListener,
private void removeOutlineDrawings() {
if (mOutlineDrawings.isEmpty()) return;
for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) {
mHotseat.removeDelegatedCellDrawing(outlineDrawing);
for (OutlineOnPage outline : mOutlineDrawings) {
outline.page.removeDelegatedCellDrawing(outline.drawing);
outline.page.invalidate();
}
mHotseat.invalidate();
mOutlineDrawings.clear();
}
@@ -300,9 +337,7 @@ public class HotseatPredictionController implements DragController.DragListener,
* Pins a predicted app icon into place.
*/
public void pinPrediction(ItemInfo info) {
PredictedAppIcon icon = (PredictedAppIcon) mHotseat.getChildAt(
mHotseat.getCellXFromOrder(info.rank),
mHotseat.getCellYFromOrder(info.rank));
PredictedAppIcon icon = (PredictedAppIcon) getChildAtRank(info.rank);
if (icon == null) {
return;
}
@@ -317,26 +352,28 @@ public class HotseatPredictionController implements DragController.DragListener,
.log(LAUNCHER_HOTSEAT_PREDICTION_PINNED);
}
private List<PredictedAppIcon> getPredictedIcons() {
List<PredictedAppIcon> icons = new ArrayList<>();
ViewGroup vg = mHotseat.getShortcutsAndWidgets();
for (int i = 0; i < vg.getChildCount(); i++) {
View child = vg.getChildAt(i);
if (isPredictedIcon(child)) {
icons.add((PredictedAppIcon) child);
private List<PredictedIconOnPage> getPredictedIcons() {
List<PredictedIconOnPage> icons = new ArrayList<>();
for (CellLayout page : mHotseat.getPageLayouts()) {
ViewGroup vg = page.getShortcutsAndWidgets();
for (int i = 0; i < vg.getChildCount(); i++) {
View child = vg.getChildAt(i);
if (isPredictedIcon(child)) {
icons.add(new PredictedIconOnPage(page, (PredictedAppIcon) child));
}
}
}
return icons;
}
private void removePredictedApps(List<PredictedAppIcon.PredictedIconOutlineDrawing> outlines,
DropTarget.DragObject dragObject) {
private void removePredictedApps(DropTarget.DragObject dragObject) {
if (mIconRemoveAnimators != null) {
mIconRemoveAnimators.end();
}
mIconRemoveAnimators = new AnimatorSet();
removeOutlineDrawings();
for (PredictedAppIcon icon : getPredictedIcons()) {
for (PredictedIconOnPage entry : getPredictedIcons()) {
PredictedAppIcon icon = entry.icon;
if (!icon.isEnabled()) {
continue;
}
@@ -345,8 +382,10 @@ public class HotseatPredictionController implements DragController.DragListener,
continue;
}
int rank = ((WorkspaceItemInfo) icon.getTag()).rank;
outlines.add(new PredictedAppIcon.PredictedIconOutlineDrawing(
mHotseat.getCellXFromOrder(rank), mHotseat.getCellYFromOrder(rank), icon));
mOutlineDrawings.add(new OutlineOnPage(entry.page,
new PredictedAppIcon.PredictedIconOutlineDrawing(
mHotseat.getCellXFromOrder(rank),
mHotseat.getCellYFromOrder(rank), icon)));
icon.setEnabled(false);
ObjectAnimator animator = ObjectAnimator.ofFloat(icon, SCALE_PROPERTY, 0);
animator.addListener(new AnimationSuccessListener() {
@@ -369,19 +408,28 @@ public class HotseatPredictionController implements DragController.DragListener,
*/
private void removeIconWithoutNotify(PredictedAppIcon icon) {
mPauseFlags |= FLAG_REMOVING_PREDICTED_ICON;
mHotseat.removeView(icon);
ViewGroup parent = (ViewGroup) icon.getParent();
if (parent != null) {
// Parent is ShortcutAndWidgetContainer; remove via CellLayout.
ViewGroup cellLayout = (ViewGroup) parent.getParent();
if (cellLayout instanceof CellLayout) {
cellLayout.removeView(icon);
} else {
parent.removeView(icon);
}
}
mPauseFlags &= ~FLAG_REMOVING_PREDICTED_ICON;
}
@Override
public void onDragStart(DropTarget.DragObject dragObject, DragOptions options) {
removePredictedApps(mOutlineDrawings, dragObject);
removePredictedApps(dragObject);
if (mOutlineDrawings.isEmpty()) return;
for (PredictedAppIcon.PredictedIconOutlineDrawing outlineDrawing : mOutlineDrawings) {
mHotseat.addDelegatedCellDrawing(outlineDrawing);
for (OutlineOnPage outline : mOutlineDrawings) {
outline.page.addDelegatedCellDrawing(outline.drawing);
outline.page.invalidate();
}
mPauseFlags |= FLAG_DRAG_IN_PROGRESS;
mHotseat.invalidate();
}
@Override
@@ -419,6 +467,7 @@ public class HotseatPredictionController implements DragController.DragListener,
@Override
public void onDeviceProfileChanged(DeviceProfile profile) {
this.mHotSeatItemsCount = profile.numShownHotseatIcons * profile.numHotseatRows;
attachPageListeners();
}
@Override
@@ -447,8 +496,8 @@ public class HotseatPredictionController implements DragController.DragListener,
}
int cardinality = 0;
for (PredictedAppIcon icon : getPredictedIcons()) {
ItemInfo info = (ItemInfo) icon.getTag();
for (PredictedIconOnPage entry : getPredictedIcons()) {
ItemInfo info = (ItemInfo) entry.icon.getTag();
cardinality |= 1 << info.screenId;
}
@@ -482,7 +531,11 @@ public class HotseatPredictionController implements DragController.DragListener,
* Called when user completes adding item requiring a config activity to the hotseat
*/
public void onDeferredDrop(int cellX, int cellY) {
View child = mHotseat.getChildAt(cellX, cellY);
CellLayout page = mHotseat.getCurrentPageLayout();
if (page == null) {
return;
}
View child = page.getChildAt(cellX, cellY);
if (child instanceof PredictedAppIcon) {
removeIconWithoutNotify((PredictedAppIcon) child);
}
@@ -534,4 +587,24 @@ public class HotseatPredictionController implements DragController.DragListener,
writer.println(prefix + "\t\t" + info);
}
}
private static final class PredictedIconOnPage {
final CellLayout page;
final PredictedAppIcon icon;
PredictedIconOnPage(CellLayout page, PredictedAppIcon icon) {
this.page = page;
this.icon = icon;
}
}
private static final class OutlineOnPage {
final CellLayout page;
final PredictedAppIcon.PredictedIconOutlineDrawing drawing;
OutlineOnPage(CellLayout page, PredictedAppIcon.PredictedIconOutlineDrawing drawing) {
this.page = page;
this.drawing = drawing;
}
}
}
@@ -160,7 +160,11 @@ object UnfoldAnimationBuilder {
LINEAR
)
}
registerViews(hotseat)
setClipChildren(hotseat, false, restoreList)
setClipToPadding(hotseat, false, restoreList)
for (page in hotseat.pageLayouts) {
registerViews(page)
}
}
anim.addEndListener { restoreList.forEach { it.action(it.target, it.value) } }
}
+3 -2
View File
@@ -12,10 +12,11 @@
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.
Modifications copyright 2026 Lawnchair
-->
<com.android.launcher3.Hotseat
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:launcher="http://schemas.android.com/apk/res-auto"
android:id="@+id/hotseat"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -23,4 +24,4 @@
android:importantForAccessibility="no"
android:clickable="false"
android:longClickable="false"
launcher:containerType="hotseat" />
android:clipChildren="false" />
+17 -1
View File
@@ -191,6 +191,8 @@ public class CellLayout extends ViewGroup {
public static final int FOLDER = 2;
@ContainerType private final int mContainerType;
/** Page index within a multi-page hotseat; unused for non-hotseat layouts. */
private int mHotseatPageIndex = 0;
public static final float DEFAULT_SCALE = 1f;
@@ -336,6 +338,20 @@ public class CellLayout extends ViewGroup {
mCellLayoutContainer = cellLayoutContainer;
}
/** Sets which dock page this layout represents when used as a hotseat page. */
public void setHotseatPageIndex(int pageIndex) {
mHotseatPageIndex = pageIndex;
}
/** Returns the dock page index for multi-page hotseat layouts. */
public int getHotseatPageIndex() {
return mHotseatPageIndex;
}
public boolean isHotseat() {
return mContainerType == HOTSEAT;
}
/**
* Sets or clears a delegate used for accessible drag and drop
*/
@@ -1473,7 +1489,7 @@ public class CellLayout extends ViewGroup {
int container = Favorites.CONTAINER_DESKTOP;
if (mContainerType == HOTSEAT) {
screenId = -1;
screenId = mHotseatPageIndex;
container = Favorites.CONTAINER_HOTSEAT;
}
@@ -221,6 +221,8 @@ public class DeviceProfile {
private final HotseatProfile hotseatProfile;
public int numShownHotseatIcons;
public int numHotseatRows;
/** Number of horizontally scrollable dock pages (1 = classic single dock). */
public int numHotseatPages;
public int hotseatCellHeightPx;
private int mHotseatColumnSpan;
private int mHotseatWidthPx; // not used in vertical bar layout
@@ -331,6 +333,7 @@ public class DeviceProfile {
hotseatQsbWidth = 0;
hotseatBorderSpace = 0;
numHotseatRows = 1;
numHotseatPages = 1;
mBubbleBarSpaceThresholdPx = 0;
numShownAllAppsColumns = 0;
mViewScaleProvider = null;
@@ -517,6 +520,7 @@ public class DeviceProfile {
numShownHotseatIcons = displayOptionSpec.numShownHotseatIcons;
numHotseatRows = Math.max(1, Math.min(2, PreferenceManager.getInstance(context).getHotseatRows().get()));
numHotseatPages = Math.max(1, Math.min(5, PreferenceManager.getInstance(context).getDockPages().get()));
mHotseatColumnSpan = inv.numColumns;
numShownAllAppsColumns = displayOptionSpec.numAllAppsColumns;
+260 -86
View File
@@ -27,11 +27,8 @@ import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.InsetDrawable;
import android.util.AttributeSet;
import android.view.Gravity;
@@ -45,35 +42,34 @@ import android.widget.FrameLayout;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import com.android.launcher3.ShortcutAndWidgetContainer.TranslationProvider;
import com.android.launcher3.accessibility.DragAndDropAccessibilityDelegate;
import com.android.launcher3.celllayout.CellLayoutLayoutParams;
import com.android.launcher3.taskbar.BlurredBitmapDrawable;
import com.android.launcher3.pageindicators.PageIndicatorDots;
import com.android.launcher3.util.HorizontalInsettableView;
import com.android.launcher3.util.MultiPropertyFactory;
import com.android.launcher3.util.MultiPropertyFactory.MultiProperty;
import com.android.launcher3.util.MultiTranslateDelegate;
import com.android.launcher3.util.MultiValueAlpha;
import com.android.launcher3.views.ActivityContext;
import android.graphics.drawable.Drawable;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import com.hoko.blur.HokoBlur;
import app.lawnchair.preferences2.PreferenceCacheExtensionsKt;
import app.lawnchair.hotseat.DisabledHotseat;
import app.lawnchair.hotseat.HotseatMode;
import app.lawnchair.hotseat.HotseatPagedView;
import app.lawnchair.hotseat.LawnchairHotseat;
import app.lawnchair.preferences.PreferenceManager;
import app.lawnchair.preferences2.PreferenceCacheExtensionsKt;
import app.lawnchair.preferences2.PreferenceManager2;
import app.lawnchair.theme.drawable.DrawableTokens;
/**
* View class that represents the bottom row of the home screen.
* View class that represents the bottom dock of the home screen.
* Hosts a {@link app.lawnchair.hotseat.HotseatPagedView} of icon grids plus an optional QSB.
*/
public class Hotseat extends CellLayout implements Insettable {
public class Hotseat extends FrameLayout implements Insettable {
public static final int ALPHA_CHANNEL_TASKBAR_ALIGNMENT = 0;
public static final int ALPHA_CHANNEL_PREVIEW_RENDERER = 1;
@@ -98,11 +94,13 @@ public class Hotseat extends CellLayout implements Insettable {
// Ratio of empty space, qsb should take up to appear visually centered.
public static final float QSB_CENTER_FACTOR = .325f;
private static final int BUBBLE_BAR_ADJUSTMENT_ANIMATION_DURATION_MS = 250;
private static final int DOCK_PAGE_INDICATOR_HEIGHT_DP = 8;
@ViewDebug.ExportedProperty(category = "launcher")
private boolean mHasVerticalHotseat;
private Workspace<?> mWorkspace;
private boolean mSendTouchToWorkspace;
private boolean mSendTouchToPager;
private final MultiValueAlpha mIconsAlphaChannels;
private final MultiValueAlpha mQsbAlphaChannels;
@@ -111,6 +109,12 @@ public class Hotseat extends CellLayout implements Insettable {
private final MultiPropertyFactory mIconsTranslationXFactory;
private final View mQsb;
private final FrameLayout mIconsContainer;
private final HotseatPagedView mPagedView;
private final PageIndicatorDots mPageIndicator;
private final int mPageIndicatorHeight;
private final ActivityContext mActivity;
PreferenceManager2 preferenceManager2;
PreferenceManager preferenceManager;
@@ -125,6 +129,7 @@ public class Hotseat extends CellLayout implements Insettable {
public Hotseat(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
mActivity = ActivityContext.lookupContext(context);
preferenceManager2 = PreferenceManager2.getInstance(context);
preferenceManager = PreferenceManager.getInstance(context);
@@ -143,6 +148,29 @@ public class Hotseat extends CellLayout implements Insettable {
}
int layoutId = hotseatMode.getLayoutResourceId();
mIconsContainer = new FrameLayout(context);
mIconsContainer.setLayoutParams(new LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mIconsContainer.setClipChildren(false);
mIconsContainer.setClipToPadding(false);
addView(mIconsContainer);
mPagedView = new HotseatPagedView(context);
mPagedView.setLayoutParams(new LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mIconsContainer.addView(mPagedView);
mPageIndicatorHeight = Math.round(
DOCK_PAGE_INDICATOR_HEIGHT_DP * getResources().getDisplayMetrics().density);
mPageIndicator = new PageIndicatorDots(context);
LayoutParams indicatorLp = new LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, mPageIndicatorHeight);
indicatorLp.gravity = Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL;
mPageIndicator.setLayoutParams(indicatorLp);
mPageIndicator.setVisibility(GONE);
mIconsContainer.addView(mPageIndicator);
mPagedView.setPageIndicator(mPageIndicator);
if (Flags.enableQsbOnHotseat()) {
mQsb = LayoutInflater.from(context).inflate(R.layout.qsb_container_hotseat, this,
false);
@@ -152,19 +180,24 @@ public class Hotseat extends CellLayout implements Insettable {
}
addView(mQsb);
mIconsAlphaChannels = new MultiValueAlpha(getShortcutsAndWidgets(),
ALPHA_CHANNEL_CHANNELS_COUNT);
// Create an initial page so alpha/translation channels have a target during construction.
mPagedView.resetPages(false, null, mActivity.getDeviceProfile());
mIconsAlphaChannels = new MultiValueAlpha(mIconsContainer, ALPHA_CHANNEL_CHANNELS_COUNT);
mIconsAlphaChannels.setUpdateVisibility(true);
if (mQsb instanceof Reorderable qsbReorderable) {
mQsbTranslationX = qsbReorderable.getTranslateDelegate()
.getTranslationX(MultiTranslateDelegate.INDEX_NAV_BAR_ANIM);
}
mIconsTranslationXFactory = new MultiPropertyFactory<>(getShortcutsAndWidgets(),
mIconsTranslationXFactory = new MultiPropertyFactory<>(mIconsContainer,
VIEW_TRANSLATE_X, ICONS_TRANSLATION_X_CHANNELS_COUNT, Float::sum);
mQsbAlphaChannels = new MultiValueAlpha(mQsb, ALPHA_CHANNEL_CHANNELS_COUNT);
mQsbAlphaChannels.setUpdateVisibility(true);
setUpBackground();
setClipChildren(false);
setClipToPadding(false);
}
private void setUpBackground() {
@@ -196,6 +229,40 @@ public class Hotseat extends CellLayout implements Insettable {
return mQsbTranslationX;
}
public HotseatPagedView getPagedView() {
return mPagedView;
}
/** Returns the CellLayout for the given dock page. */
@Nullable
public CellLayout getPageAt(int page) {
return mPagedView.getPageAt(page);
}
/** Returns the currently visible dock page layout. */
@Nullable
public CellLayout getCurrentPageLayout() {
return mPagedView.getCurrentCellLayout();
}
/** Returns all dock page layouts. */
public CellLayout[] getPageLayouts() {
int count = mPagedView.getPageCount();
CellLayout[] pages = new CellLayout[count];
for (int i = 0; i < count; i++) {
pages[i] = mPagedView.getPageAt(i);
}
return pages;
}
/** Whether {@code layout} is one of this hotseat's page CellLayouts. */
public boolean isHotseatPage(View layout) {
if (!(layout instanceof CellLayout)) {
return false;
}
return layout.getParent() == mPagedView;
}
/**
* Returns orientation specific cell X given invariant order in the hotseat
*/
@@ -205,7 +272,8 @@ public class Hotseat extends CellLayout implements Insettable {
}
DeviceProfile dp = mActivity.getDeviceProfile();
int numColumns = dp.numShownHotseatIcons;
return rank % numColumns;
int localRank = getLocalRank(rank, dp);
return localRank % numColumns;
}
/**
@@ -213,11 +281,29 @@ public class Hotseat extends CellLayout implements Insettable {
*/
public int getCellYFromOrder(int rank) {
if (mHasVerticalHotseat) {
return getCountY() - (rank + 1);
CellLayout page = getCurrentPageLayout();
int countY = page != null ? page.getCountY() : mActivity.getDeviceProfile().numShownHotseatIcons;
return countY - (rank + 1);
}
DeviceProfile dp = mActivity.getDeviceProfile();
int numColumns = dp.numShownHotseatIcons;
return rank / numColumns;
int localRank = getLocalRank(rank, dp);
return localRank / numColumns;
}
/** Returns the dock page index for a global hotseat rank. */
public int getPageFromOrder(int rank) {
if (mHasVerticalHotseat) {
return 0;
}
DeviceProfile dp = mActivity.getDeviceProfile();
int slotsPerPage = Math.max(1, dp.numShownHotseatIcons * dp.numHotseatRows);
return rank / slotsPerPage;
}
private static int getLocalRank(int rank, DeviceProfile dp) {
int slotsPerPage = Math.max(1, dp.numShownHotseatIcons * dp.numHotseatRows);
return rank % slotsPerPage;
}
boolean isHasVerticalHotseat() {
@@ -228,45 +314,34 @@ public class Hotseat extends CellLayout implements Insettable {
ActivityContext activityContext = ActivityContext.lookupContext(getContext());
boolean bubbleBarEnabled = activityContext.isBubbleBarEnabled();
boolean hasBubbles = activityContext.hasBubbles();
removeAllViewsInLayout();
mHasVerticalHotseat = hasVerticalHotseat;
DeviceProfile dp = mActivity.getDeviceProfile();
mPagedView.resetPages(hasVerticalHotseat, mWorkspace, dp);
if (bubbleBarEnabled) {
if (dp.shouldAdjustHotseatForBubbleBar(getContext(), hasBubbles)) {
getShortcutsAndWidgets().setTranslationProvider(
cellX -> dp.getHotseatAdjustedTranslation(getContext(), cellX));
if (mQsb instanceof HorizontalInsettableView) {
HorizontalInsettableView insettableQsb = (HorizontalInsettableView) mQsb;
for (CellLayout page : getPageLayouts()) {
if (dp.shouldAdjustHotseatForBubbleBar(getContext(), hasBubbles)) {
page.getShortcutsAndWidgets().setTranslationProvider(
cellX -> dp.getHotseatAdjustedTranslation(getContext(), cellX));
} else {
page.getShortcutsAndWidgets().setTranslationProvider(null);
}
}
if (mQsb instanceof HorizontalInsettableView) {
HorizontalInsettableView insettableQsb = (HorizontalInsettableView) mQsb;
if (dp.shouldAdjustHotseatForBubbleBar(getContext(), hasBubbles)) {
final float insetFraction = (float) dp.iconSizePx / dp.hotseatQsbWidth;
// post this to the looper so that QSB has a chance to redraw itself, e.g.
// after device rotation
mQsb.post(() -> insettableQsb.setHorizontalInsets(insetFraction));
}
} else {
getShortcutsAndWidgets().setTranslationProvider(null);
if (mQsb instanceof HorizontalInsettableView) {
((HorizontalInsettableView) mQsb).setHorizontalInsets(0);
} else {
insettableQsb.setHorizontalInsets(0);
}
}
}
resetCellSize(dp);
if (hasVerticalHotseat) {
setGridSize(1, dp.numShownHotseatIcons);
} else {
setGridSize(dp.numShownHotseatIcons, dp.numHotseatRows);
}
}
/**
* Adjust the hotseat icons for the bubble bar.
*
* <p>When the bubble bar becomes visible, if needed, this method animates the hotseat icons
* to reduce the spacing between them and make room for the bubble bar. The QSB width is
* animated as well to align with the hotseat icons.
*
* <p>When the bubble bar goes away, any adjustments that were previously made are reversed.
*/
public void adjustForBubbleBar(boolean isBubbleBarVisible) {
DeviceProfile dp = mActivity.getDeviceProfile();
@@ -274,30 +349,30 @@ public class Hotseat extends CellLayout implements Insettable {
&& dp.shouldAdjustHotseatOrQsbForBubbleBar(getContext());
boolean shouldAdjustHotseat = shouldAdjust
&& dp.shouldAlignBubbleBarWithHotseat();
ShortcutAndWidgetContainer icons = getShortcutsAndWidgets();
// update the translation provider for future layout passes of hotseat icons.
if (shouldAdjustHotseat) {
icons.setTranslationProvider(
cellX -> dp.getHotseatAdjustedTranslation(getContext(), cellX));
} else {
icons.setTranslationProvider(null);
}
AnimatorSet animatorSet = new AnimatorSet();
for (int i = 0; i < icons.getChildCount(); i++) {
View child = icons.getChildAt(i);
if (child.getLayoutParams() instanceof CellLayoutLayoutParams lp) {
float tx = shouldAdjustHotseat
? dp.getHotseatAdjustedTranslation(getContext(), lp.getCellX()) : 0;
if (child instanceof Reorderable) {
MultiTranslateDelegate mtd = ((Reorderable) child).getTranslateDelegate();
animatorSet.play(
mtd.getTranslationX(INDEX_BUBBLE_ADJUSTMENT_ANIM).animateToValue(tx));
} else {
animatorSet.play(ObjectAnimator.ofFloat(child, VIEW_TRANSLATE_X, tx));
for (CellLayout page : getPageLayouts()) {
ShortcutAndWidgetContainer icons = page.getShortcutsAndWidgets();
if (shouldAdjustHotseat) {
icons.setTranslationProvider(
cellX -> dp.getHotseatAdjustedTranslation(getContext(), cellX));
} else {
icons.setTranslationProvider(null);
}
for (int i = 0; i < icons.getChildCount(); i++) {
View child = icons.getChildAt(i);
if (child.getLayoutParams() instanceof CellLayoutLayoutParams lp) {
float tx = shouldAdjustHotseat
? dp.getHotseatAdjustedTranslation(getContext(), lp.getCellX()) : 0;
if (child instanceof Reorderable) {
MultiTranslateDelegate mtd = ((Reorderable) child).getTranslateDelegate();
animatorSet.play(
mtd.getTranslationX(INDEX_BUBBLE_ADJUSTMENT_ANIM).animateToValue(tx));
} else {
animatorSet.play(ObjectAnimator.ofFloat(child, VIEW_TRANSLATE_X, tx));
}
}
}
}
//TODO(b/381109832) refactor & simplify adjustment logic
boolean shouldAdjustQsb =
shouldAdjustHotseat || (shouldAdjust && dp.shouldAlignBubbleBarWithQSB());
if (mQsb instanceof HorizontalInsettableView horizontalInsettableQsb) {
@@ -315,13 +390,6 @@ public class Hotseat extends CellLayout implements Insettable {
animatorSet.setDuration(BUBBLE_BAR_ADJUSTMENT_ANIMATION_DURATION_MS).start();
}
@Override
protected int getTranslationXForCell(int cellX, int cellY) {
TranslationProvider translationProvider = getShortcutsAndWidgets().getTranslationProvider();
if (translationProvider == null) return 0;
return (int) translationProvider.getTranslationX(cellX);
}
@Override
public void setInsets(Rect insets) {
FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams();
@@ -329,6 +397,7 @@ public class Hotseat extends CellLayout implements Insettable {
if (grid.isVerticalBarLayout()) {
mQsb.setVisibility(View.GONE);
mPageIndicator.setVisibility(GONE);
lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
if (grid.isSeascape()) {
lp.gravity = Gravity.LEFT;
@@ -352,16 +421,48 @@ public class Hotseat extends CellLayout implements Insettable {
public void setWorkspace(Workspace<?> w) {
mWorkspace = w;
setCellLayoutContainer(w);
for (CellLayout page : getPageLayouts()) {
page.setCellLayoutContainer(w);
}
}
private boolean isTouchOnQsb(MotionEvent ev) {
if (mQsb == null || mQsb.getVisibility() != VISIBLE) {
return false;
}
float x = ev.getX();
float y = ev.getY();
return x >= mQsb.getLeft() && x < mQsb.getRight()
&& y >= mQsb.getTop() && y < mQsb.getBottom();
}
private MotionEvent obtainPagerEvent(MotionEvent ev) {
MotionEvent pagerEv = MotionEvent.obtain(ev);
pagerEv.offsetLocation(-mIconsContainer.getLeft() - mPagedView.getLeft(),
-mIconsContainer.getTop() - mPagedView.getTop());
return pagerEv;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// We allow horizontal workspace scrolling from within the Hotseat. We do this by delegating
// touch intercept the Workspace, and if it intercepts, delegating touch to the Workspace
// for the remainder of the this input stream.
int yThreshold = getMeasuredHeight() - getPaddingBottom();
if (mWorkspace != null && ev.getY() <= yThreshold) {
if (ev.getY() > yThreshold || isTouchOnQsb(ev)) {
return false;
}
// Multi-page dock: own the icon-band stream and dispatch it to the pager so empty
// pages can still scroll (same pattern as single-page workspace forwarding).
if (mPagedView.isPagingEnabled()) {
final int action = ev.getAction() & MotionEvent.ACTION_MASK;
if (action == MotionEvent.ACTION_DOWN) {
mSendTouchToPager = true;
mSendTouchToWorkspace = false;
}
return mSendTouchToPager;
}
// Single-page dock: forward horizontal swipes to workspace.
if (mWorkspace != null) {
mSendTouchToWorkspace = mWorkspace.onInterceptTouchEvent(ev);
return mSendTouchToWorkspace;
}
@@ -370,7 +471,16 @@ public class Hotseat extends CellLayout implements Insettable {
@Override
public boolean onTouchEvent(MotionEvent event) {
// See comment in #onInterceptTouchEvent
if (mSendTouchToPager) {
MotionEvent pagerEv = obtainPagerEvent(event);
boolean handled = mPagedView.dispatchTouchEvent(pagerEv);
pagerEv.recycle();
final int action = event.getAction() & MotionEvent.ACTION_MASK;
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
mSendTouchToPager = false;
}
return handled;
}
if (mSendTouchToWorkspace) {
final int action = event.getAction();
switch (action & MotionEvent.ACTION_MASK) {
@@ -380,45 +490,93 @@ public class Hotseat extends CellLayout implements Insettable {
}
return mWorkspace.onTouchEvent(event);
}
// Always let touch follow through to Workspace.
return false;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(widthSize, heightSize);
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
DeviceProfile dp = mActivity.getDeviceProfile();
boolean showIndicator = mPagedView.isPagingEnabled() && !dp.isVerticalBarLayout();
int indicatorSpace = showIndicator ? mPageIndicatorHeight : 0;
int pagerWidth = widthSize - paddingLeft - paddingRight;
int pagerHeight = heightSize - paddingTop - paddingBottom;
mIconsContainer.measure(
makeMeasureSpec(Math.max(0, pagerWidth), MeasureSpec.EXACTLY),
makeMeasureSpec(Math.max(0, pagerHeight), MeasureSpec.EXACTLY));
int pageHeight = Math.max(0, pagerHeight - indicatorSpace);
mPagedView.measure(
makeMeasureSpec(Math.max(0, pagerWidth), MeasureSpec.EXACTLY),
makeMeasureSpec(pageHeight, MeasureSpec.EXACTLY));
if (showIndicator) {
mPageIndicator.measure(
makeMeasureSpec(Math.max(0, pagerWidth), MeasureSpec.EXACTLY),
makeMeasureSpec(mPageIndicatorHeight, MeasureSpec.EXACTLY));
}
// LC: Fix weird sizing with hotseatQsbWidth being 0 on phone
int width;
if (dp.isQsbInline) {
width = dp.hotseatQsbWidth;
} else {
width = getShortcutsAndWidgets().getMeasuredWidth();
width = mPagedView.getMeasuredWidth();
}
mQsb.measure(makeMeasureSpec(width, MeasureSpec.EXACTLY),
mQsb.measure(makeMeasureSpec(Math.max(0, width), MeasureSpec.EXACTLY),
makeMeasureSpec(dp.getHotseatProfile().getQsbHeight(), MeasureSpec.EXACTLY));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
int width = r - l;
int height = b - t;
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
DeviceProfile dp = mActivity.getDeviceProfile();
mIconsContainer.layout(paddingLeft, paddingTop, width - paddingRight,
height - paddingBottom);
int containerWidth = mIconsContainer.getWidth();
int containerHeight = mIconsContainer.getHeight();
boolean showIndicator = mPagedView.isPagingEnabled() && !dp.isVerticalBarLayout();
int indicatorSpace = showIndicator ? mPageIndicatorHeight : 0;
mPagedView.layout(0, 0, containerWidth, Math.max(0, containerHeight - indicatorSpace));
if (showIndicator) {
int indicatorTop = containerHeight - mPageIndicatorHeight;
mPageIndicator.layout(0, indicatorTop, containerWidth,
indicatorTop + mPageIndicatorHeight);
mPageIndicator.setVisibility(VISIBLE);
} else {
mPageIndicator.setVisibility(GONE);
}
int qsbMeasuredWidth = mQsb.getMeasuredWidth();
int left;
DeviceProfile dp = mActivity.getDeviceProfile();
if (dp.isQsbInline) {
int qsbSpace = dp.hotseatBorderSpace;
left = Utilities.isRtl(getResources()) ? r - getPaddingRight() + qsbSpace
: l + getPaddingLeft() - qsbMeasuredWidth - qsbSpace;
} else {
left = (r - l - qsbMeasuredWidth) / 2;
left = (width - qsbMeasuredWidth) / 2;
}
int right = left + qsbMeasuredWidth;
int bottom = b - t - dp.getQsbOffsetY();
int bottom = height - dp.getQsbOffsetY();
int top = bottom - dp.getHotseatProfile().getQsbHeight();
mQsb.layout(left, top, right, bottom);
}
@@ -454,9 +612,25 @@ public class Hotseat extends CellLayout implements Insettable {
return mQsb;
}
/** Delegates to the current page's shortcuts container. */
@Nullable
public ShortcutAndWidgetContainer getShortcutsAndWidgets() {
CellLayout page = getCurrentPageLayout();
return page != null ? page.getShortcutsAndWidgets() : null;
}
/** Delegates accessibility drag helper to the current page. */
@Nullable
public DragAndDropAccessibilityDelegate getDragAndDropAccessibilityDelegate() {
CellLayout page = getCurrentPageLayout();
return page != null ? page.getDragAndDropAccessibilityDelegate() : null;
}
/** Dumps the Hotseat internal state */
public void dump(String prefix, PrintWriter writer) {
writer.println(prefix + "Hotseat:");
writer.println(prefix + "\tpages: " + mPagedView.getPageCount()
+ " pagingEnabled=" + mPagedView.isPagingEnabled());
mIconsAlphaChannels.dump(
prefix + "\t",
writer,
+31 -10
View File
@@ -748,7 +748,8 @@ public class Launcher extends StatefulActivity<LauncherState>
mCellPosMapper = new TwoPanelCellPosMapper(mDeviceProfile.inv.numColumns);
} else {
mCellPosMapper = new CellPosMapper(mDeviceProfile.isVerticalBarLayout(),
mDeviceProfile.numShownHotseatIcons);
mDeviceProfile.numShownHotseatIcons, mDeviceProfile.numHotseatRows,
mDeviceProfile.numHotseatPages);
}
mModelWriter = mModel.getWriter(true, mCellPosMapper, this);
updateFixedLandscape();
@@ -2151,7 +2152,7 @@ public class Launcher extends StatefulActivity<LauncherState>
boolean isHotseatLayout(View layout) {
// TODO: Remove this method
return mHotseat != null && (layout == mHotseat);
return mHotseat != null && (layout == mHotseat || mHotseat.isHotseatPage(layout));
}
@Override
@@ -2371,7 +2372,13 @@ public class Launcher extends StatefulActivity<LauncherState>
}
List<CellLayout> containers = new ArrayList<>(mWorkspace.getPanelCount() + 1);
containers.add(mWorkspace.getHotseat());
Hotseat hotseat = mWorkspace.getHotseat();
if (hotseat != null) {
CellLayout currentPage = hotseat.getCurrentPageLayout();
if (currentPage != null) {
containers.add(currentPage);
}
}
mWorkspace.forEachVisiblePage(page -> containers.add((CellLayout) page));
CellLayout[] containerArray = containers.toArray(new CellLayout[0]);
LauncherBindableItemsContainer visibleContainer =
@@ -2501,11 +2508,18 @@ public class Launcher extends StatefulActivity<LauncherState>
writer.println(prefix + " Hotseat");
mHotseat.dump(prefix, writer);
ViewGroup layout = mHotseat.getShortcutsAndWidgets();
for (int j = 0; j < layout.getChildCount(); j++) {
Object tag = layout.getChildAt(j).getTag();
if (tag != null) {
writer.println(prefix + " " + tag);
CellLayout[] hotseatPages = mHotseat.getPageLayouts();
for (int p = 0; p < hotseatPages.length; p++) {
ViewGroup layout = hotseatPages[p].getShortcutsAndWidgets();
if (layout == null) {
continue;
}
writer.println(prefix + " Dock page " + p);
for (int j = 0; j < layout.getChildCount(); j++) {
Object tag = layout.getChildAt(j).getTag();
if (tag != null) {
writer.println(prefix + " " + tag);
}
}
}
}
@@ -2928,8 +2942,15 @@ public class Launcher extends StatefulActivity<LauncherState>
* @param screenId must be presenterPos and not modelPos.
*/
public CellLayout getCellLayout(int container, int screenId) {
return (container == LauncherSettings.Favorites.CONTAINER_HOTSEAT)
? mHotseat : mWorkspace.getScreenWithId(screenId);
if (container == LauncherSettings.Favorites.CONTAINER_HOTSEAT
|| container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
if (mHotseat == null) {
return null;
}
CellLayout page = mHotseat.getPageAt(screenId);
return page != null ? page : mHotseat.getCurrentPageLayout();
}
return mWorkspace.getScreenWithId(screenId);
}
@Override
+42 -12
View File
@@ -145,6 +145,7 @@ import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import app.lawnchair.hotseat.HotseatPagedView;
import app.lawnchair.preferences2.PreferenceCacheExtensionsKt;
import static app.lawnchair.util.LawnchairUtilsKt.toBitmap;
import app.lawnchair.LawnchairApp;
@@ -765,7 +766,8 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
int dragSourceChildCount = mDragSourceInternal.getChildCount();
// If the icon was dragged from Hotseat, there is no page pair
if (isTwoPanelEnabled() && !(mDragSourceInternal.getParent() instanceof Hotseat)) {
if (isTwoPanelEnabled() && !mLauncher.isHotseatLayout(
(View) mDragSourceInternal.getParent())) {
int pagePairScreenId = getScreenPair(getCellPosMapper().mapModelToPresenter(
dragObject.dragInfo).screenId);
CellLayout pagePair = mWorkspaceScreens.get(pagePairScreenId);
@@ -1017,6 +1019,10 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
if (index != -1) {
return mWorkspaceScreens.keyAt(index);
}
Hotseat hotseat = mLauncher.getHotseat();
if (hotseat != null && hotseat.isHotseatPage(layout)) {
return layout.getHotseatPageIndex();
}
return -1;
}
@@ -1859,7 +1865,12 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
protected void enableAccessibleDrag(boolean enable,
@Nullable DragObject dragObject) {
super.enableAccessibleDrag(enable, dragObject);
setEnableForLayout(mLauncher.getHotseat(), enable);
Hotseat hotseat = mLauncher.getHotseat();
if (hotseat != null) {
for (CellLayout page : hotseat.getPageLayouts()) {
setEnableForLayout(page, enable);
}
}
if (enable && dragObject != null
&& dragObject.dragInfo instanceof LauncherAppWidgetInfo) {
mLauncher.getHotseat().setImportantForAccessibility(
@@ -2787,7 +2798,7 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
private boolean setDropLayoutForDragObject(DragObject d, float centerX, float centerY) {
CellLayout layout = null;
if (shouldUseHotseatAsDropLayout(d)) {
layout = mLauncher.getHotseat();
layout = resolveHotseatDropLayout(d);
} else if (!isDragObjectOverSmartSpace(d)) {
// If the object is over qsb/smartspace, we don't want to highlight anything.
@@ -2813,14 +2824,35 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
return false;
}
@Nullable
private CellLayout resolveHotseatDropLayout(DragObject dragObject) {
Hotseat hotseat = mLauncher.getHotseat();
if (hotseat == null) {
return null;
}
// Prefer the visible dock page so drops land where the user is looking.
CellLayout current = hotseat.getCurrentPageLayout();
HotseatPagedView pager = hotseat.getPagedView();
if (pager != null && pager.isPageInTransition()) {
float[] xy = new float[] { dragObject.x, dragObject.y };
mLauncher.getDragLayer().getDescendantCoordRelativeToSelf(this, xy, true);
mLauncher.getDragLayer().mapCoordInSelfToDescendant(pager, xy);
CellLayout underFinger = pager.findPageAtLocalX(xy[0]);
if (underFinger != null) {
return underFinger;
}
}
return current;
}
private boolean shouldUseHotseatAsDropLayout(DragObject dragObject) {
if (mLauncher.getHotseat() == null
|| mLauncher.getHotseat().getShortcutsAndWidgets() == null
|| isDragWidget(dragObject)) {
return false;
}
View hotseatShortcuts = mLauncher.getHotseat().getShortcutsAndWidgets();
getViewBoundsRelativeToWorkspace(hotseatShortcuts, mTempRect);
View hotseatIcons = mLauncher.getHotseat().getPagedView();
getViewBoundsRelativeToWorkspace(hotseatIcons, mTempRect);
return mTempRect.contains(dragObject.x, dragObject.y);
}
@@ -3710,16 +3742,14 @@ public class Workspace<T extends View & PageIndicator> extends PagedView<T>
*/
private CellLayout[] getWorkspaceAndHotseatCellLayouts() {
int screenCount = getChildCount();
final CellLayout[] layouts;
if (mLauncher.getHotseat() != null) {
layouts = new CellLayout[screenCount + 1];
layouts[screenCount] = mLauncher.getHotseat();
} else {
layouts = new CellLayout[screenCount];
}
CellLayout[] hotseatPages = mLauncher.getHotseat() != null
? mLauncher.getHotseat().getPageLayouts()
: new CellLayout[0];
final CellLayout[] layouts = new CellLayout[screenCount + hotseatPages.length];
for (int screen = 0; screen < screenCount; screen++) {
layouts[screen] = (CellLayout) getChildAt(screen);
}
System.arraycopy(hotseatPages, 0, layouts, screenCount, hotseatPages.length);
return layouts;
}
@@ -54,16 +54,18 @@ public interface WorkspaceLayoutManager {
CellPos presenterPos = getCellPosMapper().mapModelToPresenter(info);
int x = presenterPos.cellX;
int y = presenterPos.cellY;
int screenId = presenterPos.screenId;
if (info.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT
|| info.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
int screenId = presenterPos.screenId;
x = getHotseat().getCellXFromOrder(screenId);
y = getHotseat().getCellYFromOrder(screenId);
// TODO(b/335141365): Remove this log after the bug is fixed.
// Model screenId is a flat rank; presenter screenId is the dock page.
int rank = info.screenId;
x = getHotseat().getCellXFromOrder(rank);
y = getHotseat().getCellYFromOrder(rank);
screenId = getHotseat().getPageFromOrder(rank);
Log.d(TAG, "addInScreenFromBind: hotseat inflation with x = " + x
+ " and y = " + y);
+ " and y = " + y + " page = " + screenId);
}
addInScreen(child, info.container, presenterPos.screenId, x, y, info.spanX, info.spanY);
addInScreen(child, info.container, screenId, x, y, info.spanX, info.spanY);
}
/**
@@ -103,10 +105,18 @@ public interface WorkspaceLayoutManager {
throw new RuntimeException("Screen id should not be extra empty screen: " + screenId);
}
final CellLayout layout;
CellLayout layout;
if (container == LauncherSettings.Favorites.CONTAINER_HOTSEAT
|| container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) {
layout = getHotseat();
Hotseat hotseat = getHotseat();
layout = hotseat.getPageAt(screenId);
if (layout == null) {
layout = hotseat.getCurrentPageLayout();
}
if (layout == null) {
Log.e(TAG, "Skipping child, hotseat page " + screenId + " not found");
return;
}
// Hide folder title in the hotseat
if (child instanceof FolderIcon) {
@@ -12,12 +12,15 @@
* 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.
*
* Modifications copyright 2026 Lawnchair
*/
package com.android.launcher3.celllayout;
import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_DESKTOP;
import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT;
import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION;
import com.android.launcher3.LauncherSettings.Favorites;
import com.android.launcher3.model.data.ItemInfo;
import java.util.Objects;
@@ -27,19 +30,42 @@ import java.util.Objects;
*/
public class CellPosMapper {
public static final CellPosMapper DEFAULT = new CellPosMapper(false, -1);
public static final CellPosMapper DEFAULT = new CellPosMapper(false, -1, 1, 1);
private final boolean mHasVerticalHotseat;
private final int mNumOfHotseat;
private final int mNumHotseatRows;
public CellPosMapper(boolean hasVerticalHotseat, int numOfHotseat) {
this(hasVerticalHotseat, numOfHotseat, 1, 1);
}
public CellPosMapper(boolean hasVerticalHotseat, int numOfHotseat, int numHotseatRows,
int numHotseatPages) {
mHasVerticalHotseat = hasVerticalHotseat;
mNumOfHotseat = numOfHotseat;
mNumHotseatRows = Math.max(1, numHotseatRows);
}
private static boolean isHotseatContainer(int container) {
return container == CONTAINER_HOTSEAT || container == CONTAINER_HOTSEAT_PREDICTION;
}
/**
* Maps the position in model to the position in view
* Maps the position in model to the position in view.
* Hotseat model screenId is a flat rank; presenter screenId is the dock page index.
*/
public CellPos mapModelToPresenter(ItemInfo info) {
if (isHotseatContainer(info.container) && mNumOfHotseat > 0) {
if (mHasVerticalHotseat) {
return new CellPos(0, mNumOfHotseat - info.screenId - 1, 0);
}
int slotsPerPage = Math.max(1, mNumOfHotseat * mNumHotseatRows);
int page = info.screenId / slotsPerPage;
int local = info.screenId % slotsPerPage;
int cellX = local % mNumOfHotseat;
int cellY = local / mNumOfHotseat;
return new CellPos(cellX, cellY, page);
}
return new CellPos(info.cellX, info.cellY, info.screenId);
}
@@ -48,12 +74,17 @@ public class CellPosMapper {
*/
public CellPos mapPresenterToModel(int presenterX, int presenterY, int presenterScreen,
int container) {
if (container == Favorites.CONTAINER_HOTSEAT) {
if (mHasVerticalHotseat) {
if (isHotseatContainer(container)) {
if (mHasVerticalHotseat && mNumOfHotseat > 0) {
presenterScreen = mNumOfHotseat - presenterY - 1;
} else {
// For multi-row hotseat, screenId (rank) = row * numColumns + column
presenterScreen = presenterY * mNumOfHotseat + presenterX;
} else if (!mHasVerticalHotseat && mNumOfHotseat > 0) {
// presenterScreen is the dock page index (0-based)
int page = Math.max(0, presenterScreen);
int slotsPerPage = Math.max(1, mNumOfHotseat * mNumHotseatRows);
int localRank = presenterY * mNumOfHotseat + presenterX;
presenterScreen = page * slotsPerPage + localRank;
} else if (!mHasVerticalHotseat) {
presenterScreen = presenterX;
}
}
return new CellPos(presenterX, presenterY, presenterScreen);
@@ -67,7 +98,7 @@ public class CellPosMapper {
private final int mColumnCount;
public TwoPanelCellPosMapper(int columnCount) {
super(false, -1);
super(false, -1, 1, 1);
mColumnCount = columnCount;
}
@@ -183,10 +183,16 @@ public class KeyboardDragAndDropView extends AbstractFloatingView
mDelegates.add(((CellLayout) pv.getChildAt(i)).getDragAndDropAccessibilityDelegate());
}
if (openFolder == null) {
mDelegates.add(pv.getNextPage() + 1,
mLauncher.getHotseat().getDragAndDropAccessibilityDelegate());
DragAndDropAccessibilityDelegate hotseatDelegate =
mLauncher.getHotseat().getDragAndDropAccessibilityDelegate();
if (hotseatDelegate != null) {
mDelegates.add(pv.getNextPage() + 1, hotseatDelegate);
}
}
mDelegates.forEach(delegate -> {
if (delegate == null) {
return;
}
mIntList.clear();
delegate.getVisibleVirtualViews(mIntList);
mIntList.forEach(id -> mNodes.add(new VirtualNodeInfo(delegate, id)));
@@ -81,6 +81,7 @@ import com.android.launcher3.widget.LauncherWidgetHolder;
import app.lawnchair.preferences2.PreferenceCacheExtensionsKt;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -307,7 +308,10 @@ public class LauncherPreviewRenderer extends BaseContext
private List<CellLayout> getAllLayouts() {
List<CellLayout> screens = new ArrayList<>(mWorkspaceScreens.values());
screens.add(getHotseat());
Hotseat hotseat = getHotseat();
if (hotseat != null) {
Collections.addAll(screens, hotseat.getPageLayouts());
}
return screens;
}
@@ -379,7 +383,8 @@ public class LauncherPreviewRenderer extends BaseContext
int cellX = mHotseat.getCellXFromOrder(rank);
int cellY = mHotseat.getCellYFromOrder(rank);
if (mHotseat.isOccupied(cellX, cellY)) continue;
CellLayout page = mHotseat.getPageAt(mHotseat.getPageFromOrder(rank));
if (page == null || page.isOccupied(cellX, cellY)) continue;
WorkspaceItemInfo itemInfo =
new WorkspaceItemInfo((WorkspaceItemInfo) predictions.get(predictionIndex));
@@ -297,8 +297,12 @@ public class TestInformationHandler implements ResourceBasedOverride {
int cellIndex = extra.getInt(TestProtocol.TEST_INFO_PARAM_INDEX);
return getLauncherUIProperty(Bundle::putParcelable, launcher -> {
final Hotseat hotseat = launcher.getHotseat();
final CellLayout page = hotseat.getCurrentPageLayout();
if (page == null) {
return new Point(0, 0);
}
final Rect cellRect = getDescendantRectRelativeToDragLayerForCell(launcher,
hotseat, cellIndex, /* cellY= */ 0,
page, cellIndex, /* cellY= */ 0,
/* spanX= */ 1, /* spanY= */ 1);
// TODO(b/234322284): return the real center point.
return new Point(cellRect.left + (cellRect.right - cellRect.left) / 3,
@@ -428,6 +432,9 @@ public class TestInformationHandler implements ResourceBasedOverride {
ShortcutAndWidgetContainer hotseatIconsContainer =
l.getHotseat().getShortcutsAndWidgets();
ArrayList<String> hotseatIconNames = new ArrayList<>();
if (hotseatIconsContainer == null) {
return hotseatIconNames;
}
for (int i = 0; i < hotseatIconsContainer.getChildCount(); i++) {
// Use unchecked cast to catch changes in hotseat layout
@@ -554,7 +554,8 @@ public interface ActivityContext extends SavedStateRegistryOwner {
default CellPosMapper getCellPosMapper() {
DeviceProfile dp = getDeviceProfile();
return new CellPosMapper(dp.isVerticalBarLayout(), dp.numShownHotseatIcons);
return new CellPosMapper(dp.isVerticalBarLayout(), dp.numShownHotseatIcons,
dp.numHotseatRows, dp.numHotseatPages);
}
/** Returns a writer for updating model properties */