ConversationContainer.java revision a467d40eeb9c598fc6d7cb2dbafa2a331292d23e
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.browse; 19 20import android.content.Context; 21import android.content.res.Configuration; 22import android.database.DataSetObserver; 23import android.graphics.Canvas; 24import android.util.AttributeSet; 25import android.util.SparseArray; 26import android.view.Gravity; 27import android.view.MotionEvent; 28import android.view.View; 29import android.view.ViewConfiguration; 30import android.view.ViewGroup; 31import android.webkit.WebView; 32import android.widget.Adapter; 33import android.widget.ListView; 34import android.widget.ScrollView; 35 36import com.android.mail.R; 37import com.android.mail.browse.ScrollNotifier.ScrollListener; 38import com.android.mail.providers.UIProvider; 39import com.android.mail.ui.ConversationViewFragment; 40import com.android.mail.utils.DequeMap; 41import com.android.mail.utils.InputSmoother; 42import com.android.mail.utils.LogUtils; 43import com.google.common.collect.Lists; 44 45import java.util.List; 46 47/** 48 * A specialized ViewGroup container for conversation view. It is designed to contain a single 49 * {@link WebView} and a number of overlay views that draw on top of the WebView. In the Mail app, 50 * the WebView contains all HTML message bodies in a conversation, and the overlay views are the 51 * subject view, message headers, and attachment views. The WebView does all scroll handling, and 52 * this container manages scrolling of the overlay views so that they move in tandem. 53 * 54 * <h5>INPUT HANDLING</h5> 55 * Placing the WebView in the same container as the overlay views means we don't have to do a lot of 56 * manual manipulation of touch events. We do have a 57 * {@link #forwardFakeMotionEvent(MotionEvent, int)} method that deals with one WebView 58 * idiosyncrasy: it doesn't react well when touch MOVE events stream in without a preceding DOWN. 59 * 60 * <h5>VIEW RECYCLING</h5> 61 * Normally, it would make sense to put all overlay views into a {@link ListView}. But this view 62 * sandwich has unique characteristics: the list items are scrolled based on an external controller, 63 * and we happen to know all of the overlay positions up front. So it didn't make sense to shoehorn 64 * a ListView in and instead, we rolled our own view recycler by borrowing key details from 65 * ListView and AbsListView. 66 * 67 */ 68public class ConversationContainer extends ViewGroup implements ScrollListener { 69 private static final String TAG = ConversationViewFragment.LAYOUT_TAG; 70 71 private static final int[] BOTTOM_LAYER_VIEW_IDS = { 72 R.id.webview, 73 R.id.conversation_side_border_overlay 74 }; 75 76 private static final int[] TOP_LAYER_VIEW_IDS = { 77 R.id.conversation_topmost_overlay 78 }; 79 80 /** 81 * Maximum scroll speed (in dp/sec) at which the snap header animation will draw. 82 * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect). 83 */ 84 private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f; 85 86 private ConversationAccountController mAccountController; 87 private ConversationViewAdapter mOverlayAdapter; 88 private OverlayPosition[] mOverlayPositions; 89 private ConversationWebView mWebView; 90 private SnapHeader mSnapHeader; 91 private View mTopMostOverlay; 92 93 /** 94 * This is a hack. 95 * 96 * <p>Without this hack enabled, very fast scrolling can sometimes cause the top-most layers 97 * to skip being drawn for a frame or two. It happens specifically when overlay views are 98 * attached or added, and WebView happens to draw (on its own) immediately afterwards. 99 * 100 * <p>The workaround is to force an additional draw of the top-most overlay. Since the problem 101 * only occurs when scrolling overlays are added, restrict the additional draw to only occur 102 * if scrolling overlays were added since the last draw. 103 */ 104 private boolean mAttachedOverlaySinceLastDraw; 105 106 private final List<View> mNonScrollingChildren = Lists.newArrayList(); 107 108 /** 109 * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual 110 * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other. 111 */ 112 private float mScale; 113 /** 114 * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale 115 * values. 116 */ 117 private boolean mTouchInitialized; 118 119 /** 120 * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}. 121 */ 122 private final int mTouchSlop; 123 /** 124 * Current scroll position, as dictated by the background {@link WebView}. 125 */ 126 private int mOffsetY; 127 /** 128 * Original pointer Y for slop calculation. 129 */ 130 private float mLastMotionY; 131 /** 132 * Original pointer ID for slop calculation. 133 */ 134 private int mActivePointerId; 135 /** 136 * Track pointer up/down state to know whether to send a make-up DOWN event to WebView. 137 * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be 138 * preceded by a {@link MotionEvent#ACTION_DOWN} event. 139 */ 140 private boolean mTouchIsDown = false; 141 /** 142 * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN}, 143 * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}. 144 */ 145 private boolean mMissedPointerDown; 146 147 /** 148 * A recycler that holds removed scrap views, organized by integer item view type. All views 149 * in this data structure should be removed from their view parent prior to insertion. 150 */ 151 private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>(); 152 153 /** 154 * The current set of overlay views in the view hierarchy. Looking through this map is faster 155 * than traversing the view hierarchy. 156 * <p> 157 * WebView sometimes notifies of scroll changes during a draw (or display list generation), when 158 * it's not safe to detach view children because ViewGroup is in the middle of iterating over 159 * its child array. So we remove any child from this list immediately and queue up a task to 160 * detach it later. Since nobody other than the detach task references that view in the 161 * meantime, we don't need any further checks or synchronization. 162 * <p> 163 * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose 164 * of all views (on data set or adapter change), we can at least recycle them into the typed 165 * scrap piles for later reuse. 166 */ 167 private final SparseArray<OverlayView> mOverlayViews; 168 169 private int mWidthMeasureSpec; 170 171 private boolean mDisableLayoutTracing; 172 173 private final InputSmoother mVelocityTracker; 174 175 private final DataSetObserver mAdapterObserver = new AdapterObserver(); 176 177 /** 178 * The adapter index of the lowest overlay item that is above the top of the screen and reports 179 * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through 180 * {@link #positionOverlays(int, int)}. 181 * 182 */ 183 private int mSnapIndex; 184 185 private boolean mSnapEnabled; 186 187 /** 188 * A View that fills the remaining vertical space when the overlays do not take 189 * up the entire container. Otherwise, a card-like bottom white space appears. 190 */ 191 private View mAdditionalBottomBorder; 192 193 /** 194 * A flag denoting whether the fake bottom border has been added to the container. 195 */ 196 private boolean mAdditionalBottomBorderAdded; 197 198 /** 199 * An int containing the potential top value for the additional bottom border. 200 * If this value is less than the height of the scroll container, the additional 201 * bottom border will be drawn. 202 */ 203 private int mAdditionalBottomBorderOverlayTop; 204 205 /** 206 * Child views of this container should implement this interface to be notified when they are 207 * being detached. 208 * 209 */ 210 public interface DetachListener { 211 /** 212 * Called on a child view when it is removed from its parent as part of 213 * {@link ConversationContainer} view recycling. 214 */ 215 void onDetachedFromParent(); 216 } 217 218 public static class OverlayPosition { 219 public final int top; 220 public final int bottom; 221 222 public OverlayPosition(int top, int bottom) { 223 this.top = top; 224 this.bottom = bottom; 225 } 226 } 227 228 private static class OverlayView { 229 public View view; 230 int itemType; 231 232 public OverlayView(View view, int itemType) { 233 this.view = view; 234 this.itemType = itemType; 235 } 236 } 237 238 public ConversationContainer(Context c) { 239 this(c, null); 240 } 241 242 public ConversationContainer(Context c, AttributeSet attrs) { 243 super(c, attrs); 244 245 mOverlayViews = new SparseArray<OverlayView>(); 246 247 mVelocityTracker = new InputSmoother(c); 248 249 mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop(); 250 251 // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the 252 // WebView and the second pointer goes down on an overlay view. 253 // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer 254 // goes down on an overlay view. 255 setMotionEventSplittingEnabled(false); 256 } 257 258 @Override 259 protected void onFinishInflate() { 260 super.onFinishInflate(); 261 262 mWebView = (ConversationWebView) findViewById(R.id.webview); 263 mWebView.addScrollListener(this); 264 265 mTopMostOverlay = findViewById(R.id.conversation_topmost_overlay); 266 267 for (int id : BOTTOM_LAYER_VIEW_IDS) { 268 mNonScrollingChildren.add(findViewById(id)); 269 } 270 for (int id : TOP_LAYER_VIEW_IDS) { 271 mNonScrollingChildren.add(findViewById(id)); 272 } 273 } 274 275 public void setupSnapHeader() { 276 mSnapHeader = (SnapHeader) findViewById(R.id.snap_header); 277 mSnapHeader.setSnappy(); 278 } 279 280 public SnapHeader getSnapHeader() { 281 return mSnapHeader; 282 } 283 284 public void setOverlayAdapter(ConversationViewAdapter a) { 285 if (mOverlayAdapter != null) { 286 mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver); 287 clearOverlays(); 288 } 289 mOverlayAdapter = a; 290 if (mOverlayAdapter != null) { 291 mOverlayAdapter.registerDataSetObserver(mAdapterObserver); 292 } 293 } 294 295 public Adapter getOverlayAdapter() { 296 return mOverlayAdapter; 297 } 298 299 public void setAccountController(ConversationAccountController controller) { 300 mAccountController = controller; 301 302 mSnapEnabled = isSnapEnabled(); 303 } 304 305 /** 306 * Re-bind any existing views that correspond to the given adapter positions. 307 * 308 */ 309 public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) { 310 for (Integer i : affectedAdapterPositions) { 311 final ConversationOverlayItem item = mOverlayAdapter.getItem(i); 312 final OverlayView overlay = mOverlayViews.get(i); 313 if (overlay != null && overlay.view != null && item != null) { 314 item.onModelUpdated(overlay.view); 315 } 316 // update the snap header too, but only it's showing if the current item 317 if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) { 318 mSnapHeader.refresh(); 319 } 320 } 321 } 322 323 /** 324 * Return an overlay view for the given adapter item, or null if no matching view is currently 325 * visible. This can happen as you scroll away from an overlay view. 326 * 327 */ 328 public View getViewForItem(ConversationOverlayItem item) { 329 View result = null; 330 int adapterPos = -1; 331 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 332 if (mOverlayAdapter.getItem(i) == item) { 333 adapterPos = i; 334 break; 335 } 336 } 337 if (adapterPos != -1) { 338 final OverlayView overlay = mOverlayViews.get(adapterPos); 339 if (overlay != null) { 340 result = overlay.view; 341 } 342 } 343 return result; 344 } 345 346 private void clearOverlays() { 347 for (int i = 0, len = mOverlayViews.size(); i < len; i++) { 348 detachOverlay(mOverlayViews.valueAt(i)); 349 } 350 mOverlayViews.clear(); 351 } 352 353 private void onDataSetChanged() { 354 // Recycle all views and re-bind them according to the current set of spacer coordinates. 355 // This essentially resets the overlay views and re-renders them. 356 // It's fast enough that it's okay to re-do all views on any small change, as long as 357 // the change isn't too frequent (< ~1Hz). 358 359 clearOverlays(); 360 // also unbind the snap header view, so this "reset" causes the snap header to re-create 361 // its view, just like all other headers 362 mSnapHeader.unbind(); 363 364 // also clear out the additional bottom border 365 removeViewInLayout(mAdditionalBottomBorder); 366 mAdditionalBottomBorderAdded = false; 367 368 mSnapEnabled = isSnapEnabled(); 369 positionOverlays(0, mOffsetY); 370 } 371 372 private void forwardFakeMotionEvent(MotionEvent original, int newAction) { 373 MotionEvent newEvent = MotionEvent.obtain(original); 374 newEvent.setAction(newAction); 375 mWebView.onTouchEvent(newEvent); 376 LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d", 377 newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(), 378 newEvent.getPointerCount()); 379 } 380 381 /** 382 * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}. 383 */ 384 @Override 385 public boolean onInterceptTouchEvent(MotionEvent ev) { 386 387 if (!mTouchInitialized) { 388 mTouchInitialized = true; 389 } 390 391 // no interception when WebView handles the first DOWN 392 if (mWebView.isHandlingTouch()) { 393 return false; 394 } 395 396 boolean intercept = false; 397 switch (ev.getActionMasked()) { 398 case MotionEvent.ACTION_POINTER_DOWN: 399 LogUtils.d(TAG, "Container is intercepting non-primary touch!"); 400 intercept = true; 401 mMissedPointerDown = true; 402 requestDisallowInterceptTouchEvent(true); 403 break; 404 405 case MotionEvent.ACTION_DOWN: 406 mLastMotionY = ev.getY(); 407 mActivePointerId = ev.getPointerId(0); 408 break; 409 410 case MotionEvent.ACTION_MOVE: 411 final int pointerIndex = ev.findPointerIndex(mActivePointerId); 412 final float y = ev.getY(pointerIndex); 413 final int yDiff = (int) Math.abs(y - mLastMotionY); 414 if (yDiff > mTouchSlop) { 415 mLastMotionY = y; 416 intercept = true; 417 } 418 break; 419 } 420 421// LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s", 422// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept); 423 return intercept; 424 } 425 426 @Override 427 public boolean onTouchEvent(MotionEvent ev) { 428 final int action = ev.getActionMasked(); 429 430 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { 431 mTouchIsDown = false; 432 } else if (!mTouchIsDown && 433 (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) { 434 435 forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN); 436 if (mMissedPointerDown) { 437 forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN); 438 mMissedPointerDown = false; 439 } 440 441 mTouchIsDown = true; 442 } 443 444 final boolean webViewResult = mWebView.onTouchEvent(ev); 445 446// LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d", 447// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount()); 448 return webViewResult; 449 } 450 451 @Override 452 public void onNotifierScroll(final int x, final int y) { 453 mVelocityTracker.onInput(y); 454 mDisableLayoutTracing = true; 455 positionOverlays(x, y); 456 mDisableLayoutTracing = false; 457 } 458 459 private void positionOverlays(int x, int y) { 460 mOffsetY = y; 461 462 /* 463 * The scale value that WebView reports is inaccurate when measured during WebView 464 * initialization. This bug is present in ICS, so to work around it, we ignore all 465 * reported values and use a calculated expected value from ConversationWebView instead. 466 * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin 467 * to pay attention to WebView-reported scale values. 468 */ 469 if (mTouchInitialized) { 470 mScale = mWebView.getScale(); 471 } else if (mScale == 0) { 472 mScale = mWebView.getInitialScale(); 473 } 474 traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(), 475 mScale); 476 477 if (mOverlayPositions == null || mOverlayAdapter == null) { 478 return; 479 } 480 481 // recycle scrolled-off views and add newly visible views 482 483 // we want consecutive spacers/overlays to stack towards the bottom 484 // so iterate from the bottom of the conversation up 485 // starting with the last spacer bottom and the last adapter item, position adapter views 486 // in a single stack until you encounter a non-contiguous expanded message header, 487 // then decrement to the next spacer. 488 489 traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length, 490 mOverlayAdapter.getCount()); 491 492 mSnapIndex = -1; 493 mAdditionalBottomBorderOverlayTop = 0; 494 495 int adapterLoopIndex = mOverlayAdapter.getCount() - 1; 496 int spacerIndex = mOverlayPositions.length - 1; 497 while (spacerIndex >= 0 && adapterLoopIndex >= 0) { 498 499 final int spacerTop = getOverlayTop(spacerIndex); 500 final int spacerBottom = getOverlayBottom(spacerIndex); 501 502 final boolean flip; 503 final int flipOffset; 504 final int forceGravity; 505 // flip direction from bottom->top to top->bottom traversal on the very first spacer 506 // to facilitate top-aligned headers at spacer index = 0 507 if (spacerIndex == 0) { 508 flip = true; 509 flipOffset = adapterLoopIndex; 510 forceGravity = Gravity.TOP; 511 } else { 512 flip = false; 513 flipOffset = 0; 514 forceGravity = Gravity.NO_GRAVITY; 515 } 516 517 int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; 518 519 // always place at least one overlay per spacer 520 ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex); 521 522 OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom, 523 forceGravity); 524 525 traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex, 526 itemPos.top, itemPos.bottom, adapterItem); 527 positionOverlay(adapterIndex, itemPos.top, itemPos.bottom); 528 529 // and keep stacking overlays unconditionally if we are on the first spacer, or as long 530 // as overlays are contiguous 531 while (--adapterLoopIndex >= 0) { 532 adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; 533 adapterItem = mOverlayAdapter.getItem(adapterIndex); 534 if (spacerIndex > 0 && !adapterItem.isContiguous()) { 535 // advance to the next spacer, but stay on this adapter item 536 break; 537 } 538 539 // place this overlay in the region of the spacer above or below the last item, 540 // depending on direction of iteration 541 final int regionTop = flip ? itemPos.bottom : spacerTop; 542 final int regionBottom = flip ? spacerBottom : itemPos.top; 543 itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity); 544 545 traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, 546 adapterIndex, itemPos.top, itemPos.bottom, adapterItem); 547 positionOverlay(adapterIndex, itemPos.top, itemPos.bottom); 548 } 549 550 spacerIndex--; 551 } 552 553 positionSnapHeader(mSnapIndex); 554 positionAdditionalBottomBorder(); 555 } 556 557 /** 558 * Adds an additional bottom border to the overlay views in case 559 * the overlays do not fill the entire screen. 560 */ 561 private void positionAdditionalBottomBorder() { 562 final int lastBottom = mAdditionalBottomBorderOverlayTop; 563 final int containerHeight = webPxToScreenPx(mWebView.getContentHeight()); 564 final int speculativeHeight = containerHeight - lastBottom; 565 if (speculativeHeight > 0) { 566 if (mAdditionalBottomBorder == null) { 567 mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate( 568 R.layout.fake_bottom_border, this, false); 569 } 570 571 setAdditionalBottomBorderHeight(speculativeHeight); 572 573 if (!mAdditionalBottomBorderAdded) { 574 addViewInLayoutWrapper(mAdditionalBottomBorder); 575 mAdditionalBottomBorderAdded = true; 576 } 577 578 measureOverlayView(mAdditionalBottomBorder); 579 layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight); 580 } else { 581 if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) { 582 removeViewInLayout(mAdditionalBottomBorder); 583 mAdditionalBottomBorderAdded = false; 584 } 585 } 586 } 587 588 private void setAdditionalBottomBorderHeight(int speculativeHeight) { 589 LayoutParams params = mAdditionalBottomBorder.getLayoutParams(); 590 params.height = speculativeHeight; 591 mAdditionalBottomBorder.setLayoutParams(params); 592 } 593 594 private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, 595 final int withinTop, final int withinBottom, final int forceGravity) { 596 if (adapterItem.getHeight() == 0) { 597 // "place" invisible items at the bottom of their region to stay consistent with the 598 // stacking algorithm in positionOverlays(), unless gravity is forced to the top 599 final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom; 600 return new OverlayPosition(y, y); 601 } 602 603 final int v = ((forceGravity != Gravity.NO_GRAVITY) ? 604 forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK; 605 switch (v) { 606 case Gravity.BOTTOM: 607 return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom); 608 case Gravity.TOP: 609 return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight()); 610 default: 611 throw new UnsupportedOperationException("unsupported gravity: " + v); 612 } 613 } 614 615 /** 616 * Executes a measure pass over the specified child overlay view and returns the measured 617 * height. The measurement uses whatever the current container's width measure spec is. 618 * This method ignores view visibility and returns the height that the view would be if visible. 619 * 620 * @param overlayView an overlay view to measure. does not actually have to be attached yet. 621 * @return height that the view would be if it was visible 622 */ 623 public int measureOverlay(View overlayView) { 624 measureOverlayView(overlayView); 625 return overlayView.getMeasuredHeight(); 626 } 627 628 /** 629 * Copied/stolen from {@link ListView}. 630 */ 631 private void measureOverlayView(View child) { 632 MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams(); 633 if (p == null) { 634 p = (MarginLayoutParams) generateDefaultLayoutParams(); 635 } 636 637 int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, 638 getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width); 639 int lpHeight = p.height; 640 int childHeightSpec; 641 if (lpHeight > 0) { 642 childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); 643 } else { 644 childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 645 } 646 child.measure(childWidthSpec, childHeightSpec); 647 } 648 649 private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, 650 int overlayTop, int overlayBottom) { 651 // detach the view asynchronously, as scroll notification can happen during a draw, when 652 // it's not safe to remove children 653 654 // but immediately remove this view from the view set so future lookups don't find it 655 mOverlayViews.remove(adapterIndex); 656 657 post(new Runnable() { 658 @Override 659 public void run() { 660 detachOverlay(overlay); 661 } 662 }); 663 664 // push it out of view immediately 665 // otherwise this scrolled-off header will continue to draw until the runnable runs 666 layoutOverlay(overlay.view, overlayTop, overlayBottom); 667 } 668 669 /** 670 * Returns an existing scrap view, if available. The view will already be removed from the view 671 * hierarchy. This method will not remove the view from the scrap heap. 672 * 673 */ 674 public View getScrapView(int type) { 675 return mScrapViews.peek(type); 676 } 677 678 public void addScrapView(int type, View v) { 679 mScrapViews.add(type, v); 680 } 681 682 private void detachOverlay(OverlayView overlay) { 683 // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded 684 // because removing overlay views doesn't affect overall layout. 685 removeViewInLayout(overlay.view); 686 mScrapViews.add(overlay.itemType, overlay.view); 687 if (overlay.view instanceof DetachListener) { 688 ((DetachListener) overlay.view).onDetachedFromParent(); 689 } 690 } 691 692 @Override 693 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 694 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 695 if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { 696 LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", 697 MeasureSpec.toString(widthMeasureSpec), 698 MeasureSpec.toString(heightMeasureSpec)); 699 } 700 701 for (View nonScrollingChild : mNonScrollingChildren) { 702 if (nonScrollingChild.getVisibility() != GONE) { 703 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, 704 heightMeasureSpec, 0 /* heightUsed */); 705 } 706 } 707 mWidthMeasureSpec = widthMeasureSpec; 708 709 // onLayout will re-measure and re-position overlays for the new container size, but the 710 // spacer offsets would still need to be updated to have them draw at their new locations. 711 } 712 713 @Override 714 protected void onLayout(boolean changed, int l, int t, int r, int b) { 715 LogUtils.d(TAG, "*** IN header container onLayout"); 716 717 for (View nonScrollingChild : mNonScrollingChildren) { 718 if (nonScrollingChild.getVisibility() != GONE) { 719 final int w = nonScrollingChild.getMeasuredWidth(); 720 final int h = nonScrollingChild.getMeasuredHeight(); 721 722 final MarginLayoutParams lp = 723 (MarginLayoutParams) nonScrollingChild.getLayoutParams(); 724 725 final int childLeft = lp.leftMargin; 726 final int childTop = lp.topMargin; 727 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); 728 } 729 } 730 731 if (mOverlayAdapter != null) { 732 // being in a layout pass means overlay children may require measurement, 733 // so invalidate them 734 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 735 mOverlayAdapter.getItem(i).invalidateMeasurement(); 736 } 737 } 738 739 positionOverlays(0, mOffsetY); 740 } 741 742 @Override 743 protected void dispatchDraw(Canvas canvas) { 744 super.dispatchDraw(canvas); 745 746 if (mAttachedOverlaySinceLastDraw) { 747 drawChild(canvas, mTopMostOverlay, getDrawingTime()); 748 mAttachedOverlaySinceLastDraw = false; 749 } 750 } 751 752 @Override 753 protected LayoutParams generateDefaultLayoutParams() { 754 return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); 755 } 756 757 @Override 758 public LayoutParams generateLayoutParams(AttributeSet attrs) { 759 return new MarginLayoutParams(getContext(), attrs); 760 } 761 762 @Override 763 protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { 764 return new MarginLayoutParams(p); 765 } 766 767 @Override 768 protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { 769 return p instanceof MarginLayoutParams; 770 } 771 772 private int getOverlayTop(int spacerIndex) { 773 return webPxToScreenPx(mOverlayPositions[spacerIndex].top); 774 } 775 776 private int getOverlayBottom(int spacerIndex) { 777 return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); 778 } 779 780 private int webPxToScreenPx(int webPx) { 781 // TODO: round or truncate? 782 // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() 783 return (int) (webPx * mScale); 784 } 785 786 private void positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY) { 787 final OverlayView overlay = mOverlayViews.get(adapterIndex); 788 final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); 789 790 // save off the item's current top for later snap calculations 791 item.setTop(overlayTopY); 792 793 // is the overlay visible and does it have non-zero height? 794 if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY 795 && overlayTopY < mOffsetY + getHeight()) { 796 View overlayView = overlay != null ? overlay.view : null; 797 // show and/or move overlay 798 if (overlayView == null) { 799 overlayView = addOverlayView(adapterIndex); 800 measureOverlayView(overlayView); 801 item.markMeasurementValid(); 802 traceLayout("show/measure overlay %d", adapterIndex); 803 } else { 804 traceLayout("move overlay %d", adapterIndex); 805 if (!item.isMeasurementValid()) { 806 item.rebindView(overlayView); 807 measureOverlayView(overlayView); 808 item.markMeasurementValid(); 809 traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, 810 overlayView.getHeight(), overlayView.getMeasuredHeight()); 811 } 812 } 813 traceLayout("laying out overlay %d with h=%d", adapterIndex, 814 overlayView.getMeasuredHeight()); 815 final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); 816 layoutOverlay(overlayView, overlayTopY, childBottom); 817 mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? 818 childBottom : mAdditionalBottomBorderOverlayTop; 819 } else { 820 // hide overlay 821 if (overlay != null) { 822 traceLayout("hide overlay %d", adapterIndex); 823 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); 824 } else { 825 traceLayout("ignore non-visible overlay %d", adapterIndex); 826 } 827 mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) 828 ? overlayBottomY : mAdditionalBottomBorderOverlayTop; 829 } 830 831 if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { 832 if (mSnapIndex == -1) { 833 mSnapIndex = adapterIndex; 834 } else if (adapterIndex > mSnapIndex) { 835 mSnapIndex = adapterIndex; 836 } 837 } 838 839 } 840 841 // layout an existing view 842 // need its top offset into the conversation, its height, and the scroll offset 843 private void layoutOverlay(View child, int childTop, int childBottom) { 844 final int top = childTop - mOffsetY; 845 final int bottom = childBottom - mOffsetY; 846 847 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 848 final int childLeft = getPaddingLeft() + lp.leftMargin; 849 850 child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); 851 } 852 853 private View addOverlayView(int adapterIndex) { 854 final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); 855 final View convertView = mScrapViews.poll(itemType); 856 857 final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); 858 mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); 859 860 if (convertView == view) { 861 LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); 862 } else { 863 LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); 864 } 865 866 addViewInLayoutWrapper(view); 867 868 return view; 869 } 870 871 private void addViewInLayoutWrapper(View view) { 872 final int index = BOTTOM_LAYER_VIEW_IDS.length; 873 addViewInLayout(view, index, view.getLayoutParams(), true /* preventRequestLayout */); 874 mAttachedOverlaySinceLastDraw = true; 875 } 876 877 private boolean isSnapEnabled() { 878 if (mAccountController == null || mAccountController.getAccount() == null 879 || mAccountController.getAccount().settings == null) { 880 return true; 881 } 882 final int snap = mAccountController.getAccount().settings.snapHeaders; 883 return snap == UIProvider.SnapHeaderValue.ALWAYS || 884 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() 885 .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); 886 } 887 888 // render and/or re-position snap header 889 private void positionSnapHeader(int snapIndex) { 890 ConversationOverlayItem snapItem = null; 891 if (mSnapEnabled && snapIndex != -1) { 892 final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); 893 if (item.canBecomeSnapHeader()) { 894 snapItem = item; 895 } 896 } 897 if (snapItem == null) { 898 mSnapHeader.setVisibility(GONE); 899 mSnapHeader.unbind(); 900 return; 901 } 902 903 snapItem.bindView(mSnapHeader, false /* measureOnly */); 904 mSnapHeader.setVisibility(VISIBLE); 905 906 // overlap is negative or zero; bump the snap header upwards by that much 907 int overlap = 0; 908 909 final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); 910 if (next != null) { 911 overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); 912 913 // disable overlap drawing past a certain speed 914 if (overlap < 0) { 915 final Float v = mVelocityTracker.getSmoothedVelocity(); 916 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { 917 overlap = 0; 918 } 919 } 920 } 921 922 mSnapHeader.setTranslationY(overlap); 923 } 924 925 // find the next header that can push the snap header up 926 private ConversationOverlayItem findNextPushingOverlay(int start) { 927 for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { 928 final ConversationOverlayItem next = mOverlayAdapter.getItem(i); 929 if (next.canPushSnapHeader()) { 930 return next; 931 } 932 } 933 return null; 934 } 935 936 /** 937 * Return a collection of all currently visible overlay views, in no particular order. 938 * Please don't mess with them too badly (e.g. remove from parent). 939 * 940 */ 941 public List<View> getOverlayViews() { 942 final List<View> views = Lists.newArrayList(); 943 for (int i = 0, len = mOverlayViews.size(); i < len; i++) { 944 views.add(mOverlayViews.valueAt(i).view); 945 } 946 return views; 947 } 948 949 /** 950 * Prevents any layouts from happening until the next time 951 * {@link #onGeometryChange(OverlayPosition[])} is 952 * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. 953 * <p> 954 * If you call this, you must ensure that a followup call to 955 * {@link #onGeometryChange(OverlayPosition[])} 956 * is made later, when the HTML spacer coordinates are updated. 957 * 958 */ 959 public void invalidateSpacerGeometry() { 960 mOverlayPositions = null; 961 } 962 963 public void onGeometryChange(OverlayPosition[] overlayPositions) { 964 traceLayout("*** got overlay spacer positions:"); 965 for (OverlayPosition pos : overlayPositions) { 966 traceLayout("top=%d bottom=%d", pos.top, pos.bottom); 967 } 968 969 mOverlayPositions = overlayPositions; 970 positionOverlays(0, mOffsetY); 971 } 972 973 private void traceLayout(String msg, Object... params) { 974 if (mDisableLayoutTracing) { 975 return; 976 } 977 LogUtils.d(TAG, msg, params); 978 } 979 980 private class AdapterObserver extends DataSetObserver { 981 @Override 982 public void onChanged() { 983 onDataSetChanged(); 984 } 985 } 986} 987