Home | History | Annotate | Download | only in list
      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 import 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  */
     37 public 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);
    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