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