1 /*
2  * Copyright (C) 2021 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 package com.android.launcher3.keyboard;
17 
18 import static android.app.Activity.DEFAULT_KEYS_SEARCH_LOCAL;
19 
20 import static com.android.launcher3.LauncherState.EDIT_MODE;
21 import static com.android.launcher3.LauncherState.SPRING_LOADED;
22 
23 import android.app.Activity;
24 import android.content.Context;
25 import android.graphics.Canvas;
26 import android.graphics.Paint.Style;
27 import android.graphics.Rect;
28 import android.graphics.RectF;
29 import android.util.AttributeSet;
30 import android.view.KeyEvent;
31 import android.view.MotionEvent;
32 import android.view.View;
33 import android.view.ViewParent;
34 import android.widget.TextView;
35 
36 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
37 
38 import com.android.launcher3.AbstractFloatingView;
39 import com.android.launcher3.CellLayout;
40 import com.android.launcher3.Insettable;
41 import com.android.launcher3.Launcher;
42 import com.android.launcher3.LauncherState;
43 import com.android.launcher3.PagedView;
44 import com.android.launcher3.R;
45 import com.android.launcher3.Utilities;
46 import com.android.launcher3.accessibility.DragAndDropAccessibilityDelegate;
47 import com.android.launcher3.dragndrop.DragOptions;
48 import com.android.launcher3.folder.Folder;
49 import com.android.launcher3.model.data.ItemInfo;
50 import com.android.launcher3.statemanager.StateManager.StateListener;
51 import com.android.launcher3.touch.ItemLongClickListener;
52 import com.android.launcher3.util.Themes;
53 
54 import java.util.ArrayList;
55 import java.util.Objects;
56 import java.util.function.ToIntBiFunction;
57 import java.util.function.ToIntFunction;
58 
59 /**
60  * A floating view to allow keyboard navigation across virtual nodes
61  */
62 public class KeyboardDragAndDropView extends AbstractFloatingView
63         implements Insettable, StateListener<LauncherState> {
64 
65     private static final long MINOR_AXIS_WEIGHT = 13;
66 
67     private final ArrayList<Integer> mIntList = new ArrayList<>();
68     private final ArrayList<DragAndDropAccessibilityDelegate> mDelegates = new ArrayList<>();
69     private final ArrayList<VirtualNodeInfo> mNodes = new ArrayList<>();
70 
71     private final Rect mTempRect = new Rect();
72     private final Rect mTempRect2 = new Rect();
73     private final AccessibilityNodeInfoCompat mTempNodeInfo = AccessibilityNodeInfoCompat.obtain();
74 
75     private final RectFocusIndicator mFocusIndicator;
76 
77     private final Launcher mLauncher;
78     private VirtualNodeInfo mCurrentSelection;
79 
80 
KeyboardDragAndDropView(Context context, AttributeSet attrs)81     public KeyboardDragAndDropView(Context context, AttributeSet attrs) {
82         this(context, attrs, 0);
83     }
84 
KeyboardDragAndDropView(Context context, AttributeSet attrs, int defStyleAttr)85     public KeyboardDragAndDropView(Context context, AttributeSet attrs, int defStyleAttr) {
86         super(context, attrs, defStyleAttr);
87         mLauncher = Launcher.getLauncher(context);
88         mFocusIndicator = new RectFocusIndicator(this);
89         setWillNotDraw(false);
90     }
91 
92     @Override
handleClose(boolean animate)93     protected void handleClose(boolean animate) {
94         mLauncher.getDragLayer().removeView(this);
95         mLauncher.getStateManager().removeStateListener(this);
96         mLauncher.setDefaultKeyMode(DEFAULT_KEYS_SEARCH_LOCAL);
97         mIsOpen = false;
98     }
99 
100     @Override
isOfType(int type)101     protected boolean isOfType(int type) {
102         return (type & TYPE_DRAG_DROP_POPUP) != 0;
103     }
104 
105     @Override
onControllerInterceptTouchEvent(MotionEvent ev)106     public boolean onControllerInterceptTouchEvent(MotionEvent ev) {
107         // Consume all touch
108         return true;
109     }
110 
111     @Override
setInsets(Rect insets)112     public void setInsets(Rect insets) {
113         setPadding(insets.left, insets.top, insets.right, insets.bottom);
114     }
115 
116     @Override
onStateTransitionStart(LauncherState toState)117     public void onStateTransitionStart(LauncherState toState) {
118         if (toState != SPRING_LOADED && toState != EDIT_MODE) {
119             close(false);
120         }
121     }
122 
123     @Override
onStateTransitionComplete(LauncherState finalState)124     public void onStateTransitionComplete(LauncherState finalState) {
125         if (mCurrentSelection != null) {
126             setCurrentSelection(mCurrentSelection);
127         }
128     }
129 
setCurrentSelection(VirtualNodeInfo nodeInfo)130     private void setCurrentSelection(VirtualNodeInfo nodeInfo) {
131         mCurrentSelection = nodeInfo;
132         ((TextView) findViewById(R.id.label))
133                 .setText(nodeInfo.populate(mTempNodeInfo).getContentDescription());
134 
135         Rect bounds = new Rect();
136         mTempNodeInfo.getBoundsInParent(bounds);
137         View host = nodeInfo.delegate.getHost();
138         ViewParent parent = host.getParent();
139         if (parent instanceof PagedView) {
140             PagedView pv = (PagedView) parent;
141             int pageIndex = pv.indexOfChild(host);
142 
143             pv.setCurrentPage(pageIndex);
144             bounds.offset(pv.getScrollX() - pv.getScrollForPage(pageIndex), 0);
145         }
146         float[] pos = new float[] {bounds.left, bounds.top, bounds.right, bounds.bottom};
147         Utilities.getDescendantCoordRelativeToAncestor(host, mLauncher.getDragLayer(), pos, true);
148 
149         new RectF(pos[0], pos[1], pos[2], pos[3]).roundOut(bounds);
150         mFocusIndicator.changeFocus(bounds, true);
151     }
152 
153     @Override
onDraw(Canvas canvas)154     protected void onDraw(Canvas canvas) {
155         mFocusIndicator.draw(canvas);
156     }
157 
158     @Override
dispatchUnhandledMove(View focused, int direction)159     public boolean dispatchUnhandledMove(View focused, int direction) {
160         VirtualNodeInfo nodeInfo = getNextSelection(direction);
161         if (nodeInfo == null) {
162             return false;
163         }
164         setCurrentSelection(nodeInfo);
165         return true;
166     }
167 
168     /**
169      * Focus finding logic:
170      * Collect all virtual nodes in reading order (used for forward and backwards).
171      * Then find the closest view by comparing the distances spatially. Since it is a move
172      * operation. consider all cell sizes to be approximately of the same size.
173      */
getNextSelection(int direction)174     private VirtualNodeInfo getNextSelection(int direction) {
175         // Collect all virtual nodes
176         mDelegates.clear();
177         mNodes.clear();
178 
179         Folder openFolder = Folder.getOpen(mLauncher);
180         PagedView pv = openFolder == null ? mLauncher.getWorkspace() : openFolder.getContent();
181         int count = pv.getPageCount();
182         for (int i = 0; i < count; i++) {
183             mDelegates.add(((CellLayout) pv.getChildAt(i)).getDragAndDropAccessibilityDelegate());
184         }
185         if (openFolder == null) {
186             mDelegates.add(pv.getNextPage() + 1,
187                     mLauncher.getHotseat().getDragAndDropAccessibilityDelegate());
188         }
189         mDelegates.forEach(delegate -> {
190             mIntList.clear();
191             delegate.getVisibleVirtualViews(mIntList);
192             mIntList.forEach(id -> mNodes.add(new VirtualNodeInfo(delegate, id)));
193         });
194 
195         if (mNodes.isEmpty()) {
196             return null;
197         }
198         int index = mNodes.indexOf(mCurrentSelection);
199         if (mCurrentSelection == null || index < 0) {
200             return null;
201         }
202         int totalNodes = mNodes.size();
203 
204         final ToIntBiFunction<Rect, Rect> majorAxis;
205         final ToIntFunction<Rect> minorAxis;
206 
207         switch (direction) {
208             case View.FOCUS_RIGHT:
209                 majorAxis = (source, dest) -> dest.left - source.left;
210                 minorAxis = Rect::centerY;
211                 break;
212             case View.FOCUS_LEFT:
213                 majorAxis = (source, dest) -> source.left - dest.left;
214                 minorAxis = Rect::centerY;
215                 break;
216             case View.FOCUS_UP:
217                 majorAxis = (source, dest) -> source.top - dest.top;
218                 minorAxis = Rect::centerX;
219                 break;
220             case View.FOCUS_DOWN:
221                 majorAxis = (source, dest) -> dest.top - source.top;
222                 minorAxis = Rect::centerX;
223                 break;
224             case View.FOCUS_FORWARD:
225                 return mNodes.get((index + 1) % totalNodes);
226             case View.FOCUS_BACKWARD:
227                 return mNodes.get((index + totalNodes - 1) % totalNodes);
228             default:
229                 // Unknown direction
230                 return null;
231         }
232         mCurrentSelection.populate(mTempNodeInfo).getBoundsInScreen(mTempRect);
233 
234         float minWeight = Float.MAX_VALUE;
235         VirtualNodeInfo match = null;
236         for (int i = 0; i < totalNodes; i++) {
237             VirtualNodeInfo node = mNodes.get(i);
238             node.populate(mTempNodeInfo).getBoundsInScreen(mTempRect2);
239 
240             int majorAxisWeight = majorAxis.applyAsInt(mTempRect, mTempRect2);
241             if (majorAxisWeight <= 0) {
242                 continue;
243             }
244             int minorAxisWeight = minorAxis.applyAsInt(mTempRect2)
245                     - minorAxis.applyAsInt(mTempRect);
246 
247             float weight = majorAxisWeight * majorAxisWeight
248                     + minorAxisWeight * minorAxisWeight * MINOR_AXIS_WEIGHT;
249             if (weight < minWeight) {
250                 minWeight = weight;
251                 match = node;
252             }
253         }
254         return match;
255     }
256 
257     @Override
onKeyUp(int keyCode, KeyEvent event)258     public boolean onKeyUp(int keyCode, KeyEvent event) {
259         if (keyCode == KeyEvent.KEYCODE_ENTER && mCurrentSelection != null) {
260             mCurrentSelection.delegate.onPerformActionForVirtualView(
261                     mCurrentSelection.id, AccessibilityNodeInfoCompat.ACTION_CLICK, null);
262             return true;
263         }
264         return super.onKeyUp(keyCode, event);
265     }
266 
267     /**
268      * Shows the keyboard drag popup for the provided view
269      */
showForIcon(View icon, ItemInfo item, DragOptions dragOptions)270     public void showForIcon(View icon, ItemInfo item, DragOptions dragOptions) {
271         mIsOpen = true;
272         mLauncher.getDragLayer().addView(this);
273         mLauncher.getStateManager().addStateListener(this);
274 
275         // Find current selection
276         CellLayout currentParent = (CellLayout) icon.getParent().getParent();
277         float[] iconPos = new float[] {currentParent.getCellWidth() / 2,
278                 currentParent.getCellHeight() / 2};
279         Utilities.getDescendantCoordRelativeToAncestor(icon, currentParent, iconPos, false);
280 
281         ItemLongClickListener.beginDrag(icon, mLauncher, item, dragOptions);
282 
283         DragAndDropAccessibilityDelegate dndDelegate =
284                 currentParent.getDragAndDropAccessibilityDelegate();
285         setCurrentSelection(new VirtualNodeInfo(
286                 dndDelegate, dndDelegate.getVirtualViewAt(iconPos[0], iconPos[1])));
287 
288         mLauncher.setDefaultKeyMode(Activity.DEFAULT_KEYS_DISABLE);
289         requestFocus();
290     }
291 
292     private static class VirtualNodeInfo {
293         public final DragAndDropAccessibilityDelegate delegate;
294         public final int id;
295 
VirtualNodeInfo(DragAndDropAccessibilityDelegate delegate, int id)296         VirtualNodeInfo(DragAndDropAccessibilityDelegate delegate, int id) {
297             this.id = id;
298             this.delegate = delegate;
299         }
300 
301         @Override
equals(Object o)302         public boolean equals(Object o) {
303             if (this == o) {
304                 return true;
305             }
306             if (!(o instanceof VirtualNodeInfo)) {
307                 return false;
308             }
309             VirtualNodeInfo that = (VirtualNodeInfo) o;
310             return id == that.id && delegate.equals(that.delegate);
311         }
312 
populate(AccessibilityNodeInfoCompat nodeInfo)313         public AccessibilityNodeInfoCompat populate(AccessibilityNodeInfoCompat nodeInfo) {
314             delegate.onPopulateNodeForVirtualView(id, nodeInfo);
315             return nodeInfo;
316         }
317 
getBounds(AccessibilityNodeInfoCompat nodeInfo, Rect out)318         public void getBounds(AccessibilityNodeInfoCompat nodeInfo, Rect out) {
319             delegate.onPopulateNodeForVirtualView(id, nodeInfo);
320             nodeInfo.getBoundsInScreen(out);
321         }
322 
323         @Override
hashCode()324         public int hashCode() {
325             return Objects.hash(id, delegate);
326         }
327     }
328 
329     private static class RectFocusIndicator extends ItemFocusIndicatorHelper<Rect> {
330 
RectFocusIndicator(View container)331         RectFocusIndicator(View container) {
332             super(container, Themes.getColorAccent(container.getContext()));
333             mPaint.setStrokeWidth(container.getResources()
334                     .getDimension(R.dimen.keyboard_drag_stroke_width));
335             mPaint.setStyle(Style.STROKE);
336         }
337 
338         @Override
viewToRect(Rect item, Rect outRect)339         public void viewToRect(Rect item, Rect outRect) {
340             outRect.set(item);
341         }
342     }
343 }
344