com.android.mail.browse.ConversationContainer.java Source code

Java tutorial

Introduction

Here is the source code for com.android.mail.browse.ConversationContainer.java

Source

/*
 * Copyright (C) 2012 Google Inc.
 * Licensed to The Android Open Source Project.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mail.browse;

import android.content.Context;
import android.content.res.Configuration;
import android.database.DataSetObserver;
import android.graphics.Rect;
import android.support.v4.view.ViewCompat;
import android.util.AttributeSet;
import android.util.SparseArray;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.webkit.WebView;
import android.widget.ListView;
import android.widget.ScrollView;

import com.android.mail.R;
import com.android.mail.browse.ScrollNotifier.ScrollListener;
import com.android.mail.providers.UIProvider;
import com.android.mail.ui.ConversationViewFragment;
import com.android.mail.utils.DequeMap;
import com.android.mail.utils.InputSmoother;
import com.android.mail.utils.LogUtils;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;

import java.util.List;
import java.util.Set;

/**
 * A specialized ViewGroup container for conversation view. It is designed to contain a single
 * {@link WebView} and a number of overlay views that draw on top of the WebView. In the Mail app,
 * the WebView contains all HTML message bodies in a conversation, and the overlay views are the
 * subject view, message headers, and attachment views. The WebView does all scroll handling, and
 * this container manages scrolling of the overlay views so that they move in tandem.
 *
 * <h5>INPUT HANDLING</h5>
 * Placing the WebView in the same container as the overlay views means we don't have to do a lot of
 * manual manipulation of touch events. We do have a
 * {@link #forwardFakeMotionEvent(MotionEvent, int)} method that deals with one WebView
 * idiosyncrasy: it doesn't react well when touch MOVE events stream in without a preceding DOWN.
 *
 * <h5>VIEW RECYCLING</h5>
 * Normally, it would make sense to put all overlay views into a {@link ListView}. But this view
 * sandwich has unique characteristics: the list items are scrolled based on an external controller,
 * and we happen to know all of the overlay positions up front. So it didn't make sense to shoehorn
 * a ListView in and instead, we rolled our own view recycler by borrowing key details from
 * ListView and AbsListView.<br/><br/>
 *
 * There is one additional constraint with the recycling: since scroll
 * notifications happen during the WebView's draw, we do not remove and re-add views for recycling.
 * Instead, we simply move the views off-screen and add them to our recycle cache. When the views
 * are reused, they are simply moved back on screen instead of added. This practice
 * circumvents the issues found when views are added or removed during draw (which results in
 * elements not being drawn and other visual oddities). See b/10994303 for more details.
 */
public class ConversationContainer extends ViewGroup implements ScrollListener {
    private static final String TAG = ConversationViewFragment.LAYOUT_TAG;

    private static final int[] BOTTOM_LAYER_VIEW_IDS = { R.id.conversation_webview };

    private static final int[] TOP_LAYER_VIEW_IDS = { R.id.conversation_topmost_overlay };

    /**
     * Maximum scroll speed (in dp/sec) at which the snap header animation will draw.
     * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect).
     */
    private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f;

    private ConversationAccountController mAccountController;
    private ConversationViewAdapter mOverlayAdapter;
    private OverlayPosition[] mOverlayPositions;
    private ConversationWebView mWebView;
    private SnapHeader mSnapHeader;

    private final List<View> mNonScrollingChildren = Lists.newArrayList();

    /**
     * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual
     * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other.
     */
    private float mScale;
    /**
     * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale
     * values.
     */
    private boolean mTouchInitialized;

    /**
     * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}.
     */
    private final int mTouchSlop;
    /**
     * Current scroll position, as dictated by the background {@link WebView}.
     */
    private int mOffsetY;
    /**
     * Original pointer Y for slop calculation.
     */
    private float mLastMotionY;
    /**
     * Original pointer ID for slop calculation.
     */
    private int mActivePointerId;
    /**
     * Track pointer up/down state to know whether to send a make-up DOWN event to WebView.
     * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be
     * preceded by a {@link MotionEvent#ACTION_DOWN} event.
     */
    private boolean mTouchIsDown = false;
    /**
     * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN},
     * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}.
     */
    private boolean mMissedPointerDown;

    /**
     * A recycler that holds removed scrap views, organized by integer item view type. All views
     * in this data structure should be removed from their view parent prior to insertion.
     */
    private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>();

    /**
     * The current set of overlay views in the view hierarchy. Looking through this map is faster
     * than traversing the view hierarchy.
     * <p>
     * WebView sometimes notifies of scroll changes during a draw (or display list generation), when
     * it's not safe to detach view children because ViewGroup is in the middle of iterating over
     * its child array. So we remove any child from this list immediately and queue up a task to
     * detach it later. Since nobody other than the detach task references that view in the
     * meantime, we don't need any further checks or synchronization.
     * <p>
     * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose
     * of all views (on data set or adapter change), we can at least recycle them into the typed
     * scrap piles for later reuse.
     */
    private final SparseArray<OverlayView> mOverlayViews;

    private int mWidthMeasureSpec;

    private boolean mDisableLayoutTracing;

    private final InputSmoother mVelocityTracker;

    private final DataSetObserver mAdapterObserver = new AdapterObserver();

    /**
     * The adapter index of the lowest overlay item that is above the top of the screen and reports
     * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through
     * {@link #positionOverlays}.
     *
     */
    private int mSnapIndex;

    private boolean mSnapEnabled;

    /**
     * A View that fills the remaining vertical space when the overlays do not take
     * up the entire container. Otherwise, a card-like bottom white space appears.
     */
    private View mAdditionalBottomBorder;

    /**
     * A flag denoting whether the fake bottom border has been added to the container.
     */
    private boolean mAdditionalBottomBorderAdded;

    /**
     * An int containing the potential top value for the additional bottom border.
     * If this value is less than the height of the scroll container, the additional
     * bottom border will be drawn.
     */
    private int mAdditionalBottomBorderOverlayTop;

    /**
     * Child views of this container should implement this interface to be notified when they are
     * being detached.
     */
    public interface DetachListener {
        /**
         * Called on a child view when it is removed from its parent as part of
         * {@link ConversationContainer} view recycling.
         */
        void onDetachedFromParent();
    }

    public static class OverlayPosition {
        public final int top;
        public final int bottom;

        public OverlayPosition(int top, int bottom) {
            this.top = top;
            this.bottom = bottom;
        }
    }

    private static class OverlayView {
        public View view;
        int itemType;

        public OverlayView(View view, int itemType) {
            this.view = view;
            this.itemType = itemType;
        }
    }

    public ConversationContainer(Context c) {
        this(c, null);
    }

    public ConversationContainer(Context c, AttributeSet attrs) {
        super(c, attrs);

        mOverlayViews = new SparseArray<OverlayView>();

        mVelocityTracker = new InputSmoother(c);

        mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop();

        // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the
        // WebView and the second pointer goes down on an overlay view.
        // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer
        // goes down on an overlay view.
        setMotionEventSplittingEnabled(false);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        mWebView = (ConversationWebView) findViewById(R.id.conversation_webview);
        mWebView.addScrollListener(this);

        for (int id : BOTTOM_LAYER_VIEW_IDS) {
            mNonScrollingChildren.add(findViewById(id));
        }
        for (int id : TOP_LAYER_VIEW_IDS) {
            mNonScrollingChildren.add(findViewById(id));
        }
    }

    public void setupSnapHeader() {
        mSnapHeader = (SnapHeader) findViewById(R.id.snap_header);
        mSnapHeader.setSnappy();
    }

    public SnapHeader getSnapHeader() {
        return mSnapHeader;
    }

    public void setOverlayAdapter(ConversationViewAdapter a) {
        if (mOverlayAdapter != null) {
            mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver);
            clearOverlays();
        }
        mOverlayAdapter = a;
        if (mOverlayAdapter != null) {
            mOverlayAdapter.registerDataSetObserver(mAdapterObserver);
        }
    }

    public void setAccountController(ConversationAccountController controller) {
        mAccountController = controller;

        //        mSnapEnabled = isSnapEnabled();
        mSnapEnabled = false; // TODO - re-enable when dogfooders howl
    }

    /**
     * Re-bind any existing views that correspond to the given adapter positions.
     *
     */
    public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) {
        for (Integer i : affectedAdapterPositions) {
            final ConversationOverlayItem item = mOverlayAdapter.getItem(i);
            final OverlayView overlay = mOverlayViews.get(i);
            if (overlay != null && overlay.view != null && item != null) {
                item.onModelUpdated(overlay.view);
            }
            // update the snap header too, but only it's showing if the current item
            if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) {
                mSnapHeader.refresh();
            }
        }
    }

    /**
     * Return an overlay view for the given adapter item, or null if no matching view is currently
     * visible. This can happen as you scroll away from an overlay view.
     *
     */
    public View getViewForItem(ConversationOverlayItem item) {
        if (mOverlayAdapter == null) {
            return null;
        }
        View result = null;
        int adapterPos = -1;
        for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
            if (mOverlayAdapter.getItem(i) == item) {
                adapterPos = i;
                break;
            }
        }
        if (adapterPos != -1) {
            final OverlayView overlay = mOverlayViews.get(adapterPos);
            if (overlay != null) {
                result = overlay.view;
            }
        }
        return result;
    }

    private void clearOverlays() {
        for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
            detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */);
        }
        mOverlayViews.clear();
    }

    private void onDataSetChanged() {
        // Recycle all views and re-bind them according to the current set of spacer coordinates.
        // This essentially resets the overlay views and re-renders them.
        // It's fast enough that it's okay to re-do all views on any small change, as long as
        // the change isn't too frequent (< ~1Hz).

        clearOverlays();
        // also unbind the snap header view, so this "reset" causes the snap header to re-create
        // its view, just like all other headers
        mSnapHeader.unbind();

        // also clear out the additional bottom border
        removeViewInLayout(mAdditionalBottomBorder);
        mAdditionalBottomBorderAdded = false;

        //        mSnapEnabled = isSnapEnabled();
        mSnapEnabled = false; // TODO - re-enable when dogfooders howl
        positionOverlays(mOffsetY, false /* postAddView */);
    }

    private void forwardFakeMotionEvent(MotionEvent original, int newAction) {
        MotionEvent newEvent = MotionEvent.obtain(original);
        newEvent.setAction(newAction);
        mWebView.onTouchEvent(newEvent);
        LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d", newEvent.getActionMasked(),
                newEvent.getX(), newEvent.getY(), newEvent.getPointerCount());
    }

    /**
     * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}.
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {

        if (!mTouchInitialized) {
            mTouchInitialized = true;
        }

        // no interception when WebView handles the first DOWN
        if (mWebView.isHandlingTouch()) {
            return false;
        }

        boolean intercept = false;
        switch (ev.getActionMasked()) {
        case MotionEvent.ACTION_POINTER_DOWN:
            LogUtils.d(TAG, "Container is intercepting non-primary touch!");
            intercept = true;
            mMissedPointerDown = true;
            requestDisallowInterceptTouchEvent(true);
            break;

        case MotionEvent.ACTION_DOWN:
            mLastMotionY = ev.getY();
            mActivePointerId = ev.getPointerId(0);
            break;

        case MotionEvent.ACTION_MOVE:
            final int pointerIndex = ev.findPointerIndex(mActivePointerId);
            final float y = ev.getY(pointerIndex);
            final int yDiff = (int) Math.abs(y - mLastMotionY);
            if (yDiff > mTouchSlop) {
                mLastMotionY = y;
                intercept = true;
            }
            break;
        }

        //        LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s",
        //                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept);
        return intercept;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        final int action = ev.getActionMasked();

        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
            mTouchIsDown = false;
        } else if (!mTouchIsDown
                && (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) {

            forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN);
            if (mMissedPointerDown) {
                forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN);
                mMissedPointerDown = false;
            }

            mTouchIsDown = true;
        }

        final boolean webViewResult = mWebView.onTouchEvent(ev);

        //        LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d",
        //                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount());
        return webViewResult;
    }

    @Override
    public void onNotifierScroll(final int y) {
        mVelocityTracker.onInput(y);
        mDisableLayoutTracing = true;
        positionOverlays(y, true /* postAddView */); // post the addView since we're in draw code
        mDisableLayoutTracing = false;
    }

    /**
     * Positions the overlays given an updated y position for the container.
     * @param y the current top position on screen
     * @param postAddView If {@code true}, posts all calls to
     *                    {@link #addViewInLayoutWrapper(android.view.View, boolean)}
     *                    to the UI thread rather than adding it immediately. If {@code false},
     *                    calls {@link #addViewInLayoutWrapper(android.view.View, boolean)}
     *                    immediately.
     */
    private void positionOverlays(int y, boolean postAddView) {
        mOffsetY = y;

        /*
         * The scale value that WebView reports is inaccurate when measured during WebView
         * initialization. This bug is present in ICS, so to work around it, we ignore all
         * reported values and use a calculated expected value from ConversationWebView instead.
         * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin
         * to pay attention to WebView-reported scale values.
         */
        if (mTouchInitialized) {
            mScale = mWebView.getScale();
        } else if (mScale == 0) {
            mScale = mWebView.getInitialScale();
        }
        traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(), mScale);

        if (mOverlayPositions == null || mOverlayAdapter == null) {
            return;
        }

        // recycle scrolled-off views and add newly visible views

        // we want consecutive spacers/overlays to stack towards the bottom
        // so iterate from the bottom of the conversation up
        // starting with the last spacer bottom and the last adapter item, position adapter views
        // in a single stack until you encounter a non-contiguous expanded message header,
        // then decrement to the next spacer.

        traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length,
                mOverlayAdapter.getCount());

        mSnapIndex = -1;
        mAdditionalBottomBorderOverlayTop = 0;

        int adapterLoopIndex = mOverlayAdapter.getCount() - 1;
        int spacerIndex = mOverlayPositions.length - 1;
        while (spacerIndex >= 0 && adapterLoopIndex >= 0) {

            final int spacerTop = getOverlayTop(spacerIndex);
            final int spacerBottom = getOverlayBottom(spacerIndex);

            final boolean flip;
            final int flipOffset;
            final int forceGravity;
            // flip direction from bottom->top to top->bottom traversal on the very first spacer
            // to facilitate top-aligned headers at spacer index = 0
            if (spacerIndex == 0) {
                flip = true;
                flipOffset = adapterLoopIndex;
                forceGravity = Gravity.TOP;
            } else {
                flip = false;
                flipOffset = 0;
                forceGravity = Gravity.NO_GRAVITY;
            }

            int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;

            // always place at least one overlay per spacer
            ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex);

            OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom, forceGravity);

            traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex, itemPos.top,
                    itemPos.bottom, adapterItem);
            positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView);

            // and keep stacking overlays unconditionally if we are on the first spacer, or as long
            // as overlays are contiguous
            while (--adapterLoopIndex >= 0) {
                adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;
                adapterItem = mOverlayAdapter.getItem(adapterIndex);
                if (spacerIndex > 0 && !adapterItem.isContiguous()) {
                    // advance to the next spacer, but stay on this adapter item
                    break;
                }

                // place this overlay in the region of the spacer above or below the last item,
                // depending on direction of iteration
                final int regionTop = flip ? itemPos.bottom : spacerTop;
                final int regionBottom = flip ? spacerBottom : itemPos.top;
                itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity);

                traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex,
                        itemPos.top, itemPos.bottom, adapterItem);
                positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView);
            }

            spacerIndex--;
        }

        positionSnapHeader(mSnapIndex);
        positionAdditionalBottomBorder(postAddView);
    }

    /**
     * Adds an additional bottom border to the overlay views in case
     * the overlays do not fill the entire screen.
     */
    private void positionAdditionalBottomBorder(boolean postAddView) {
        final int lastBottom = mAdditionalBottomBorderOverlayTop;
        final int containerHeight = webPxToScreenPx(mWebView.getContentHeight());
        final int speculativeHeight = containerHeight - lastBottom;
        if (speculativeHeight > 0) {
            if (mAdditionalBottomBorder == null) {
                mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate(R.layout.fake_bottom_border,
                        this, false);
            }

            setAdditionalBottomBorderHeight(speculativeHeight);

            if (!mAdditionalBottomBorderAdded) {
                addViewInLayoutWrapper(mAdditionalBottomBorder, postAddView);
                mAdditionalBottomBorderAdded = true;
            }

            measureOverlayView(mAdditionalBottomBorder);
            layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight);
        } else {
            if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) {
                if (postAddView) {
                    post(mRemoveBorderRunnable);
                } else {
                    mRemoveBorderRunnable.run();
                }
                mAdditionalBottomBorderAdded = false;
            }
        }
    }

    private final RemoveBorderRunnable mRemoveBorderRunnable = new RemoveBorderRunnable();

    private void setAdditionalBottomBorderHeight(int speculativeHeight) {
        LayoutParams params = mAdditionalBottomBorder.getLayoutParams();
        params.height = speculativeHeight;
        mAdditionalBottomBorder.setLayoutParams(params);
    }

    private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, final int withinTop,
            final int withinBottom, final int forceGravity) {
        if (adapterItem.getHeight() == 0) {
            // "place" invisible items at the bottom of their region to stay consistent with the
            // stacking algorithm in positionOverlays(), unless gravity is forced to the top
            final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom;
            return new OverlayPosition(y, y);
        }

        final int v = ((forceGravity != Gravity.NO_GRAVITY) ? forceGravity : adapterItem.getGravity())
                & Gravity.VERTICAL_GRAVITY_MASK;
        switch (v) {
        case Gravity.BOTTOM:
            return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom);
        case Gravity.TOP:
            return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight());
        default:
            throw new UnsupportedOperationException("unsupported gravity: " + v);
        }
    }

    /**
     * Executes a measure pass over the specified child overlay view and returns the measured
     * height. The measurement uses whatever the current container's width measure spec is.
     * This method ignores view visibility and returns the height that the view would be if visible.
     *
     * @param overlayView an overlay view to measure. does not actually have to be attached yet.
     * @return height that the view would be if it was visible
     */
    public int measureOverlay(View overlayView) {
        measureOverlayView(overlayView);
        return overlayView.getMeasuredHeight();
    }

    /**
     * Copied/stolen from {@link ListView}.
     */
    private void measureOverlayView(View child) {
        MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams();
        if (p == null) {
            p = (MarginLayoutParams) generateDefaultLayoutParams();
        }

        int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width);
        int lpHeight = p.height;
        int childHeightSpec;
        if (lpHeight > 0) {
            childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
        } else {
            childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        }
        child.measure(childWidthSpec, childHeightSpec);
    }

    private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, int overlayTop,
            int overlayBottom) {
        // immediately remove this view from the view set so future lookups don't find it
        mOverlayViews.remove(adapterIndex);

        // detach but don't actually remove from the view
        detachOverlay(overlay, false /* removeFromContainer */);

        // push it out of view immediately
        // otherwise this scrolled-off header will continue to draw until the runnable runs
        layoutOverlay(overlay.view, overlayTop, overlayBottom);
    }

    /**
     * Returns an existing scrap view, if available. The view will already be removed from the view
     * hierarchy. This method will not remove the view from the scrap heap.
     *
     */
    public View getScrapView(int type) {
        return mScrapViews.peek(type);
    }

    public void addScrapView(int type, View v) {
        mScrapViews.add(type, v);
        addViewInLayoutWrapper(v, false /* postAddView */);
    }

    private void detachOverlay(OverlayView overlay, boolean removeFromContainer) {
        // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded
        // because removing overlay views doesn't affect overall layout.
        if (removeFromContainer) {
            removeViewInLayout(overlay.view);
        }
        mScrapViews.add(overlay.itemType, overlay.view);
        if (overlay.view instanceof DetachListener) {
            ((DetachListener) overlay.view).onDetachedFromParent();
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) {
            LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s",
                    MeasureSpec.toString(widthMeasureSpec), MeasureSpec.toString(heightMeasureSpec));
        }

        for (View nonScrollingChild : mNonScrollingChildren) {
            if (nonScrollingChild.getVisibility() != GONE) {
                measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, heightMeasureSpec,
                        0 /* heightUsed */);
            }
        }
        mWidthMeasureSpec = widthMeasureSpec;

        // onLayout will re-measure and re-position overlays for the new container size, but the
        // spacer offsets would still need to be updated to have them draw at their new locations.
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        LogUtils.d(TAG, "*** IN header container onLayout");

        for (View nonScrollingChild : mNonScrollingChildren) {
            if (nonScrollingChild.getVisibility() != GONE) {
                final int w = nonScrollingChild.getMeasuredWidth();
                final int h = nonScrollingChild.getMeasuredHeight();

                final MarginLayoutParams lp = (MarginLayoutParams) nonScrollingChild.getLayoutParams();

                final int childLeft = lp.leftMargin;
                final int childTop = lp.topMargin;
                nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h);
            }
        }

        if (mOverlayAdapter != null) {
            // being in a layout pass means overlay children may require measurement,
            // so invalidate them
            for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
                mOverlayAdapter.getItem(i).invalidateMeasurement();
            }
        }

        positionOverlays(mOffsetY, false /* postAddView */);
    }

    @Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
        return new MarginLayoutParams(p);
    }

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return p instanceof MarginLayoutParams;
    }

    private int getOverlayTop(int spacerIndex) {
        return webPxToScreenPx(mOverlayPositions[spacerIndex].top);
    }

    private int getOverlayBottom(int spacerIndex) {
        return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom);
    }

    private int webPxToScreenPx(int webPx) {
        // TODO: round or truncate?
        // TODO: refactor and unify with ConversationWebView.webPxToScreenPx()
        return (int) (webPx * mScale);
    }

    private void positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) {
        final OverlayView overlay = mOverlayViews.get(adapterIndex);
        final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex);

        // save off the item's current top for later snap calculations
        item.setTop(overlayTopY);

        // is the overlay visible and does it have non-zero height?
        if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY && overlayTopY < mOffsetY + getHeight()) {
            View overlayView = overlay != null ? overlay.view : null;
            // show and/or move overlay
            if (overlayView == null) {
                overlayView = addOverlayView(adapterIndex, postAddView);
                ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this));
                measureOverlayView(overlayView);
                item.markMeasurementValid();
                traceLayout("show/measure overlay %d", adapterIndex);
            } else {
                traceLayout("move overlay %d", adapterIndex);
                if (!item.isMeasurementValid()) {
                    item.rebindView(overlayView);
                    measureOverlayView(overlayView);
                    item.markMeasurementValid();
                    traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex,
                            overlayView.getHeight(), overlayView.getMeasuredHeight());
                }
            }
            traceLayout("laying out overlay %d with h=%d", adapterIndex, overlayView.getMeasuredHeight());
            final int childBottom = overlayTopY + overlayView.getMeasuredHeight();
            layoutOverlay(overlayView, overlayTopY, childBottom);
            mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? childBottom
                    : mAdditionalBottomBorderOverlayTop;
        } else {
            // hide overlay
            if (overlay != null) {
                traceLayout("hide overlay %d", adapterIndex);
                onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY);
            } else {
                traceLayout("ignore non-visible overlay %d", adapterIndex);
            }
            mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop)
                    ? overlayBottomY
                    : mAdditionalBottomBorderOverlayTop;
        }

        if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) {
            if (mSnapIndex == -1) {
                mSnapIndex = adapterIndex;
            } else if (adapterIndex > mSnapIndex) {
                mSnapIndex = adapterIndex;
            }
        }

    }

    // layout an existing view
    // need its top offset into the conversation, its height, and the scroll offset
    private void layoutOverlay(View child, int childTop, int childBottom) {
        final int top = childTop - mOffsetY;
        final int bottom = childBottom - mOffsetY;

        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        final int childLeft = getPaddingLeft() + lp.leftMargin;

        child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom);
    }

    private View addOverlayView(int adapterIndex, boolean postAddView) {
        final int itemType = mOverlayAdapter.getItemViewType(adapterIndex);
        final View convertView = mScrapViews.poll(itemType);

        final View view = mOverlayAdapter.getView(adapterIndex, convertView, this);
        mOverlayViews.put(adapterIndex, new OverlayView(view, itemType));

        if (convertView == view) {
            LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
        } else {
            LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
        }

        if (view.getParent() == null) {
            addViewInLayoutWrapper(view, postAddView);
        } else {
            // Need to call postInvalidate since the view is being moved back on
            // screen and we want to force it to draw the view. Without doing this,
            // the view may not draw itself when it comes back on screen.
            view.postInvalidate();
        }

        return view;
    }

    private void addViewInLayoutWrapper(View view, boolean postAddView) {
        final AddViewRunnable addviewRunnable = new AddViewRunnable(view);
        if (postAddView) {
            post(addviewRunnable);
        } else {
            addviewRunnable.run();
        }
    }

    private class AddViewRunnable implements Runnable {
        private final View mView;

        public AddViewRunnable(View view) {
            mView = view;
        }

        @Override
        public void run() {
            final int index = BOTTOM_LAYER_VIEW_IDS.length;
            addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */);
        }
    };

    private class RemoveBorderRunnable implements Runnable {
        @Override
        public void run() {
            removeViewInLayout(mAdditionalBottomBorder);
        }
    }

    private boolean isSnapEnabled() {
        if (mAccountController == null || mAccountController.getAccount() == null
                || mAccountController.getAccount().settings == null) {
            return true;
        }
        final int snap = mAccountController.getAccount().settings.snapHeaders;
        return snap == UIProvider.SnapHeaderValue.ALWAYS || (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY
                && getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT);
    }

    // render and/or re-position snap header
    private void positionSnapHeader(int snapIndex) {
        ConversationOverlayItem snapItem = null;
        if (mSnapEnabled && snapIndex != -1) {
            final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex);
            if (item.canBecomeSnapHeader()) {
                snapItem = item;
            }
        }
        if (snapItem == null) {
            mSnapHeader.setVisibility(GONE);
            mSnapHeader.unbind();
            return;
        }

        snapItem.bindView(mSnapHeader, false /* measureOnly */);
        mSnapHeader.setVisibility(VISIBLE);

        // overlap is negative or zero; bump the snap header upwards by that much
        int overlap = 0;

        final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1);
        if (next != null) {
            overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY);

            // disable overlap drawing past a certain speed
            if (overlap < 0) {
                final Float v = mVelocityTracker.getSmoothedVelocity();
                if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) {
                    overlap = 0;
                }
            }
        }

        mSnapHeader.setTranslationY(overlap);
    }

    // find the next header that can push the snap header up
    private ConversationOverlayItem findNextPushingOverlay(int start) {
        for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) {
            final ConversationOverlayItem next = mOverlayAdapter.getItem(i);
            if (next.canPushSnapHeader()) {
                return next;
            }
        }
        return null;
    }

    /**
     * Prevents any layouts from happening until the next time
     * {@link #onGeometryChange(OverlayPosition[])} is
     * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
     * <p>
     * If you call this, you must ensure that a followup call to
     * {@link #onGeometryChange(OverlayPosition[])}
     * is made later, when the HTML spacer coordinates are updated.
     *
     */
    public void invalidateSpacerGeometry() {
        mOverlayPositions = null;
    }

    public void onGeometryChange(OverlayPosition[] overlayPositions) {
        traceLayout("*** got overlay spacer positions:");
        for (OverlayPosition pos : overlayPositions) {
            traceLayout("top=%d bottom=%d", pos.top, pos.bottom);
        }

        mOverlayPositions = overlayPositions;
        positionOverlays(mOffsetY, false /* postAddView */);
    }

    /**
     * Remove the view that corresponds to the item in the {@link ConversationViewAdapter}
     * at the specified index.<p/>
     *
     * <b>Note:</b> the view is actually pushed off-screen and recycled
     * as though it were scrolled off.
     * @param adapterIndex The index for the view in the adapter.
     */
    public void removeViewAtAdapterIndex(int adapterIndex) {
        // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen.
        final int offsetY = mOffsetY;
        mOffsetY = 0;
        final OverlayView overlay = mOverlayViews.get(adapterIndex);
        if (overlay != null) {
            final int height = getHeight();
            onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight());
            LogUtils.i(TAG, "footer scrolled off. container height=%s, measuredHeight=%s", height,
                    getMeasuredHeight());
        } else {
            LogUtils.i(TAG, "footer not found with adapterIndex=%s", adapterIndex);
            for (int i = 0, size = mOverlayViews.size(); i < size; i++) {
                final int index = mOverlayViews.keyAt(i);
                final OverlayView overlayView = mOverlayViews.valueAt(i);
                LogUtils.i(TAG, "OverlayView: adapterIndex=%s, itemType=%s, view=%s", index, overlayView.itemType,
                        overlayView.view);
            }
            for (int i = 0, size = mOverlayAdapter.getCount(); i < size; i++) {
                final ConversationOverlayItem item = mOverlayAdapter.getItem(i);
                LogUtils.i(TAG, "adapter item: index=%s, item=%s", i, item);
            }
        }
        // restore the offset to its original value after the view has been moved off-screen.
        mOffsetY = offsetY;
    }

    private void traceLayout(String msg, Object... params) {
        if (mDisableLayoutTracing) {
            return;
        }
        LogUtils.d(TAG, msg, params);
    }

    @Override
    protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
        if (mOverlayAdapter != null) {
            return mOverlayAdapter.focusFirstMessageHeader();
        }
        return false;
    }

    public void focusFirstMessageHeader() {
        mOverlayAdapter.focusFirstMessageHeader();
    }

    public View getNextOverlayView(View curr, boolean isDown) {
        // Find the scraps that we should avoid when fetching the next view.
        final Set<View> scraps = Sets.newHashSet();
        mScrapViews.visitAll(new DequeMap.Visitor<View>() {
            @Override
            public void visit(View item) {
                scraps.add(item);
            }
        });
        return mOverlayAdapter.getNextOverlayView(curr, isDown, scraps);
    }

    private class AdapterObserver extends DataSetObserver {
        @Override
        public void onChanged() {
            onDataSetChanged();
        }
    }
}