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 class PinnedHeader {
     80         View view;
     81         boolean visible;
     82         int y;
     83         int height;
     84         int alpha;
     85         int state;
     86 
     87         boolean animating;
     88         boolean targetVisible;
     89         int sourceY;
     90         int targetY;
     91         long targetTime;
     92     }
     93 
     94     private PinnedHeaderAdapter mAdapter;
     95     private int mSize;
     96     private PinnedHeader[] mHeaders;
     97     private RectF mBounds = new RectF();
     98     private Rect mClipRect = new Rect();
     99     private OnScrollListener mOnScrollListener;
    100     private OnItemSelectedListener mOnItemSelectedListener;
    101     private int mScrollState;
    102 
    103     private int mAnimationDuration = DEFAULT_ANIMATION_DURATION;
    104     private boolean mAnimating;
    105     private long mAnimationTargetTime;
    106     private int mHeaderPaddingStart;
    107     private int mHeaderWidth;
    108 
    109     public PinnedHeaderListView(Context context) {
    110         this(context, null, com.android.internal.R.attr.listViewStyle);
    111     }
    112 
    113     public PinnedHeaderListView(Context context, AttributeSet attrs) {
    114         this(context, attrs, com.android.internal.R.attr.listViewStyle);
    115     }
    116 
    117     public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) {
    118         super(context, attrs, defStyle);
    119         super.setOnScrollListener(this);
    120         super.setOnItemSelectedListener(this);
    121     }
    122 
    123     @Override
    124     protected void onLayout(boolean changed, int l, int t, int r, int b) {
    125         super.onLayout(changed, l, t, r, b);
    126         mHeaderPaddingStart = getPaddingStart();
    127         mHeaderWidth = r - l - mHeaderPaddingStart - getPaddingEnd();
    128     }
    129 
    130     public void setPinnedHeaderAnimationDuration(int duration) {
    131         mAnimationDuration = duration;
    132     }
    133 
    134     @Override
    135     public void setAdapter(ListAdapter adapter) {
    136         mAdapter = (PinnedHeaderAdapter)adapter;
    137         super.setAdapter(adapter);
    138     }
    139 
    140     @Override
    141     public void setOnScrollListener(OnScrollListener onScrollListener) {
    142         mOnScrollListener = onScrollListener;
    143         super.setOnScrollListener(this);
    144     }
    145 
    146     @Override
    147     public void setOnItemSelectedListener(OnItemSelectedListener listener) {
    148         mOnItemSelectedListener = listener;
    149         super.setOnItemSelectedListener(this);
    150     }
    151 
    152     @Override
    153     public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
    154             int totalItemCount) {
    155         if (mAdapter != null) {
    156             int count = mAdapter.getPinnedHeaderCount();
    157             if (count != mSize) {
    158                 mSize = count;
    159                 if (mHeaders == null) {
    160                     mHeaders = new PinnedHeader[mSize];
    161                 } else if (mHeaders.length < mSize) {
    162                     PinnedHeader[] headers = mHeaders;
    163                     mHeaders = new PinnedHeader[mSize];
    164                     System.arraycopy(headers, 0, mHeaders, 0, headers.length);
    165                 }
    166             }
    167 
    168             for (int i = 0; i < mSize; i++) {
    169                 if (mHeaders[i] == null) {
    170                     mHeaders[i] = new PinnedHeader();
    171                 }
    172                 mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this);
    173             }
    174 
    175             mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration;
    176             mAdapter.configurePinnedHeaders(this);
    177             invalidateIfAnimating();
    178 
    179         }
    180         if (mOnScrollListener != null) {
    181             mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount);
    182         }
    183     }
    184 
    185     @Override
    186     protected float getTopFadingEdgeStrength() {
    187         // Disable vertical fading at the top when the pinned header is present
    188         return mSize > 0 ? 0 : super.getTopFadingEdgeStrength();
    189     }
    190 
    191     @Override
    192     public void onScrollStateChanged(AbsListView view, int scrollState) {
    193         mScrollState = scrollState;
    194         if (mOnScrollListener != null) {
    195             mOnScrollListener.onScrollStateChanged(this, scrollState);
    196         }
    197     }
    198 
    199     /**
    200      * Ensures that the selected item is positioned below the top-pinned headers
    201      * and above the bottom-pinned ones.
    202      */
    203     @Override
    204     public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
    205         int height = getHeight();
    206 
    207         int windowTop = 0;
    208         int windowBottom = height;
    209 
    210         for (int i = 0; i < mSize; i++) {
    211             PinnedHeader header = mHeaders[i];
    212             if (header.visible) {
    213                 if (header.state == TOP) {
    214                     windowTop = header.y + header.height;
    215                 } else if (header.state == BOTTOM) {
    216                     windowBottom = header.y;
    217                     break;
    218                 }
    219             }
    220         }
    221 
    222         View selectedView = getSelectedView();
    223         if (selectedView != null) {
    224             if (selectedView.getTop() < windowTop) {
    225                 setSelectionFromTop(position, windowTop);
    226             } else if (selectedView.getBottom() > windowBottom) {
    227                 setSelectionFromTop(position, windowBottom - selectedView.getHeight());
    228             }
    229         }
    230 
    231         if (mOnItemSelectedListener != null) {
    232             mOnItemSelectedListener.onItemSelected(parent, view, position, id);
    233         }
    234     }
    235 
    236     @Override
    237     public void onNothingSelected(AdapterView<?> parent) {
    238         if (mOnItemSelectedListener != null) {
    239             mOnItemSelectedListener.onNothingSelected(parent);
    240         }
    241     }
    242 
    243     public int getPinnedHeaderHeight(int viewIndex) {
    244         ensurePinnedHeaderLayout(viewIndex);
    245         return mHeaders[viewIndex].view.getHeight();
    246     }
    247 
    248     /**
    249      * Set header to be pinned at the top.
    250      *
    251      * @param viewIndex index of the header view
    252      * @param y is position of the header in pixels.
    253      * @param animate true if the transition to the new coordinate should be animated
    254      */
    255     public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) {
    256         ensurePinnedHeaderLayout(viewIndex);
    257         PinnedHeader header = mHeaders[viewIndex];
    258         header.visible = true;
    259         header.y = y;
    260         header.state = TOP;
    261 
    262         // TODO perhaps we should animate at the top as well
    263         header.animating = false;
    264     }
    265 
    266     /**
    267      * Set header to be pinned at the bottom.
    268      *
    269      * @param viewIndex index of the header view
    270      * @param y is position of the header in pixels.
    271      * @param animate true if the transition to the new coordinate should be animated
    272      */
    273     public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) {
    274         ensurePinnedHeaderLayout(viewIndex);
    275         PinnedHeader header = mHeaders[viewIndex];
    276         header.state = BOTTOM;
    277         if (header.animating) {
    278             header.targetTime = mAnimationTargetTime;
    279             header.sourceY = header.y;
    280             header.targetY = y;
    281         } else if (animate && (header.y != y || !header.visible)) {
    282             if (header.visible) {
    283                 header.sourceY = header.y;
    284             } else {
    285                 header.visible = true;
    286                 header.sourceY = y + header.height;
    287             }
    288             header.animating = true;
    289             header.targetVisible = true;
    290             header.targetTime = mAnimationTargetTime;
    291             header.targetY = y;
    292         } else {
    293             header.visible = true;
    294             header.y = y;
    295         }
    296     }
    297 
    298     /**
    299      * Set header to be pinned at the top of the first visible item.
    300      *
    301      * @param viewIndex index of the header view
    302      * @param position is position of the header in pixels.
    303      */
    304     public void setFadingHeader(int viewIndex, int position, boolean fade) {
    305         ensurePinnedHeaderLayout(viewIndex);
    306 
    307         View child = getChildAt(position - getFirstVisiblePosition());
    308         if (child == null) return;
    309 
    310         PinnedHeader header = mHeaders[viewIndex];
    311         header.visible = true;
    312         header.state = FADING;
    313         header.alpha = MAX_ALPHA;
    314         header.animating = false;
    315 
    316         int top = getTotalTopPinnedHeaderHeight();
    317         header.y = top;
    318         if (fade) {
    319             int bottom = child.getBottom() - top;
    320             int headerHeight = header.height;
    321             if (bottom < headerHeight) {
    322                 int portion = bottom - headerHeight;
    323                 header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight;
    324                 header.y = top + portion;
    325             }
    326         }
    327     }
    328 
    329     /**
    330      * Makes header invisible.
    331      *
    332      * @param viewIndex index of the header view
    333      * @param animate true if the transition to the new coordinate should be animated
    334      */
    335     public void setHeaderInvisible(int viewIndex, boolean animate) {
    336         PinnedHeader header = mHeaders[viewIndex];
    337         if (header.visible && (animate || header.animating) && header.state == BOTTOM) {
    338             header.sourceY = header.y;
    339             if (!header.animating) {
    340                 header.visible = true;
    341                 header.targetY = getBottom() + header.height;
    342             }
    343             header.animating = true;
    344             header.targetTime = mAnimationTargetTime;
    345             header.targetVisible = false;
    346         } else {
    347             header.visible = false;
    348         }
    349     }
    350 
    351     private void ensurePinnedHeaderLayout(int viewIndex) {
    352         View view = mHeaders[viewIndex].view;
    353         if (view.isLayoutRequested()) {
    354             int widthSpec = View.MeasureSpec.makeMeasureSpec(mHeaderWidth, View.MeasureSpec.EXACTLY);
    355             int heightSpec;
    356             ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
    357             if (layoutParams != null && layoutParams.height > 0) {
    358                 heightSpec = View.MeasureSpec
    359                         .makeMeasureSpec(layoutParams.height, View.MeasureSpec.EXACTLY);
    360             } else {
    361                 heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
    362             }
    363             view.measure(widthSpec, heightSpec);
    364             int height = view.getMeasuredHeight();
    365             mHeaders[viewIndex].height = height;
    366             view.layout(0, 0, mHeaderWidth, height);
    367         }
    368     }
    369 
    370     /**
    371      * Returns the sum of heights of headers pinned to the top.
    372      */
    373     public int getTotalTopPinnedHeaderHeight() {
    374         for (int i = mSize; --i >= 0;) {
    375             PinnedHeader header = mHeaders[i];
    376             if (header.visible && header.state == TOP) {
    377                 return header.y + header.height;
    378             }
    379         }
    380         return 0;
    381     }
    382 
    383     /**
    384      * Returns the list item position at the specified y coordinate.
    385      */
    386     public int getPositionAt(int y) {
    387         do {
    388             int position = pointToPosition(getPaddingLeft() + 1, y);
    389             if (position != -1) {
    390                 return position;
    391             }
    392             // If position == -1, we must have hit a separator. Let's examine
    393             // a nearby pixel
    394             y--;
    395         } while (y > 0);
    396         return 0;
    397     }
    398 
    399     @Override
    400     public boolean onInterceptTouchEvent(MotionEvent ev) {
    401         if (mScrollState == SCROLL_STATE_IDLE) {
    402             final int y = (int)ev.getY();
    403             for (int i = mSize; --i >= 0;) {
    404                 PinnedHeader header = mHeaders[i];
    405                 if (header.visible && header.y <= y && header.y + header.height > y) {
    406                     if (ev.getAction() == MotionEvent.ACTION_DOWN) {
    407                         return smoothScrollToPartition(i);
    408                     } else {
    409                         return true;
    410                     }
    411                 }
    412             }
    413         }
    414 
    415         return super.onInterceptTouchEvent(ev);
    416     }
    417 
    418     private boolean smoothScrollToPartition(int partition) {
    419         final int position = mAdapter.getScrollPositionForHeader(partition);
    420         if (position == -1) {
    421             return false;
    422         }
    423 
    424         int offset = 0;
    425         for (int i = 0; i < partition; i++) {
    426             PinnedHeader header = mHeaders[i];
    427             if (header.visible) {
    428                 offset += header.height;
    429             }
    430         }
    431 
    432         smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset);
    433         return true;
    434     }
    435 
    436     private void invalidateIfAnimating() {
    437         mAnimating = false;
    438         for (int i = 0; i < mSize; i++) {
    439             if (mHeaders[i].animating) {
    440                 mAnimating = true;
    441                 invalidate();
    442                 return;
    443             }
    444         }
    445     }
    446 
    447     @Override
    448     protected void dispatchDraw(Canvas canvas) {
    449         long currentTime = mAnimating ? System.currentTimeMillis() : 0;
    450 
    451         int top = 0;
    452         int bottom = getBottom();
    453         boolean hasVisibleHeaders = false;
    454         for (int i = 0; i < mSize; i++) {
    455             PinnedHeader header = mHeaders[i];
    456             if (header.visible) {
    457                 hasVisibleHeaders = true;
    458                 if (header.state == BOTTOM && header.y < bottom) {
    459                     bottom = header.y;
    460                 } else if (header.state == TOP || header.state == FADING) {
    461                     int newTop = header.y + header.height;
    462                     if (newTop > top) {
    463                         top = newTop;
    464                     }
    465                 }
    466             }
    467         }
    468 
    469         if (hasVisibleHeaders) {
    470             canvas.save();
    471             mClipRect.set(0, top, getWidth(), bottom);
    472             canvas.clipRect(mClipRect);
    473         }
    474 
    475         super.dispatchDraw(canvas);
    476 
    477         if (hasVisibleHeaders) {
    478             canvas.restore();
    479 
    480             // First draw top headers, then the bottom ones to handle the Z axis correctly
    481             for (int i = mSize; --i >= 0;) {
    482                 PinnedHeader header = mHeaders[i];
    483                 if (header.visible && (header.state == TOP || header.state == FADING)) {
    484                     drawHeader(canvas, header, currentTime);
    485                 }
    486             }
    487 
    488             for (int i = 0; i < mSize; i++) {
    489                 PinnedHeader header = mHeaders[i];
    490                 if (header.visible && header.state == BOTTOM) {
    491                     drawHeader(canvas, header, currentTime);
    492                 }
    493             }
    494         }
    495 
    496         invalidateIfAnimating();
    497     }
    498 
    499     private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) {
    500         if (header.animating) {
    501             int timeLeft = (int)(header.targetTime - currentTime);
    502             if (timeLeft <= 0) {
    503                 header.y = header.targetY;
    504                 header.visible = header.targetVisible;
    505                 header.animating = false;
    506             } else {
    507                 header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft
    508                         / mAnimationDuration;
    509             }
    510         }
    511         if (header.visible) {
    512             View view = header.view;
    513             int saveCount = canvas.save();
    514             canvas.translate(isLayoutRtl() ?
    515                     getWidth() - mHeaderPaddingStart - mHeaderWidth : mHeaderPaddingStart,
    516                     header.y);
    517             if (header.state == FADING) {
    518                 mBounds.set(0, 0, mHeaderWidth, view.getHeight());
    519                 canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG);
    520             }
    521             view.draw(canvas);
    522             canvas.restoreToCount(saveCount);
    523         }
    524     }
    525 }
    526