PinnedHeaderListView.java revision ac14436a4db64f7e40cc5ba3fe40ced25a7f829d
1/*
2 * Copyright (C) 2010 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 com.android.contacts.widget;
18
19import android.content.Context;
20import android.graphics.Canvas;
21import android.graphics.Paint;
22import android.graphics.Rect;
23import android.graphics.RectF;
24import android.util.AttributeSet;
25import android.view.MotionEvent;
26import android.view.View;
27import android.view.ViewGroup;
28import android.widget.AbsListView;
29import android.widget.AbsListView.OnScrollListener;
30import android.widget.AdapterView;
31import android.widget.AdapterView.OnItemSelectedListener;
32import android.widget.ListAdapter;
33import android.widget.ListView;
34
35/**
36 * A ListView that maintains a header pinned at the top of the list. The
37 * pinned header can be pushed up and dissolved as needed.
38 */
39public class PinnedHeaderListView extends ListView
40        implements OnScrollListener, OnItemSelectedListener {
41
42    /**
43     * Adapter interface.  The list adapter must implement this interface.
44     */
45    public interface PinnedHeaderAdapter {
46
47        /**
48         * Returns the overall number of pinned headers, visible or not.
49         */
50        int getPinnedHeaderCount();
51
52        /**
53         * Creates or updates the pinned header view.
54         */
55        View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent);
56
57        /**
58         * Configures the pinned headers to match the visible list items. The
59         * adapter should call {@link PinnedHeaderListView#setHeaderPinnedAtTop},
60         * {@link PinnedHeaderListView#setHeaderPinnedAtBottom},
61         * {@link PinnedHeaderListView#setFadingHeader} or
62         * {@link PinnedHeaderListView#setHeaderInvisible}, for each header that
63         * needs to change its position or visibility.
64         */
65        void configurePinnedHeaders(PinnedHeaderListView listView);
66
67        /**
68         * Returns the list position to scroll to if the pinned header is touched.
69         * Return -1 if the list does not need to be scrolled.
70         */
71        int getScrollPositionForHeader(int viewIndex);
72    }
73
74    private static final int MAX_ALPHA = 255;
75    private static final int TOP = 0;
76    private static final int BOTTOM = 1;
77    private static final int FADING = 2;
78
79    private static final int DEFAULT_ANIMATION_DURATION = 100;
80
81    private static final class PinnedHeader {
82        View view;
83        boolean visible;
84        int y;
85        int height;
86        int alpha;
87        int state;
88
89        boolean animating;
90        boolean targetVisible;
91        int sourceY;
92        int targetY;
93        long targetTime;
94    }
95
96    private PinnedHeaderAdapter mAdapter;
97    private int mSize;
98    private PinnedHeader[] mHeaders;
99    private RectF mBounds = new RectF();
100    private Rect mClipRect = new Rect();
101    private OnScrollListener mOnScrollListener;
102    private OnItemSelectedListener mOnItemSelectedListener;
103    private int mScrollState;
104
105    private int mAnimationDuration = DEFAULT_ANIMATION_DURATION;
106    private boolean mAnimating;
107    private long mAnimationTargetTime;
108
109    public PinnedHeaderListView(Context context) {
110        this(context, null, com.android.internal.R.attr.listViewStyle);
111    }
112
113    public PinnedHeaderListView(Context context, AttributeSet attrs) {
114        this(context, attrs, com.android.internal.R.attr.listViewStyle);
115    }
116
117    public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) {
118        super(context, attrs, defStyle);
119        super.setOnScrollListener(this);
120        super.setOnItemSelectedListener(this);
121    }
122
123    public void setPinnedHeaderAnimationDuration(int duration) {
124        mAnimationDuration = duration;
125    }
126
127    @Override
128    public void setAdapter(ListAdapter adapter) {
129        mAdapter = (PinnedHeaderAdapter)adapter;
130        super.setAdapter(adapter);
131    }
132
133    @Override
134    public void setOnScrollListener(OnScrollListener onScrollListener) {
135        mOnScrollListener = onScrollListener;
136        super.setOnScrollListener(this);
137    }
138
139    @Override
140    public void setOnItemSelectedListener(OnItemSelectedListener listener) {
141        mOnItemSelectedListener = listener;
142        super.setOnItemSelectedListener(this);
143    }
144
145    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
146            int totalItemCount) {
147        if (mAdapter != null) {
148            int count = mAdapter.getPinnedHeaderCount();
149            if (count != mSize) {
150                mSize = count;
151                if (mHeaders == null) {
152                    mHeaders = new PinnedHeader[mSize];
153                } else if (mHeaders.length < mSize) {
154                    PinnedHeader[] headers = mHeaders;
155                    mHeaders = new PinnedHeader[mSize];
156                    System.arraycopy(headers, 0, mHeaders, 0, headers.length);
157                }
158            }
159
160            for (int i = 0; i < mSize; i++) {
161                if (mHeaders[i] == null) {
162                    mHeaders[i] = new PinnedHeader();
163                }
164                mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this);
165            }
166
167            // Disable vertical fading when the pinned header is present
168            // TODO change ListView to allow separate measures for top and bottom fading edge;
169            // in this particular case we would like to disable the top, but not the bottom edge.
170            if (mSize > 0) {
171                setFadingEdgeLength(0);
172            }
173
174            mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration;
175            mAdapter.configurePinnedHeaders(this);
176            invalidateIfAnimating();
177
178        }
179        if (mOnScrollListener != null) {
180            mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount);
181        }
182    }
183
184    public void onScrollStateChanged(AbsListView view, int scrollState) {
185        mScrollState = scrollState;
186        if (mOnScrollListener != null) {
187            mOnScrollListener.onScrollStateChanged(this, scrollState);
188        }
189    }
190
191    /**
192     * Ensures that the selected item is positioned below the top-pinned headers
193     * and above the bottom-pinned ones.
194     */
195    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
196        int height = getHeight();
197
198        int windowTop = 0;
199        int windowBottom = height;
200
201        int prevHeaderBottom = 0;
202        for (int i = 0; i < mSize; i++) {
203            PinnedHeader header = mHeaders[i];
204            if (header.visible) {
205                if (header.state == TOP) {
206                    windowTop = header.y + header.height;
207                } else if (header.state == BOTTOM) {
208                    windowBottom = header.y;
209                    break;
210                }
211            }
212        }
213
214        View selectedView = getSelectedView();
215        if (selectedView != null) {
216            if (selectedView.getTop() < windowTop) {
217                setSelectionFromTop(position, windowTop);
218            } else if (selectedView.getBottom() > windowBottom) {
219                setSelectionFromTop(position, windowBottom - selectedView.getHeight());
220            }
221        }
222
223        if (mOnItemSelectedListener != null) {
224            mOnItemSelectedListener.onItemSelected(parent, view, position, id);
225        }
226    }
227
228    public void onNothingSelected(AdapterView<?> parent) {
229        if (mOnItemSelectedListener != null) {
230            mOnItemSelectedListener.onNothingSelected(parent);
231        }
232    }
233
234    public int getPinnedHeaderHeight(int viewIndex) {
235        ensurePinnedHeaderLayout(viewIndex);
236        return mHeaders[viewIndex].view.getHeight();
237    }
238
239    /**
240     * Set header to be pinned at the top.
241     *
242     * @param viewIndex index of the header view
243     * @param y is position of the header in pixels.
244     * @param animate true if the transition to the new coordinate should be animated
245     */
246    public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) {
247        ensurePinnedHeaderLayout(viewIndex);
248        PinnedHeader header = mHeaders[viewIndex];
249        header.visible = true;
250        header.y = y;
251        header.state = TOP;
252
253        // TODO perhaps we should animate at the top as well
254        header.animating = false;
255    }
256
257    /**
258     * Set header to be pinned at the bottom.
259     *
260     * @param viewIndex index of the header view
261     * @param y is position of the header in pixels.
262     * @param animate true if the transition to the new coordinate should be animated
263     */
264    public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) {
265        ensurePinnedHeaderLayout(viewIndex);
266        PinnedHeader header = mHeaders[viewIndex];
267        header.state = BOTTOM;
268        if (header.animating) {
269            header.targetTime = mAnimationTargetTime;
270            header.sourceY = header.y;
271            header.targetY = y;
272        } else if (animate && (header.y != y || !header.visible)) {
273            if (header.visible) {
274                header.sourceY = header.y;
275            } else {
276                header.visible = true;
277                header.sourceY = y + header.height;
278            }
279            header.animating = true;
280            header.targetVisible = true;
281            header.targetTime = mAnimationTargetTime;
282            header.targetY = y;
283        } else {
284            header.visible = true;
285            header.y = y;
286        }
287    }
288
289    /**
290     * Set header to be pinned at the top of the first visible item.
291     *
292     * @param viewIndex index of the header view
293     * @param position is position of the header in pixels.
294     */
295    public void setFadingHeader(int viewIndex, int position, boolean fade) {
296        ensurePinnedHeaderLayout(viewIndex);
297
298        View child = getChildAt(position - getFirstVisiblePosition());
299
300        PinnedHeader header = mHeaders[viewIndex];
301        header.visible = true;
302        header.state = FADING;
303        header.alpha = MAX_ALPHA;
304        header.animating = false;
305
306        int top = getTotalTopPinnedHeaderHeight();
307        header.y = top;
308        if (fade) {
309            int bottom = child.getBottom() - top;
310            int headerHeight = header.height;
311            if (bottom < headerHeight) {
312                int portion = bottom - headerHeight;
313                header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight;
314                header.y = top + portion;
315            }
316        }
317    }
318
319    /**
320     * Makes header invisible.
321     *
322     * @param viewIndex index of the header view
323     * @param animate true if the transition to the new coordinate should be animated
324     */
325    public void setHeaderInvisible(int viewIndex, boolean animate) {
326        PinnedHeader header = mHeaders[viewIndex];
327        if (header.visible && (animate || header.animating) && header.state == BOTTOM) {
328            header.sourceY = header.y;
329            if (!header.animating) {
330                header.visible = true;
331                header.targetY = getBottom() + header.height;
332            }
333            header.animating = true;
334            header.targetTime = mAnimationTargetTime;
335            header.targetVisible = false;
336        } else {
337            header.visible = false;
338        }
339    }
340
341    private void ensurePinnedHeaderLayout(int viewIndex) {
342        View view = mHeaders[viewIndex].view;
343        if (view.isLayoutRequested()) {
344            int widthSpec = MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY);
345            int heightSpec;
346            int lpHeight = view.getLayoutParams().height;
347            if (lpHeight > 0) {
348                heightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
349            } else {
350                heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
351            }
352            view.measure(widthSpec, heightSpec);
353            int height = view.getMeasuredHeight();
354            mHeaders[viewIndex].height = height;
355            view.layout(0, 0, view.getMeasuredWidth(), height);
356        }
357    }
358
359    /**
360     * Returns the sum of heights of headers pinned to the top.
361     */
362    public int getTotalTopPinnedHeaderHeight() {
363        for (int i = mSize; --i >= 0;) {
364            PinnedHeader header = mHeaders[i];
365            if (header.visible && header.state == TOP) {
366                return header.y + header.height;
367            }
368        }
369        return 0;
370    }
371
372    /**
373     * Returns the list item position at the specified y coordinate.
374     */
375    public int getPositionAt(int y) {
376        do {
377            int position = pointToPosition(0, y);
378            if (position != -1) {
379                return position;
380            }
381            // If position == -1, we must have hit a separator. Let's examine
382            // a nearby pixel
383            y--;
384        } while (y > 0);
385        return 0;
386    }
387
388    @Override
389    public boolean onInterceptTouchEvent(MotionEvent ev) {
390        if (mScrollState == SCROLL_STATE_IDLE) {
391            final int y = (int)ev.getY();
392            for (int i = mSize; --i >= 0;) {
393                PinnedHeader header = mHeaders[i];
394                if (header.visible && header.y <= y && header.y + header.height > y) {
395                    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
396                        return smoothScrollToPartition(i);
397                    } else {
398                        return true;
399                    }
400                }
401            }
402        }
403
404        return super.onInterceptTouchEvent(ev);
405    }
406
407    private boolean smoothScrollToPartition(int partition) {
408        final int position = mAdapter.getScrollPositionForHeader(partition);
409        if (position == -1) {
410            return false;
411        }
412
413        int offset = 0;
414        for (int i = 0; i < partition; i++) {
415            PinnedHeader header = mHeaders[i];
416            if (header.visible) {
417                offset += header.height;
418            }
419        }
420
421        smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset);
422        return true;
423    }
424
425    private void invalidateIfAnimating() {
426        mAnimating = false;
427        for (int i = 0; i < mSize; i++) {
428            if (mHeaders[i].animating) {
429                mAnimating = true;
430                invalidate();
431                return;
432            }
433        }
434    }
435
436    @Override
437    protected void dispatchDraw(Canvas canvas) {
438        long currentTime = mAnimating ? System.currentTimeMillis() : 0;
439
440        int top = 0;
441        int bottom = getBottom();
442        boolean hasVisibleHeaders = false;
443        for (int i = 0; i < mSize; i++) {
444            PinnedHeader header = mHeaders[i];
445            if (header.visible) {
446                hasVisibleHeaders = true;
447                if (header.state == BOTTOM && header.y < bottom) {
448                    bottom = header.y;
449                } else if (header.state == TOP || header.state == FADING) {
450                    int newTop = header.y + header.height;
451                    if (newTop > top) {
452                        top = newTop;
453                    }
454                }
455            }
456        }
457
458        if (hasVisibleHeaders) {
459            canvas.save();
460            mClipRect.set(0, top, getWidth(), bottom);
461            canvas.clipRect(mClipRect);
462        }
463
464        super.dispatchDraw(canvas);
465
466        if (hasVisibleHeaders) {
467            canvas.restore();
468
469            // First draw top headers, then the bottom ones to handle the Z axis correctly
470            for (int i = mSize; --i >= 0;) {
471                PinnedHeader header = mHeaders[i];
472                if (header.visible && (header.state == TOP || header.state == FADING)) {
473                    drawHeader(canvas, header, currentTime);
474                }
475            }
476
477            for (int i = 0; i < mSize; i++) {
478                PinnedHeader header = mHeaders[i];
479                if (header.visible && header.state == BOTTOM) {
480                    drawHeader(canvas, header, currentTime);
481                }
482            }
483        }
484
485        invalidateIfAnimating();
486    }
487
488    private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) {
489        if (header.animating) {
490            int timeLeft = (int)(header.targetTime - currentTime);
491            if (timeLeft <= 0) {
492                header.y = header.targetY;
493                header.visible = header.targetVisible;
494                header.animating = false;
495            } else {
496                header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft
497                        / mAnimationDuration;
498            }
499        }
500        if (header.visible) {
501            View view = header.view;
502            int saveCount = canvas.save();
503            canvas.translate(0, header.y);
504            if (header.state == FADING) {
505                mBounds.set(0, 0, view.getWidth(), view.getHeight());
506                canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG);
507            }
508            view.draw(canvas);
509            canvas.restoreToCount(saveCount);
510        }
511    }
512}
513