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