1 /* 2 * Copyright (C) 2020 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.systemui.navigationbar.buttons; 18 19 import android.content.Context; 20 import android.content.res.Configuration; 21 import android.content.res.TypedArray; 22 import android.graphics.Rect; 23 import android.util.AttributeSet; 24 import android.view.MotionEvent; 25 import android.view.View; 26 import android.view.ViewGroup; 27 import android.widget.FrameLayout; 28 29 import androidx.annotation.VisibleForTesting; 30 31 import com.android.systemui.R; 32 33 import java.util.ArrayList; 34 import java.util.Comparator; 35 import java.util.HashMap; 36 import java.util.List; 37 import java.util.Map; 38 39 /** 40 * Redirects touches that aren't handled by any child view to the nearest 41 * clickable child. Only takes effect on <sw600dp. 42 */ 43 public class NearestTouchFrame extends FrameLayout { 44 45 private final List<View> mClickableChildren = new ArrayList<>(); 46 private final List<View> mAttachedChildren = new ArrayList<>(); 47 private final boolean mIsActive; 48 private final int[] mTmpInt = new int[2]; 49 private final int[] mOffset = 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 = 0; 60 if (mIsVertical) { 61 // Compare view bound's "top" values 62 leftTopIndex = 1; 63 } 64 view1.getLocationInWindow(mTmpInt); 65 int startingCoordView1 = mTmpInt[leftTopIndex] - mOffset[leftTopIndex]; 66 view2.getLocationInWindow(mTmpInt); 67 int startingCoordView2 = mTmpInt[leftTopIndex] - mOffset[leftTopIndex]; 68 69 return startingCoordView1 - startingCoordView2; 70 }; 71 NearestTouchFrame(Context context, AttributeSet attrs)72 public NearestTouchFrame(Context context, AttributeSet attrs) { 73 this(context, attrs, context.getResources().getConfiguration()); 74 } 75 76 @VisibleForTesting NearestTouchFrame(Context context, AttributeSet attrs, Configuration c)77 NearestTouchFrame(Context context, AttributeSet attrs, Configuration c) { 78 super(context, attrs); 79 mIsActive = c.smallestScreenWidthDp < 600; 80 int[] attrsArray = new int[] {R.attr.isVertical}; 81 TypedArray ta = context.obtainStyledAttributes(attrs, attrsArray); 82 mIsVertical = ta.getBoolean(0, false); 83 ta.recycle(); 84 } 85 86 @Override 87 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 88 super.onLayout(changed, left, top, right, bottom); 89 mClickableChildren.clear(); 90 mAttachedChildren.clear(); 91 mTouchableRegions.clear(); 92 addClickableChildren(this); 93 getLocationInWindow(mOffset); 94 cacheClosestChildLocations(); 95 } 96 97 /** 98 * Populates {@link #mTouchableRegions} with the regions where each clickable child is the 99 * closest for a given point on this layout. 100 */ 101 private void cacheClosestChildLocations() { 102 if (getWidth() == 0 || getHeight() == 0) { 103 return; 104 } 105 106 // Sort by either top or left depending on mIsVertical, then take out all children 107 // that are not attached to window 108 mClickableChildren.sort(mChildRegionComparator); 109 mClickableChildren.stream() 110 .filter(View::isAttachedToWindow) 111 .forEachOrdered(mAttachedChildren::add); 112 113 // Cache bounds of children 114 // Mark coordinates where the actual child layout resides in this frame's window 115 for (int i = 0; i < mAttachedChildren.size(); i++) { 116 View child = mAttachedChildren.get(i); 117 if (!child.isAttachedToWindow()) { 118 continue; 119 } 120 Rect childRegion = getChildsBounds(child); 121 122 // We compute closest child from this child to the previous one 123 if (i == 0) { 124 // First child, nothing to the left/top of it 125 if (mIsVertical) { 126 childRegion.top = 0; 127 } else { 128 childRegion.left = 0; 129 } 130 mTouchableRegions.put(child, childRegion); 131 continue; 132 } 133 134 View previousChild = mAttachedChildren.get(i - 1); 135 Rect previousChildBounds = mTouchableRegions.get(previousChild); 136 int midPoint; 137 if (mIsVertical) { 138 int distance = childRegion.top - previousChildBounds.bottom; 139 midPoint = distance / 2; 140 childRegion.top -= midPoint; 141 previousChildBounds.bottom += midPoint - ((distance % 2) == 0 ? 1 : 0); 142 } else { 143 int distance = childRegion.left - previousChildBounds.right; 144 midPoint = distance / 2; 145 childRegion.left -= midPoint; 146 previousChildBounds.right += midPoint - ((distance % 2) == 0 ? 1 : 0); 147 } 148 149 if (i == mClickableChildren.size() - 1) { 150 // Last child, nothing to right/bottom of it 151 if (mIsVertical) { 152 childRegion.bottom = getHeight(); 153 } else { 154 childRegion.right = getWidth(); 155 } 156 } 157 158 mTouchableRegions.put(child, childRegion); 159 } 160 } 161 162 @VisibleForTesting 163 void setIsVertical(boolean isVertical) { 164 mIsVertical = isVertical; 165 } 166 167 private Rect getChildsBounds(View child) { 168 child.getLocationInWindow(mTmpInt); 169 int left = mTmpInt[0] - mOffset[0]; 170 int top = mTmpInt[1] - mOffset[1]; 171 int right = left + child.getWidth(); 172 int bottom = top + child.getHeight(); 173 return new Rect(left, top, right, bottom); 174 } 175 176 private void addClickableChildren(ViewGroup group) { 177 final int N = group.getChildCount(); 178 for (int i = 0; i < N; i++) { 179 View child = group.getChildAt(i); 180 if (child.isClickable()) { 181 mClickableChildren.add(child); 182 } else if (child instanceof ViewGroup) { 183 addClickableChildren((ViewGroup) child); 184 } 185 } 186 } 187 188 /** 189 * @return A Map where the key is the view object of the button and the value 190 * is the Rect where that button will receive a touch event if pressed. This Rect will 191 * usually be larger than the layout bounds for the button. 192 * The Rect is in screen coordinates. 193 */ 194 public Map<View, Rect> getFullTouchableChildRegions() { 195 Map<View, Rect> fullTouchRegions = new HashMap<>(mTouchableRegions.size()); 196 getLocationOnScreen(mTmpInt); 197 for (Map.Entry<View, Rect> entry : mTouchableRegions.entrySet()) { 198 View child = entry.getKey(); 199 Rect screenRegion = new Rect(entry.getValue()); 200 screenRegion.offset(mTmpInt[0], mTmpInt[1]); 201 fullTouchRegions.put(child, screenRegion); 202 } 203 return fullTouchRegions; 204 } 205 206 @Override 207 public boolean onTouchEvent(MotionEvent event) { 208 if (mIsActive) { 209 int x = (int) event.getX(); 210 int y = (int) event.getY(); 211 if (event.getAction() == MotionEvent.ACTION_DOWN) { 212 mTouchingChild = mClickableChildren 213 .stream() 214 .filter(View::isAttachedToWindow) 215 .filter(view -> mTouchableRegions.get(view).contains(x, y)) 216 .findFirst() 217 .orElse(null); 218 219 } 220 if (mTouchingChild != null) { 221 event.offsetLocation(mTouchingChild.getWidth() / 2 - x, 222 mTouchingChild.getHeight() / 2 - y); 223 return mTouchingChild.getVisibility() == VISIBLE 224 && mTouchingChild.dispatchTouchEvent(event); 225 } 226 } 227 return super.onTouchEvent(event); 228 } 229 } 230