Home | History | Annotate | Download | only in browse
      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