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.common.list;
18
19import android.content.Context;
20import android.graphics.Canvas;
21import android.graphics.RectF;
22import android.util.AttributeSet;
23import android.view.MotionEvent;
24import android.view.View;
25import android.view.ViewGroup;
26import android.widget.AbsListView;
27import android.widget.AbsListView.OnScrollListener;
28import android.widget.AdapterView;
29import android.widget.AdapterView.OnItemSelectedListener;
30import android.widget.ListAdapter;
31
32import com.android.contacts.common.util.ViewUtil;
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 AutoScrollListView
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 = 20;
79
80    private static final int DEFAULT_SMOOTH_SCROLL_DURATION = 100;
81
82    private static final class PinnedHeader {
83        View view;
84        boolean visible;
85        int y;
86        int height;
87        int alpha;
88        int state;
89
90        boolean animating;
91        boolean targetVisible;
92        int sourceY;
93        int targetY;
94        long targetTime;
95    }
96
97    private PinnedHeaderAdapter mAdapter;
98    private int mSize;
99    private PinnedHeader[] mHeaders;
100    private RectF mBounds = new RectF();
101    private OnScrollListener mOnScrollListener;
102    private OnItemSelectedListener mOnItemSelectedListener;
103    private int mScrollState;
104
105    private boolean mScrollToSectionOnHeaderTouch = false;
106    private boolean mHeaderTouched = false;
107
108    private int mAnimationDuration = DEFAULT_ANIMATION_DURATION;
109    private boolean mAnimating;
110    private long mAnimationTargetTime;
111    private int mHeaderPaddingStart;
112    private int mHeaderWidth;
113
114    public PinnedHeaderListView(Context context) {
115        this(context, null, android.R.attr.listViewStyle);
116    }
117
118    public PinnedHeaderListView(Context context, AttributeSet attrs) {
119        this(context, attrs, android.R.attr.listViewStyle);
120    }
121
122    public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) {
123        super(context, attrs, defStyle);
124        super.setOnScrollListener(this);
125        super.setOnItemSelectedListener(this);
126    }
127
128    @Override
129    protected void onLayout(boolean changed, int l, int t, int r, int b) {
130        super.onLayout(changed, l, t, r, b);
131        mHeaderPaddingStart = getPaddingStart();
132        mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd();
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 setScrollToSectionOnHeaderTouch(boolean value) {
154        mScrollToSectionOnHeaderTouch = value;
155    }
156
157    @Override
158    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
159            int totalItemCount) {
160        if (mAdapter != null) {
161            int count = mAdapter.getPinnedHeaderCount();
162            if (count != mSize) {
163                mSize = count;
164                if (mHeaders == null) {
165                    mHeaders = new PinnedHeader[mSize];
166                } else if (mHeaders.length < mSize) {
167                    PinnedHeader[] headers = mHeaders;
168                    mHeaders = new PinnedHeader[mSize];
169                    System.arraycopy(headers, 0, mHeaders, 0, headers.length);
170                }
171            }
172
173            for (int i = 0; i < mSize; i++) {
174                if (mHeaders[i] == null) {
175                    mHeaders[i] = new PinnedHeader();
176                }
177                mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this);
178            }
179
180            mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration;
181            mAdapter.configurePinnedHeaders(this);
182            invalidateIfAnimating();
183        }
184        if (mOnScrollListener != null) {
185            mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount);
186        }
187    }
188
189    @Override
190    protected float getTopFadingEdgeStrength() {
191        // Disable vertical fading at the top when the pinned header is present
192        return mSize > 0 ? 0 : super.getTopFadingEdgeStrength();
193    }
194
195    @Override
196    public void onScrollStateChanged(AbsListView view, int scrollState) {
197        mScrollState = scrollState;
198        if (mOnScrollListener != null) {
199            mOnScrollListener.onScrollStateChanged(this, scrollState);
200        }
201    }
202
203    /**
204     * Ensures that the selected item is positioned below the top-pinned headers
205     * and above the bottom-pinned ones.
206     */
207    @Override
208    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
209        int height = getHeight();
210
211        int windowTop = 0;
212        int windowBottom = height;
213
214        for (int i = 0; i < mSize; i++) {
215            PinnedHeader header = mHeaders[i];
216            if (header.visible) {
217                if (header.state == TOP) {
218                    windowTop = header.y + header.height;
219                } else if (header.state == BOTTOM) {
220                    windowBottom = header.y;
221                    break;
222                }
223            }
224        }
225
226        View selectedView = getSelectedView();
227        if (selectedView != null) {
228            if (selectedView.getTop() < windowTop) {
229                setSelectionFromTop(position, windowTop);
230            } else if (selectedView.getBottom() > windowBottom) {
231                setSelectionFromTop(position, windowBottom - selectedView.getHeight());
232            }
233        }
234
235        if (mOnItemSelectedListener != null) {
236            mOnItemSelectedListener.onItemSelected(parent, view, position, id);
237        }
238    }
239
240    @Override
241    public void onNothingSelected(AdapterView<?> parent) {
242        if (mOnItemSelectedListener != null) {
243            mOnItemSelectedListener.onNothingSelected(parent);
244        }
245    }
246
247    public int getPinnedHeaderHeight(int viewIndex) {
248        ensurePinnedHeaderLayout(viewIndex);
249        return mHeaders[viewIndex].view.getHeight();
250    }
251
252    /**
253     * Set header to be pinned at the top.
254     *
255     * @param viewIndex index of the header view
256     * @param y is position of the header in pixels.
257     * @param animate true if the transition to the new coordinate should be animated
258     */
259    public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) {
260        ensurePinnedHeaderLayout(viewIndex);
261        PinnedHeader header = mHeaders[viewIndex];
262        header.visible = true;
263        header.y = y;
264        header.state = TOP;
265
266        // TODO perhaps we should animate at the top as well
267        header.animating = false;
268    }
269
270    /**
271     * Set header to be pinned at the bottom.
272     *
273     * @param viewIndex index of the header view
274     * @param y is position of the header in pixels.
275     * @param animate true if the transition to the new coordinate should be animated
276     */
277    public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) {
278        ensurePinnedHeaderLayout(viewIndex);
279        PinnedHeader header = mHeaders[viewIndex];
280        header.state = BOTTOM;
281        if (header.animating) {
282            header.targetTime = mAnimationTargetTime;
283            header.sourceY = header.y;
284            header.targetY = y;
285        } else if (animate && (header.y != y || !header.visible)) {
286            if (header.visible) {
287                header.sourceY = header.y;
288            } else {
289                header.visible = true;
290                header.sourceY = y + header.height;
291            }
292            header.animating = true;
293            header.targetVisible = true;
294            header.targetTime = mAnimationTargetTime;
295            header.targetY = y;
296        } else {
297            header.visible = true;
298            header.y = y;
299        }
300    }
301
302    /**
303     * Set header to be pinned at the top of the first visible item.
304     *
305     * @param viewIndex index of the header view
306     * @param position is position of the header in pixels.
307     */
308    public void setFadingHeader(int viewIndex, int position, boolean fade) {
309        ensurePinnedHeaderLayout(viewIndex);
310
311        View child = getChildAt(position - getFirstVisiblePosition());
312        if (child == null) return;
313
314        PinnedHeader header = mHeaders[viewIndex];
315        header.visible = true;
316        header.state = FADING;
317        header.alpha = MAX_ALPHA;
318        header.animating = false;
319
320        int top = getTotalTopPinnedHeaderHeight();
321        header.y = top;
322        if (fade) {
323            int bottom = child.getBottom() - top;
324            int headerHeight = header.height;
325            if (bottom < headerHeight) {
326                int portion = bottom - headerHeight;
327                header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight;
328                header.y = top + portion;
329            }
330        }
331    }
332
333    /**
334     * Makes header invisible.
335     *
336     * @param viewIndex index of the header view
337     * @param animate true if the transition to the new coordinate should be animated
338     */
339    public void setHeaderInvisible(int viewIndex, boolean animate) {
340        PinnedHeader header = mHeaders[viewIndex];
341        if (header.visible && (animate || header.animating) && header.state == BOTTOM) {
342            header.sourceY = header.y;
343            if (!header.animating) {
344                header.visible = true;
345                header.targetY = getBottom() + header.height;
346            }
347            header.animating = true;
348            header.targetTime = mAnimationTargetTime;
349            header.targetVisible = false;
350        } else {
351            header.visible = false;
352        }
353    }
354
355    private void ensurePinnedHeaderLayout(int viewIndex) {
356        View view = mHeaders[viewIndex].view;
357        if (view.isLayoutRequested()) {
358            ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
359            int widthSpec;
360            int heightSpec;
361
362            if (layoutParams != null && layoutParams.width > 0) {
363                widthSpec = View.MeasureSpec
364                        .makeMeasureSpec(layoutParams.width, View.MeasureSpec.EXACTLY);
365            } else {
366                widthSpec = View.MeasureSpec
367                        .makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY);
368            }
369
370            if (layoutParams != null && layoutParams.height > 0) {
371                heightSpec = View.MeasureSpec
372                        .makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY);
373            } else {
374                heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
375            }
376            view.measure(widthSpec, heightSpec);
377            int height = view.getMeasuredHeight();
378            mHeaders[viewIndex].height = height;
379            view.layout(0, 0, view.getMeasuredWidth(), height);
380        }
381    }
382
383    /**
384     * Returns the sum of heights of headers pinned to the top.
385     */
386    public int getTotalTopPinnedHeaderHeight() {
387        for (int i = mSize; --i >= 0;) {
388            PinnedHeader header = mHeaders[i];
389            if (header.visible && header.state == TOP) {
390                return header.y + header.height;
391            }
392        }
393        return 0;
394    }
395
396    /**
397     * Returns the list item position at the specified y coordinate.
398     */
399    public int getPositionAt(int y) {
400        do {
401            int position = pointToPosition(getPaddingLeft() + 1, y);
402            if (position != -1) {
403                return position;
404            }
405            // If position == -1, we must have hit a separator. Let's examine
406            // a nearby pixel
407            y--;
408        } while (y > 0);
409        return 0;
410    }
411
412    @Override
413    public boolean onInterceptTouchEvent(MotionEvent ev) {
414        mHeaderTouched = false;
415        if (super.onInterceptTouchEvent(ev)) {
416            return true;
417        }
418
419        if (mScrollState == SCROLL_STATE_IDLE) {
420            final int y = (int)ev.getY();
421            final int x = (int)ev.getX();
422            for (int i = mSize; --i >= 0;) {
423                PinnedHeader header = mHeaders[i];
424                // For RTL layouts, this also takes into account that the scrollbar is on the left
425                // side.
426                final int padding = getPaddingLeft();
427                if (header.visible && header.y <= y && header.y + header.height > y &&
428                        x >= padding && padding + header.view.getWidth() >= x) {
429                    mHeaderTouched = true;
430                    if (mScrollToSectionOnHeaderTouch &&
431                            ev.getAction() == MotionEvent.ACTION_DOWN) {
432                        return smoothScrollToPartition(i);
433                    } else {
434                        return true;
435                    }
436                }
437            }
438        }
439
440        return false;
441    }
442
443    @Override
444    public boolean onTouchEvent(MotionEvent ev) {
445        if (mHeaderTouched) {
446            if (ev.getAction() == MotionEvent.ACTION_UP) {
447                mHeaderTouched = false;
448            }
449            return true;
450        }
451        return super.onTouchEvent(ev);
452    };
453
454    private boolean smoothScrollToPartition(int partition) {
455        if (mAdapter == null) {
456            return false;
457        }
458        final int position = mAdapter.getScrollPositionForHeader(partition);
459        if (position == -1) {
460            return false;
461        }
462
463        int offset = 0;
464        for (int i = 0; i < partition; i++) {
465            PinnedHeader header = mHeaders[i];
466            if (header.visible) {
467                offset += header.height;
468            }
469        }
470        smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset,
471                DEFAULT_SMOOTH_SCROLL_DURATION);
472        return true;
473    }
474
475    private void invalidateIfAnimating() {
476        mAnimating = false;
477        for (int i = 0; i < mSize; i++) {
478            if (mHeaders[i].animating) {
479                mAnimating = true;
480                invalidate();
481                return;
482            }
483        }
484    }
485
486    @Override
487    protected void dispatchDraw(Canvas canvas) {
488        long currentTime = mAnimating ? System.currentTimeMillis() : 0;
489
490        int top = 0;
491        int right = 0;
492        int bottom = getBottom();
493        boolean hasVisibleHeaders = false;
494        for (int i = 0; i < mSize; i++) {
495            PinnedHeader header = mHeaders[i];
496            if (header.visible) {
497                hasVisibleHeaders = true;
498                if (header.state == BOTTOM && header.y < bottom) {
499                    bottom = header.y;
500                } else if (header.state == TOP || header.state == FADING) {
501                    int newTop = header.y + header.height;
502                    if (newTop > top) {
503                        top = newTop;
504                    }
505                }
506            }
507        }
508
509        if (hasVisibleHeaders) {
510            canvas.save();
511        }
512
513        super.dispatchDraw(canvas);
514
515        if (hasVisibleHeaders) {
516            canvas.restore();
517
518            // If the first item is visible and if it has a positive top that is greater than the
519            // first header's assigned y-value, use that for the first header's y value. This way,
520            // the header inherits any padding applied to the list view.
521            if (mSize > 0 && getFirstVisiblePosition() == 0) {
522                View firstChild = getChildAt(0);
523                PinnedHeader firstHeader = mHeaders[0];
524
525                if (firstHeader != null) {
526                    int firstHeaderTop = firstChild != null ? firstChild.getTop() : 0;
527                    firstHeader.y = Math.max(firstHeader.y, firstHeaderTop);
528                }
529            }
530
531            // First draw top headers, then the bottom ones to handle the Z axis correctly
532            for (int i = mSize; --i >= 0;) {
533                PinnedHeader header = mHeaders[i];
534                if (header.visible && (header.state == TOP || header.state == FADING)) {
535                    drawHeader(canvas, header, currentTime);
536                }
537            }
538
539            for (int i = 0; i < mSize; i++) {
540                PinnedHeader header = mHeaders[i];
541                if (header.visible && header.state == BOTTOM) {
542                    drawHeader(canvas, header, currentTime);
543                }
544            }
545        }
546
547        invalidateIfAnimating();
548    }
549
550    private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) {
551        if (header.animating) {
552            int timeLeft = (int)(header.targetTime - currentTime);
553            if (timeLeft <= 0) {
554                header.y = header.targetY;
555                header.visible = header.targetVisible;
556                header.animating = false;
557            } else {
558                header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft
559                        / mAnimationDuration;
560            }
561        }
562        if (header.visible) {
563            View view = header.view;
564            int saveCount = canvas.save();
565            int translateX = ViewUtil.isViewLayoutRtl(this) ?
566                    getWidth() - mHeaderPaddingStart - view.getWidth() :
567                    mHeaderPaddingStart;
568            canvas.translate(translateX, header.y);
569            if (header.state == FADING) {
570                mBounds.set(0, 0, view.getWidth(), view.getHeight());
571                canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG);
572            }
573            view.draw(canvas);
574            canvas.restoreToCount(saveCount);
575        }
576    }
577}
578