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.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 import android.widget.TextView;
32 
33 import 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  */
39 public 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          */
getPinnedHeaderCount()50         int getPinnedHeaderCount();
51 
52         /**
53          * Creates or updates the pinned header view.
54          */
getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent)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          */
configurePinnedHeaders(PinnedHeaderListView listView)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          */
getScrollPositionForHeader(int viewIndex)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 
PinnedHeaderListView(Context context)115     public PinnedHeaderListView(Context context) {
116         this(context, null, android.R.attr.listViewStyle);
117     }
118 
PinnedHeaderListView(Context context, AttributeSet attrs)119     public PinnedHeaderListView(Context context, AttributeSet attrs) {
120         this(context, attrs, android.R.attr.listViewStyle);
121     }
122 
PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle)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
onLayout(boolean changed, int l, int t, int r, int b)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
setAdapter(ListAdapter adapter)137     public void setAdapter(ListAdapter adapter) {
138         mAdapter = (PinnedHeaderAdapter)adapter;
139         super.setAdapter(adapter);
140     }
141 
142     @Override
setOnScrollListener(OnScrollListener onScrollListener)143     public void setOnScrollListener(OnScrollListener onScrollListener) {
144         mOnScrollListener = onScrollListener;
145         super.setOnScrollListener(this);
146     }
147 
148     @Override
setOnItemSelectedListener(OnItemSelectedListener listener)149     public void setOnItemSelectedListener(OnItemSelectedListener listener) {
150         mOnItemSelectedListener = listener;
151         super.setOnItemSelectedListener(this);
152     }
153 
setScrollToSectionOnHeaderTouch(boolean value)154     public void setScrollToSectionOnHeaderTouch(boolean value) {
155         mScrollToSectionOnHeaderTouch = value;
156     }
157 
158     @Override
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)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
getTopFadingEdgeStrength()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
onScrollStateChanged(AbsListView view, int scrollState)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
onItemSelected(AdapterView<?> parent, View view, int position, long id)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
onNothingSelected(AdapterView<?> parent)242     public void onNothingSelected(AdapterView<?> parent) {
243         if (mOnItemSelectedListener != null) {
244             mOnItemSelectedListener.onNothingSelected(parent);
245         }
246     }
247 
getPinnedHeaderHeight(int viewIndex)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      */
setHeaderPinnedAtTop(int viewIndex, int y, boolean animate)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      */
setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate)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      */
setFadingHeader(int viewIndex, int position, boolean fade)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      */
setHeaderInvisible(int viewIndex, boolean animate)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 
ensurePinnedHeaderLayout(int viewIndex)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      */
getTotalTopPinnedHeaderHeight()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      */
getPositionAt(int y)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
onInterceptTouchEvent(MotionEvent ev)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
onTouchEvent(MotionEvent ev)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 
smoothScrollToPartition(int partition)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 
invalidateIfAnimating()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
dispatchDraw(Canvas canvas)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 
drawHeader(Canvas canvas, PinnedHeader header, long currentTime)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);
576             }
577             view.draw(canvas);
578             canvas.restoreToCount(saveCount);
579         }
580     }
581 }
582