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
18package com.android.mail.browse;
19
20import android.content.Context;
21import android.content.res.Configuration;
22import android.database.DataSetObserver;
23import android.graphics.Canvas;
24import android.util.AttributeSet;
25import android.util.SparseArray;
26import android.view.Gravity;
27import android.view.MotionEvent;
28import android.view.View;
29import android.view.ViewConfiguration;
30import android.view.ViewGroup;
31import android.webkit.WebView;
32import android.widget.Adapter;
33import android.widget.ListView;
34import android.widget.ScrollView;
35
36import com.android.mail.R;
37import com.android.mail.browse.ScrollNotifier.ScrollListener;
38import com.android.mail.providers.UIProvider;
39import com.android.mail.ui.ConversationViewFragment;
40import com.android.mail.utils.DequeMap;
41import com.android.mail.utils.InputSmoother;
42import com.android.mail.utils.LogUtils;
43import com.google.common.collect.Lists;
44
45import 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 */
68public 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