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