diff --git a/quickstep/res/layout/taskbar.xml b/quickstep/res/layout/taskbar.xml
index 72d7485d8f..736706a70d 100644
--- a/quickstep/res/layout/taskbar.xml
+++ b/quickstep/res/layout/taskbar.xml
@@ -35,7 +35,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"/>
-
-
+
-
-
+
{
if (!deviceProfile.isLandscape) {
+ navButtonsView.setIsVertical(false)
PhonePortraitNavLayoutter(
resources,
navButtonContainer,
@@ -88,6 +89,7 @@ class NavButtonLayoutFactory {
a11yButton
)
} else if (surfaceRotation == ROTATION_90) {
+ navButtonsView.setIsVertical(true)
PhoneLandscapeNavLayoutter(
resources,
navButtonContainer,
@@ -98,6 +100,7 @@ class NavButtonLayoutFactory {
a11yButton
)
} else {
+ navButtonsView.setIsVertical(true)
PhoneSeascapeNavLayoutter(
resources,
navButtonContainer,
diff --git a/quickstep/src/com/android/launcher3/taskbar/navbutton/NearestTouchFrame.java b/quickstep/src/com/android/launcher3/taskbar/navbutton/NearestTouchFrame.java
new file mode 100644
index 0000000000..a477303c25
--- /dev/null
+++ b/quickstep/src/com/android/launcher3/taskbar/navbutton/NearestTouchFrame.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.launcher3.taskbar.navbutton;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.util.AttributeSet;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Redirects touches that aren't handled by any child view to the nearest
+ * clickable child. Only takes effect on mClickableChildren = new ArrayList<>();
+ private final List mAttachedChildren = new ArrayList<>();
+ private final boolean mIsActive;
+
+ private boolean mIsVertical;
+ private View mTouchingChild;
+ private final Map mTouchableRegions = new HashMap<>();
+ /**
+ * Used to sort all child views either by their left position or their top position,
+ * depending on if this layout is used horizontally or vertically, respectively
+ */
+ private final Comparator mChildRegionComparator =
+ (view1, view2) -> {
+ int startingCoordView1 = mIsVertical ? view1.getTop() : view1.getLeft();
+ int startingCoordView2 = mIsVertical ? view2.getTop() : view2.getLeft();
+
+ return startingCoordView1 - startingCoordView2;
+ };
+
+ public NearestTouchFrame(Context context, AttributeSet attrs) {
+ this(context, attrs, context.getResources().getConfiguration());
+ }
+
+ public NearestTouchFrame(Context context, AttributeSet attrs, Configuration c) {
+ super(context, attrs);
+ mIsActive = c.smallestScreenWidthDp < 600;
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ mClickableChildren.clear();
+ mAttachedChildren.clear();
+ mTouchableRegions.clear();
+ addClickableChildren(this);
+ cacheClosestChildLocations();
+ }
+
+ /**
+ * Populates {@link #mTouchableRegions} with the regions where each clickable child is the
+ * closest for a given point on this layout.
+ */
+ private void cacheClosestChildLocations() {
+ if (getWidth() == 0 || getHeight() == 0) {
+ return;
+ }
+
+ // Sort by either top or left depending on mIsVertical, then take out all children
+ // that are not attached to window
+ mClickableChildren.sort(mChildRegionComparator);
+ mClickableChildren.stream()
+ .filter(View::isAttachedToWindow)
+ .forEachOrdered(mAttachedChildren::add);
+
+ // Cache bounds of children
+ // Mark coordinates where the actual child layout resides in this frame's window
+ for (int i = 0; i < mAttachedChildren.size(); i++) {
+ View child = mAttachedChildren.get(i);
+ if (!child.isAttachedToWindow()) {
+ continue;
+ }
+ Rect childRegion = getChildsBounds(child);
+
+ // We compute closest child from this child to the previous one
+ if (i == 0) {
+ // First child, nothing to the left/top of it
+ if (mIsVertical) {
+ childRegion.top = 0;
+ } else {
+ childRegion.left = 0;
+ }
+ mTouchableRegions.put(child, childRegion);
+ continue;
+ }
+
+ View previousChild = mAttachedChildren.get(i - 1);
+ Rect previousChildBounds = mTouchableRegions.get(previousChild);
+ int midPoint;
+ if (mIsVertical) {
+ int distance = childRegion.top - previousChildBounds.bottom;
+ midPoint = distance / 2;
+ childRegion.top -= midPoint;
+ previousChildBounds.bottom += midPoint - ((distance % 2) == 0 ? 1 : 0);
+ } else {
+ int distance = childRegion.left - previousChildBounds.right;
+ midPoint = distance / 2;
+ childRegion.left -= midPoint;
+ previousChildBounds.right += midPoint - ((distance % 2) == 0 ? 1 : 0);
+ }
+
+ if (i == mClickableChildren.size() - 1) {
+ // Last child, nothing to right/bottom of it
+ if (mIsVertical) {
+ childRegion.bottom = getHeight();
+ } else {
+ childRegion.right = getWidth();
+ }
+ }
+
+ mTouchableRegions.put(child, childRegion);
+ }
+ }
+
+ void setIsVertical(boolean isVertical) {
+ mIsVertical = isVertical;
+ }
+
+ private Rect getChildsBounds(View child) {
+ int left = child.getLeft();
+ int top = child.getTop();
+ int right = left + child.getWidth();
+ int bottom = top + child.getHeight();
+ return new Rect(left, top, right, bottom);
+ }
+
+ private void addClickableChildren(ViewGroup group) {
+ final int N = group.getChildCount();
+ for (int i = 0; i < N; i++) {
+ View child = group.getChildAt(i);
+ if (child.isClickable()) {
+ mClickableChildren.add(child);
+ } else if (child instanceof ViewGroup) {
+ addClickableChildren((ViewGroup) child);
+ }
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ if (mIsActive) {
+ int x = (int) event.getX();
+ int y = (int) event.getY();
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ mTouchingChild = mClickableChildren
+ .stream()
+ .filter(View::isAttachedToWindow)
+ .filter(view -> mTouchableRegions.get(view).contains(x, y))
+ .findFirst()
+ .orElse(null);
+
+ }
+ if (mTouchingChild != null) {
+ // Translate the touch event to the view center of the touching child.
+ event.offsetLocation(mTouchingChild.getWidth() / 2 - x,
+ mTouchingChild.getHeight() / 2 - y);
+ return mTouchingChild.getVisibility() == VISIBLE
+ && mTouchingChild.dispatchTouchEvent(event);
+ }
+ }
+ return super.onTouchEvent(event);
+ }
+
+ public void dumpLogs(String prefix, PrintWriter pw) {
+ pw.println(prefix + "NearestTouchFrame:");
+
+ pw.println(String.format("%s\tmIsVertical=%s", prefix, mIsVertical));
+ pw.println(String.format("%s\tmTouchingChild=%s", prefix, mTouchingChild));
+ pw.println(String.format("%s\tmTouchableRegions=%s", prefix,
+ mTouchableRegions.keySet().stream()
+ .map(key -> key + "=" + mTouchableRegions.get(key))
+ .collect(Collectors.joining(", ", "{", "}"))));
+ }
+}
diff --git a/quickstep/tests/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactoryTest.kt b/quickstep/tests/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactoryTest.kt
index 9c7f0144c1..9c7fdc6707 100644
--- a/quickstep/tests/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactoryTest.kt
+++ b/quickstep/tests/src/com/android/launcher3/taskbar/navbutton/NavButtonLayoutFactoryTest.kt
@@ -27,7 +27,7 @@ import org.mockito.kotlin.whenever
class NavButtonLayoutFactoryTest {
private val mockDeviceProfile: DeviceProfile = mock()
- private val mockParentButtonContainer: FrameLayout = mock()
+ private val mockParentButtonContainer: NearestTouchFrame = mock()
private val mockNavLayout: LinearLayout = mock()
private val mockStartContextualLayout: ViewGroup = mock()
private val mockEndContextualLayout: ViewGroup = mock()