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