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