1/*
2 * Copyright (C) 2016 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
17package androidx.appcompat.widget;
18
19import android.content.Context;
20import android.graphics.Canvas;
21import android.graphics.Rect;
22import android.graphics.drawable.Drawable;
23import android.os.Build;
24import android.view.MotionEvent;
25import android.view.View;
26import android.view.ViewGroup;
27import android.widget.AbsListView;
28import android.widget.ListAdapter;
29import android.widget.ListView;
30
31import androidx.annotation.NonNull;
32import androidx.appcompat.R;
33import androidx.appcompat.graphics.drawable.DrawableWrapper;
34import androidx.core.graphics.drawable.DrawableCompat;
35import androidx.core.view.ViewPropertyAnimatorCompat;
36import androidx.core.widget.ListViewAutoScrollHelper;
37
38import java.lang.reflect.Field;
39
40/**
41 * <p>Wrapper class for a ListView. This wrapper can hijack the focus to
42 * make sure the list uses the appropriate drawables and states when
43 * displayed on screen within a drop down. The focus is never actually
44 * passed to the drop down in this mode; the list only looks focused.</p>
45 */
46class DropDownListView extends ListView {
47    public static final int INVALID_POSITION = -1;
48    public static final int NO_POSITION = -1;
49
50    private final Rect mSelectorRect = new Rect();
51    private int mSelectionLeftPadding = 0;
52    private int mSelectionTopPadding = 0;
53    private int mSelectionRightPadding = 0;
54    private int mSelectionBottomPadding = 0;
55
56    private int mMotionPosition;
57
58    private Field mIsChildViewEnabled;
59
60    private GateKeeperDrawable mSelector;
61
62    /*
63    * WARNING: This is a workaround for a touch mode issue.
64    *
65    * Touch mode is propagated lazily to windows. This causes problems in
66    * the following scenario:
67    * - Type something in the AutoCompleteTextView and get some results
68    * - Move down with the d-pad to select an item in the list
69    * - Move up with the d-pad until the selection disappears
70    * - Type more text in the AutoCompleteTextView *using the soft keyboard*
71    *   and get new results; you are now in touch mode
72    * - The selection comes back on the first item in the list, even though
73    *   the list is supposed to be in touch mode
74    *
75    * Using the soft keyboard triggers the touch mode change but that change
76    * is propagated to our window only after the first list layout, therefore
77    * after the list attempts to resurrect the selection.
78    *
79    * The trick to work around this issue is to pretend the list is in touch
80    * mode when we know that the selection should not appear, that is when
81    * we know the user moved the selection away from the list.
82    *
83    * This boolean is set to true whenever we explicitly hide the list's
84    * selection and reset to false whenever we know the user moved the
85    * selection back to the list.
86    *
87    * When this boolean is true, isInTouchMode() returns true, otherwise it
88    * returns super.isInTouchMode().
89    */
90    private boolean mListSelectionHidden;
91
92    /**
93     * True if this wrapper should fake focus.
94     */
95    private boolean mHijackFocus;
96
97    /** Whether to force drawing of the pressed state selector. */
98    private boolean mDrawsInPressedState;
99
100    /** Current drag-to-open click animation, if any. */
101    private ViewPropertyAnimatorCompat mClickAnimation;
102
103    /** Helper for drag-to-open auto scrolling. */
104    private ListViewAutoScrollHelper mScrollHelper;
105
106    /**
107     * Runnable posted when we are awaiting hover event resolution. When set,
108     * drawable state changes are postponed.
109     */
110    private ResolveHoverRunnable mResolveHoverRunnable;
111
112    /**
113     * <p>Creates a new list view wrapper.</p>
114     *
115     * @param context this view's context
116     */
117    DropDownListView(Context context, boolean hijackFocus) {
118        super(context, null, R.attr.dropDownListViewStyle);
119        mHijackFocus = hijackFocus;
120        setCacheColorHint(0); // Transparent, since the background drawable could be anything.
121
122        try {
123            mIsChildViewEnabled = AbsListView.class.getDeclaredField("mIsChildViewEnabled");
124            mIsChildViewEnabled.setAccessible(true);
125        } catch (NoSuchFieldException e) {
126            e.printStackTrace();
127        }
128    }
129
130
131    @Override
132    public boolean isInTouchMode() {
133        // WARNING: Please read the comment where mListSelectionHidden is declared
134        return (mHijackFocus && mListSelectionHidden) || super.isInTouchMode();
135    }
136
137    /**
138     * <p>Returns the focus state in the drop down.</p>
139     *
140     * @return true always if hijacking focus
141     */
142    @Override
143    public boolean hasWindowFocus() {
144        return mHijackFocus || super.hasWindowFocus();
145    }
146
147    /**
148     * <p>Returns the focus state in the drop down.</p>
149     *
150     * @return true always if hijacking focus
151     */
152    @Override
153    public boolean isFocused() {
154        return mHijackFocus || super.isFocused();
155    }
156
157    /**
158     * <p>Returns the focus state in the drop down.</p>
159     *
160     * @return true always if hijacking focus
161     */
162    @Override
163    public boolean hasFocus() {
164        return mHijackFocus || super.hasFocus();
165    }
166
167    @Override
168    public void setSelector(Drawable sel) {
169        mSelector = sel != null ? new GateKeeperDrawable(sel) : null;
170        super.setSelector(mSelector);
171
172        final Rect padding = new Rect();
173        if (sel != null) {
174            sel.getPadding(padding);
175        }
176
177        mSelectionLeftPadding = padding.left;
178        mSelectionTopPadding = padding.top;
179        mSelectionRightPadding = padding.right;
180        mSelectionBottomPadding = padding.bottom;
181    }
182
183    @Override
184    protected void drawableStateChanged() {
185        //postpone drawableStateChanged until pending hover to pressed transition finishes.
186        if (mResolveHoverRunnable != null) {
187            return;
188        }
189
190        super.drawableStateChanged();
191
192        setSelectorEnabled(true);
193        updateSelectorStateCompat();
194    }
195
196    @Override
197    protected void dispatchDraw(Canvas canvas) {
198        final boolean drawSelectorOnTop = false;
199        if (!drawSelectorOnTop) {
200            drawSelectorCompat(canvas);
201        }
202
203        super.dispatchDraw(canvas);
204    }
205
206    @Override
207    public boolean onTouchEvent(MotionEvent ev) {
208        switch (ev.getAction()) {
209            case MotionEvent.ACTION_DOWN:
210                mMotionPosition = pointToPosition((int) ev.getX(), (int) ev.getY());
211                break;
212        }
213        if (mResolveHoverRunnable != null) {
214            // Resolved hover event as hover => touch transition.
215            mResolveHoverRunnable.cancel();
216        }
217        return super.onTouchEvent(ev);
218    }
219
220    /**
221     * Find a position that can be selected (i.e., is not a separator).
222     *
223     * @param position The starting position to look at.
224     * @param lookDown Whether to look down for other positions.
225     * @return The next selectable position starting at position and then searching either up or
226     *         down. Returns {@link #INVALID_POSITION} if nothing can be found.
227     */
228    public int lookForSelectablePosition(int position, boolean lookDown) {
229        final ListAdapter adapter = getAdapter();
230        if (adapter == null || isInTouchMode()) {
231            return INVALID_POSITION;
232        }
233
234        final int count = adapter.getCount();
235        if (!getAdapter().areAllItemsEnabled()) {
236            if (lookDown) {
237                position = Math.max(0, position);
238                while (position < count && !adapter.isEnabled(position)) {
239                    position++;
240                }
241            } else {
242                position = Math.min(position, count - 1);
243                while (position >= 0 && !adapter.isEnabled(position)) {
244                    position--;
245                }
246            }
247
248            if (position < 0 || position >= count) {
249                return INVALID_POSITION;
250            }
251            return position;
252        } else {
253            if (position < 0 || position >= count) {
254                return INVALID_POSITION;
255            }
256            return position;
257        }
258    }
259
260    /**
261     * Measures the height of the given range of children (inclusive) and returns the height
262     * with this ListView's padding and divider heights included. If maxHeight is provided, the
263     * measuring will stop when the current height reaches maxHeight.
264     *
265     * @param widthMeasureSpec             The width measure spec to be given to a child's
266     *                                     {@link View#measure(int, int)}.
267     * @param startPosition                The position of the first child to be shown.
268     * @param endPosition                  The (inclusive) position of the last child to be
269     *                                     shown. Specify {@link #NO_POSITION} if the last child
270     *                                     should be the last available child from the adapter.
271     * @param maxHeight                    The maximum height that will be returned (if all the
272     *                                     children don't fit in this value, this value will be
273     *                                     returned).
274     * @param disallowPartialChildPosition In general, whether the returned height should only
275     *                                     contain entire children. This is more powerful--it is
276     *                                     the first inclusive position at which partial
277     *                                     children will not be allowed. Example: it looks nice
278     *                                     to have at least 3 completely visible children, and
279     *                                     in portrait this will most likely fit; but in
280     *                                     landscape there could be times when even 2 children
281     *                                     can not be completely shown, so a value of 2
282     *                                     (remember, inclusive) would be good (assuming
283     *                                     startPosition is 0).
284     * @return The height of this ListView with the given children.
285     */
286    public int measureHeightOfChildrenCompat(int widthMeasureSpec, int startPosition,
287            int endPosition, final int maxHeight,
288            int disallowPartialChildPosition) {
289
290        final int paddingTop = getListPaddingTop();
291        final int paddingBottom = getListPaddingBottom();
292        final int paddingLeft = getListPaddingLeft();
293        final int paddingRight = getListPaddingRight();
294        final int reportedDividerHeight = getDividerHeight();
295        final Drawable divider = getDivider();
296
297        final ListAdapter adapter = getAdapter();
298
299        if (adapter == null) {
300            return paddingTop + paddingBottom;
301        }
302
303        // Include the padding of the list
304        int returnedHeight = paddingTop + paddingBottom;
305        final int dividerHeight = ((reportedDividerHeight > 0) && divider != null)
306                ? reportedDividerHeight : 0;
307
308        // The previous height value that was less than maxHeight and contained
309        // no partial children
310        int prevHeightWithoutPartialChild = 0;
311
312        View child = null;
313        int viewType = 0;
314        int count = adapter.getCount();
315        for (int i = 0; i < count; i++) {
316            int newType = adapter.getItemViewType(i);
317            if (newType != viewType) {
318                child = null;
319                viewType = newType;
320            }
321            child = adapter.getView(i, child, this);
322
323            // Compute child height spec
324            int heightMeasureSpec;
325            ViewGroup.LayoutParams childLp = child.getLayoutParams();
326
327            if (childLp == null) {
328                childLp = generateDefaultLayoutParams();
329                child.setLayoutParams(childLp);
330            }
331
332            if (childLp.height > 0) {
333                heightMeasureSpec = MeasureSpec.makeMeasureSpec(childLp.height,
334                        MeasureSpec.EXACTLY);
335            } else {
336                heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
337            }
338            child.measure(widthMeasureSpec, heightMeasureSpec);
339
340            // Since this view was measured directly against the parent measure
341            // spec, we must measure it again before reuse.
342            child.forceLayout();
343
344            if (i > 0) {
345                // Count the divider for all but one child
346                returnedHeight += dividerHeight;
347            }
348
349            returnedHeight += child.getMeasuredHeight();
350
351            if (returnedHeight >= maxHeight) {
352                // We went over, figure out which height to return.  If returnedHeight >
353                // maxHeight, then the i'th position did not fit completely.
354                return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1)
355                        && (i > disallowPartialChildPosition) // We've past the min pos
356                        && (prevHeightWithoutPartialChild > 0) // We have a prev height
357                        && (returnedHeight != maxHeight) // i'th child did not fit completely
358                        ? prevHeightWithoutPartialChild
359                        : maxHeight;
360            }
361
362            if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) {
363                prevHeightWithoutPartialChild = returnedHeight;
364            }
365        }
366
367        // At this point, we went through the range of children, and they each
368        // completely fit, so return the returnedHeight
369        return returnedHeight;
370    }
371
372    private void setSelectorEnabled(boolean enabled) {
373        if (mSelector != null) {
374            mSelector.setEnabled(enabled);
375        }
376    }
377
378    private static class GateKeeperDrawable extends DrawableWrapper {
379        private boolean mEnabled;
380
381        GateKeeperDrawable(Drawable drawable) {
382            super(drawable);
383            mEnabled = true;
384        }
385
386        void setEnabled(boolean enabled) {
387            mEnabled = enabled;
388        }
389
390        @Override
391        public boolean setState(int[] stateSet) {
392            if (mEnabled) {
393                return super.setState(stateSet);
394            }
395            return false;
396        }
397
398        @Override
399        public void draw(Canvas canvas) {
400            if (mEnabled) {
401                super.draw(canvas);
402            }
403        }
404
405        @Override
406        public void setHotspot(float x, float y) {
407            if (mEnabled) {
408                super.setHotspot(x, y);
409            }
410        }
411
412        @Override
413        public void setHotspotBounds(int left, int top, int right, int bottom) {
414            if (mEnabled) {
415                super.setHotspotBounds(left, top, right, bottom);
416            }
417        }
418
419        @Override
420        public boolean setVisible(boolean visible, boolean restart) {
421            if (mEnabled) {
422                return super.setVisible(visible, restart);
423            }
424            return false;
425        }
426    }
427
428    @Override
429    public boolean onHoverEvent(@NonNull MotionEvent ev) {
430        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
431            // For SDK_INT prior to O the code below fails to change the selection.
432            // This is because prior to O mouse events used to enable touch mode, and
433            //  View.setSelectionFromTop does not do the right thing in touch mode.
434            return super.onHoverEvent(ev);
435        }
436
437        final int action = ev.getActionMasked();
438        if (action == MotionEvent.ACTION_HOVER_EXIT && mResolveHoverRunnable == null) {
439            // This may be transitioning to TOUCH_DOWN. Postpone drawable state
440            // updates until either the next frame or the next touch event.
441            mResolveHoverRunnable = new ResolveHoverRunnable();
442            mResolveHoverRunnable.post();
443        }
444
445        // Allow the super class to handle hover state management first.
446        final boolean handled = super.onHoverEvent(ev);
447        if (action == MotionEvent.ACTION_HOVER_ENTER
448                || action == MotionEvent.ACTION_HOVER_MOVE) {
449            final int position = pointToPosition((int) ev.getX(), (int) ev.getY());
450
451            if (position != INVALID_POSITION && position != getSelectedItemPosition()) {
452                final View hoveredItem = getChildAt(position - getFirstVisiblePosition());
453                if (hoveredItem.isEnabled()) {
454                    // Force a focus on the hovered item so that
455                    // the proper selector state gets used when we update.
456                    setSelectionFromTop(position, hoveredItem.getTop() - this.getTop());
457                }
458                updateSelectorStateCompat();
459            }
460        } else {
461            // Do not cancel the selected position if the selection is visible
462            // by other means.
463            setSelection(INVALID_POSITION);
464        }
465
466        return handled;
467    }
468
469    @Override
470    protected void onDetachedFromWindow() {
471        mResolveHoverRunnable = null;
472        super.onDetachedFromWindow();
473    }
474
475    /**
476     * Handles forwarded events.
477     *
478     * @param activePointerId id of the pointer that activated forwarding
479     * @return whether the event was handled
480     */
481    public boolean onForwardedEvent(MotionEvent event, int activePointerId) {
482        boolean handledEvent = true;
483        boolean clearPressedItem = false;
484
485        final int actionMasked = event.getActionMasked();
486        switch (actionMasked) {
487            case MotionEvent.ACTION_CANCEL:
488                handledEvent = false;
489                break;
490            case MotionEvent.ACTION_UP:
491                handledEvent = false;
492                // $FALL-THROUGH$
493            case MotionEvent.ACTION_MOVE:
494                final int activeIndex = event.findPointerIndex(activePointerId);
495                if (activeIndex < 0) {
496                    handledEvent = false;
497                    break;
498                }
499
500                final int x = (int) event.getX(activeIndex);
501                final int y = (int) event.getY(activeIndex);
502                final int position = pointToPosition(x, y);
503                if (position == INVALID_POSITION) {
504                    clearPressedItem = true;
505                    break;
506                }
507
508                final View child = getChildAt(position - getFirstVisiblePosition());
509                setPressedItem(child, position, x, y);
510                handledEvent = true;
511
512                if (actionMasked == MotionEvent.ACTION_UP) {
513                    clickPressedItem(child, position);
514                }
515                break;
516        }
517
518        // Failure to handle the event cancels forwarding.
519        if (!handledEvent || clearPressedItem) {
520            clearPressedItem();
521        }
522
523        // Manage automatic scrolling.
524        if (handledEvent) {
525            if (mScrollHelper == null) {
526                mScrollHelper = new ListViewAutoScrollHelper(this);
527            }
528            mScrollHelper.setEnabled(true);
529            mScrollHelper.onTouch(this, event);
530        } else if (mScrollHelper != null) {
531            mScrollHelper.setEnabled(false);
532        }
533
534        return handledEvent;
535    }
536
537    /**
538     * Starts an alpha animation on the selector. When the animation ends,
539     * the list performs a click on the item.
540     */
541    private void clickPressedItem(final View child, final int position) {
542        final long id = getItemIdAtPosition(position);
543        performItemClick(child, position, id);
544    }
545
546    /**
547     * Sets whether the list selection is hidden, as part of a workaround for a
548     * touch mode issue (see the declaration for mListSelectionHidden).
549     *
550     * @param hideListSelection {@code true} to hide list selection,
551     *                          {@code false} to show
552     */
553    void setListSelectionHidden(boolean hideListSelection) {
554        mListSelectionHidden = hideListSelection;
555    }
556
557    private void updateSelectorStateCompat() {
558        Drawable selector = getSelector();
559        if (selector != null && touchModeDrawsInPressedStateCompat() && isPressed()) {
560            selector.setState(getDrawableState());
561        }
562    }
563
564    private void drawSelectorCompat(Canvas canvas) {
565        if (!mSelectorRect.isEmpty()) {
566            final Drawable selector = getSelector();
567            if (selector != null) {
568                selector.setBounds(mSelectorRect);
569                selector.draw(canvas);
570            }
571        }
572    }
573
574    private void positionSelectorLikeTouchCompat(int position, View sel, float x, float y) {
575        positionSelectorLikeFocusCompat(position, sel);
576
577        Drawable selector = getSelector();
578        if (selector != null && position != INVALID_POSITION) {
579            DrawableCompat.setHotspot(selector, x, y);
580        }
581    }
582
583    private void positionSelectorLikeFocusCompat(int position, View sel) {
584        // If we're changing position, update the visibility since the selector
585        // is technically being detached from the previous selection.
586        final Drawable selector = getSelector();
587        final boolean manageState = selector != null && position != INVALID_POSITION;
588        if (manageState) {
589            selector.setVisible(false, false);
590        }
591
592        positionSelectorCompat(position, sel);
593
594        if (manageState) {
595            final Rect bounds = mSelectorRect;
596            final float x = bounds.exactCenterX();
597            final float y = bounds.exactCenterY();
598            selector.setVisible(getVisibility() == VISIBLE, false);
599            DrawableCompat.setHotspot(selector, x, y);
600        }
601    }
602
603    private void positionSelectorCompat(int position, View sel) {
604        final Rect selectorRect = mSelectorRect;
605        selectorRect.set(sel.getLeft(), sel.getTop(), sel.getRight(), sel.getBottom());
606
607        // Adjust for selection padding.
608        selectorRect.left -= mSelectionLeftPadding;
609        selectorRect.top -= mSelectionTopPadding;
610        selectorRect.right += mSelectionRightPadding;
611        selectorRect.bottom += mSelectionBottomPadding;
612
613        try {
614            // AbsListView.mIsChildViewEnabled controls the selector's state so we need to
615            // modify its value
616            final boolean isChildViewEnabled = mIsChildViewEnabled.getBoolean(this);
617            if (sel.isEnabled() != isChildViewEnabled) {
618                mIsChildViewEnabled.set(this, !isChildViewEnabled);
619                if (position != INVALID_POSITION) {
620                    refreshDrawableState();
621                }
622            }
623        } catch (IllegalAccessException e) {
624            e.printStackTrace();
625        }
626    }
627
628    private void clearPressedItem() {
629        mDrawsInPressedState = false;
630        setPressed(false);
631        // This will call through to updateSelectorState()
632        drawableStateChanged();
633
634        final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
635        if (motionView != null) {
636            motionView.setPressed(false);
637        }
638
639        if (mClickAnimation != null) {
640            mClickAnimation.cancel();
641            mClickAnimation = null;
642        }
643    }
644
645    private void setPressedItem(View child, int position, float x, float y) {
646        mDrawsInPressedState = true;
647
648        // Ordering is essential. First, update the container's pressed state.
649        if (Build.VERSION.SDK_INT >= 21) {
650            drawableHotspotChanged(x, y);
651        }
652        if (!isPressed()) {
653            setPressed(true);
654        }
655
656        // Next, run layout to stabilize child positions.
657        layoutChildren();
658
659        // Manage the pressed view based on motion position. This allows us to
660        // play nicely with actual touch and scroll events.
661        if (mMotionPosition != INVALID_POSITION) {
662            final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
663            if (motionView != null && motionView != child && motionView.isPressed()) {
664                motionView.setPressed(false);
665            }
666        }
667        mMotionPosition = position;
668
669        // Offset for child coordinates.
670        final float childX = x - child.getLeft();
671        final float childY = y - child.getTop();
672        if (Build.VERSION.SDK_INT >= 21) {
673            child.drawableHotspotChanged(childX, childY);
674        }
675        if (!child.isPressed()) {
676            child.setPressed(true);
677        }
678
679        // Ensure that keyboard focus starts from the last touched position.
680        positionSelectorLikeTouchCompat(position, child, x, y);
681
682        // This needs some explanation. We need to disable the selector for this next call
683        // due to the way that ListViewCompat works. Otherwise both ListView and ListViewCompat
684        // will draw the selector and bad things happen.
685        setSelectorEnabled(false);
686
687        // Refresh the drawable state to reflect the new pressed state,
688        // which will also update the selector state.
689        refreshDrawableState();
690    }
691
692    private boolean touchModeDrawsInPressedStateCompat() {
693        return mDrawsInPressedState;
694    }
695
696    /**
697     * Runnable that forces hover event resolution and updates drawable state.
698     */
699    private class ResolveHoverRunnable implements Runnable {
700        @Override
701        public void run() {
702            // Resolved hover event as standard hover exit.
703            mResolveHoverRunnable = null;
704            drawableStateChanged();
705        }
706
707        public void cancel() {
708            mResolveHoverRunnable = null;
709            removeCallbacks(this);
710        }
711
712        public void post() {
713            DropDownListView.this.post(this);
714        }
715    }
716}
717