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 android.support.v7.widget;
18
19import android.content.Context;
20import android.os.Build;
21import android.support.v4.view.ViewPropertyAnimatorCompat;
22import android.support.v4.widget.ListViewAutoScrollHelper;
23import android.support.v7.appcompat.R;
24import android.view.MotionEvent;
25import android.view.View;
26
27/**
28 * <p>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.</p>
32 */
33class DropDownListView extends ListViewCompat {
34
35    /*
36    * WARNING: This is a workaround for a touch mode issue.
37    *
38    * Touch mode is propagated lazily to windows. This causes problems in
39    * the following scenario:
40    * - Type something in the AutoCompleteTextView and get some results
41    * - Move down with the d-pad to select an item in the list
42    * - Move up with the d-pad until the selection disappears
43    * - Type more text in the AutoCompleteTextView *using the soft keyboard*
44    *   and get new results; you are now in touch mode
45    * - The selection comes back on the first item in the list, even though
46    *   the list is supposed to be in touch mode
47    *
48    * Using the soft keyboard triggers the touch mode change but that change
49    * is propagated to our window only after the first list layout, therefore
50    * after the list attempts to resurrect the selection.
51    *
52    * The trick to work around this issue is to pretend the list is in touch
53    * mode when we know that the selection should not appear, that is when
54    * we know the user moved the selection away from the list.
55    *
56    * This boolean is set to true whenever we explicitly hide the list's
57    * selection and reset to false whenever we know the user moved the
58    * selection back to the list.
59    *
60    * When this boolean is true, isInTouchMode() returns true, otherwise it
61    * returns super.isInTouchMode().
62    */
63    private boolean mListSelectionHidden;
64
65    /**
66     * True if this wrapper should fake focus.
67     */
68    private boolean mHijackFocus;
69
70    /** Whether to force drawing of the pressed state selector. */
71    private boolean mDrawsInPressedState;
72
73    /** Current drag-to-open click animation, if any. */
74    private ViewPropertyAnimatorCompat mClickAnimation;
75
76    /** Helper for drag-to-open auto scrolling. */
77    private ListViewAutoScrollHelper mScrollHelper;
78
79    /**
80     * <p>Creates a new list view wrapper.</p>
81     *
82     * @param context this view's context
83     */
84    public DropDownListView(Context context, boolean hijackFocus) {
85        super(context, null, R.attr.dropDownListViewStyle);
86        mHijackFocus = hijackFocus;
87        setCacheColorHint(0); // Transparent, since the background drawable could be anything.
88    }
89
90    /**
91     * Handles forwarded events.
92     *
93     * @param activePointerId id of the pointer that activated forwarding
94     * @return whether the event was handled
95     */
96    public boolean onForwardedEvent(MotionEvent event, int activePointerId) {
97        boolean handledEvent = true;
98        boolean clearPressedItem = false;
99
100        final int actionMasked = event.getActionMasked();
101        switch (actionMasked) {
102            case MotionEvent.ACTION_CANCEL:
103                handledEvent = false;
104                break;
105            case MotionEvent.ACTION_UP:
106                handledEvent = false;
107                // $FALL-THROUGH$
108            case MotionEvent.ACTION_MOVE:
109                final int activeIndex = event.findPointerIndex(activePointerId);
110                if (activeIndex < 0) {
111                    handledEvent = false;
112                    break;
113                }
114
115                final int x = (int) event.getX(activeIndex);
116                final int y = (int) event.getY(activeIndex);
117                final int position = pointToPosition(x, y);
118                if (position == INVALID_POSITION) {
119                    clearPressedItem = true;
120                    break;
121                }
122
123                final View child = getChildAt(position - getFirstVisiblePosition());
124                setPressedItem(child, position, x, y);
125                handledEvent = true;
126
127                if (actionMasked == MotionEvent.ACTION_UP) {
128                    clickPressedItem(child, position);
129                }
130                break;
131        }
132
133        // Failure to handle the event cancels forwarding.
134        if (!handledEvent || clearPressedItem) {
135            clearPressedItem();
136        }
137
138        // Manage automatic scrolling.
139        if (handledEvent) {
140            if (mScrollHelper == null) {
141                mScrollHelper = new ListViewAutoScrollHelper(this);
142            }
143            mScrollHelper.setEnabled(true);
144            mScrollHelper.onTouch(this, event);
145        } else if (mScrollHelper != null) {
146            mScrollHelper.setEnabled(false);
147        }
148
149        return handledEvent;
150    }
151
152    /**
153     * Starts an alpha animation on the selector. When the animation ends,
154     * the list performs a click on the item.
155     */
156    private void clickPressedItem(final View child, final int position) {
157        final long id = getItemIdAtPosition(position);
158        performItemClick(child, position, id);
159    }
160
161    /**
162     * Sets whether the list selection is hidden, as part of a workaround for a
163     * touch mode issue (see the declaration for mListSelectionHidden).
164     *
165     * @param hideListSelection {@code true} to hide list selection,
166     *                          {@code false} to show
167     */
168    void setListSelectionHidden(boolean hideListSelection) {
169        mListSelectionHidden = hideListSelection;
170    }
171
172    private void clearPressedItem() {
173        mDrawsInPressedState = false;
174        setPressed(false);
175        // This will call through to updateSelectorState()
176        drawableStateChanged();
177
178        final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
179        if (motionView != null) {
180            motionView.setPressed(false);
181        }
182
183        if (mClickAnimation != null) {
184            mClickAnimation.cancel();
185            mClickAnimation = null;
186        }
187    }
188
189    private void setPressedItem(View child, int position, float x, float y) {
190        mDrawsInPressedState = true;
191
192        // Ordering is essential. First, update the container's pressed state.
193        if (Build.VERSION.SDK_INT >= 21) {
194            drawableHotspotChanged(x, y);
195        }
196        if (!isPressed()) {
197            setPressed(true);
198        }
199
200        // Next, run layout to stabilize child positions.
201        layoutChildren();
202
203        // Manage the pressed view based on motion position. This allows us to
204        // play nicely with actual touch and scroll events.
205        if (mMotionPosition != INVALID_POSITION) {
206            final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
207            if (motionView != null && motionView != child && motionView.isPressed()) {
208                motionView.setPressed(false);
209            }
210        }
211        mMotionPosition = position;
212
213        // Offset for child coordinates.
214        final float childX = x - child.getLeft();
215        final float childY = y - child.getTop();
216        if (Build.VERSION.SDK_INT >= 21) {
217            child.drawableHotspotChanged(childX, childY);
218        }
219        if (!child.isPressed()) {
220            child.setPressed(true);
221        }
222
223        // Ensure that keyboard focus starts from the last touched position.
224        positionSelectorLikeTouchCompat(position, child, x, y);
225
226        // This needs some explanation. We need to disable the selector for this next call
227        // due to the way that ListViewCompat works. Otherwise both ListView and ListViewCompat
228        // will draw the selector and bad things happen.
229        setSelectorEnabled(false);
230
231        // Refresh the drawable state to reflect the new pressed state,
232        // which will also update the selector state.
233        refreshDrawableState();
234    }
235
236    @Override
237    protected boolean touchModeDrawsInPressedStateCompat() {
238        return mDrawsInPressedState || super.touchModeDrawsInPressedStateCompat();
239    }
240
241    @Override
242    public boolean isInTouchMode() {
243        // WARNING: Please read the comment where mListSelectionHidden is declared
244        return (mHijackFocus && mListSelectionHidden) || super.isInTouchMode();
245    }
246
247    /**
248     * <p>Returns the focus state in the drop down.</p>
249     *
250     * @return true always if hijacking focus
251     */
252    @Override
253    public boolean hasWindowFocus() {
254        return mHijackFocus || super.hasWindowFocus();
255    }
256
257    /**
258     * <p>Returns the focus state in the drop down.</p>
259     *
260     * @return true always if hijacking focus
261     */
262    @Override
263    public boolean isFocused() {
264        return mHijackFocus || super.isFocused();
265    }
266
267    /**
268     * <p>Returns the focus state in the drop down.</p>
269     *
270     * @return true always if hijacking focus
271     */
272    @Override
273    public boolean hasFocus() {
274        return mHijackFocus || super.hasFocus();
275    }
276}
277