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