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 */ getPinnedHeaderCount()48 int getPinnedHeaderCount(); 49 50 /** 51 * Creates or updates the pinned header view. 52 */ getPinnedHeaderView(int viewIndex, View convertView, ViewGroup parent)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 */ configurePinnedHeaders(PinnedHeaderListView listView)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 */ getScrollPositionForHeader(int viewIndex)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 mHeaderPaddingLeft; 107 private int mHeaderWidth; 108 PinnedHeaderListView(Context context)109 public PinnedHeaderListView(Context context) { 110 this(context, null, com.android.internal.R.attr.listViewStyle); 111 } 112 PinnedHeaderListView(Context context, AttributeSet attrs)113 public PinnedHeaderListView(Context context, AttributeSet attrs) { 114 this(context, attrs, com.android.internal.R.attr.listViewStyle); 115 } 116 PinnedHeaderListView(Context context, AttributeSet attrs, int defStyle)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 onLayout(boolean changed, int l, int t, int r, int b)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 setPinnedHeaderAnimationDuration(int duration)130 public void setPinnedHeaderAnimationDuration(int duration) { 131 mAnimationDuration = duration; 132 } 133 134 @Override setAdapter(ListAdapter adapter)135 public void setAdapter(ListAdapter adapter) { 136 mAdapter = (PinnedHeaderAdapter)adapter; 137 super.setAdapter(adapter); 138 } 139 140 @Override setOnScrollListener(OnScrollListener onScrollListener)141 public void setOnScrollListener(OnScrollListener onScrollListener) { 142 mOnScrollListener = onScrollListener; 143 super.setOnScrollListener(this); 144 } 145 146 @Override setOnItemSelectedListener(OnItemSelectedListener listener)147 public void setOnItemSelectedListener(OnItemSelectedListener listener) { 148 mOnItemSelectedListener = listener; 149 super.setOnItemSelectedListener(this); 150 } 151 152 @Override onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)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 getTopFadingEdgeStrength()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 onScrollStateChanged(AbsListView view, int scrollState)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 onItemSelected(AdapterView<?> parent, View view, int position, long id)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 onNothingSelected(AdapterView<?> parent)237 public void onNothingSelected(AdapterView<?> parent) { 238 if (mOnItemSelectedListener != null) { 239 mOnItemSelectedListener.onNothingSelected(parent); 240 } 241 } 242 getPinnedHeaderHeight(int viewIndex)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 */ setHeaderPinnedAtTop(int viewIndex, int y, boolean animate)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 */ setHeaderPinnedAtBottom(int viewIndex, int y, boolean animate)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 */ setFadingHeader(int viewIndex, int position, boolean fade)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 */ setHeaderInvisible(int viewIndex, boolean animate)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 ensurePinnedHeaderLayout(int viewIndex)351 private void ensurePinnedHeaderLayout(int viewIndex) { 352 View view = mHeaders[viewIndex].view; 353 if (view.isLayoutRequested()) { 354 int widthSpec = MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY); 355 int heightSpec; 356 ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); 357 if (layoutParams != null && layoutParams.height > 0) { 358 heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY); 359 } else { 360 heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 361 } 362 view.measure(widthSpec, heightSpec); 363 int height = view.getMeasuredHeight(); 364 mHeaders[viewIndex].height = height; 365 view.layout(0, 0, mHeaderWidth, height); 366 } 367 } 368 369 /** 370 * Returns the sum of heights of headers pinned to the top. 371 */ getTotalTopPinnedHeaderHeight()372 public int getTotalTopPinnedHeaderHeight() { 373 for (int i = mSize; --i >= 0;) { 374 PinnedHeader header = mHeaders[i]; 375 if (header.visible && header.state == TOP) { 376 return header.y + header.height; 377 } 378 } 379 return 0; 380 } 381 382 /** 383 * Returns the list item position at the specified y coordinate. 384 */ getPositionAt(int y)385 public int getPositionAt(int y) { 386 do { 387 int position = pointToPosition(getPaddingLeft() + 1, y); 388 if (position != -1) { 389 return position; 390 } 391 // If position == -1, we must have hit a separator. Let's examine 392 // a nearby pixel 393 y--; 394 } while (y > 0); 395 return 0; 396 } 397 398 @Override onInterceptTouchEvent(MotionEvent ev)399 public boolean onInterceptTouchEvent(MotionEvent ev) { 400 if (mScrollState == SCROLL_STATE_IDLE) { 401 final int y = (int)ev.getY(); 402 for (int i = mSize; --i >= 0;) { 403 PinnedHeader header = mHeaders[i]; 404 if (header.visible && header.y <= y && header.y + header.height > y) { 405 if (ev.getAction() == MotionEvent.ACTION_DOWN) { 406 return smoothScrollToPartition(i); 407 } else { 408 return true; 409 } 410 } 411 } 412 } 413 414 return super.onInterceptTouchEvent(ev); 415 } 416 smoothScrollToPartition(int partition)417 private boolean smoothScrollToPartition(int partition) { 418 final int position = mAdapter.getScrollPositionForHeader(partition); 419 if (position == -1) { 420 return false; 421 } 422 423 int offset = 0; 424 for (int i = 0; i < partition; i++) { 425 PinnedHeader header = mHeaders[i]; 426 if (header.visible) { 427 offset += header.height; 428 } 429 } 430 431 smoothScrollToPositionFromTop(position + getHeaderViewsCount(), offset); 432 return true; 433 } 434 invalidateIfAnimating()435 private void invalidateIfAnimating() { 436 mAnimating = false; 437 for (int i = 0; i < mSize; i++) { 438 if (mHeaders[i].animating) { 439 mAnimating = true; 440 invalidate(); 441 return; 442 } 443 } 444 } 445 446 @Override dispatchDraw(Canvas canvas)447 protected void dispatchDraw(Canvas canvas) { 448 long currentTime = mAnimating ? System.currentTimeMillis() : 0; 449 450 int top = 0; 451 int bottom = getBottom(); 452 boolean hasVisibleHeaders = false; 453 for (int i = 0; i < mSize; i++) { 454 PinnedHeader header = mHeaders[i]; 455 if (header.visible) { 456 hasVisibleHeaders = true; 457 if (header.state == BOTTOM && header.y < bottom) { 458 bottom = header.y; 459 } else if (header.state == TOP || header.state == FADING) { 460 int newTop = header.y + header.height; 461 if (newTop > top) { 462 top = newTop; 463 } 464 } 465 } 466 } 467 468 if (hasVisibleHeaders) { 469 canvas.save(); 470 mClipRect.set(0, top, getWidth(), bottom); 471 canvas.clipRect(mClipRect); 472 } 473 474 super.dispatchDraw(canvas); 475 476 if (hasVisibleHeaders) { 477 canvas.restore(); 478 479 // First draw top headers, then the bottom ones to handle the Z axis correctly 480 for (int i = mSize; --i >= 0;) { 481 PinnedHeader header = mHeaders[i]; 482 if (header.visible && (header.state == TOP || header.state == FADING)) { 483 drawHeader(canvas, header, currentTime); 484 } 485 } 486 487 for (int i = 0; i < mSize; i++) { 488 PinnedHeader header = mHeaders[i]; 489 if (header.visible && header.state == BOTTOM) { 490 drawHeader(canvas, header, currentTime); 491 } 492 } 493 } 494 495 invalidateIfAnimating(); 496 } 497 drawHeader(Canvas canvas, PinnedHeader header, long currentTime)498 private void drawHeader(Canvas canvas, PinnedHeader header, long currentTime) { 499 if (header.animating) { 500 int timeLeft = (int)(header.targetTime - currentTime); 501 if (timeLeft <= 0) { 502 header.y = header.targetY; 503 header.visible = header.targetVisible; 504 header.animating = false; 505 } else { 506 header.y = header.targetY + (header.sourceY - header.targetY) * timeLeft 507 / mAnimationDuration; 508 } 509 } 510 if (header.visible) { 511 View view = header.view; 512 int saveCount = canvas.save(); 513 canvas.translate(mHeaderPaddingLeft, header.y); 514 if (header.state == FADING) { 515 mBounds.set(0, 0, mHeaderWidth, view.getHeight()); 516 canvas.saveLayerAlpha(mBounds, header.alpha, Canvas.ALL_SAVE_FLAG); 517 } 518 view.draw(canvas); 519 canvas.restoreToCount(saveCount); 520 } 521 } 522 } 523