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