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 
17 package com.android.contacts.common.list;
18 
19 import android.content.Context;
20 import android.graphics.Canvas;
21 import android.graphics.RectF;
22 import android.util.AttributeSet;
23 import android.view.MotionEvent;
24 import android.view.View;
25 import android.view.ViewGroup;
26 import android.widget.AbsListView;
27 import android.widget.AbsListView.OnScrollListener;
28 import android.widget.AdapterView;
29 import android.widget.AdapterView.OnItemSelectedListener;
30 import android.widget.ListAdapter;
31 
32 import 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  */
38 public 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          */
getPinnedHeaderCount()49         int getPinnedHeaderCount();
50 
51         /**
52          * Creates or updates the pinned header view.
53          */
getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent)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          */
configurePinnedHeaders(PinnedHeaderListView listView)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          */
getScrollPositionForHeader(int viewIndex)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 
PinnedHeaderListView(Context context)114     public PinnedHeaderListView(Context context) {
115         this(context, null, android.R.attr.listViewStyle);
116     }
117 
PinnedHeaderListView(Context context, AttributeSet attrs)118     public PinnedHeaderListView(Context context, AttributeSet attrs) {
119         this(context, attrs, android.R.attr.listViewStyle);
120     }
121 
PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle)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
onLayout(boolean changed, int l, int t, int r, int b)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
setAdapter(ListAdapter adapter)136     public void setAdapter(ListAdapter adapter) {
137         mAdapter = (PinnedHeaderAdapter)adapter;
138         super.setAdapter(adapter);
139     }
140 
141     @Override
setOnScrollListener(OnScrollListener onScrollListener)142     public void setOnScrollListener(OnScrollListener onScrollListener) {
143         mOnScrollListener = onScrollListener;
144         super.setOnScrollListener(this);
145     }
146 
147     @Override
setOnItemSelectedListener(OnItemSelectedListener listener)148     public void setOnItemSelectedListener(OnItemSelectedListener listener) {
149         mOnItemSelectedListener = listener;
150         super.setOnItemSelectedListener(this);
151     }
152 
setScrollToSectionOnHeaderTouch(boolean value)153     public void setScrollToSectionOnHeaderTouch(boolean value) {
154         mScrollToSectionOnHeaderTouch = value;
155     }
156 
157     @Override
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)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
getTopFadingEdgeStrength()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
onScrollStateChanged(AbsListView view, int scrollState)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
onItemSelected(AdapterView<?> parent, View view, int position, long id)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
onNothingSelected(AdapterView<?> parent)241     public void onNothingSelected(AdapterView<?> parent) {
242         if (mOnItemSelectedListener != null) {
243             mOnItemSelectedListener.onNothingSelected(parent);
244         }
245     }
246 
getPinnedHeaderHeight(int viewIndex)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      */
setHeaderPinnedAtTop(int viewIndex, int y, boolean animate)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      */
setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate)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      */
setFadingHeader(int viewIndex, int position, boolean fade)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      */
setHeaderInvisible(int viewIndex, boolean animate)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 
ensurePinnedHeaderLayout(int viewIndex)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      */
getTotalTopPinnedHeaderHeight()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      */
getPositionAt(int y)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
onInterceptTouchEvent(MotionEvent ev)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
onTouchEvent(MotionEvent ev)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 
smoothScrollToPartition(int partition)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 
invalidateIfAnimating()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
dispatchDraw(Canvas canvas)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 
drawHeader(Canvas canvas, PinnedHeader header, long currentTime)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