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