ConversationContainer.java revision 59ccec3db4710f2aea6a4a9a30160ad19331367d
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.support.v4.view.ViewCompat; 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.<br/><br/> 66 * 67 * There is one additional constraint with the recycling: since scroll 68 * notifications happen during the WebView's draw, we do not remove and re-add views for recycling. 69 * Instead, we simply move the views off-screen and add them to our recycle cache. When the views 70 * are reused, they are simply moved back on screen instead of added. This practice 71 * circumvents the issues found when views are added or removed during draw (which results in 72 * elements not being drawn and other visual oddities). See b/10994303 for more details. 73 */ 74public class ConversationContainer extends ViewGroup implements ScrollListener { 75 private static final String TAG = ConversationViewFragment.LAYOUT_TAG; 76 77 private static final int[] BOTTOM_LAYER_VIEW_IDS = { 78 R.id.webview 79 }; 80 81 private static final int[] TOP_LAYER_VIEW_IDS = { 82 R.id.conversation_topmost_overlay 83 }; 84 85 /** 86 * Maximum scroll speed (in dp/sec) at which the snap header animation will draw. 87 * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect). 88 */ 89 private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f; 90 91 private ConversationAccountController mAccountController; 92 private ConversationViewAdapter mOverlayAdapter; 93 private OverlayPosition[] mOverlayPositions; 94 private ConversationWebView mWebView; 95 private SnapHeader mSnapHeader; 96 97 private final List<View> mNonScrollingChildren = Lists.newArrayList(); 98 99 /** 100 * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual 101 * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other. 102 */ 103 private float mScale; 104 /** 105 * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale 106 * values. 107 */ 108 private boolean mTouchInitialized; 109 110 /** 111 * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}. 112 */ 113 private final int mTouchSlop; 114 /** 115 * Current scroll position, as dictated by the background {@link WebView}. 116 */ 117 private int mOffsetY; 118 /** 119 * Original pointer Y for slop calculation. 120 */ 121 private float mLastMotionY; 122 /** 123 * Original pointer ID for slop calculation. 124 */ 125 private int mActivePointerId; 126 /** 127 * Track pointer up/down state to know whether to send a make-up DOWN event to WebView. 128 * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be 129 * preceded by a {@link MotionEvent#ACTION_DOWN} event. 130 */ 131 private boolean mTouchIsDown = false; 132 /** 133 * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN}, 134 * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}. 135 */ 136 private boolean mMissedPointerDown; 137 138 /** 139 * A recycler that holds removed scrap views, organized by integer item view type. All views 140 * in this data structure should be removed from their view parent prior to insertion. 141 */ 142 private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>(); 143 144 /** 145 * The current set of overlay views in the view hierarchy. Looking through this map is faster 146 * than traversing the view hierarchy. 147 * <p> 148 * WebView sometimes notifies of scroll changes during a draw (or display list generation), when 149 * it's not safe to detach view children because ViewGroup is in the middle of iterating over 150 * its child array. So we remove any child from this list immediately and queue up a task to 151 * detach it later. Since nobody other than the detach task references that view in the 152 * meantime, we don't need any further checks or synchronization. 153 * <p> 154 * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose 155 * of all views (on data set or adapter change), we can at least recycle them into the typed 156 * scrap piles for later reuse. 157 */ 158 private final SparseArray<OverlayView> mOverlayViews; 159 160 private int mWidthMeasureSpec; 161 162 private boolean mDisableLayoutTracing; 163 164 private final InputSmoother mVelocityTracker; 165 166 private final DataSetObserver mAdapterObserver = new AdapterObserver(); 167 168 /** 169 * The adapter index of the lowest overlay item that is above the top of the screen and reports 170 * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through 171 * {@link #positionOverlays}. 172 * 173 */ 174 private int mSnapIndex; 175 176 private boolean mSnapEnabled; 177 178 /** 179 * A View that fills the remaining vertical space when the overlays do not take 180 * up the entire container. Otherwise, a card-like bottom white space appears. 181 */ 182 private View mAdditionalBottomBorder; 183 184 /** 185 * A flag denoting whether the fake bottom border has been added to the container. 186 */ 187 private boolean mAdditionalBottomBorderAdded; 188 189 /** 190 * An int containing the potential top value for the additional bottom border. 191 * If this value is less than the height of the scroll container, the additional 192 * bottom border will be drawn. 193 */ 194 private int mAdditionalBottomBorderOverlayTop; 195 196 /** 197 * Child views of this container should implement this interface to be notified when they are 198 * being detached. 199 */ 200 public interface DetachListener { 201 /** 202 * Called on a child view when it is removed from its parent as part of 203 * {@link ConversationContainer} view recycling. 204 */ 205 void onDetachedFromParent(); 206 } 207 208 public static class OverlayPosition { 209 public final int top; 210 public final int bottom; 211 212 public OverlayPosition(int top, int bottom) { 213 this.top = top; 214 this.bottom = bottom; 215 } 216 } 217 218 private static class OverlayView { 219 public View view; 220 int itemType; 221 222 public OverlayView(View view, int itemType) { 223 this.view = view; 224 this.itemType = itemType; 225 } 226 } 227 228 public ConversationContainer(Context c) { 229 this(c, null); 230 } 231 232 public ConversationContainer(Context c, AttributeSet attrs) { 233 super(c, attrs); 234 235 mOverlayViews = new SparseArray<OverlayView>(); 236 237 mVelocityTracker = new InputSmoother(c); 238 239 mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop(); 240 241 // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the 242 // WebView and the second pointer goes down on an overlay view. 243 // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer 244 // goes down on an overlay view. 245 setMotionEventSplittingEnabled(false); 246 } 247 248 @Override 249 protected void onFinishInflate() { 250 super.onFinishInflate(); 251 252 mWebView = (ConversationWebView) findViewById(R.id.webview); 253 mWebView.addScrollListener(this); 254 255 for (int id : BOTTOM_LAYER_VIEW_IDS) { 256 mNonScrollingChildren.add(findViewById(id)); 257 } 258 for (int id : TOP_LAYER_VIEW_IDS) { 259 mNonScrollingChildren.add(findViewById(id)); 260 } 261 } 262 263 public void setupSnapHeader() { 264 mSnapHeader = (SnapHeader) findViewById(R.id.snap_header); 265 mSnapHeader.setSnappy(); 266 } 267 268 public SnapHeader getSnapHeader() { 269 return mSnapHeader; 270 } 271 272 public void setOverlayAdapter(ConversationViewAdapter a) { 273 if (mOverlayAdapter != null) { 274 mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver); 275 clearOverlays(); 276 } 277 mOverlayAdapter = a; 278 if (mOverlayAdapter != null) { 279 mOverlayAdapter.registerDataSetObserver(mAdapterObserver); 280 } 281 } 282 283 public Adapter getOverlayAdapter() { 284 return mOverlayAdapter; 285 } 286 287 public void setAccountController(ConversationAccountController controller) { 288 mAccountController = controller; 289 290 mSnapEnabled = isSnapEnabled(); 291 } 292 293 /** 294 * Re-bind any existing views that correspond to the given adapter positions. 295 * 296 */ 297 public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) { 298 for (Integer i : affectedAdapterPositions) { 299 final ConversationOverlayItem item = mOverlayAdapter.getItem(i); 300 final OverlayView overlay = mOverlayViews.get(i); 301 if (overlay != null && overlay.view != null && item != null) { 302 item.onModelUpdated(overlay.view); 303 } 304 // update the snap header too, but only it's showing if the current item 305 if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) { 306 mSnapHeader.refresh(); 307 } 308 } 309 } 310 311 /** 312 * Return an overlay view for the given adapter item, or null if no matching view is currently 313 * visible. This can happen as you scroll away from an overlay view. 314 * 315 */ 316 public View getViewForItem(ConversationOverlayItem item) { 317 if (mOverlayAdapter == null) { 318 return null; 319 } 320 View result = null; 321 int adapterPos = -1; 322 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 323 if (mOverlayAdapter.getItem(i) == item) { 324 adapterPos = i; 325 break; 326 } 327 } 328 if (adapterPos != -1) { 329 final OverlayView overlay = mOverlayViews.get(adapterPos); 330 if (overlay != null) { 331 result = overlay.view; 332 } 333 } 334 return result; 335 } 336 337 private void clearOverlays() { 338 for (int i = 0, len = mOverlayViews.size(); i < len; i++) { 339 detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */); 340 } 341 mOverlayViews.clear(); 342 } 343 344 private void onDataSetChanged() { 345 // Recycle all views and re-bind them according to the current set of spacer coordinates. 346 // This essentially resets the overlay views and re-renders them. 347 // It's fast enough that it's okay to re-do all views on any small change, as long as 348 // the change isn't too frequent (< ~1Hz). 349 350 clearOverlays(); 351 // also unbind the snap header view, so this "reset" causes the snap header to re-create 352 // its view, just like all other headers 353 mSnapHeader.unbind(); 354 355 // also clear out the additional bottom border 356 removeViewInLayout(mAdditionalBottomBorder); 357 mAdditionalBottomBorderAdded = false; 358 359 mSnapEnabled = isSnapEnabled(); 360 positionOverlays(mOffsetY, false /* postAddView */); 361 } 362 363 private void forwardFakeMotionEvent(MotionEvent original, int newAction) { 364 MotionEvent newEvent = MotionEvent.obtain(original); 365 newEvent.setAction(newAction); 366 mWebView.onTouchEvent(newEvent); 367 LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d", 368 newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(), 369 newEvent.getPointerCount()); 370 } 371 372 /** 373 * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}. 374 */ 375 @Override 376 public boolean onInterceptTouchEvent(MotionEvent ev) { 377 378 if (!mTouchInitialized) { 379 mTouchInitialized = true; 380 } 381 382 // no interception when WebView handles the first DOWN 383 if (mWebView.isHandlingTouch()) { 384 return false; 385 } 386 387 boolean intercept = false; 388 switch (ev.getActionMasked()) { 389 case MotionEvent.ACTION_POINTER_DOWN: 390 LogUtils.d(TAG, "Container is intercepting non-primary touch!"); 391 intercept = true; 392 mMissedPointerDown = true; 393 requestDisallowInterceptTouchEvent(true); 394 break; 395 396 case MotionEvent.ACTION_DOWN: 397 mLastMotionY = ev.getY(); 398 mActivePointerId = ev.getPointerId(0); 399 break; 400 401 case MotionEvent.ACTION_MOVE: 402 final int pointerIndex = ev.findPointerIndex(mActivePointerId); 403 final float y = ev.getY(pointerIndex); 404 final int yDiff = (int) Math.abs(y - mLastMotionY); 405 if (yDiff > mTouchSlop) { 406 mLastMotionY = y; 407 intercept = true; 408 } 409 break; 410 } 411 412// LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s", 413// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept); 414 return intercept; 415 } 416 417 @Override 418 public boolean onTouchEvent(MotionEvent ev) { 419 final int action = ev.getActionMasked(); 420 421 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { 422 mTouchIsDown = false; 423 } else if (!mTouchIsDown && 424 (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) { 425 426 forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN); 427 if (mMissedPointerDown) { 428 forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN); 429 mMissedPointerDown = false; 430 } 431 432 mTouchIsDown = true; 433 } 434 435 final boolean webViewResult = mWebView.onTouchEvent(ev); 436 437// LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d", 438// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount()); 439 return webViewResult; 440 } 441 442 @Override 443 public void onNotifierScroll(final int y) { 444 mVelocityTracker.onInput(y); 445 mDisableLayoutTracing = true; 446 positionOverlays(y, true /* postAddView */); // post the addView since we're in draw code 447 mDisableLayoutTracing = false; 448 } 449 450 /** 451 * Positions the overlays given an updated y position for the container. 452 * @param y the current top position on screen 453 * @param postAddView If {@code true}, posts all calls to 454 * {@link #addViewInLayoutWrapper(android.view.View, boolean)} 455 * to the UI thread rather than adding it immediately. If {@code false}, 456 * calls {@link #addViewInLayoutWrapper(android.view.View, boolean)} 457 * immediately. 458 */ 459 private void positionOverlays(int y, boolean postAddView) { 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, postAddView); 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, postAddView); 548 } 549 550 spacerIndex--; 551 } 552 553 positionSnapHeader(mSnapIndex); 554 positionAdditionalBottomBorder(postAddView); 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(boolean postAddView) { 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, postAddView); 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 // immediately remove this view from the view set so future lookups don't find it 652 mOverlayViews.remove(adapterIndex); 653 654 // detach but don't actually remove from the view 655 detachOverlay(overlay, false /* removeFromContainer */); 656 657 // push it out of view immediately 658 // otherwise this scrolled-off header will continue to draw until the runnable runs 659 layoutOverlay(overlay.view, overlayTop, overlayBottom); 660 } 661 662 /** 663 * Returns an existing scrap view, if available. The view will already be removed from the view 664 * hierarchy. This method will not remove the view from the scrap heap. 665 * 666 */ 667 public View getScrapView(int type) { 668 return mScrapViews.peek(type); 669 } 670 671 public void addScrapView(int type, View v) { 672 mScrapViews.add(type, v); 673 addViewInLayoutWrapper(v, false /* postAddView */); 674 } 675 676 private void detachOverlay(OverlayView overlay, boolean removeFromContainer) { 677 // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded 678 // because removing overlay views doesn't affect overall layout. 679 if (removeFromContainer) { 680 removeViewInLayout(overlay.view); 681 } 682 mScrapViews.add(overlay.itemType, overlay.view); 683 if (overlay.view instanceof DetachListener) { 684 ((DetachListener) overlay.view).onDetachedFromParent(); 685 } 686 } 687 688 @Override 689 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 690 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 691 if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { 692 LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", 693 MeasureSpec.toString(widthMeasureSpec), 694 MeasureSpec.toString(heightMeasureSpec)); 695 } 696 697 for (View nonScrollingChild : mNonScrollingChildren) { 698 if (nonScrollingChild.getVisibility() != GONE) { 699 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, 700 heightMeasureSpec, 0 /* heightUsed */); 701 } 702 } 703 mWidthMeasureSpec = widthMeasureSpec; 704 705 // onLayout will re-measure and re-position overlays for the new container size, but the 706 // spacer offsets would still need to be updated to have them draw at their new locations. 707 } 708 709 @Override 710 protected void onLayout(boolean changed, int l, int t, int r, int b) { 711 LogUtils.d(TAG, "*** IN header container onLayout"); 712 713 for (View nonScrollingChild : mNonScrollingChildren) { 714 if (nonScrollingChild.getVisibility() != GONE) { 715 final int w = nonScrollingChild.getMeasuredWidth(); 716 final int h = nonScrollingChild.getMeasuredHeight(); 717 718 final MarginLayoutParams lp = 719 (MarginLayoutParams) nonScrollingChild.getLayoutParams(); 720 721 final int childLeft = lp.leftMargin; 722 final int childTop = lp.topMargin; 723 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); 724 } 725 } 726 727 if (mOverlayAdapter != null) { 728 // being in a layout pass means overlay children may require measurement, 729 // so invalidate them 730 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 731 mOverlayAdapter.getItem(i).invalidateMeasurement(); 732 } 733 } 734 735 positionOverlays(mOffsetY, false /* postAddView */); 736 } 737 738 @Override 739 protected LayoutParams generateDefaultLayoutParams() { 740 return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); 741 } 742 743 @Override 744 public LayoutParams generateLayoutParams(AttributeSet attrs) { 745 return new MarginLayoutParams(getContext(), attrs); 746 } 747 748 @Override 749 protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { 750 return new MarginLayoutParams(p); 751 } 752 753 @Override 754 protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { 755 return p instanceof MarginLayoutParams; 756 } 757 758 private int getOverlayTop(int spacerIndex) { 759 return webPxToScreenPx(mOverlayPositions[spacerIndex].top); 760 } 761 762 private int getOverlayBottom(int spacerIndex) { 763 return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); 764 } 765 766 private int webPxToScreenPx(int webPx) { 767 // TODO: round or truncate? 768 // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() 769 return (int) (webPx * mScale); 770 } 771 772 private void positionOverlay( 773 int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) { 774 final OverlayView overlay = mOverlayViews.get(adapterIndex); 775 final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); 776 777 // save off the item's current top for later snap calculations 778 item.setTop(overlayTopY); 779 780 // is the overlay visible and does it have non-zero height? 781 if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY 782 && overlayTopY < mOffsetY + getHeight()) { 783 View overlayView = overlay != null ? overlay.view : null; 784 // show and/or move overlay 785 if (overlayView == null) { 786 overlayView = addOverlayView(adapterIndex, postAddView); 787 ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this)); 788 measureOverlayView(overlayView); 789 item.markMeasurementValid(); 790 traceLayout("show/measure overlay %d", adapterIndex); 791 } else { 792 traceLayout("move overlay %d", adapterIndex); 793 if (!item.isMeasurementValid()) { 794 item.rebindView(overlayView); 795 measureOverlayView(overlayView); 796 item.markMeasurementValid(); 797 traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, 798 overlayView.getHeight(), overlayView.getMeasuredHeight()); 799 } 800 } 801 traceLayout("laying out overlay %d with h=%d", adapterIndex, 802 overlayView.getMeasuredHeight()); 803 final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); 804 layoutOverlay(overlayView, overlayTopY, childBottom); 805 mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? 806 childBottom : mAdditionalBottomBorderOverlayTop; 807 } else { 808 // hide overlay 809 if (overlay != null) { 810 traceLayout("hide overlay %d", adapterIndex); 811 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); 812 } else { 813 traceLayout("ignore non-visible overlay %d", adapterIndex); 814 } 815 mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) 816 ? overlayBottomY : mAdditionalBottomBorderOverlayTop; 817 } 818 819 if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { 820 if (mSnapIndex == -1) { 821 mSnapIndex = adapterIndex; 822 } else if (adapterIndex > mSnapIndex) { 823 mSnapIndex = adapterIndex; 824 } 825 } 826 827 } 828 829 // layout an existing view 830 // need its top offset into the conversation, its height, and the scroll offset 831 private void layoutOverlay(View child, int childTop, int childBottom) { 832 final int top = childTop - mOffsetY; 833 final int bottom = childBottom - mOffsetY; 834 835 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 836 final int childLeft = getPaddingLeft() + lp.leftMargin; 837 838 child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); 839 } 840 841 private View addOverlayView(int adapterIndex, boolean postAddView) { 842 final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); 843 final View convertView = mScrapViews.poll(itemType); 844 845 final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); 846 mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); 847 848 if (convertView == view) { 849 LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); 850 } else { 851 LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); 852 } 853 854 if (view.getParent() == null) { 855 addViewInLayoutWrapper(view, postAddView); 856 } else { 857 // Need to call postInvalidate since the view is being moved back on 858 // screen and we want to force it to draw the view. Without doing this, 859 // the view may not draw itself when it comes back on screen. 860 view.postInvalidate(); 861 } 862 863 return view; 864 } 865 866 private void addViewInLayoutWrapper(View view, boolean postAddView) { 867 final AddViewRunnable addviewRunnable = new AddViewRunnable(view); 868 if (postAddView) { 869 post(addviewRunnable); 870 } else { 871 addviewRunnable.run(); 872 } 873 } 874 875 private class AddViewRunnable implements Runnable { 876 public final View mView; 877 878 public AddViewRunnable(View view) { 879 mView = view; 880 } 881 882 @Override 883 public void run() { 884 final int index = BOTTOM_LAYER_VIEW_IDS.length; 885 addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */); 886 } 887 }; 888 889 private boolean isSnapEnabled() { 890 if (mAccountController == null || mAccountController.getAccount() == null 891 || mAccountController.getAccount().settings == null) { 892 return true; 893 } 894 final int snap = mAccountController.getAccount().settings.snapHeaders; 895 return snap == UIProvider.SnapHeaderValue.ALWAYS || 896 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() 897 .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); 898 } 899 900 // render and/or re-position snap header 901 private void positionSnapHeader(int snapIndex) { 902 ConversationOverlayItem snapItem = null; 903 if (mSnapEnabled && snapIndex != -1) { 904 final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); 905 if (item.canBecomeSnapHeader()) { 906 snapItem = item; 907 } 908 } 909 if (snapItem == null) { 910 mSnapHeader.setVisibility(GONE); 911 mSnapHeader.unbind(); 912 return; 913 } 914 915 snapItem.bindView(mSnapHeader, false /* measureOnly */); 916 mSnapHeader.setVisibility(VISIBLE); 917 918 // overlap is negative or zero; bump the snap header upwards by that much 919 int overlap = 0; 920 921 final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); 922 if (next != null) { 923 overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); 924 925 // disable overlap drawing past a certain speed 926 if (overlap < 0) { 927 final Float v = mVelocityTracker.getSmoothedVelocity(); 928 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { 929 overlap = 0; 930 } 931 } 932 } 933 934 mSnapHeader.setTranslationY(overlap); 935 } 936 937 // find the next header that can push the snap header up 938 private ConversationOverlayItem findNextPushingOverlay(int start) { 939 for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { 940 final ConversationOverlayItem next = mOverlayAdapter.getItem(i); 941 if (next.canPushSnapHeader()) { 942 return next; 943 } 944 } 945 return null; 946 } 947 948 /** 949 * Prevents any layouts from happening until the next time 950 * {@link #onGeometryChange(OverlayPosition[])} is 951 * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. 952 * <p> 953 * If you call this, you must ensure that a followup call to 954 * {@link #onGeometryChange(OverlayPosition[])} 955 * is made later, when the HTML spacer coordinates are updated. 956 * 957 */ 958 public void invalidateSpacerGeometry() { 959 mOverlayPositions = null; 960 } 961 962 public void onGeometryChange(OverlayPosition[] overlayPositions) { 963 traceLayout("*** got overlay spacer positions:"); 964 for (OverlayPosition pos : overlayPositions) { 965 traceLayout("top=%d bottom=%d", pos.top, pos.bottom); 966 } 967 968 mOverlayPositions = overlayPositions; 969 positionOverlays(mOffsetY, false /* postAddView */); 970 } 971 972 private void traceLayout(String msg, Object... params) { 973 if (mDisableLayoutTracing) { 974 return; 975 } 976 LogUtils.d(TAG, msg, params); 977 } 978 979 private class AdapterObserver extends DataSetObserver { 980 @Override 981 public void onChanged() { 982 onDataSetChanged(); 983 } 984 } 985} 986