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