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 
     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          */
     49         int getPinnedHeaderCount();
     50 
     51         /**
     52          * Creates or updates the pinned header view.
     53          */
     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          */
     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          */
     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 
    114     public PinnedHeaderListView(Context context) {
    115         this(context, null, android.R.attr.listViewStyle);
    116     }
    117 
    118     public PinnedHeaderListView(Context context, AttributeSet attrs) {
    119         this(context, attrs, android.R.attr.listViewStyle);
    120     }
    121 
    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
    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
    136     public void setAdapter(ListAdapter adapter) {
    137         mAdapter = (PinnedHeaderAdapter)adapter;
    138         super.setAdapter(adapter);
    139     }
    140 
    141     @Override
    142     public void setOnScrollListener(OnScrollListener onScrollListener) {
    143         mOnScrollListener = onScrollListener;
    144         super.setOnScrollListener(this);
    145     }
    146 
    147     @Override
    148     public void setOnItemSelectedListener(OnItemSelectedListener listener) {
    149         mOnItemSelectedListener = listener;
    150         super.setOnItemSelectedListener(this);
    151     }
    152 
    153     public void setScrollToSectionOnHeaderTouch(boolean value) {
    154         mScrollToSectionOnHeaderTouch = value;
    155     }
    156 
    157     @Override
    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
    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
    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
    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
    241     public void onNothingSelected(AdapterView<?> parent) {
    242         if (mOnItemSelectedListener != null) {
    243             mOnItemSelectedListener.onNothingSelected(parent);
    244         }
    245     }
    246 
    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      */
    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      */
    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      */
    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      */
    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 
    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      */
    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      */
    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
    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
    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 
    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 
    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
    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 
    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