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