PinnedHeaderListView.java revision ac14436a4db64f7e40cc5ba3fe40ced25a7f829d
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 17package com.android.contacts.widget; 18 19import android.content.Context; 20import android.graphics.Canvas; 21import android.graphics.Paint; 22import android.graphics.Rect; 23import android.graphics.RectF; 24import android.util.AttributeSet; 25import android.view.MotionEvent; 26import android.view.View; 27import android.view.ViewGroup; 28import android.widget.AbsListView; 29import android.widget.AbsListView.OnScrollListener; 30import android.widget.AdapterView; 31import android.widget.AdapterView.OnItemSelectedListener; 32import android.widget.ListAdapter; 33import android.widget.ListView; 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 */ 39public class PinnedHeaderListView extends ListView 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 = 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 int mAnimationDuration = DEFAULT_ANIMATION_DURATION; 106 private boolean mAnimating; 107 private long mAnimationTargetTime; 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 public void setPinnedHeaderAnimationDuration(int duration) { 124 mAnimationDuration = duration; 125 } 126 127 @Override 128 public void setAdapter(ListAdapter adapter) { 129 mAdapter = (PinnedHeaderAdapter)adapter; 130 super.setAdapter(adapter); 131 } 132 133 @Override 134 public void setOnScrollListener(OnScrollListener onScrollListener) { 135 mOnScrollListener = onScrollListener; 136 super.setOnScrollListener(this); 137 } 138 139 @Override 140 public void setOnItemSelectedListener(OnItemSelectedListener listener) { 141 mOnItemSelectedListener = listener; 142 super.setOnItemSelectedListener(this); 143 } 144 145 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 146 int totalItemCount) { 147 if (mAdapter != null) { 148 int count = mAdapter.getPinnedHeaderCount(); 149 if (count != mSize) { 150 mSize = count; 151 if (mHeaders == null) { 152 mHeaders = new PinnedHeader[mSize]; 153 } else if (mHeaders.length < mSize) { 154 PinnedHeader[] headers = mHeaders; 155 mHeaders = new PinnedHeader[mSize]; 156 System.arraycopy(headers, 0, mHeaders, 0, headers.length); 157 } 158 } 159 160 for (int i = 0; i < mSize; i++) { 161 if (mHeaders[i] == null) { 162 mHeaders[i] = new PinnedHeader(); 163 } 164 mHeaders[i].view = mAdapter.getPinnedHeaderView(i, mHeaders[i].view, this); 165 } 166 167 // Disable vertical fading when the pinned header is present 168 // TODO change ListView to allow separate measures for top and bottom fading edge; 169 // in this particular case we would like to disable the top, but not the bottom edge. 170 if (mSize > 0) { 171 setFadingEdgeLength(0); 172 } 173 174 mAnimationTargetTime = System.currentTimeMillis() + mAnimationDuration; 175 mAdapter.configurePinnedHeaders(this); 176 invalidateIfAnimating(); 177 178 } 179 if (mOnScrollListener != null) { 180 mOnScrollListener.onScroll(this, firstVisibleItem, visibleItemCount, totalItemCount); 181 } 182 } 183 184 public void onScrollStateChanged(AbsListView view, int scrollState) { 185 mScrollState = scrollState; 186 if (mOnScrollListener != null) { 187 mOnScrollListener.onScrollStateChanged(this, scrollState); 188 } 189 } 190 191 /** 192 * Ensures that the selected item is positioned below the top-pinned headers 193 * and above the bottom-pinned ones. 194 */ 195 public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { 196 int height = getHeight(); 197 198 int windowTop = 0; 199 int windowBottom = height; 200 201 int prevHeaderBottom = 0; 202 for (int i = 0; i < mSize; i++) { 203 PinnedHeader header = mHeaders[i]; 204 if (header.visible) { 205 if (header.state == TOP) { 206 windowTop = header.y + header.height; 207 } else if (header.state == BOTTOM) { 208 windowBottom = header.y; 209 break; 210 } 211 } 212 } 213 214 View selectedView = getSelectedView(); 215 if (selectedView != null) { 216 if (selectedView.getTop() < windowTop) { 217 setSelectionFromTop(position, windowTop); 218 } else if (selectedView.getBottom() > windowBottom) { 219 setSelectionFromTop(position, windowBottom - selectedView.getHeight()); 220 } 221 } 222 223 if (mOnItemSelectedListener != null) { 224 mOnItemSelectedListener.onItemSelected(parent, view, position, id); 225 } 226 } 227 228 public void onNothingSelected(AdapterView<?> parent) { 229 if (mOnItemSelectedListener != null) { 230 mOnItemSelectedListener.onNothingSelected(parent); 231 } 232 } 233 234 public int getPinnedHeaderHeight(int viewIndex) { 235 ensurePinnedHeaderLayout(viewIndex); 236 return mHeaders[viewIndex].view.getHeight(); 237 } 238 239 /** 240 * Set header to be pinned at the top. 241 * 242 * @param viewIndex index of the header view 243 * @param y is position of the header in pixels. 244 * @param animate true if the transition to the new coordinate should be animated 245 */ 246 public void setHeaderPinnedAtTop(int viewIndex, int y, boolean animate) { 247 ensurePinnedHeaderLayout(viewIndex); 248 PinnedHeader header = mHeaders[viewIndex]; 249 header.visible = true; 250 header.y = y; 251 header.state = TOP; 252 253 // TODO perhaps we should animate at the top as well 254 header.animating = false; 255 } 256 257 /** 258 * Set header to be pinned at the bottom. 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 setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate) { 265 ensurePinnedHeaderLayout(viewIndex); 266 PinnedHeader header = mHeaders[viewIndex]; 267 header.state = BOTTOM; 268 if (header.animating) { 269 header.targetTime = mAnimationTargetTime; 270 header.sourceY = header.y; 271 header.targetY = y; 272 } else if (animate && (header.y != y || !header.visible)) { 273 if (header.visible) { 274 header.sourceY = header.y; 275 } else { 276 header.visible = true; 277 header.sourceY = y + header.height; 278 } 279 header.animating = true; 280 header.targetVisible = true; 281 header.targetTime = mAnimationTargetTime; 282 header.targetY = y; 283 } else { 284 header.visible = true; 285 header.y = y; 286 } 287 } 288 289 /** 290 * Set header to be pinned at the top of the first visible item. 291 * 292 * @param viewIndex index of the header view 293 * @param position is position of the header in pixels. 294 */ 295 public void setFadingHeader(int viewIndex, int position, boolean fade) { 296 ensurePinnedHeaderLayout(viewIndex); 297 298 View child = getChildAt(position - getFirstVisiblePosition()); 299 300 PinnedHeader header = mHeaders[viewIndex]; 301 header.visible = true; 302 header.state = FADING; 303 header.alpha = MAX_ALPHA; 304 header.animating = false; 305 306 int top = getTotalTopPinnedHeaderHeight(); 307 header.y = top; 308 if (fade) { 309 int bottom = child.getBottom() - top; 310 int headerHeight = header.height; 311 if (bottom < headerHeight) { 312 int portion = bottom - headerHeight; 313 header.alpha = MAX_ALPHA * (headerHeight + portion) / headerHeight; 314 header.y = top + portion; 315 } 316 } 317 } 318 319 /** 320 * Makes header invisible. 321 * 322 * @param viewIndex index of the header view 323 * @param animate true if the transition to the new coordinate should be animated 324 */ 325 public void setHeaderInvisible(int viewIndex, boolean animate) { 326 PinnedHeader header = mHeaders[viewIndex]; 327 if (header.visible && (animate || header.animating) && header.state == BOTTOM) { 328 header.sourceY = header.y; 329 if (!header.animating) { 330 header.visible = true; 331 header.targetY = getBottom() + header.height; 332 } 333 header.animating = true; 334 header.targetTime = mAnimationTargetTime; 335 header.targetVisible = false; 336 } else { 337 header.visible = false; 338 } 339 } 340 341 private void ensurePinnedHeaderLayout(int viewIndex) { 342 View view = mHeaders[viewIndex].view; 343 if (view.isLayoutRequested()) { 344 int widthSpec = MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY); 345 int heightSpec; 346 int lpHeight = view.getLayoutParams().height; 347 if (lpHeight > 0) { 348 heightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); 349 } else { 350 heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 351 } 352 view.measure(widthSpec, heightSpec); 353 int height = view.getMeasuredHeight(); 354 mHeaders[viewIndex].height = height; 355 view.layout(0, 0, view.getMeasuredWidth(), height); 356 } 357 } 358 359 /** 360 * Returns the sum of heights of headers pinned to the top. 361 */ 362 public int getTotalTopPinnedHeaderHeight() { 363 for (int i = mSize; --i >= 0;) { 364 PinnedHeader header = mHeaders[i]; 365 if (header.visible && header.state == TOP) { 366 return header.y + header.height; 367 } 368 } 369 return 0; 370 } 371 372 /** 373 * Returns the list item position at the specified y coordinate. 374 */ 375 public int getPositionAt(int y) { 376 do { 377 int position = pointToPosition(0, y); 378 if (position != -1) { 379 return position; 380 } 381 // If position == -1, we must have hit a separator. Let's examine 382 // a nearby pixel 383 y--; 384 } while (y > 0); 385 return 0; 386 } 387 388 @Override 389 public boolean onInterceptTouchEvent(MotionEvent ev) { 390 if (mScrollState == SCROLL_STATE_IDLE) { 391 final int y = (int)ev.getY(); 392 for (int i = mSize; --i >= 0;) { 393 PinnedHeader header = mHeaders[i]; 394 if (header.visible && header.y <= y && header.y + header.height > y) { 395 if (ev.getAction() == MotionEvent.ACTION_DOWN) { 396 return smoothScrollToPartition(i); 397 } else { 398 return true; 399 } 400 } 401 } 402 } 403 404 return super.onInterceptTouchEvent(ev); 405 } 406 407 private boolean smoothScrollToPartition(int partition) { 408 final int position = mAdapter.getScrollPositionForHeader(partition); 409 if (position == -1) { 410 return false; 411 } 412 413 int offset = 0; 414 for (int i = 0; i < partition; i++) { 415 PinnedHeader header = mHeaders[i]; 416 if (header.visible) { 417 offset += header.height; 418 } 419 } 420 421 smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset); 422 return true; 423 } 424 425 private void invalidateIfAnimating() { 426 mAnimating = false; 427 for (int i = 0; i < mSize; i++) { 428 if (mHeaders[i].animating) { 429 mAnimating = true; 430 invalidate(); 431 return; 432 } 433 } 434 } 435 436 @Override 437 protected void dispatchDraw(Canvas canvas) { 438 long currentTime = mAnimating ? System.currentTimeMillis() : 0; 439 440 int top = 0; 441 int bottom = getBottom(); 442 boolean hasVisibleHeaders = false; 443 for (int i = 0; i < mSize; i++) { 444 PinnedHeader header = mHeaders[i]; 445 if (header.visible) { 446 hasVisibleHeaders = true; 447 if (header.state == BOTTOM && header.y < bottom) { 448 bottom = header.y; 449 } else if (header.state == TOP || header.state == FADING) { 450 int newTop = header.y + header.height; 451 if (newTop > top) { 452 top = newTop; 453 } 454 } 455 } 456 } 457 458 if (hasVisibleHeaders) { 459 canvas.save(); 460 mClipRect.set(0, top, getWidth(), bottom); 461 canvas.clipRect(mClipRect); 462 } 463 464 super.dispatchDraw(canvas); 465 466 if (hasVisibleHeaders) { 467 canvas.restore(); 468 469 // First draw top headers, then the bottom ones to handle the Z axis correctly 470 for (int i = mSize; --i >= 0;) { 471 PinnedHeader header = mHeaders[i]; 472 if (header.visible && (header.state == TOP || header.state == FADING)) { 473 drawHeader(canvas, header, currentTime); 474 } 475 } 476 477 for (int i = 0; i < mSize; i++) { 478 PinnedHeader header = mHeaders[i]; 479 if (header.visible && header.state == BOTTOM) { 480 drawHeader(canvas, header, currentTime); 481 } 482 } 483 } 484 485 invalidateIfAnimating(); 486 } 487 488 private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { 489 if (header.animating) { 490 int timeLeft = (int)(header.targetTime - currentTime); 491 if (timeLeft <= 0) { 492 header.y = header.targetY; 493 header.visible = header.targetVisible; 494 header.animating = false; 495 } else { 496 header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft 497 / mAnimationDuration; 498 } 499 } 500 if (header.visible) { 501 View view = header.view; 502 int saveCount = canvas.save(); 503 canvas.translate(0, header.y); 504 if (header.state == FADING) { 505 mBounds.set(0, 0, view.getWidth(), view.getHeight()); 506 canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); 507 } 508 view.draw(canvas); 509 canvas.restoreToCount(saveCount); 510 } 511 } 512} 513