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