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