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