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