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.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