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 18 package com.android.mail.browse; 19 20 import android.content.Context; 21 import android.content.res.Configuration; 22 import android.database.DataSetObserver; 23 import android.graphics.Rect; 24 import android.support.v4.view.ViewCompat; 25 import android.util.AttributeSet; 26 import android.util.SparseArray; 27 import android.view.Gravity; 28 import android.view.MotionEvent; 29 import android.view.View; 30 import android.view.ViewConfiguration; 31 import android.view.ViewGroup; 32 import android.webkit.WebView; 33 import android.widget.ListView; 34 import android.widget.ScrollView; 35 36 import com.android.mail.R; 37 import com.android.mail.browse.ScrollNotifier.ScrollListener; 38 import com.android.mail.providers.UIProvider; 39 import com.android.mail.ui.ConversationViewFragment; 40 import com.android.mail.utils.DequeMap; 41 import com.android.mail.utils.InputSmoother; 42 import com.android.mail.utils.LogUtils; 43 import com.google.common.collect.Lists; 44 import com.google.common.collect.Sets; 45 46 import java.util.List; 47 import java.util.Set; 48 49 /** 50 * A specialized ViewGroup container for conversation view. It is designed to contain a single 51 * {@link WebView} and a number of overlay views that draw on top of the WebView. In the Mail app, 52 * the WebView contains all HTML message bodies in a conversation, and the overlay views are the 53 * subject view, message headers, and attachment views. The WebView does all scroll handling, and 54 * this container manages scrolling of the overlay views so that they move in tandem. 55 * 56 * <h5>INPUT HANDLING</h5> 57 * Placing the WebView in the same container as the overlay views means we don't have to do a lot of 58 * manual manipulation of touch events. We do have a 59 * {@link #forwardFakeMotionEvent(MotionEvent, int)} method that deals with one WebView 60 * idiosyncrasy: it doesn't react well when touch MOVE events stream in without a preceding DOWN. 61 * 62 * <h5>VIEW RECYCLING</h5> 63 * Normally, it would make sense to put all overlay views into a {@link ListView}. But this view 64 * sandwich has unique characteristics: the list items are scrolled based on an external controller, 65 * and we happen to know all of the overlay positions up front. So it didn't make sense to shoehorn 66 * a ListView in and instead, we rolled our own view recycler by borrowing key details from 67 * ListView and AbsListView.<br/><br/> 68 * 69 * There is one additional constraint with the recycling: since scroll 70 * notifications happen during the WebView's draw, we do not remove and re-add views for recycling. 71 * Instead, we simply move the views off-screen and add them to our recycle cache. When the views 72 * are reused, they are simply moved back on screen instead of added. This practice 73 * circumvents the issues found when views are added or removed during draw (which results in 74 * elements not being drawn and other visual oddities). See b/10994303 for more details. 75 */ 76 public class ConversationContainer extends ViewGroup implements ScrollListener { 77 private static final String TAG = ConversationViewFragment.LAYOUT_TAG; 78 79 private static final int[] BOTTOM_LAYER_VIEW_IDS = { 80 R.id.conversation_webview 81 }; 82 83 private static final int[] TOP_LAYER_VIEW_IDS = { 84 R.id.conversation_topmost_overlay 85 }; 86 87 /** 88 * Maximum scroll speed (in dp/sec) at which the snap header animation will draw. 89 * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect). 90 */ 91 private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f; 92 93 private ConversationAccountController mAccountController; 94 private ConversationViewAdapter mOverlayAdapter; 95 private OverlayPosition[] mOverlayPositions; 96 private ConversationWebView mWebView; 97 private SnapHeader mSnapHeader; 98 99 private final List<View> mNonScrollingChildren = Lists.newArrayList(); 100 101 /** 102 * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual 103 * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other. 104 */ 105 private float mScale; 106 /** 107 * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale 108 * values. 109 */ 110 private boolean mTouchInitialized; 111 112 /** 113 * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}. 114 */ 115 private final int mTouchSlop; 116 /** 117 * Current scroll position, as dictated by the background {@link WebView}. 118 */ 119 private int mOffsetY; 120 /** 121 * Original pointer Y for slop calculation. 122 */ 123 private float mLastMotionY; 124 /** 125 * Original pointer ID for slop calculation. 126 */ 127 private int mActivePointerId; 128 /** 129 * Track pointer up/down state to know whether to send a make-up DOWN event to WebView. 130 * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be 131 * preceded by a {@link MotionEvent#ACTION_DOWN} event. 132 */ 133 private boolean mTouchIsDown = false; 134 /** 135 * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN}, 136 * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}. 137 */ 138 private boolean mMissedPointerDown; 139 140 /** 141 * A recycler that holds removed scrap views, organized by integer item view type. All views 142 * in this data structure should be removed from their view parent prior to insertion. 143 */ 144 private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>(); 145 146 /** 147 * The current set of overlay views in the view hierarchy. Looking through this map is faster 148 * than traversing the view hierarchy. 149 * <p> 150 * WebView sometimes notifies of scroll changes during a draw (or display list generation), when 151 * it's not safe to detach view children because ViewGroup is in the middle of iterating over 152 * its child array. So we remove any child from this list immediately and queue up a task to 153 * detach it later. Since nobody other than the detach task references that view in the 154 * meantime, we don't need any further checks or synchronization. 155 * <p> 156 * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose 157 * of all views (on data set or adapter change), we can at least recycle them into the typed 158 * scrap piles for later reuse. 159 */ 160 private final SparseArray<OverlayView> mOverlayViews; 161 162 private int mWidthMeasureSpec; 163 164 private boolean mDisableLayoutTracing; 165 166 private final InputSmoother mVelocityTracker; 167 168 private final DataSetObserver mAdapterObserver = new AdapterObserver(); 169 170 /** 171 * The adapter index of the lowest overlay item that is above the top of the screen and reports 172 * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through 173 * {@link #positionOverlays}. 174 * 175 */ 176 private int mSnapIndex; 177 178 private boolean mSnapEnabled; 179 180 /** 181 * A View that fills the remaining vertical space when the overlays do not take 182 * up the entire container. Otherwise, a card-like bottom white space appears. 183 */ 184 private View mAdditionalBottomBorder; 185 186 /** 187 * A flag denoting whether the fake bottom border has been added to the container. 188 */ 189 private boolean mAdditionalBottomBorderAdded; 190 191 /** 192 * An int containing the potential top value for the additional bottom border. 193 * If this value is less than the height of the scroll container, the additional 194 * bottom border will be drawn. 195 */ 196 private int mAdditionalBottomBorderOverlayTop; 197 198 /** 199 * Child views of this container should implement this interface to be notified when they are 200 * being detached. 201 */ 202 public interface DetachListener { 203 /** 204 * Called on a child view when it is removed from its parent as part of 205 * {@link ConversationContainer} view recycling. 206 */ 207 void onDetachedFromParent(); 208 } 209 210 public static class OverlayPosition { 211 public final int top; 212 public final int bottom; 213 214 public OverlayPosition(int top, int bottom) { 215 this.top = top; 216 this.bottom = bottom; 217 } 218 } 219 220 private static class OverlayView { 221 public View view; 222 int itemType; 223 224 public OverlayView(View view, int itemType) { 225 this.view = view; 226 this.itemType = itemType; 227 } 228 } 229 230 public ConversationContainer(Context c) { 231 this(c, null); 232 } 233 234 public ConversationContainer(Context c, AttributeSet attrs) { 235 super(c, attrs); 236 237 mOverlayViews = new SparseArray<OverlayView>(); 238 239 mVelocityTracker = new InputSmoother(c); 240 241 mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop(); 242 243 // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the 244 // WebView and the second pointer goes down on an overlay view. 245 // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer 246 // goes down on an overlay view. 247 setMotionEventSplittingEnabled(false); 248 } 249 250 @Override 251 protected void onFinishInflate() { 252 super.onFinishInflate(); 253 254 mWebView = (ConversationWebView) findViewById(R.id.conversation_webview); 255 mWebView.addScrollListener(this); 256 257 for (int id : BOTTOM_LAYER_VIEW_IDS) { 258 mNonScrollingChildren.add(findViewById(id)); 259 } 260 for (int id : TOP_LAYER_VIEW_IDS) { 261 mNonScrollingChildren.add(findViewById(id)); 262 } 263 } 264 265 public void setupSnapHeader() { 266 mSnapHeader = (SnapHeader) findViewById(R.id.snap_header); 267 mSnapHeader.setSnappy(); 268 } 269 270 public SnapHeader getSnapHeader() { 271 return mSnapHeader; 272 } 273 274 public void setOverlayAdapter(ConversationViewAdapter a) { 275 if (mOverlayAdapter != null) { 276 mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver); 277 clearOverlays(); 278 } 279 mOverlayAdapter = a; 280 if (mOverlayAdapter != null) { 281 mOverlayAdapter.registerDataSetObserver(mAdapterObserver); 282 } 283 } 284 285 public void setAccountController(ConversationAccountController controller) { 286 mAccountController = controller; 287 288 // mSnapEnabled = isSnapEnabled(); 289 mSnapEnabled = false; // TODO - re-enable when dogfooders howl 290 } 291 292 /** 293 * Re-bind any existing views that correspond to the given adapter positions. 294 * 295 */ 296 public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) { 297 for (Integer i : affectedAdapterPositions) { 298 final ConversationOverlayItem item = mOverlayAdapter.getItem(i); 299 final OverlayView overlay = mOverlayViews.get(i); 300 if (overlay != null && overlay.view != null && item != null) { 301 item.onModelUpdated(overlay.view); 302 } 303 // update the snap header too, but only it's showing if the current item 304 if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) { 305 mSnapHeader.refresh(); 306 } 307 } 308 } 309 310 /** 311 * Return an overlay view for the given adapter item, or null if no matching view is currently 312 * visible. This can happen as you scroll away from an overlay view. 313 * 314 */ 315 public View getViewForItem(ConversationOverlayItem item) { 316 if (mOverlayAdapter == null) { 317 return null; 318 } 319 View result = null; 320 int adapterPos = -1; 321 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 322 if (mOverlayAdapter.getItem(i) == item) { 323 adapterPos = i; 324 break; 325 } 326 } 327 if (adapterPos != -1) { 328 final OverlayView overlay = mOverlayViews.get(adapterPos); 329 if (overlay != null) { 330 result = overlay.view; 331 } 332 } 333 return result; 334 } 335 336 private void clearOverlays() { 337 for (int i = 0, len = mOverlayViews.size(); i < len; i++) { 338 detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */); 339 } 340 mOverlayViews.clear(); 341 } 342 343 private void onDataSetChanged() { 344 // Recycle all views and re-bind them according to the current set of spacer coordinates. 345 // This essentially resets the overlay views and re-renders them. 346 // It's fast enough that it's okay to re-do all views on any small change, as long as 347 // the change isn't too frequent (< ~1Hz). 348 349 clearOverlays(); 350 // also unbind the snap header view, so this "reset" causes the snap header to re-create 351 // its view, just like all other headers 352 mSnapHeader.unbind(); 353 354 // also clear out the additional bottom border 355 removeViewInLayout(mAdditionalBottomBorder); 356 mAdditionalBottomBorderAdded = false; 357 358 // mSnapEnabled = isSnapEnabled(); 359 mSnapEnabled = false; // TODO - re-enable when dogfooders howl 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 if (postAddView) { 583 post(mRemoveBorderRunnable); 584 } else { 585 mRemoveBorderRunnable.run(); 586 } 587 mAdditionalBottomBorderAdded = false; 588 } 589 } 590 } 591 592 private final RemoveBorderRunnable mRemoveBorderRunnable = new RemoveBorderRunnable(); 593 594 private void setAdditionalBottomBorderHeight(int speculativeHeight) { 595 LayoutParams params = mAdditionalBottomBorder.getLayoutParams(); 596 params.height = speculativeHeight; 597 mAdditionalBottomBorder.setLayoutParams(params); 598 } 599 600 private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, 601 final int withinTop, final int withinBottom, final int forceGravity) { 602 if (adapterItem.getHeight() == 0) { 603 // "place" invisible items at the bottom of their region to stay consistent with the 604 // stacking algorithm in positionOverlays(), unless gravity is forced to the top 605 final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom; 606 return new OverlayPosition(y, y); 607 } 608 609 final int v = ((forceGravity != Gravity.NO_GRAVITY) ? 610 forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK; 611 switch (v) { 612 case Gravity.BOTTOM: 613 return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom); 614 case Gravity.TOP: 615 return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight()); 616 default: 617 throw new UnsupportedOperationException("unsupported gravity: " + v); 618 } 619 } 620 621 /** 622 * Executes a measure pass over the specified child overlay view and returns the measured 623 * height. The measurement uses whatever the current container's width measure spec is. 624 * This method ignores view visibility and returns the height that the view would be if visible. 625 * 626 * @param overlayView an overlay view to measure. does not actually have to be attached yet. 627 * @return height that the view would be if it was visible 628 */ 629 public int measureOverlay(View overlayView) { 630 measureOverlayView(overlayView); 631 return overlayView.getMeasuredHeight(); 632 } 633 634 /** 635 * Copied/stolen from {@link ListView}. 636 */ 637 private void measureOverlayView(View child) { 638 MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams(); 639 if (p == null) { 640 p = (MarginLayoutParams) generateDefaultLayoutParams(); 641 } 642 643 int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, 644 getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width); 645 int lpHeight = p.height; 646 int childHeightSpec; 647 if (lpHeight > 0) { 648 childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); 649 } else { 650 childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); 651 } 652 child.measure(childWidthSpec, childHeightSpec); 653 } 654 655 private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, 656 int overlayTop, int overlayBottom) { 657 // immediately remove this view from the view set so future lookups don't find it 658 mOverlayViews.remove(adapterIndex); 659 660 // detach but don't actually remove from the view 661 detachOverlay(overlay, false /* removeFromContainer */); 662 663 // push it out of view immediately 664 // otherwise this scrolled-off header will continue to draw until the runnable runs 665 layoutOverlay(overlay.view, overlayTop, overlayBottom); 666 } 667 668 /** 669 * Returns an existing scrap view, if available. The view will already be removed from the view 670 * hierarchy. This method will not remove the view from the scrap heap. 671 * 672 */ 673 public View getScrapView(int type) { 674 return mScrapViews.peek(type); 675 } 676 677 public void addScrapView(int type, View v) { 678 mScrapViews.add(type, v); 679 addViewInLayoutWrapper(v, false /* postAddView */); 680 } 681 682 private void detachOverlay(OverlayView overlay, boolean removeFromContainer) { 683 // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded 684 // because removing overlay views doesn't affect overall layout. 685 if (removeFromContainer) { 686 removeViewInLayout(overlay.view); 687 } 688 mScrapViews.add(overlay.itemType, overlay.view); 689 if (overlay.view instanceof DetachListener) { 690 ((DetachListener) overlay.view).onDetachedFromParent(); 691 } 692 } 693 694 @Override 695 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 696 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 697 if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { 698 LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", 699 MeasureSpec.toString(widthMeasureSpec), 700 MeasureSpec.toString(heightMeasureSpec)); 701 } 702 703 for (View nonScrollingChild : mNonScrollingChildren) { 704 if (nonScrollingChild.getVisibility() != GONE) { 705 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, 706 heightMeasureSpec, 0 /* heightUsed */); 707 } 708 } 709 mWidthMeasureSpec = widthMeasureSpec; 710 711 // onLayout will re-measure and re-position overlays for the new container size, but the 712 // spacer offsets would still need to be updated to have them draw at their new locations. 713 } 714 715 @Override 716 protected void onLayout(boolean changed, int l, int t, int r, int b) { 717 LogUtils.d(TAG, "*** IN header container onLayout"); 718 719 for (View nonScrollingChild : mNonScrollingChildren) { 720 if (nonScrollingChild.getVisibility() != GONE) { 721 final int w = nonScrollingChild.getMeasuredWidth(); 722 final int h = nonScrollingChild.getMeasuredHeight(); 723 724 final MarginLayoutParams lp = 725 (MarginLayoutParams) nonScrollingChild.getLayoutParams(); 726 727 final int childLeft = lp.leftMargin; 728 final int childTop = lp.topMargin; 729 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); 730 } 731 } 732 733 if (mOverlayAdapter != null) { 734 // being in a layout pass means overlay children may require measurement, 735 // so invalidate them 736 for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { 737 mOverlayAdapter.getItem(i).invalidateMeasurement(); 738 } 739 } 740 741 positionOverlays(mOffsetY, false /* postAddView */); 742 } 743 744 @Override 745 protected LayoutParams generateDefaultLayoutParams() { 746 return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); 747 } 748 749 @Override 750 public LayoutParams generateLayoutParams(AttributeSet attrs) { 751 return new MarginLayoutParams(getContext(), attrs); 752 } 753 754 @Override 755 protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { 756 return new MarginLayoutParams(p); 757 } 758 759 @Override 760 protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { 761 return p instanceof MarginLayoutParams; 762 } 763 764 private int getOverlayTop(int spacerIndex) { 765 return webPxToScreenPx(mOverlayPositions[spacerIndex].top); 766 } 767 768 private int getOverlayBottom(int spacerIndex) { 769 return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); 770 } 771 772 private int webPxToScreenPx(int webPx) { 773 // TODO: round or truncate? 774 // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() 775 return (int) (webPx * mScale); 776 } 777 778 private void positionOverlay( 779 int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) { 780 final OverlayView overlay = mOverlayViews.get(adapterIndex); 781 final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); 782 783 // save off the item's current top for later snap calculations 784 item.setTop(overlayTopY); 785 786 // is the overlay visible and does it have non-zero height? 787 if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY 788 && overlayTopY < mOffsetY + getHeight()) { 789 View overlayView = overlay != null ? overlay.view : null; 790 // show and/or move overlay 791 if (overlayView == null) { 792 overlayView = addOverlayView(adapterIndex, postAddView); 793 ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this)); 794 measureOverlayView(overlayView); 795 item.markMeasurementValid(); 796 traceLayout("show/measure overlay %d", adapterIndex); 797 } else { 798 traceLayout("move overlay %d", adapterIndex); 799 if (!item.isMeasurementValid()) { 800 item.rebindView(overlayView); 801 measureOverlayView(overlayView); 802 item.markMeasurementValid(); 803 traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, 804 overlayView.getHeight(), overlayView.getMeasuredHeight()); 805 } 806 } 807 traceLayout("laying out overlay %d with h=%d", adapterIndex, 808 overlayView.getMeasuredHeight()); 809 final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); 810 layoutOverlay(overlayView, overlayTopY, childBottom); 811 mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? 812 childBottom : mAdditionalBottomBorderOverlayTop; 813 } else { 814 // hide overlay 815 if (overlay != null) { 816 traceLayout("hide overlay %d", adapterIndex); 817 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); 818 } else { 819 traceLayout("ignore non-visible overlay %d", adapterIndex); 820 } 821 mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) 822 ? overlayBottomY : mAdditionalBottomBorderOverlayTop; 823 } 824 825 if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { 826 if (mSnapIndex == -1) { 827 mSnapIndex = adapterIndex; 828 } else if (adapterIndex > mSnapIndex) { 829 mSnapIndex = adapterIndex; 830 } 831 } 832 833 } 834 835 // layout an existing view 836 // need its top offset into the conversation, its height, and the scroll offset 837 private void layoutOverlay(View child, int childTop, int childBottom) { 838 final int top = childTop - mOffsetY; 839 final int bottom = childBottom - mOffsetY; 840 841 final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); 842 final int childLeft = getPaddingLeft() + lp.leftMargin; 843 844 child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); 845 } 846 847 private View addOverlayView(int adapterIndex, boolean postAddView) { 848 final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); 849 final View convertView = mScrapViews.poll(itemType); 850 851 final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); 852 mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); 853 854 if (convertView == view) { 855 LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); 856 } else { 857 LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); 858 } 859 860 if (view.getParent() == null) { 861 addViewInLayoutWrapper(view, postAddView); 862 } else { 863 // Need to call postInvalidate since the view is being moved back on 864 // screen and we want to force it to draw the view. Without doing this, 865 // the view may not draw itself when it comes back on screen. 866 view.postInvalidate(); 867 } 868 869 return view; 870 } 871 872 private void addViewInLayoutWrapper(View view, boolean postAddView) { 873 final AddViewRunnable addviewRunnable = new AddViewRunnable(view); 874 if (postAddView) { 875 post(addviewRunnable); 876 } else { 877 addviewRunnable.run(); 878 } 879 } 880 881 private class AddViewRunnable implements Runnable { 882 private final View mView; 883 884 public AddViewRunnable(View view) { 885 mView = view; 886 } 887 888 @Override 889 public void run() { 890 final int index = BOTTOM_LAYER_VIEW_IDS.length; 891 addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */); 892 } 893 }; 894 895 private class RemoveBorderRunnable implements Runnable { 896 @Override 897 public void run() { 898 removeViewInLayout(mAdditionalBottomBorder); 899 } 900 } 901 902 private boolean isSnapEnabled() { 903 if (mAccountController == null || mAccountController.getAccount() == null 904 || mAccountController.getAccount().settings == null) { 905 return true; 906 } 907 final int snap = mAccountController.getAccount().settings.snapHeaders; 908 return snap == UIProvider.SnapHeaderValue.ALWAYS || 909 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() 910 .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); 911 } 912 913 // render and/or re-position snap header 914 private void positionSnapHeader(int snapIndex) { 915 ConversationOverlayItem snapItem = null; 916 if (mSnapEnabled && snapIndex != -1) { 917 final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); 918 if (item.canBecomeSnapHeader()) { 919 snapItem = item; 920 } 921 } 922 if (snapItem == null) { 923 mSnapHeader.setVisibility(GONE); 924 mSnapHeader.unbind(); 925 return; 926 } 927 928 snapItem.bindView(mSnapHeader, false /* measureOnly */); 929 mSnapHeader.setVisibility(VISIBLE); 930 931 // overlap is negative or zero; bump the snap header upwards by that much 932 int overlap = 0; 933 934 final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); 935 if (next != null) { 936 overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); 937 938 // disable overlap drawing past a certain speed 939 if (overlap < 0) { 940 final Float v = mVelocityTracker.getSmoothedVelocity(); 941 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { 942 overlap = 0; 943 } 944 } 945 } 946 947 mSnapHeader.setTranslationY(overlap); 948 } 949 950 // find the next header that can push the snap header up 951 private ConversationOverlayItem findNextPushingOverlay(int start) { 952 for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { 953 final ConversationOverlayItem next = mOverlayAdapter.getItem(i); 954 if (next.canPushSnapHeader()) { 955 return next; 956 } 957 } 958 return null; 959 } 960 961 /** 962 * Prevents any layouts from happening until the next time 963 * {@link #onGeometryChange(OverlayPosition[])} is 964 * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. 965 * <p> 966 * If you call this, you must ensure that a followup call to 967 * {@link #onGeometryChange(OverlayPosition[])} 968 * is made later, when the HTML spacer coordinates are updated. 969 * 970 */ 971 public void invalidateSpacerGeometry() { 972 mOverlayPositions = null; 973 } 974 975 public void onGeometryChange(OverlayPosition[] overlayPositions) { 976 traceLayout("*** got overlay spacer positions:"); 977 for (OverlayPosition pos : overlayPositions) { 978 traceLayout("top=%d bottom=%d", pos.top, pos.bottom); 979 } 980 981 mOverlayPositions = overlayPositions; 982 positionOverlays(mOffsetY, false /* postAddView */); 983 } 984 985 /** 986 * Remove the view that corresponds to the item in the {@link ConversationViewAdapter} 987 * at the specified index.<p/> 988 * 989 * <b>Note:</b> the view is actually pushed off-screen and recycled 990 * as though it were scrolled off. 991 * @param adapterIndex The index for the view in the adapter. 992 */ 993 public void removeViewAtAdapterIndex(int adapterIndex) { 994 // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen. 995 final int offsetY = mOffsetY; 996 mOffsetY = 0; 997 final OverlayView overlay = mOverlayViews.get(adapterIndex); 998 if (overlay != null) { 999 final int height = getHeight(); 1000 onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight()); 1001 LogUtils.i(TAG, "footer scrolled off. container height=%s, measuredHeight=%s", 1002 height, getMeasuredHeight()); 1003 } else { 1004 LogUtils.i(TAG, "footer not found with adapterIndex=%s", adapterIndex); 1005 for (int i = 0, size = mOverlayViews.size(); i < size; i++) { 1006 final int index = mOverlayViews.keyAt(i); 1007 final OverlayView overlayView = mOverlayViews.valueAt(i); 1008 LogUtils.i(TAG, "OverlayView: adapterIndex=%s, itemType=%s, view=%s", 1009 index, overlayView.itemType, overlayView.view); 1010 } 1011 for (int i = 0, size = mOverlayAdapter.getCount(); i < size; i++) { 1012 final ConversationOverlayItem item = mOverlayAdapter.getItem(i); 1013 LogUtils.i(TAG, "adapter item: index=%s, item=%s", i, item); 1014 } 1015 } 1016 // restore the offset to its original value after the view has been moved off-screen. 1017 mOffsetY = offsetY; 1018 } 1019 1020 private void traceLayout(String msg, Object... params) { 1021 if (mDisableLayoutTracing) { 1022 return; 1023 } 1024 LogUtils.d(TAG, msg, params); 1025 } 1026 1027 @Override 1028 protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { 1029 if (mOverlayAdapter != null) { 1030 return mOverlayAdapter.focusFirstMessageHeader(); 1031 } 1032 return false; 1033 } 1034 1035 public void focusFirstMessageHeader() { 1036 mOverlayAdapter.focusFirstMessageHeader(); 1037 } 1038 1039 public View getNextOverlayView(View curr, boolean isDown) { 1040 // Find the scraps that we should avoid when fetching the next view. 1041 final Set<View> scraps = Sets.newHashSet(); 1042 mScrapViews.visitAll(new DequeMap.Visitor<View>() { 1043 @Override 1044 public void visit(View item) { 1045 scraps.add(item); 1046 } 1047 }); 1048 return mOverlayAdapter.getNextOverlayView(curr, isDown, scraps); 1049 } 1050 1051 private class AdapterObserver extends DataSetObserver { 1052 @Override 1053 public void onChanged() { 1054 onDataSetChanged(); 1055 } 1056 } 1057 } 1058