ConversationContainer.java revision 24e052ad62145bfcb1189817e750e78b60c8645d
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.annotation.IdRes; 24import android.support.v4.view.ViewCompat; 25import android.util.AttributeSet; 26import android.util.SparseArray; 27import android.view.Gravity; 28import android.view.MotionEvent; 29import android.view.View; 30import android.view.ViewConfiguration; 31import android.view.ViewGroup; 32import android.webkit.WebView; 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.conversation_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.conversation_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 void setAccountController(ConversationAccountController controller) { 284 mAccountController = controller; 285 286// mSnapEnabled = isSnapEnabled(); 287 mSnapEnabled = false; // TODO - re-enable when dogfooders howl 288 } 289 290 /** 291 * Re-bind any existing views that correspond to the given adapter positions. 292 * 293 */ 294 public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) { 295 for (Integer i : affectedAdapterPositions) { 296 final ConversationOverlayItem item = mOverlayAdapter.getItem(i); 297 final OverlayView overlay = mOverlayViews.get(i); 298 if (overlay != null && overlay.view != null && item != null) { 299 item.onModelUpdated(overlay.view); 300 } 301 // update the snap header too, but only it's showing if the current item 302 if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) { 303 mSnapHeader.refresh(); 304 } 305 } 306 } 307 308 /** 309 * Return an overlay view for the given adapter item, or null if no matching view is currently 310 * visible. This can happen as you scroll away from an overlay view. 311 * 312 */ 313 public View getViewForItem(ConversationOverlayItem item) { 314 if (mOverlayAdapter == null) { 315 return null; 316 } 317 View result = null; 318 int adapterPos = -1; 319 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 320 if (mOverlayAdapter.getItem(i) == item) { 321 adapterPos = i; 322 break; 323 } 324 } 325 if (adapterPos != -1) { 326 final OverlayView overlay = mOverlayViews.get(adapterPos); 327 if (overlay != null) { 328 result = overlay.view; 329 } 330 } 331 return result; 332 } 333 334 private void clearOverlays() { 335 for (int i = 0, len = mOverlayViews.size(); i < len; i++) { 336 detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */); 337 } 338 mOverlayViews.clear(); 339 } 340 341 private void onDataSetChanged() { 342 // Recycle all views and re-bind them according to the current set of spacer coordinates. 343 // This essentially resets the overlay views and re-renders them. 344 // It's fast enough that it's okay to re-do all views on any small change, as long as 345 // the change isn't too frequent (< ~1Hz). 346 347 clearOverlays(); 348 // also unbind the snap header view, so this "reset" causes the snap header to re-create 349 // its view, just like all other headers 350 mSnapHeader.unbind(); 351 352 // also clear out the additional bottom border 353 removeViewInLayout(mAdditionalBottomBorder); 354 mAdditionalBottomBorderAdded = false; 355 356// mSnapEnabled = isSnapEnabled(); 357 mSnapEnabled = false; // TODO - re-enable when dogfooders howl 358 positionOverlays(mOffsetY, false /* postAddView */); 359 } 360 361 private void forwardFakeMotionEvent(MotionEvent original, int newAction) { 362 MotionEvent newEvent = MotionEvent.obtain(original); 363 newEvent.setAction(newAction); 364 mWebView.onTouchEvent(newEvent); 365 LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d", 366 newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(), 367 newEvent.getPointerCount()); 368 } 369 370 /** 371 * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}. 372 */ 373 @Override 374 public boolean onInterceptTouchEvent(MotionEvent ev) { 375 376 if (!mTouchInitialized) { 377 mTouchInitialized = true; 378 } 379 380 // no interception when WebView handles the first DOWN 381 if (mWebView.isHandlingTouch()) { 382 return false; 383 } 384 385 boolean intercept = false; 386 switch (ev.getActionMasked()) { 387 case MotionEvent.ACTION_POINTER_DOWN: 388 LogUtils.d(TAG, "Container is intercepting non-primary touch!"); 389 intercept = true; 390 mMissedPointerDown = true; 391 requestDisallowInterceptTouchEvent(true); 392 break; 393 394 case MotionEvent.ACTION_DOWN: 395 mLastMotionY = ev.getY(); 396 mActivePointerId = ev.getPointerId(0); 397 break; 398 399 case MotionEvent.ACTION_MOVE: 400 final int pointerIndex = ev.findPointerIndex(mActivePointerId); 401 final float y = ev.getY(pointerIndex); 402 final int yDiff = (int) Math.abs(y - mLastMotionY); 403 if (yDiff > mTouchSlop) { 404 mLastMotionY = y; 405 intercept = true; 406 } 407 break; 408 } 409 410// LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s", 411// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept); 412 return intercept; 413 } 414 415 @Override 416 public boolean onTouchEvent(MotionEvent ev) { 417 final int action = ev.getActionMasked(); 418 419 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { 420 mTouchIsDown = false; 421 } else if (!mTouchIsDown && 422 (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) { 423 424 forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN); 425 if (mMissedPointerDown) { 426 forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN); 427 mMissedPointerDown = false; 428 } 429 430 mTouchIsDown = true; 431 } 432 433 final boolean webViewResult = mWebView.onTouchEvent(ev); 434 435// LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d", 436// ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount()); 437 return webViewResult; 438 } 439 440 @Override 441 public void onNotifierScroll(final int y) { 442 mVelocityTracker.onInput(y); 443 mDisableLayoutTracing = true; 444 positionOverlays(y, true /* postAddView */); // post the addView since we're in draw code 445 mDisableLayoutTracing = false; 446 } 447 448 /** 449 * Positions the overlays given an updated y position for the container. 450 * @param y the current top position on screen 451 * @param postAddView If {@code true}, posts all calls to 452 * {@link #addViewInLayoutWrapper(android.view.View, boolean)} 453 * to the UI thread rather than adding it immediately. If {@code false}, 454 * calls {@link #addViewInLayoutWrapper(android.view.View, boolean)} 455 * immediately. 456 */ 457 private void positionOverlays(int y, boolean postAddView) { 458 mOffsetY = y; 459 460 /* 461 * The scale value that WebView reports is inaccurate when measured during WebView 462 * initialization. This bug is present in ICS, so to work around it, we ignore all 463 * reported values and use a calculated expected value from ConversationWebView instead. 464 * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin 465 * to pay attention to WebView-reported scale values. 466 */ 467 if (mTouchInitialized) { 468 mScale = mWebView.getScale(); 469 } else if (mScale == 0) { 470 mScale = mWebView.getInitialScale(); 471 } 472 traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(), 473 mScale); 474 475 if (mOverlayPositions == null || mOverlayAdapter == null) { 476 return; 477 } 478 479 // recycle scrolled-off views and add newly visible views 480 481 // we want consecutive spacers/overlays to stack towards the bottom 482 // so iterate from the bottom of the conversation up 483 // starting with the last spacer bottom and the last adapter item, position adapter views 484 // in a single stack until you encounter a non-contiguous expanded message header, 485 // then decrement to the next spacer. 486 487 traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length, 488 mOverlayAdapter.getCount()); 489 490 mSnapIndex = -1; 491 mAdditionalBottomBorderOverlayTop = 0; 492 493 int adapterLoopIndex = mOverlayAdapter.getCount() - 1; 494 int spacerIndex = mOverlayPositions.length - 1; 495 while (spacerIndex >= 0 && adapterLoopIndex >= 0) { 496 497 final int spacerTop = getOverlayTop(spacerIndex); 498 final int spacerBottom = getOverlayBottom(spacerIndex); 499 500 final boolean flip; 501 final int flipOffset; 502 final int forceGravity; 503 // flip direction from bottom->top to top->bottom traversal on the very first spacer 504 // to facilitate top-aligned headers at spacer index = 0 505 if (spacerIndex == 0) { 506 flip = true; 507 flipOffset = adapterLoopIndex; 508 forceGravity = Gravity.TOP; 509 } else { 510 flip = false; 511 flipOffset = 0; 512 forceGravity = Gravity.NO_GRAVITY; 513 } 514 515 int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; 516 517 // always place at least one overlay per spacer 518 ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex); 519 520 OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom, 521 forceGravity); 522 523 traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex, 524 itemPos.top, itemPos.bottom, adapterItem); 525 positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView); 526 527 // and keep stacking overlays unconditionally if we are on the first spacer, or as long 528 // as overlays are contiguous 529 while (--adapterLoopIndex >= 0) { 530 adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; 531 adapterItem = mOverlayAdapter.getItem(adapterIndex); 532 if (spacerIndex > 0 && !adapterItem.isContiguous()) { 533 // advance to the next spacer, but stay on this adapter item 534 break; 535 } 536 537 // place this overlay in the region of the spacer above or below the last item, 538 // depending on direction of iteration 539 final int regionTop = flip ? itemPos.bottom : spacerTop; 540 final int regionBottom = flip ? spacerBottom : itemPos.top; 541 itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity); 542 543 traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, 544 adapterIndex, itemPos.top, itemPos.bottom, adapterItem); 545 positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView); 546 } 547 548 spacerIndex--; 549 } 550 551 positionSnapHeader(mSnapIndex); 552 positionAdditionalBottomBorder(postAddView); 553 } 554 555 /** 556 * Adds an additional bottom border to the overlay views in case 557 * the overlays do not fill the entire screen. 558 */ 559 private void positionAdditionalBottomBorder(boolean postAddView) { 560 final int lastBottom = mAdditionalBottomBorderOverlayTop; 561 final int containerHeight = webPxToScreenPx(mWebView.getContentHeight()); 562 final int speculativeHeight = containerHeight - lastBottom; 563 if (speculativeHeight > 0) { 564 if (mAdditionalBottomBorder == null) { 565 mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate( 566 R.layout.fake_bottom_border, this, false); 567 } 568 569 setAdditionalBottomBorderHeight(speculativeHeight); 570 571 if (!mAdditionalBottomBorderAdded) { 572 addViewInLayoutWrapper(mAdditionalBottomBorder, postAddView); 573 mAdditionalBottomBorderAdded = true; 574 } 575 576 measureOverlayView(mAdditionalBottomBorder); 577 layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight); 578 } else { 579 if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) { 580 if (postAddView) { 581 post(mRemoveAdditionalBottomBorderRunnable); 582 } else { 583 mRemoveAdditionalBottomBorderRunnable.run(); 584 } 585 mAdditionalBottomBorderAdded = false; 586 } 587 } 588 } 589 590 private final RemoveViewRunnable mRemoveAdditionalBottomBorderRunnable = 591 new RemoveViewRunnable(mAdditionalBottomBorder); 592 593 private void setAdditionalBottomBorderHeight(int speculativeHeight) { 594 LayoutParams params = mAdditionalBottomBorder.getLayoutParams(); 595 params.height = speculativeHeight; 596 mAdditionalBottomBorder.setLayoutParams(params); 597 } 598 599 private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, 600 final int withinTop, final int withinBottom, final int forceGravity) { 601 if (adapterItem.getHeight() == 0) { 602 // "place" invisible items at the bottom of their region to stay consistent with the 603 // stacking algorithm in positionOverlays(), unless gravity is forced to the top 604 final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom; 605 return new OverlayPosition(y, y); 606 } 607 608 final int v = ((forceGravity != Gravity.NO_GRAVITY) ? 609 forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK; 610 switch (v) { 611 case Gravity.BOTTOM: 612 return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom); 613 case Gravity.TOP: 614 return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight()); 615 default: 616 throw new UnsupportedOperationException("unsupported gravity: " + v); 617 } 618 } 619 620 /** 621 * Executes a measure pass over the specified child overlay view and returns the measured 622 * height. The measurement uses whatever the current container's width measure spec is. 623 * This method ignores view visibility and returns the height that the view would be if visible. 624 * 625 * @param overlayView an overlay view to measure. does not actually have to be attached yet. 626 * @return height that the view would be if it was visible 627 */ 628 public int measureOverlay(View overlayView) { 629 measureOverlayView(overlayView); 630 return overlayView.getMeasuredHeight(); 631 } 632 633 /** 634 * Copied/stolen from {@link ListView}. 635 */ 636 private void measureOverlayView(View child) { 637 MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams(); 638 if (p == null) { 639 p = (MarginLayoutParams) generateDefaultLayoutParams(); 640 } 641 642 int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, 643 getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width); 644 int lpHeight = p.height; 645 int childHeightSpec; 646 if (lpHeight > 0) { 647 childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); 648 } else { 649 childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 650 } 651 child.measure(childWidthSpec, childHeightSpec); 652 } 653 654 private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, 655 int overlayTop, int overlayBottom) { 656 // immediately remove this view from the view set so future lookups don't find it 657 mOverlayViews.remove(adapterIndex); 658 659 // detach but don't actually remove from the view 660 detachOverlay(overlay, false /* removeFromContainer */); 661 662 // push it out of view immediately 663 // otherwise this scrolled-off header will continue to draw until the runnable runs 664 layoutOverlay(overlay.view, overlayTop, overlayBottom); 665 } 666 667 /** 668 * Returns an existing scrap view, if available. The view will already be removed from the view 669 * hierarchy. This method will not remove the view from the scrap heap. 670 * 671 */ 672 public View getScrapView(int type) { 673 return mScrapViews.peek(type); 674 } 675 676 public void addScrapView(int type, View v) { 677 mScrapViews.add(type, v); 678 addViewInLayoutWrapper(v, false /* postAddView */); 679 } 680 681 private void detachOverlay(OverlayView overlay, boolean removeFromContainer) { 682 // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded 683 // because removing overlay views doesn't affect overall layout. 684 if (removeFromContainer) { 685 removeViewInLayout(overlay.view); 686 } 687 mScrapViews.add(overlay.itemType, overlay.view); 688 if (overlay.view instanceof DetachListener) { 689 ((DetachListener) overlay.view).onDetachedFromParent(); 690 } 691 } 692 693 @Override 694 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 695 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 696 if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { 697 LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", 698 MeasureSpec.toString(widthMeasureSpec), 699 MeasureSpec.toString(heightMeasureSpec)); 700 } 701 702 for (View nonScrollingChild : mNonScrollingChildren) { 703 if (nonScrollingChild.getVisibility() != GONE) { 704 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, 705 heightMeasureSpec, 0 /* heightUsed */); 706 } 707 } 708 mWidthMeasureSpec = widthMeasureSpec; 709 710 // onLayout will re-measure and re-position overlays for the new container size, but the 711 // spacer offsets would still need to be updated to have them draw at their new locations. 712 } 713 714 @Override 715 protected void onLayout(boolean changed, int l, int t, int r, int b) { 716 LogUtils.d(TAG, "*** IN header container onLayout"); 717 718 for (View nonScrollingChild : mNonScrollingChildren) { 719 if (nonScrollingChild.getVisibility() != GONE) { 720 final int w = nonScrollingChild.getMeasuredWidth(); 721 final int h = nonScrollingChild.getMeasuredHeight(); 722 723 final MarginLayoutParams lp = 724 (MarginLayoutParams) nonScrollingChild.getLayoutParams(); 725 726 final int childLeft = lp.leftMargin; 727 final int childTop = lp.topMargin; 728 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); 729 } 730 } 731 732 if (mOverlayAdapter != null) { 733 // being in a layout pass means overlay children may require measurement, 734 // so invalidate them 735 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 736 mOverlayAdapter.getItem(i).invalidateMeasurement(); 737 } 738 } 739 740 positionOverlays(mOffsetY, false /* postAddView */); 741 } 742 743 @Override 744 protected LayoutParams generateDefaultLayoutParams() { 745 return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); 746 } 747 748 @Override 749 public LayoutParams generateLayoutParams(AttributeSet attrs) { 750 return new MarginLayoutParams(getContext(), attrs); 751 } 752 753 @Override 754 protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { 755 return new MarginLayoutParams(p); 756 } 757 758 @Override 759 protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { 760 return p instanceof MarginLayoutParams; 761 } 762 763 private int getOverlayTop(int spacerIndex) { 764 return webPxToScreenPx(mOverlayPositions[spacerIndex].top); 765 } 766 767 private int getOverlayBottom(int spacerIndex) { 768 return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); 769 } 770 771 private int webPxToScreenPx(int webPx) { 772 // TODO: round or truncate? 773 // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() 774 return (int) (webPx * mScale); 775 } 776 777 private void positionOverlay( 778 int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) { 779 final OverlayView overlay = mOverlayViews.get(adapterIndex); 780 final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); 781 782 // save off the item's current top for later snap calculations 783 item.setTop(overlayTopY); 784 785 // is the overlay visible and does it have non-zero height? 786 if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY 787 && overlayTopY < mOffsetY + getHeight()) { 788 View overlayView = overlay != null ? overlay.view : null; 789 // show and/or move overlay 790 if (overlayView == null) { 791 overlayView = addOverlayView(adapterIndex, postAddView); 792 ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this)); 793 measureOverlayView(overlayView); 794 item.markMeasurementValid(); 795 traceLayout("show/measure overlay %d", adapterIndex); 796 } else { 797 traceLayout("move overlay %d", adapterIndex); 798 if (!item.isMeasurementValid()) { 799 item.rebindView(overlayView); 800 measureOverlayView(overlayView); 801 item.markMeasurementValid(); 802 traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, 803 overlayView.getHeight(), overlayView.getMeasuredHeight()); 804 } 805 } 806 traceLayout("laying out overlay %d with h=%d", adapterIndex, 807 overlayView.getMeasuredHeight()); 808 final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); 809 layoutOverlay(overlayView, overlayTopY, childBottom); 810 mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? 811 childBottom : mAdditionalBottomBorderOverlayTop; 812 } else { 813 // hide overlay 814 if (overlay != null) { 815 traceLayout("hide overlay %d", adapterIndex); 816 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); 817 } else { 818 traceLayout("ignore non-visible overlay %d", adapterIndex); 819 } 820 mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) 821 ? overlayBottomY : mAdditionalBottomBorderOverlayTop; 822 } 823 824 if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { 825 if (mSnapIndex == -1) { 826 mSnapIndex = adapterIndex; 827 } else if (adapterIndex > mSnapIndex) { 828 mSnapIndex = adapterIndex; 829 } 830 } 831 832 } 833 834 // layout an existing view 835 // need its top offset into the conversation, its height, and the scroll offset 836 private void layoutOverlay(View child, int childTop, int childBottom) { 837 final int top = childTop - mOffsetY; 838 final int bottom = childBottom - mOffsetY; 839 840 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 841 final int childLeft = getPaddingLeft() + lp.leftMargin; 842 843 child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); 844 } 845 846 private View addOverlayView(int adapterIndex, boolean postAddView) { 847 final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); 848 final View convertView = mScrapViews.poll(itemType); 849 850 final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); 851 mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); 852 853 if (convertView == view) { 854 LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); 855 } else { 856 LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); 857 } 858 859 if (view.getParent() == null) { 860 addViewInLayoutWrapper(view, postAddView); 861 } else { 862 // Need to call postInvalidate since the view is being moved back on 863 // screen and we want to force it to draw the view. Without doing this, 864 // the view may not draw itself when it comes back on screen. 865 view.postInvalidate(); 866 } 867 868 return view; 869 } 870 871 private void addViewInLayoutWrapper(View view, boolean postAddView) { 872 final AddViewRunnable addviewRunnable = new AddViewRunnable(view); 873 if (postAddView) { 874 post(addviewRunnable); 875 } else { 876 addviewRunnable.run(); 877 } 878 } 879 880 private class AddViewRunnable implements Runnable { 881 private final View mView; 882 883 public AddViewRunnable(View view) { 884 mView = view; 885 } 886 887 @Override 888 public void run() { 889 final int index = BOTTOM_LAYER_VIEW_IDS.length; 890 addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */); 891 } 892 }; 893 894 private class RemoveViewRunnable implements Runnable { 895 private final View mView; 896 897 private RemoveViewRunnable(View view) { 898 mView = view; 899 } 900 901 @Override 902 public void run() { 903 removeViewInLayout(mView); 904 } 905 } 906 907 private boolean isSnapEnabled() { 908 if (mAccountController == null || mAccountController.getAccount() == null 909 || mAccountController.getAccount().settings == null) { 910 return true; 911 } 912 final int snap = mAccountController.getAccount().settings.snapHeaders; 913 return snap == UIProvider.SnapHeaderValue.ALWAYS || 914 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() 915 .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); 916 } 917 918 // render and/or re-position snap header 919 private void positionSnapHeader(int snapIndex) { 920 ConversationOverlayItem snapItem = null; 921 if (mSnapEnabled && snapIndex != -1) { 922 final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); 923 if (item.canBecomeSnapHeader()) { 924 snapItem = item; 925 } 926 } 927 if (snapItem == null) { 928 mSnapHeader.setVisibility(GONE); 929 mSnapHeader.unbind(); 930 return; 931 } 932 933 snapItem.bindView(mSnapHeader, false /* measureOnly */); 934 mSnapHeader.setVisibility(VISIBLE); 935 936 // overlap is negative or zero; bump the snap header upwards by that much 937 int overlap = 0; 938 939 final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); 940 if (next != null) { 941 overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); 942 943 // disable overlap drawing past a certain speed 944 if (overlap < 0) { 945 final Float v = mVelocityTracker.getSmoothedVelocity(); 946 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { 947 overlap = 0; 948 } 949 } 950 } 951 952 mSnapHeader.setTranslationY(overlap); 953 } 954 955 // find the next header that can push the snap header up 956 private ConversationOverlayItem findNextPushingOverlay(int start) { 957 for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { 958 final ConversationOverlayItem next = mOverlayAdapter.getItem(i); 959 if (next.canPushSnapHeader()) { 960 return next; 961 } 962 } 963 return null; 964 } 965 966 /** 967 * Prevents any layouts from happening until the next time 968 * {@link #onGeometryChange(OverlayPosition[])} is 969 * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. 970 * <p> 971 * If you call this, you must ensure that a followup call to 972 * {@link #onGeometryChange(OverlayPosition[])} 973 * is made later, when the HTML spacer coordinates are updated. 974 * 975 */ 976 public void invalidateSpacerGeometry() { 977 mOverlayPositions = null; 978 } 979 980 public void onGeometryChange(OverlayPosition[] overlayPositions) { 981 traceLayout("*** got overlay spacer positions:"); 982 for (OverlayPosition pos : overlayPositions) { 983 traceLayout("top=%d bottom=%d", pos.top, pos.bottom); 984 } 985 986 mOverlayPositions = overlayPositions; 987 positionOverlays(mOffsetY, false /* postAddView */); 988 } 989 990 /** 991 * Remove the view that corresponds to the item in the {@link ConversationViewAdapter} 992 * at the specified index.<p/> 993 * 994 * <b>Note:</b> the view is actually pushed off-screen and recycled 995 * as though it were scrolled off. 996 * @param adapterIndex The index for the view in the adapter. 997 */ 998 public void removeViewAtAdapterIndex(int adapterIndex) { 999 // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen. 1000 final int offsetY = mOffsetY; 1001 mOffsetY = 0; 1002 final OverlayView overlay = mOverlayViews.get(adapterIndex); 1003 if (overlay != null) { 1004 final int height = getHeight(); 1005 onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight()); 1006 } 1007 // restore the offset to its original value after the view has been moved off-screen. 1008 mOffsetY = offsetY; 1009 } 1010 1011 private void traceLayout(String msg, Object... params) { 1012 if (mDisableLayoutTracing) { 1013 return; 1014 } 1015 LogUtils.d(TAG, msg, params); 1016 } 1017 1018 public void focusFirstMessageHeader() { 1019 mOverlayAdapter.focusFirstMessageHeader(); 1020 } 1021 1022 public int getOverlayCount() { 1023 return mOverlayAdapter.getCount(); 1024 } 1025 1026 public int getViewPosition(View v) { 1027 return mOverlayAdapter.getViewPosition(v); 1028 } 1029 1030 public View getNextOverlayView(int position, boolean isDown) { 1031 return mOverlayAdapter.getNextOverlayView(position, isDown); 1032 } 1033 1034 public boolean shouldInterceptLeftRightEvents(@IdRes int id, boolean isLeft, boolean isRight, 1035 boolean twoPaneLand) { 1036 return mOverlayAdapter.shouldInterceptLeftRightEvents(id, isLeft, isRight, twoPaneLand); 1037 } 1038 1039 public boolean shouldNavigateAway(@IdRes int id, boolean isLeft, boolean twoPaneLand) { 1040 return mOverlayAdapter.shouldNavigateAway(id, isLeft, twoPaneLand); 1041 } 1042 1043 private class AdapterObserver extends DataSetObserver { 1044 @Override 1045 public void onChanged() { 1046 onDataSetChanged(); 1047 } 1048 } 1049} 1050