PinnedHeaderListView.java revision 5245ea63b4cca18ee504b27abd534fc13d33dea9
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 17package com.android.contacts.widget; 18 19import android.content.Context; 20import android.graphics.Canvas; 21import android.graphics.Rect; 22import android.graphics.RectF; 23import android.util.AttributeSet; 24import android.view.MotionEvent; 25import android.view.View; 26import android.view.ViewGroup; 27import android.widget.AbsListView; 28import android.widget.AbsListView.OnScrollListener; 29import android.widget.AdapterView; 30import android.widget.AdapterView.OnItemSelectedListener; 31import android.widget.ListAdapter; 32import android.widget.ListView; 33 34/** 35 * A ListView that maintains a header pinned at the top of the list. The 36 * pinned header can be pushed up and dissolved as needed. 37 */ 38public class PinnedHeaderListView extends ListView 39 implements OnScrollListener, OnItemSelectedListener { 40 41 /** 42 * Adapter interface. The list adapter must implement this interface. 43 */ 44 public interface PinnedHeaderAdapter { 45 46 /** 47 * Returns the overall number of pinned headers, visible or not. 48 */ 49 int getPinnedHeaderCount(); 50 51 /** 52 * Creates or updates the pinned header view. 53 */ 54 View getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent); 55 56 /** 57 * Configures the pinned headers to match the visible list items. The 58 * adapter should call {@link PinnedHeaderListView#setHeaderPinnedAtTop}, 59 * {@link PinnedHeaderListView#setHeaderPinnedAtBottom}, 60 * {@link PinnedHeaderListView#setFadingHeader} or 61 * {@link PinnedHeaderListView#setHeaderInvisible}, for each header that 62 * needs to change its position or visibility. 63 */ 64 void configurePinnedHeaders(PinnedHeaderListView listView); 65 66 /** 67 * Returns the list position to scroll to if the pinned header is touched. 68 * Return -1 if the list does not need to be scrolled. 69 */ 70 int getScrollPositionForHeader(int viewIndex); 71 } 72 73 private static final int MAX_ALPHA = 255; 74 private static final int TOP = 0; 75 private static final int BOTTOM = 1; 76 private static final int FADING = 2; 77 78 private static final int DEFAULT_ANIMATION_DURATION = 100; 79 80 private static final class PinnedHeader { 81 View view; 82 boolean visible; 83 int y; 84 int height; 85 int alpha; 86 int state; 87 88 boolean animating; 89 boolean targetVisible; 90 int sourceY; 91 int targetY; 92 long targetTime; 93 } 94 95 private PinnedHeaderAdapter mAdapter; 96 private int mSize; 97 private PinnedHeader[] mHeaders; 98 private RectF mBounds = new RectF(); 99 private Rect mClipRect = new Rect(); 100 private OnScrollListener mOnScrollListener; 101 private OnItemSelectedListener mOnItemSelectedListener; 102 private int mScrollState; 103 104 private int mAnimationDuration = DEFAULT_ANIMATION_DURATION; 105 private boolean mAnimating; 106 private long mAnimationTargetTime; 107 private int mHeaderPaddingLeft; 108 private int mHeaderWidth; 109 110 public PinnedHeaderListView(Context context) { 111 this(context, null, com.android.internal.R.attr.listViewStyle); 112 } 113 114 public PinnedHeaderListView(Context context, AttributeSet attrs) { 115 this(context, attrs, com.android.internal.R.attr.listViewStyle); 116 } 117 118 public PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle) { 119 super(context, attrs, defStyle); 120 super.setOnScrollListener(this); 121 super.setOnItemSelectedListener(this); 122 } 123 124 @Override 125 protected void onLayout(boolean changed, int l, int t, int r, int b) { 126 super.onLayout(changed, l, t, r, b); 127 mHeaderPaddingLeft = getPaddingLeft(); 128 mHeaderWidth = r - l - mHeaderPaddingLeft - getPaddingRight(); 129 } 130 131 public void setPinnedHeaderAnimationDuration(int duration) { 132 mAnimationDuration = duration; 133 } 134 135 @Override 136 public void setAdapter(ListAdapter adapter) { 137 mAdapter = (PinnedHeaderAdapter)adapter; 138 super.setAdapter(adapter); 139 } 140 141 @Override 142 public void setOnScrollListener(OnScrollListener onScrollListener) { 143 mOnScrollListener = onScrollListener; 144 super.setOnScrollListener(this); 145 } 146 147 @Override 148 public void setOnItemSelectedListener(OnItemSelectedListener listener) { 149 mOnItemSelectedListener = listener; 150 super.setOnItemSelectedListener(this); 151 } 152 153 public void 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 // Disable vertical fading when the pinned header is present 176 // TODO change ListView to allow separate measures for top and bottom fading edge; 177 // in this particular case we would like to disable the top, but not the bottom edge. 178 if (mSize > 0) { 179 setFadingEdgeLength(0); 180 } 181 182 mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration; 183 mAdapter.configurePinnedHeaders(this); 184 invalidateIfAnimating(); 185 186 } 187 if (mOnScrollListener != null) { 188 mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount); 189 } 190 } 191 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 public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { 204 int height = getHeight(); 205 206 int windowTop = 0; 207 int windowBottom = height; 208 209 int prevHeaderBottom = 0; 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 public void onNothingSelected(AdapterView<?> parent) { 237 if (mOnItemSelectedListener != null) { 238 mOnItemSelectedListener.onNothingSelected(parent); 239 } 240 } 241 242 public int getPinnedHeaderHeight(int viewIndex) { 243 ensurePinnedHeaderLayout(viewIndex); 244 return mHeaders[viewIndex].view.getHeight(); 245 } 246 247 /** 248 * Set header to be pinned at the top. 249 * 250 * @param viewIndex index of the header view 251 * @param y is position of the header in pixels. 252 * @param animate true if the transition to the new coordinate should be animated 253 */ 254 public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) { 255 ensurePinnedHeaderLayout(viewIndex); 256 PinnedHeader header = mHeaders[viewIndex]; 257 header.visible = true; 258 header.y = y; 259 header.state = TOP; 260 261 // TODO perhaps we should animate at the top as well 262 header.animating = false; 263 } 264 265 /** 266 * Set header to be pinned at the bottom. 267 * 268 * @param viewIndex index of the header view 269 * @param y is position of the header in pixels. 270 * @param animate true if the transition to the new coordinate should be animated 271 */ 272 public void setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) { 273 ensurePinnedHeaderLayout(viewIndex); 274 PinnedHeader header = mHeaders[viewIndex]; 275 header.state = BOTTOM; 276 if (header.animating) { 277 header.targetTime = mAnimationTargetTime; 278 header.sourceY = header.y; 279 header.targetY = y; 280 } else if (animate && (header.y != y || !header.visible)) { 281 if (header.visible) { 282 header.sourceY = header.y; 283 } else { 284 header.visible = true; 285 header.sourceY = y + header.height; 286 } 287 header.animating = true; 288 header.targetVisible = true; 289 header.targetTime = mAnimationTargetTime; 290 header.targetY = y; 291 } else { 292 header.visible = true; 293 header.y = y; 294 } 295 } 296 297 /** 298 * Set header to be pinned at the top of the first visible item. 299 * 300 * @param viewIndex index of the header view 301 * @param position is position of the header in pixels. 302 */ 303 public void setFadingHeader(int viewIndex, int position, boolean fade) { 304 ensurePinnedHeaderLayout(viewIndex); 305 306 View child = getChildAt(position - getFirstVisiblePosition()); 307 if (child == null) return; 308 309 PinnedHeader header = mHeaders[viewIndex]; 310 header.visible = true; 311 header.state = FADING; 312 header.alpha = MAX_ALPHA; 313 header.animating = false; 314 315 int top = getTotalTopPinnedHeaderHeight(); 316 header.y = top; 317 if (fade) { 318 int bottom = child.getBottom() - top; 319 int headerHeight = header.height; 320 if (bottom < headerHeight) { 321 int portion = bottom - headerHeight; 322 header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight; 323 header.y = top + portion; 324 } 325 } 326 } 327 328 /** 329 * Makes header invisible. 330 * 331 * @param viewIndex index of the header view 332 * @param animate true if the transition to the new coordinate should be animated 333 */ 334 public void setHeaderInvisible(int viewIndex, boolean animate) { 335 PinnedHeader header = mHeaders[viewIndex]; 336 if (header.visible && (animate || header.animating) && header.state == BOTTOM) { 337 header.sourceY = header.y; 338 if (!header.animating) { 339 header.visible = true; 340 header.targetY = getBottom() + header.height; 341 } 342 header.animating = true; 343 header.targetTime = mAnimationTargetTime; 344 header.targetVisible = false; 345 } else { 346 header.visible = false; 347 } 348 } 349 350 private void ensurePinnedHeaderLayout(int viewIndex) { 351 View view = mHeaders[viewIndex].view; 352 if (view.isLayoutRequested()) { 353 int widthSpec = MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY); 354 int heightSpec; 355 ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); 356 if (layoutParams != null && layoutParams.height > 0) { 357 heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY); 358 } else { 359 heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 360 } 361 view.measure(widthSpec, heightSpec); 362 int height = view.getMeasuredHeight(); 363 mHeaders[viewIndex].height = height; 364 view.layout(0, 0, view.getMeasuredWidth(), height); 365 } 366 } 367 368 /** 369 * Returns the sum of heights of headers pinned to the top. 370 */ 371 public int getTotalTopPinnedHeaderHeight() { 372 for (int i = mSize; --i >= 0;) { 373 PinnedHeader header = mHeaders[i]; 374 if (header.visible && header.state == TOP) { 375 return header.y + header.height; 376 } 377 } 378 return 0; 379 } 380 381 /** 382 * Returns the list item position at the specified y coordinate. 383 */ 384 public int getPositionAt(int y) { 385 do { 386 int position = pointToPosition(getPaddingLeft() + 1, y); 387 if (position != -1) { 388 return position; 389 } 390 // If position == -1, we must have hit a separator. Let's examine 391 // a nearby pixel 392 y--; 393 } while (y > 0); 394 return 0; 395 } 396 397 @Override 398 public boolean onInterceptTouchEvent(MotionEvent ev) { 399 if (mScrollState == SCROLL_STATE_IDLE) { 400 final int y = (int)ev.getY(); 401 for (int i = mSize; --i >= 0;) { 402 PinnedHeader header = mHeaders[i]; 403 if (header.visible && header.y <= y && header.y + header.height > y) { 404 if (ev.getAction() == MotionEvent.ACTION_DOWN) { 405 return smoothScrollToPartition(i); 406 } else { 407 return true; 408 } 409 } 410 } 411 } 412 413 return super.onInterceptTouchEvent(ev); 414 } 415 416 private boolean smoothScrollToPartition(int partition) { 417 final int position = mAdapter.getScrollPositionForHeader(partition); 418 if (position == -1) { 419 return false; 420 } 421 422 int offset = 0; 423 for (int i = 0; i < partition; i++) { 424 PinnedHeader header = mHeaders[i]; 425 if (header.visible) { 426 offset += header.height; 427 } 428 } 429 430 smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset); 431 return true; 432 } 433 434 private void invalidateIfAnimating() { 435 mAnimating = false; 436 for (int i = 0; i < mSize; i++) { 437 if (mHeaders[i].animating) { 438 mAnimating = true; 439 invalidate(); 440 return; 441 } 442 } 443 } 444 445 @Override 446 protected void dispatchDraw(Canvas canvas) { 447 long currentTime = mAnimating ? System.currentTimeMillis() : 0; 448 449 int top = 0; 450 int bottom = getBottom(); 451 boolean hasVisibleHeaders = false; 452 for (int i = 0; i < mSize; i++) { 453 PinnedHeader header = mHeaders[i]; 454 if (header.visible) { 455 hasVisibleHeaders = true; 456 if (header.state == BOTTOM && header.y < bottom) { 457 bottom = header.y; 458 } else if (header.state == TOP || header.state == FADING) { 459 int newTop = header.y + header.height; 460 if (newTop > top) { 461 top = newTop; 462 } 463 } 464 } 465 } 466 467 if (hasVisibleHeaders) { 468 canvas.save(); 469 mClipRect.set(0, top, getWidth(), bottom); 470 canvas.clipRect(mClipRect); 471 } 472 473 super.dispatchDraw(canvas); 474 475 if (hasVisibleHeaders) { 476 canvas.restore(); 477 478 // First draw top headers, then the bottom ones to handle the Z axis correctly 479 for (int i = mSize; --i >= 0;) { 480 PinnedHeader header = mHeaders[i]; 481 if (header.visible && (header.state == TOP || header.state == FADING)) { 482 drawHeader(canvas, header, currentTime); 483 } 484 } 485 486 for (int i = 0; i < mSize; i++) { 487 PinnedHeader header = mHeaders[i]; 488 if (header.visible && header.state == BOTTOM) { 489 drawHeader(canvas, header, currentTime); 490 } 491 } 492 } 493 494 invalidateIfAnimating(); 495 } 496 497 private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { 498 if (header.animating) { 499 int timeLeft = (int)(header.targetTime - currentTime); 500 if (timeLeft <= 0) { 501 header.y = header.targetY; 502 header.visible = header.targetVisible; 503 header.animating = false; 504 } else { 505 header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft 506 / mAnimationDuration; 507 } 508 } 509 if (header.visible) { 510 View view = header.view; 511 int saveCount = canvas.save(); 512 canvas.translate(mHeaderPaddingLeft, header.y); 513 if (header.state == FADING) { 514 mBounds.set(0, 0, mHeaderWidth, view.getHeight()); 515 canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); 516 } 517 view.draw(canvas); 518 canvas.restoreToCount(saveCount); 519 } 520 } 521} 522