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.MotionEventCompat;
22import android.support.v4.view.ViewPropertyAnimatorCompat;
23import android.support.v4.widget.ListViewAutoScrollHelper;
24import android.support.v7.appcompat.R;
25import android.view.MotionEvent;
26import android.view.View;
27
28/**
29 * <p>Wrapper class for a ListView. This wrapper can hijack the focus to
30 * make sure the list uses the appropriate drawables and states when
31 * displayed on screen within a drop down. The focus is never actually
32 * passed to the drop down in this mode; the list only looks focused.</p>
33 */
34class DropDownListView extends ListViewCompat {
35
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    /** Current drag-to-open click animation, if any. */
75    private ViewPropertyAnimatorCompat mClickAnimation;
76
77    /** Helper for drag-to-open auto scrolling. */
78    private ListViewAutoScrollHelper mScrollHelper;
79
80    /**
81     * <p>Creates a new list view wrapper.</p>
82     *
83     * @param context this view's context
84     */
85    public DropDownListView(Context context, boolean hijackFocus) {
86        super(context, null, R.attr.dropDownListViewStyle);
87        mHijackFocus = hijackFocus;
88        setCacheColorHint(0); // Transparent, since the background drawable could be anything.
89    }
90
91    /**
92     * Handles forwarded events.
93     *
94     * @param activePointerId id of the pointer that activated forwarding
95     * @return whether the event was handled
96     */
97    public boolean onForwardedEvent(MotionEvent event, int activePointerId) {
98        boolean handledEvent = true;
99        boolean clearPressedItem = false;
100
101        final int actionMasked = MotionEventCompat.getActionMasked(event);
102        switch (actionMasked) {
103            case MotionEvent.ACTION_CANCEL:
104                handledEvent = false;
105                break;
106            case MotionEvent.ACTION_UP:
107                handledEvent = false;
108                // $FALL-THROUGH$
109            case MotionEvent.ACTION_MOVE:
110                final int activeIndex = event.findPointerIndex(activePointerId);
111                if (activeIndex < 0) {
112                    handledEvent = false;
113                    break;
114                }
115
116                final int x = (int) event.getX(activeIndex);
117                final int y = (int) event.getY(activeIndex);
118                final int position = pointToPosition(x, y);
119                if (position == INVALID_POSITION) {
120                    clearPressedItem = true;
121                    break;
122                }
123
124                final View child = getChildAt(position - getFirstVisiblePosition());
125                setPressedItem(child, position, x, y);
126                handledEvent = true;
127
128                if (actionMasked == MotionEvent.ACTION_UP) {
129                    clickPressedItem(child, position);
130                }
131                break;
132        }
133
134        // Failure to handle the event cancels forwarding.
135        if (!handledEvent || clearPressedItem) {
136            clearPressedItem();
137        }
138
139        // Manage automatic scrolling.
140        if (handledEvent) {
141            if (mScrollHelper == null) {
142                mScrollHelper = new ListViewAutoScrollHelper(this);
143            }
144            mScrollHelper.setEnabled(true);
145            mScrollHelper.onTouch(this, event);
146        } else if (mScrollHelper != null) {
147            mScrollHelper.setEnabled(false);
148        }
149
150        return handledEvent;
151    }
152
153    /**
154     * Starts an alpha animation on the selector. When the animation ends,
155     * the list performs a click on the item.
156     */
157    private void clickPressedItem(final View child, final int position) {
158        final long id = getItemIdAtPosition(position);
159        performItemClick(child, position, id);
160    }
161
162    /**
163     * Sets whether the list selection is hidden, as part of a workaround for a
164     * touch mode issue (see the declaration for mListSelectionHidden).
165     *
166     * @param hideListSelection {@code true} to hide list selection,
167     *                          {@code false} to show
168     */
169    void setListSelectionHidden(boolean hideListSelection) {
170        mListSelectionHidden = hideListSelection;
171    }
172
173    private void clearPressedItem() {
174        mDrawsInPressedState = false;
175        setPressed(false);
176        // This will call through to updateSelectorState()
177        drawableStateChanged();
178
179        final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
180        if (motionView != null) {
181            motionView.setPressed(false);
182        }
183
184        if (mClickAnimation != null) {
185            mClickAnimation.cancel();
186            mClickAnimation = null;
187        }
188    }
189
190    private void setPressedItem(View child, int position, float x, float y) {
191        mDrawsInPressedState = true;
192
193        // Ordering is essential. First, update the container's pressed state.
194        if (Build.VERSION.SDK_INT >= 21) {
195            drawableHotspotChanged(x, y);
196        }
197        if (!isPressed()) {
198            setPressed(true);
199        }
200
201        // Next, run layout to stabilize child positions.
202        layoutChildren();
203
204        // Manage the pressed view based on motion position. This allows us to
205        // play nicely with actual touch and scroll events.
206        if (mMotionPosition != INVALID_POSITION) {
207            final View motionView = getChildAt(mMotionPosition - getFirstVisiblePosition());
208            if (motionView != null && motionView != child && motionView.isPressed()) {
209                motionView.setPressed(false);
210            }
211        }
212        mMotionPosition = position;
213
214        // Offset for child coordinates.
215        final float childX = x - child.getLeft();
216        final float childY = y - child.getTop();
217        if (Build.VERSION.SDK_INT >= 21) {
218            child.drawableHotspotChanged(childX, childY);
219        }
220        if (!child.isPressed()) {
221            child.setPressed(true);
222        }
223
224        // Ensure that keyboard focus starts from the last touched position.
225        positionSelectorLikeTouchCompat(position, child, x, y);
226
227        // This needs some explanation. We need to disable the selector for this next call
228        // due to the way that ListViewCompat works. Otherwise both ListView and ListViewCompat
229        // will draw the selector and bad things happen.
230        setSelectorEnabled(false);
231
232        // Refresh the drawable state to reflect the new pressed state,
233        // which will also update the selector state.
234        refreshDrawableState();
235    }
236
237    @Override
238    protected boolean touchModeDrawsInPressedStateCompat() {
239        return mDrawsInPressedState || super.touchModeDrawsInPressedStateCompat();
240    }
241
242    @Override
243    public boolean isInTouchMode() {
244        // WARNING: Please read the comment where mListSelectionHidden is declared
245        return (mHijackFocus && mListSelectionHidden) || super.isInTouchMode();
246    }
247
248    /**
249     * <p>Returns the focus state in the drop down.</p>
250     *
251     * @return true always if hijacking focus
252     */
253    @Override
254    public boolean hasWindowFocus() {
255        return mHijackFocus || super.hasWindowFocus();
256    }
257
258    /**
259     * <p>Returns the focus state in the drop down.</p>
260     *
261     * @return true always if hijacking focus
262     */
263    @Override
264    public boolean isFocused() {
265        return mHijackFocus || super.isFocused();
266    }
267
268    /**
269     * <p>Returns the focus state in the drop down.</p>
270     *
271     * @return true always if hijacking focus
272     */
273    @Override
274    public boolean hasFocus() {
275        return mHijackFocus || super.hasFocus();
276    }
277}
278