1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.launcher3.taskbar.navbutton; 18 19 import android.content.Context; 20 import android.content.res.Configuration; 21 import android.graphics.Rect; 22 import android.util.AttributeSet; 23 import android.view.MotionEvent; 24 import android.view.View; 25 import android.view.ViewGroup; 26 import android.widget.FrameLayout; 27 28 import java.io.PrintWriter; 29 import java.util.ArrayList; 30 import java.util.Arrays; 31 import java.util.Comparator; 32 import java.util.HashMap; 33 import java.util.List; 34 import java.util.Map; 35 import java.util.stream.Collectors; 36 37 /** 38 * Redirects touches that aren't handled by any child view to the nearest 39 * clickable child. Only takes effect on <sw600dp. 40 */ 41 public class NearestTouchFrame extends FrameLayout { 42 43 private final List<View> mClickableChildren = new ArrayList<>(); 44 private final List<View> mAttachedChildren = new ArrayList<>(); 45 private final boolean mIsActive; 46 private final int[] mTmpInt = new int[2]; 47 48 // Offset (as the base) to translate window cords to view cords. 49 private final int[] mWindowOffset = new int[2]; 50 private boolean mIsVertical; 51 private View mTouchingChild; 52 private final Map<View, Rect> mTouchableRegions = new HashMap<>(); 53 /** 54 * Used to sort all child views either by their left position or their top position, 55 * depending on if this layout is used horizontally or vertically, respectively 56 */ 57 private final Comparator<View> mChildRegionComparator = 58 (view1, view2) -> { 59 int leftTopIndex = mIsVertical ? 1 : 0; 60 view1.getLocationInWindow(mTmpInt); 61 int startingCoordView1 = mTmpInt[leftTopIndex] - mWindowOffset[leftTopIndex]; 62 view2.getLocationInWindow(mTmpInt); 63 int startingCoordView2 = mTmpInt[leftTopIndex] - mWindowOffset[leftTopIndex]; 64 65 return startingCoordView1 - startingCoordView2; 66 }; 67 NearestTouchFrame(Context context, AttributeSet attrs)68 public NearestTouchFrame(Context context, AttributeSet attrs) { 69 this(context, attrs, context.getResources().getConfiguration()); 70 } 71 NearestTouchFrame(Context context, AttributeSet attrs, Configuration c)72 public NearestTouchFrame(Context context, AttributeSet attrs, Configuration c) { 73 super(context, attrs); 74 mIsActive = c.smallestScreenWidthDp < 600; 75 } 76 77 @Override 78 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 79 super.onLayout(changed, left, top, right, bottom); 80 mClickableChildren.clear(); 81 mAttachedChildren.clear(); 82 mTouchableRegions.clear(); 83 addClickableChildren(this); 84 getLocationInWindow(mWindowOffset); 85 cacheClosestChildLocations(); 86 } 87 88 /** 89 * Populates {@link #mTouchableRegions} with the regions where each clickable child is the 90 * closest for a given point on this layout. 91 */ 92 private void cacheClosestChildLocations() { 93 if (getWidth() == 0 || getHeight() == 0) { 94 return; 95 } 96 97 // Sort by either top or left depending on mIsVertical, then take out all children 98 // that are not attached to window 99 mClickableChildren.sort(mChildRegionComparator); 100 mClickableChildren.stream() 101 .filter(View::isAttachedToWindow) 102 .forEachOrdered(mAttachedChildren::add); 103 104 // Cache bounds of children 105 // Mark coordinates where the actual child layout resides in this frame's window 106 for (int i = 0; i < mAttachedChildren.size(); i++) { 107 View child = mAttachedChildren.get(i); 108 if (!child.isAttachedToWindow()) { 109 continue; 110 } 111 Rect childRegion = getChildsBounds(child); 112 113 // We compute closest child from this child to the previous one 114 if (i == 0) { 115 // First child, nothing to the left/top of it 116 if (mIsVertical) { 117 childRegion.top = 0; 118 } else { 119 childRegion.left = 0; 120 } 121 mTouchableRegions.put(child, childRegion); 122 continue; 123 } 124 125 View previousChild = mAttachedChildren.get(i - 1); 126 Rect previousChildBounds = mTouchableRegions.get(previousChild); 127 int midPoint; 128 if (mIsVertical) { 129 int distance = childRegion.top - previousChildBounds.bottom; 130 midPoint = distance / 2; 131 childRegion.top -= midPoint; 132 previousChildBounds.bottom += midPoint - ((distance % 2) == 0 ? 1 : 0); 133 } else { 134 int distance = childRegion.left - previousChildBounds.right; 135 midPoint = distance / 2; 136 childRegion.left -= midPoint; 137 previousChildBounds.right += midPoint - ((distance % 2) == 0 ? 1 : 0); 138 } 139 140 if (i == mClickableChildren.size() - 1) { 141 // Last child, nothing to right/bottom of it 142 if (mIsVertical) { 143 childRegion.bottom = getHeight(); 144 } else { 145 childRegion.right = getWidth(); 146 } 147 } 148 149 mTouchableRegions.put(child, childRegion); 150 } 151 } 152 153 void setIsVertical(boolean isVertical) { 154 mIsVertical = isVertical; 155 } 156 157 private Rect getChildsBounds(View child) { 158 child.getLocationInWindow(mTmpInt); 159 int left = mTmpInt[0] - mWindowOffset[0]; 160 int top = mTmpInt[1] - mWindowOffset[1]; 161 int right = left + child.getWidth(); 162 int bottom = top + child.getHeight(); 163 return new Rect(left, top, right, bottom); 164 } 165 166 private void addClickableChildren(ViewGroup group) { 167 final int N = group.getChildCount(); 168 for (int i = 0; i < N; i++) { 169 View child = group.getChildAt(i); 170 if (child.isClickable()) { 171 mClickableChildren.add(child); 172 } else if (child instanceof ViewGroup) { 173 addClickableChildren((ViewGroup) child); 174 } 175 } 176 } 177 178 @Override 179 public boolean onTouchEvent(MotionEvent event) { 180 if (mIsActive) { 181 int x = (int) event.getX(); 182 int y = (int) event.getY(); 183 if (event.getAction() == MotionEvent.ACTION_DOWN) { 184 mTouchingChild = mClickableChildren 185 .stream() 186 .filter(View::isAttachedToWindow) 187 .filter(view -> mTouchableRegions.get(view).contains(x, y)) 188 .findFirst() 189 .orElse(null); 190 191 } 192 if (mTouchingChild != null) { 193 // Translate the touch event to the view center of the touching child. 194 event.offsetLocation(mTouchingChild.getWidth() / 2 - x, 195 mTouchingChild.getHeight() / 2 - y); 196 return mTouchingChild.getVisibility() == VISIBLE 197 && mTouchingChild.dispatchTouchEvent(event); 198 } 199 } 200 return super.onTouchEvent(event); 201 } 202 203 public void dumpLogs(String prefix, PrintWriter pw) { 204 pw.println(prefix + "NearestTouchFrame:"); 205 206 pw.println(String.format("%s\tmWindowOffset=%s", prefix, Arrays.toString(mWindowOffset))); 207 pw.println(String.format("%s\tmIsVertical=%s", prefix, mIsVertical)); 208 pw.println(String.format("%s\tmTouchingChild=%s", prefix, mTouchingChild)); 209 pw.println(String.format("%s\tmTouchableRegions=%s", prefix, 210 mTouchableRegions.keySet().stream() 211 .map(key -> key + "=" + mTouchableRegions.get(key)) 212 .collect(Collectors.joining(", ", "{", "}")))); 213 } 214 } 215