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