1 /*
2  * Copyright (C) 2015 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 android.widget;
18 
19 
20 import android.annotation.NonNull;
21 import android.content.Context;
22 import android.view.MotionEvent;
23 import android.view.View;
24 
25 import com.android.internal.widget.AutoScrollHelper.AbsListViewAutoScroller;
26 
27 /**
28  * Wrapper class for a ListView. This wrapper can hijack the focus to
29  * make sure the list uses the appropriate drawables and states when
30  * displayed on screen within a drop down. The focus is never actually
31  * passed to the drop down in this mode; the list only looks focused.
32  *
33  * @hide
34  */
35 public class DropDownListView extends ListView {
36     /*
37      * WARNING: This is a workaround for a touch mode issue.
38      *
39      * Touch mode is propagated lazily to windows. This causes problems in
40      * the following scenario:
41      * - Type something in the AutoCompleteTextView and get some results
42      * - Move down with the d-pad to select an item in the list
43      * - Move up with the d-pad until the selection disappears
44      * - Type more text in the AutoCompleteTextView *using the soft keyboard*
45      *   and get new results; you are now in touch mode
46      * - The selection comes back on the first item in the list, even though
47      *   the list is supposed to be in touch mode
48      *
49      * Using the soft keyboard triggers the touch mode change but that change
50      * is propagated to our window only after the first list layout, therefore
51      * after the list attempts to resurrect the selection.
52      *
53      * The trick to work around this issue is to pretend the list is in touch
54      * mode when we know that the selection should not appear, that is when
55      * we know the user moved the selection away from the list.
56      *
57      * This boolean is set to true whenever we explicitly hide the list's
58      * selection and reset to false whenever we know the user moved the
59      * selection back to the list.
60      *
61      * When this boolean is true, isInTouchMode() returns true, otherwise it
62      * returns super.isInTouchMode().
63      */
64     private boolean mListSelectionHidden;
65 
66     /**
67      * True if this wrapper should fake focus.
68      */
69     private boolean mHijackFocus;
70 
71     /** Whether to force drawing of the pressed state selector. */
72     private boolean mDrawsInPressedState;
73 
74     /** Helper for drag-to-open auto scrolling. */
75     private AbsListViewAutoScroller mScrollHelper;
76 
77     /**
78      * Runnable posted when we are awaiting hover event resolution. When set,
79      * drawable state changes are postponed.
80      */
81     private ResolveHoverRunnable mResolveHoverRunnable;
82 
83     /**
84      * Creates a new list view wrapper.
85      *
86      * @param context this view's context
87      */
DropDownListView(@onNull Context context, boolean hijackFocus)88     public DropDownListView(@NonNull Context context, boolean hijackFocus) {
89         this(context, hijackFocus, com.android.internal.R.attr.dropDownListViewStyle);
90     }
91 
92     /**
93      * Creates a new list view wrapper.
94      *
95      * @param context this view's context
96      */
DropDownListView(@onNull Context context, boolean hijackFocus, int defStyleAttr)97     public DropDownListView(@NonNull Context context, boolean hijackFocus, int defStyleAttr) {
98         super(context, null, defStyleAttr);
99         mHijackFocus = hijackFocus;
100         // TODO: Add an API to control this
101         setCacheColorHint(0); // Transparent, since the background drawable could be anything.
102     }
103 
104     @Override
shouldShowSelector()105     boolean shouldShowSelector() {
106         return isHovered() || super.shouldShowSelector();
107     }
108 
109     @Override
onTouchEvent(MotionEvent ev)110     public boolean onTouchEvent(MotionEvent ev) {
111         if (mResolveHoverRunnable != null) {
112             // Resolved hover event as hover => touch transition.
113             mResolveHoverRunnable.cancel();
114         }
115 
116         return super.onTouchEvent(ev);
117     }
118 
119     @Override
onHoverEvent(@onNull MotionEvent ev)120     public boolean onHoverEvent(@NonNull MotionEvent ev) {
121         final int action = ev.getActionMasked();
122         if (action == MotionEvent.ACTION_HOVER_EXIT && mResolveHoverRunnable == null) {
123             // This may be transitioning to TOUCH_DOWN. Postpone drawable state
124             // updates until either the next frame or the next touch event.
125             mResolveHoverRunnable = new ResolveHoverRunnable();
126             mResolveHoverRunnable.post();
127         }
128 
129         // Allow the super class to handle hover state management first.
130         final boolean handled = super.onHoverEvent(ev);
131 
132         if (action == MotionEvent.ACTION_HOVER_ENTER
133                 || action == MotionEvent.ACTION_HOVER_MOVE) {
134             final int position = pointToPosition((int) ev.getX(), (int) ev.getY());
135             if (position != INVALID_POSITION && position != mSelectedPosition) {
136                 final View hoveredItem = getChildAt(position - getFirstVisiblePosition());
137                 if (hoveredItem.isEnabled()) {
138                     // Force a focus so that the proper selector state gets
139                     // used when we update.
140                     requestFocus();
141 
142                     positionSelector(position, hoveredItem);
143                     setSelectedPositionInt(position);
144                     setNextSelectedPositionInt(position);
145                 }
146                 updateSelectorState();
147             }
148         } else {
149             // Do not cancel the selected position if the selection is visible
150             // by other means.
151             if (!super.shouldShowSelector()) {
152                 setSelectedPositionInt(INVALID_POSITION);
153                 setNextSelectedPositionInt(INVALID_POSITION);
154             }
155         }
156 
157         return handled;
158     }
159 
160     @Override
drawableStateChanged()161     protected void drawableStateChanged() {
162         if (mResolveHoverRunnable == null) {
163             super.drawableStateChanged();
164         }
165     }
166 
167     /**
168      * Handles forwarded events.
169      *
170      * @param activePointerId id of the pointer that activated forwarding
171      * @return whether the event was handled
172      */
onForwardedEvent(@onNull MotionEvent event, int activePointerId)173     public boolean onForwardedEvent(@NonNull MotionEvent event, int activePointerId) {
174         boolean handledEvent = true;
175         boolean clearPressedItem = false;
176 
177         final int actionMasked = event.getActionMasked();
178         switch (actionMasked) {
179             case MotionEvent.ACTION_CANCEL:
180                 handledEvent = false;
181                 break;
182             case MotionEvent.ACTION_UP:
183                 handledEvent = false;
184                 // $FALL-THROUGH$
185             case MotionEvent.ACTION_MOVE:
186                 final int activeIndex = event.findPointerIndex(activePointerId);
187                 if (activeIndex < 0) {
188                     handledEvent = false;
189                     break;
190                 }
191 
192                 final int x = (int) event.getX(activeIndex);
193                 final int y = (int) event.getY(activeIndex);
194                 final int position = pointToPosition(x, y);
195                 if (position == INVALID_POSITION) {
196                     clearPressedItem = true;
197                     break;
198                 }
199 
200                 final View child = getChildAt(position - getFirstVisiblePosition());
201                 setPressedItem(child, position, x, y);
202                 handledEvent = true;
203 
204                 if (actionMasked == MotionEvent.ACTION_UP) {
205                     final long id = getItemIdAtPosition(position);
206                     performItemClick(child, position, id);
207                 }
208                 break;
209         }
210 
211         // Failure to handle the event cancels forwarding.
212         if (!handledEvent || clearPressedItem) {
213             clearPressedItem();
214         }
215 
216         // Manage automatic scrolling.
217         if (handledEvent) {
218             if (mScrollHelper == null) {
219                 mScrollHelper = new AbsListViewAutoScroller(this);
220             }
221             mScrollHelper.setEnabled(true);
222             mScrollHelper.onTouch(this, event);
223         } else if (mScrollHelper != null) {
224             mScrollHelper.setEnabled(false);
225         }
226 
227         return handledEvent;
228     }
229 
230     /**
231      * Sets whether the list selection is hidden, as part of a workaround for a
232      * touch mode issue (see the declaration for mListSelectionHidden).
233      *
234      * @param hideListSelection {@code true} to hide list selection,
235      *                          {@code false} to show
236      */
setListSelectionHidden(boolean hideListSelection)237     public void setListSelectionHidden(boolean hideListSelection) {
238         mListSelectionHidden = hideListSelection;
239     }
240 
clearPressedItem()241     private void clearPressedItem() {
242         mDrawsInPressedState = false;
243         setPressed(false);
244         updateSelectorState();
245 
246         final View motionView = getChildAt(mMotionPosition - mFirstPosition);
247         if (motionView != null) {
248             motionView.setPressed(false);
249         }
250     }
251 
setPressedItem(@onNull View child, int position, float x, float y)252     private void setPressedItem(@NonNull View child, int position, float x, float y) {
253         mDrawsInPressedState = true;
254 
255         // Ordering is essential. First, update the container's pressed state.
256         drawableHotspotChanged(x, y);
257         if (!isPressed()) {
258             setPressed(true);
259         }
260 
261         // Next, run layout if we need to stabilize child positions.
262         if (mDataChanged) {
263             layoutChildren();
264         }
265 
266         // Manage the pressed view based on motion position. This allows us to
267         // play nicely with actual touch and scroll events.
268         final View motionView = getChildAt(mMotionPosition - mFirstPosition);
269         if (motionView != null && motionView != child && motionView.isPressed()) {
270             motionView.setPressed(false);
271         }
272         mMotionPosition = position;
273 
274         // Offset for child coordinates.
275         final float childX = x - child.getLeft();
276         final float childY = y - child.getTop();
277         child.drawableHotspotChanged(childX, childY);
278         if (!child.isPressed()) {
279             child.setPressed(true);
280         }
281 
282         // Ensure that keyboard focus starts from the last touched position.
283         setSelectedPositionInt(position);
284         positionSelectorLikeTouch(position, child, x, y);
285 
286         // Refresh the drawable state to reflect the new pressed state,
287         // which will also update the selector state.
288         refreshDrawableState();
289     }
290 
291     @Override
touchModeDrawsInPressedState()292     boolean touchModeDrawsInPressedState() {
293         return mDrawsInPressedState || super.touchModeDrawsInPressedState();
294     }
295 
296     /**
297      * Avoids jarring scrolling effect by ensuring that list elements
298      * made of a text view fit on a single line.
299      *
300      * @param position the item index in the list to get a view for
301      * @return the view for the specified item
302      */
303     @Override
obtainView(int position, boolean[] isScrap)304     View obtainView(int position, boolean[] isScrap) {
305         View view = super.obtainView(position, isScrap);
306 
307         if (view instanceof TextView) {
308             ((TextView) view).setHorizontallyScrolling(true);
309         }
310 
311         return view;
312     }
313 
314     @Override
isInTouchMode()315     public boolean isInTouchMode() {
316         // WARNING: Please read the comment where mListSelectionHidden is declared
317         return (mHijackFocus && mListSelectionHidden) || super.isInTouchMode();
318     }
319 
320     /**
321      * Returns the focus state in the drop down.
322      *
323      * @return true always if hijacking focus
324      */
325     @Override
hasWindowFocus()326     public boolean hasWindowFocus() {
327         return mHijackFocus || super.hasWindowFocus();
328     }
329 
330     /**
331      * Returns the focus state in the drop down.
332      *
333      * @return true always if hijacking focus
334      */
335     @Override
isFocused()336     public boolean isFocused() {
337         return mHijackFocus || super.isFocused();
338     }
339 
340     /**
341      * Returns the focus state in the drop down.
342      *
343      * @return true always if hijacking focus
344      */
345     @Override
hasFocus()346     public boolean hasFocus() {
347         return mHijackFocus || super.hasFocus();
348     }
349 
350     /**
351      * Runnable that forces hover event resolution and updates drawable state.
352      */
353     private class ResolveHoverRunnable implements Runnable {
354         @Override
run()355         public void run() {
356             // Resolved hover event as standard hover exit.
357             mResolveHoverRunnable = null;
358             drawableStateChanged();
359         }
360 
cancel()361         public void cancel() {
362             mResolveHoverRunnable = null;
363             removeCallbacks(this);
364         }
365 
post()366         public void post() {
367             DropDownListView.this.post(this);
368         }
369     }
370 }