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 removeViewInLayout(mAdditionalBottomBorder); 581 mAdditionalBottomBorderAdded = false; 582 } 583 } 584 } 585 586 private void setAdditionalBottomBorderHeight(int speculativeHeight) { 587 LayoutParams params = mAdditionalBottomBorder.getLayoutParams(); 588 params.height = speculativeHeight; 589 mAdditionalBottomBorder.setLayoutParams(params); 590 } 591 592 private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, 593 final int withinTop, final int withinBottom, final int forceGravity) { 594 if (adapterItem.getHeight() == 0) { 595 // "place" invisible items at the bottom of their region to stay consistent with the 596 // stacking algorithm in positionOverlays(), unless gravity is forced to the top 597 final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom; 598 return new OverlayPosition(y, y); 599 } 600 601 final int v = ((forceGravity != Gravity.NO_GRAVITY) ? 602 forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK; 603 switch (v) { 604 case Gravity.BOTTOM: 605 return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom); 606 case Gravity.TOP: 607 return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight()); 608 default: 609 throw new UnsupportedOperationException("unsupported gravity: " + v); 610 } 611 } 612 613 /** 614 * Executes a measure pass over the specified child overlay view and returns the measured 615 * height. The measurement uses whatever the current container's width measure spec is. 616 * This method ignores view visibility and returns the height that the view would be if visible. 617 * 618 * @param overlayView an overlay view to measure. does not actually have to be attached yet. 619 * @return height that the view would be if it was visible 620 */ 621 public int measureOverlay(View overlayView) { 622 measureOverlayView(overlayView); 623 return overlayView.getMeasuredHeight(); 624 } 625 626 /** 627 * Copied/stolen from {@link ListView}. 628 */ 629 private void measureOverlayView(View child) { 630 MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams(); 631 if (p == null) { 632 p = (MarginLayoutParams) generateDefaultLayoutParams(); 633 } 634 635 int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, 636 getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width); 637 int lpHeight = p.height; 638 int childHeightSpec; 639 if (lpHeight > 0) { 640 childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); 641 } else { 642 childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 643 } 644 child.measure(childWidthSpec, childHeightSpec); 645 } 646 647 private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, 648 int overlayTop, int overlayBottom) { 649 // immediately remove this view from the view set so future lookups don't find it 650 mOverlayViews.remove(adapterIndex); 651 652 // detach but don't actually remove from the view 653 detachOverlay(overlay, false /* removeFromContainer */); 654 655 // push it out of view immediately 656 // otherwise this scrolled-off header will continue to draw until the runnable runs 657 layoutOverlay(overlay.view, overlayTop, overlayBottom); 658 } 659 660 /** 661 * Returns an existing scrap view, if available. The view will already be removed from the view 662 * hierarchy. This method will not remove the view from the scrap heap. 663 * 664 */ 665 public View getScrapView(int type) { 666 return mScrapViews.peek(type); 667 } 668 669 public void addScrapView(int type, View v) { 670 mScrapViews.add(type, v); 671 addViewInLayoutWrapper(v, false /* postAddView */); 672 } 673 674 private void detachOverlay(OverlayView overlay, boolean removeFromContainer) { 675 // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded 676 // because removing overlay views doesn't affect overall layout. 677 if (removeFromContainer) { 678 removeViewInLayout(overlay.view); 679 } 680 mScrapViews.add(overlay.itemType, overlay.view); 681 if (overlay.view instanceof DetachListener) { 682 ((DetachListener) overlay.view).onDetachedFromParent(); 683 } 684 } 685 686 @Override 687 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 688 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 689 if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { 690 LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", 691 MeasureSpec.toString(widthMeasureSpec), 692 MeasureSpec.toString(heightMeasureSpec)); 693 } 694 695 for (View nonScrollingChild : mNonScrollingChildren) { 696 if (nonScrollingChild.getVisibility() != GONE) { 697 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, 698 heightMeasureSpec, 0 /* heightUsed */); 699 } 700 } 701 mWidthMeasureSpec = widthMeasureSpec; 702 703 // onLayout will re-measure and re-position overlays for the new container size, but the 704 // spacer offsets would still need to be updated to have them draw at their new locations. 705 } 706 707 @Override 708 protected void onLayout(boolean changed, int l, int t, int r, int b) { 709 LogUtils.d(TAG, "*** IN header container onLayout"); 710 711 for (View nonScrollingChild : mNonScrollingChildren) { 712 if (nonScrollingChild.getVisibility() != GONE) { 713 final int w = nonScrollingChild.getMeasuredWidth(); 714 final int h = nonScrollingChild.getMeasuredHeight(); 715 716 final MarginLayoutParams lp = 717 (MarginLayoutParams) nonScrollingChild.getLayoutParams(); 718 719 final int childLeft = lp.leftMargin; 720 final int childTop = lp.topMargin; 721 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); 722 } 723 } 724 725 if (mOverlayAdapter != null) { 726 // being in a layout pass means overlay children may require measurement, 727 // so invalidate them 728 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 729 mOverlayAdapter.getItem(i).invalidateMeasurement(); 730 } 731 } 732 733 positionOverlays(mOffsetY, false /* postAddView */); 734 } 735 736 @Override 737 protected LayoutParams generateDefaultLayoutParams() { 738 return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); 739 } 740 741 @Override 742 public LayoutParams generateLayoutParams(AttributeSet attrs) { 743 return new MarginLayoutParams(getContext(), attrs); 744 } 745 746 @Override 747 protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { 748 return new MarginLayoutParams(p); 749 } 750 751 @Override 752 protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { 753 return p instanceof MarginLayoutParams; 754 } 755 756 private int getOverlayTop(int spacerIndex) { 757 return webPxToScreenPx(mOverlayPositions[spacerIndex].top); 758 } 759 760 private int getOverlayBottom(int spacerIndex) { 761 return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); 762 } 763 764 private int webPxToScreenPx(int webPx) { 765 // TODO: round or truncate? 766 // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() 767 return (int) (webPx * mScale); 768 } 769 770 private void positionOverlay( 771 int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) { 772 final OverlayView overlay = mOverlayViews.get(adapterIndex); 773 final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); 774 775 // save off the item's current top for later snap calculations 776 item.setTop(overlayTopY); 777 778 // is the overlay visible and does it have non-zero height? 779 if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY 780 && overlayTopY < mOffsetY + getHeight()) { 781 View overlayView = overlay != null ? overlay.view : null; 782 // show and/or move overlay 783 if (overlayView == null) { 784 overlayView = addOverlayView(adapterIndex, postAddView); 785 ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this)); 786 measureOverlayView(overlayView); 787 item.markMeasurementValid(); 788 traceLayout("show/measure overlay %d", adapterIndex); 789 } else { 790 traceLayout("move overlay %d", adapterIndex); 791 if (!item.isMeasurementValid()) { 792 item.rebindView(overlayView); 793 measureOverlayView(overlayView); 794 item.markMeasurementValid(); 795 traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, 796 overlayView.getHeight(), overlayView.getMeasuredHeight()); 797 } 798 } 799 traceLayout("laying out overlay %d with h=%d", adapterIndex, 800 overlayView.getMeasuredHeight()); 801 final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); 802 layoutOverlay(overlayView, overlayTopY, childBottom); 803 mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? 804 childBottom : mAdditionalBottomBorderOverlayTop; 805 } else { 806 // hide overlay 807 if (overlay != null) { 808 traceLayout("hide overlay %d", adapterIndex); 809 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); 810 } else { 811 traceLayout("ignore non-visible overlay %d", adapterIndex); 812 } 813 mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) 814 ? overlayBottomY : mAdditionalBottomBorderOverlayTop; 815 } 816 817 if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { 818 if (mSnapIndex == -1) { 819 mSnapIndex = adapterIndex; 820 } else if (adapterIndex > mSnapIndex) { 821 mSnapIndex = adapterIndex; 822 } 823 } 824 825 } 826 827 // layout an existing view 828 // need its top offset into the conversation, its height, and the scroll offset 829 private void layoutOverlay(View child, int childTop, int childBottom) { 830 final int top = childTop - mOffsetY; 831 final int bottom = childBottom - mOffsetY; 832 833 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 834 final int childLeft = getPaddingLeft() + lp.leftMargin; 835 836 child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); 837 } 838 839 private View addOverlayView(int adapterIndex, boolean postAddView) { 840 final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); 841 final View convertView = mScrapViews.poll(itemType); 842 843 final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); 844 mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); 845 846 if (convertView == view) { 847 LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); 848 } else { 849 LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); 850 } 851 852 if (view.getParent() == null) { 853 addViewInLayoutWrapper(view, postAddView); 854 } else { 855 // Need to call postInvalidate since the view is being moved back on 856 // screen and we want to force it to draw the view. Without doing this, 857 // the view may not draw itself when it comes back on screen. 858 view.postInvalidate(); 859 } 860 861 return view; 862 } 863 864 private void addViewInLayoutWrapper(View view, boolean postAddView) { 865 final AddViewRunnable addviewRunnable = new AddViewRunnable(view); 866 if (postAddView) { 867 post(addviewRunnable); 868 } else { 869 addviewRunnable.run(); 870 } 871 } 872 873 private class AddViewRunnable implements Runnable { 874 public final View mView; 875 876 public AddViewRunnable(View view) { 877 mView = view; 878 } 879 880 @Override 881 public void run() { 882 final int index = BOTTOM_LAYER_VIEW_IDS.length; 883 addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */); 884 } 885 }; 886 887 private boolean isSnapEnabled() { 888 if (mAccountController == null || mAccountController.getAccount() == null 889 || mAccountController.getAccount().settings == null) { 890 return true; 891 } 892 final int snap = mAccountController.getAccount().settings.snapHeaders; 893 return snap == UIProvider.SnapHeaderValue.ALWAYS || 894 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() 895 .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); 896 } 897 898 // render and/or re-position snap header 899 private void positionSnapHeader(int snapIndex) { 900 ConversationOverlayItem snapItem = null; 901 if (mSnapEnabled && snapIndex != -1) { 902 final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); 903 if (item.canBecomeSnapHeader()) { 904 snapItem = item; 905 } 906 } 907 if (snapItem == null) { 908 mSnapHeader.setVisibility(GONE); 909 mSnapHeader.unbind(); 910 return; 911 } 912 913 snapItem.bindView(mSnapHeader, false /* measureOnly */); 914 mSnapHeader.setVisibility(VISIBLE); 915 916 // overlap is negative or zero; bump the snap header upwards by that much 917 int overlap = 0; 918 919 final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); 920 if (next != null) { 921 overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); 922 923 // disable overlap drawing past a certain speed 924 if (overlap < 0) { 925 final Float v = mVelocityTracker.getSmoothedVelocity(); 926 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { 927 overlap = 0; 928 } 929 } 930 } 931 932 mSnapHeader.setTranslationY(overlap); 933 } 934 935 // find the next header that can push the snap header up 936 private ConversationOverlayItem findNextPushingOverlay(int start) { 937 for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { 938 final ConversationOverlayItem next = mOverlayAdapter.getItem(i); 939 if (next.canPushSnapHeader()) { 940 return next; 941 } 942 } 943 return null; 944 } 945 946 /** 947 * Prevents any layouts from happening until the next time 948 * {@link #onGeometryChange(OverlayPosition[])} is 949 * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. 950 * <p> 951 * If you call this, you must ensure that a followup call to 952 * {@link #onGeometryChange(OverlayPosition[])} 953 * is made later, when the HTML spacer coordinates are updated. 954 * 955 */ 956 public void invalidateSpacerGeometry() { 957 mOverlayPositions = null; 958 } 959 960 public void onGeometryChange(OverlayPosition[] overlayPositions) { 961 traceLayout("*** got overlay spacer positions:"); 962 for (OverlayPosition pos : overlayPositions) { 963 traceLayout("top=%d bottom=%d", pos.top, pos.bottom); 964 } 965 966 mOverlayPositions = overlayPositions; 967 positionOverlays(mOffsetY, false /* postAddView */); 968 } 969 970 /** 971 * Remove the view that corresponds to the item in the {@link ConversationViewAdapter} 972 * at the specified index.<p/> 973 * 974 * <b>Note:</b> the view is actually pushed off-screen and recycled 975 * as though it were scrolled off. 976 * @param adapterIndex The index for the view in the adapter. 977 */ 978 public void removeViewAtAdapterIndex(int adapterIndex) { 979 // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen. 980 final int offsetY = mOffsetY; 981 mOffsetY = 0; 982 final OverlayView overlay = mOverlayViews.get(adapterIndex); 983 if (overlay != null) { 984 final int height = getHeight(); 985 onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight()); 986 } 987 // restore the offset to its original value after the view has been moved off-screen. 988 mOffsetY = offsetY; 989 } 990 991 private void traceLayout(String msg, Object... params) { 992 if (mDisableLayoutTracing) { 993 return; 994 } 995 LogUtils.d(TAG, msg, params); 996 } 997 998 public void focusFirstMessageHeader() { 999 mOverlayAdapter.focusFirstMessageHeader(); 1000 } 1001 1002 public int getOverlayCount() { 1003 return mOverlayAdapter.getCount(); 1004 } 1005 1006 public int getViewPosition(View v) { 1007 return mOverlayAdapter.getViewPosition(v); 1008 } 1009 1010 public View getNextOverlayView(int position, boolean isDown) { 1011 return mOverlayAdapter.getNextOverlayView(position, isDown); 1012 } 1013 1014 public boolean shouldInterceptLeftRightEvents(@IdRes int id, boolean isLeft, boolean isRight, 1015 boolean twoPaneLand) { 1016 return mOverlayAdapter.shouldInterceptLeftRightEvents(id, isLeft, isRight, twoPaneLand); 1017 } 1018 1019 public boolean shouldNavigateAway(@IdRes int id, boolean isLeft, boolean twoPaneLand) { 1020 return mOverlayAdapter.shouldNavigateAway(id, isLeft, twoPaneLand); 1021 } 1022 1023 private class AdapterObserver extends DataSetObserver { 1024 @Override 1025 public void onChanged() { 1026 onDataSetChanged(); 1027 } 1028 } 1029} 1030