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