ConversationContainer.java revision a467d40eeb9c598fc6d7cb2dbafa2a331292d23e
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 SnapHeader 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        for (int id : BOTTOM_LAYER_VIEW_IDS) {
268            mNonScrollingChildren.add(findViewById(id));
269        }
270        for (int id : TOP_LAYER_VIEW_IDS) {
271            mNonScrollingChildren.add(findViewById(id));
272        }
273    }
274
275    public void setupSnapHeader() {
276        mSnapHeader = (SnapHeader) findViewById(R.id.snap_header);
277        mSnapHeader.setSnappy();
278    }
279
280    public SnapHeader getSnapHeader() {
281        return mSnapHeader;
282    }
283
284    public void setOverlayAdapter(ConversationViewAdapter a) {
285        if (mOverlayAdapter != null) {
286            mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver);
287            clearOverlays();
288        }
289        mOverlayAdapter = a;
290        if (mOverlayAdapter != null) {
291            mOverlayAdapter.registerDataSetObserver(mAdapterObserver);
292        }
293    }
294
295    public Adapter getOverlayAdapter() {
296        return mOverlayAdapter;
297    }
298
299    public void setAccountController(ConversationAccountController controller) {
300        mAccountController = controller;
301
302        mSnapEnabled = isSnapEnabled();
303    }
304
305    /**
306     * Re-bind any existing views that correspond to the given adapter positions.
307     *
308     */
309    public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) {
310        for (Integer i : affectedAdapterPositions) {
311            final ConversationOverlayItem item = mOverlayAdapter.getItem(i);
312            final OverlayView overlay = mOverlayViews.get(i);
313            if (overlay != null && overlay.view != null && item != null) {
314                item.onModelUpdated(overlay.view);
315            }
316            // update the snap header too, but only it's showing if the current item
317            if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) {
318                mSnapHeader.refresh();
319            }
320        }
321    }
322
323    /**
324     * Return an overlay view for the given adapter item, or null if no matching view is currently
325     * visible. This can happen as you scroll away from an overlay view.
326     *
327     */
328    public View getViewForItem(ConversationOverlayItem item) {
329        View result = null;
330        int adapterPos = -1;
331        for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
332            if (mOverlayAdapter.getItem(i) == item) {
333                adapterPos = i;
334                break;
335            }
336        }
337        if (adapterPos != -1) {
338            final OverlayView overlay = mOverlayViews.get(adapterPos);
339            if (overlay != null) {
340                result = overlay.view;
341            }
342        }
343        return result;
344    }
345
346    private void clearOverlays() {
347        for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
348            detachOverlay(mOverlayViews.valueAt(i));
349        }
350        mOverlayViews.clear();
351    }
352
353    private void onDataSetChanged() {
354        // Recycle all views and re-bind them according to the current set of spacer coordinates.
355        // This essentially resets the overlay views and re-renders them.
356        // It's fast enough that it's okay to re-do all views on any small change, as long as
357        // the change isn't too frequent (< ~1Hz).
358
359        clearOverlays();
360        // also unbind the snap header view, so this "reset" causes the snap header to re-create
361        // its view, just like all other headers
362        mSnapHeader.unbind();
363
364        // also clear out the additional bottom border
365        removeViewInLayout(mAdditionalBottomBorder);
366        mAdditionalBottomBorderAdded = false;
367
368        mSnapEnabled = isSnapEnabled();
369        positionOverlays(0, mOffsetY);
370    }
371
372    private void forwardFakeMotionEvent(MotionEvent original, int newAction) {
373        MotionEvent newEvent = MotionEvent.obtain(original);
374        newEvent.setAction(newAction);
375        mWebView.onTouchEvent(newEvent);
376        LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d",
377                newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(),
378                newEvent.getPointerCount());
379    }
380
381    /**
382     * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}.
383     */
384    @Override
385    public boolean onInterceptTouchEvent(MotionEvent ev) {
386
387        if (!mTouchInitialized) {
388            mTouchInitialized = true;
389        }
390
391        // no interception when WebView handles the first DOWN
392        if (mWebView.isHandlingTouch()) {
393            return false;
394        }
395
396        boolean intercept = false;
397        switch (ev.getActionMasked()) {
398            case MotionEvent.ACTION_POINTER_DOWN:
399                LogUtils.d(TAG, "Container is intercepting non-primary touch!");
400                intercept = true;
401                mMissedPointerDown = true;
402                requestDisallowInterceptTouchEvent(true);
403                break;
404
405            case MotionEvent.ACTION_DOWN:
406                mLastMotionY = ev.getY();
407                mActivePointerId = ev.getPointerId(0);
408                break;
409
410            case MotionEvent.ACTION_MOVE:
411                final int pointerIndex = ev.findPointerIndex(mActivePointerId);
412                final float y = ev.getY(pointerIndex);
413                final int yDiff = (int) Math.abs(y - mLastMotionY);
414                if (yDiff > mTouchSlop) {
415                    mLastMotionY = y;
416                    intercept = true;
417                }
418                break;
419        }
420
421//        LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s",
422//                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept);
423        return intercept;
424    }
425
426    @Override
427    public boolean onTouchEvent(MotionEvent ev) {
428        final int action = ev.getActionMasked();
429
430        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
431            mTouchIsDown = false;
432        } else if (!mTouchIsDown &&
433                (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) {
434
435            forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN);
436            if (mMissedPointerDown) {
437                forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN);
438                mMissedPointerDown = false;
439            }
440
441            mTouchIsDown = true;
442        }
443
444        final boolean webViewResult = mWebView.onTouchEvent(ev);
445
446//        LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d",
447//                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount());
448        return webViewResult;
449    }
450
451    @Override
452    public void onNotifierScroll(final int x, final int y) {
453        mVelocityTracker.onInput(y);
454        mDisableLayoutTracing = true;
455        positionOverlays(x, y);
456        mDisableLayoutTracing = false;
457    }
458
459    private void positionOverlays(int x, int y) {
460        mOffsetY = y;
461
462        /*
463         * The scale value that WebView reports is inaccurate when measured during WebView
464         * initialization. This bug is present in ICS, so to work around it, we ignore all
465         * reported values and use a calculated expected value from ConversationWebView instead.
466         * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin
467         * to pay attention to WebView-reported scale values.
468         */
469        if (mTouchInitialized) {
470            mScale = mWebView.getScale();
471        } else if (mScale == 0) {
472            mScale = mWebView.getInitialScale();
473        }
474        traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(),
475                mScale);
476
477        if (mOverlayPositions == null || mOverlayAdapter == null) {
478            return;
479        }
480
481        // recycle scrolled-off views and add newly visible views
482
483        // we want consecutive spacers/overlays to stack towards the bottom
484        // so iterate from the bottom of the conversation up
485        // starting with the last spacer bottom and the last adapter item, position adapter views
486        // in a single stack until you encounter a non-contiguous expanded message header,
487        // then decrement to the next spacer.
488
489        traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length,
490                mOverlayAdapter.getCount());
491
492        mSnapIndex = -1;
493        mAdditionalBottomBorderOverlayTop = 0;
494
495        int adapterLoopIndex = mOverlayAdapter.getCount() - 1;
496        int spacerIndex = mOverlayPositions.length - 1;
497        while (spacerIndex >= 0 && adapterLoopIndex >= 0) {
498
499            final int spacerTop = getOverlayTop(spacerIndex);
500            final int spacerBottom = getOverlayBottom(spacerIndex);
501
502            final boolean flip;
503            final int flipOffset;
504            final int forceGravity;
505            // flip direction from bottom->top to top->bottom traversal on the very first spacer
506            // to facilitate top-aligned headers at spacer index = 0
507            if (spacerIndex == 0) {
508                flip = true;
509                flipOffset = adapterLoopIndex;
510                forceGravity = Gravity.TOP;
511            } else {
512                flip = false;
513                flipOffset = 0;
514                forceGravity = Gravity.NO_GRAVITY;
515            }
516
517            int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;
518
519            // always place at least one overlay per spacer
520            ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex);
521
522            OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom,
523                    forceGravity);
524
525            traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex,
526                    itemPos.top, itemPos.bottom, adapterItem);
527            positionOverlay(adapterIndex, itemPos.top, itemPos.bottom);
528
529            // and keep stacking overlays unconditionally if we are on the first spacer, or as long
530            // as overlays are contiguous
531            while (--adapterLoopIndex >= 0) {
532                adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;
533                adapterItem = mOverlayAdapter.getItem(adapterIndex);
534                if (spacerIndex > 0 && !adapterItem.isContiguous()) {
535                    // advance to the next spacer, but stay on this adapter item
536                    break;
537                }
538
539                // place this overlay in the region of the spacer above or below the last item,
540                // depending on direction of iteration
541                final int regionTop = flip ? itemPos.bottom : spacerTop;
542                final int regionBottom = flip ? spacerBottom : itemPos.top;
543                itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity);
544
545                traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex,
546                        adapterIndex, itemPos.top, itemPos.bottom, adapterItem);
547                positionOverlay(adapterIndex, itemPos.top, itemPos.bottom);
548            }
549
550            spacerIndex--;
551        }
552
553        positionSnapHeader(mSnapIndex);
554        positionAdditionalBottomBorder();
555    }
556
557    /**
558     * Adds an additional bottom border to the overlay views in case
559     * the overlays do not fill the entire screen.
560     */
561    private void positionAdditionalBottomBorder() {
562        final int lastBottom = mAdditionalBottomBorderOverlayTop;
563        final int containerHeight = webPxToScreenPx(mWebView.getContentHeight());
564        final int speculativeHeight = containerHeight - lastBottom;
565        if (speculativeHeight > 0) {
566            if (mAdditionalBottomBorder == null) {
567                mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate(
568                        R.layout.fake_bottom_border, this, false);
569            }
570
571            setAdditionalBottomBorderHeight(speculativeHeight);
572
573            if (!mAdditionalBottomBorderAdded) {
574                addViewInLayoutWrapper(mAdditionalBottomBorder);
575                mAdditionalBottomBorderAdded = true;
576            }
577
578            measureOverlayView(mAdditionalBottomBorder);
579            layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight);
580        } else {
581            if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) {
582                removeViewInLayout(mAdditionalBottomBorder);
583                mAdditionalBottomBorderAdded = false;
584            }
585        }
586    }
587
588    private void setAdditionalBottomBorderHeight(int speculativeHeight) {
589        LayoutParams params = mAdditionalBottomBorder.getLayoutParams();
590        params.height = speculativeHeight;
591        mAdditionalBottomBorder.setLayoutParams(params);
592    }
593
594    private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem,
595            final int withinTop, final int withinBottom, final int forceGravity) {
596        if (adapterItem.getHeight() == 0) {
597            // "place" invisible items at the bottom of their region to stay consistent with the
598            // stacking algorithm in positionOverlays(), unless gravity is forced to the top
599            final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom;
600            return new OverlayPosition(y, y);
601        }
602
603        final int v = ((forceGravity != Gravity.NO_GRAVITY) ?
604                forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK;
605        switch (v) {
606            case Gravity.BOTTOM:
607                return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom);
608            case Gravity.TOP:
609                return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight());
610            default:
611                throw new UnsupportedOperationException("unsupported gravity: " + v);
612        }
613    }
614
615    /**
616     * Executes a measure pass over the specified child overlay view and returns the measured
617     * height. The measurement uses whatever the current container's width measure spec is.
618     * This method ignores view visibility and returns the height that the view would be if visible.
619     *
620     * @param overlayView an overlay view to measure. does not actually have to be attached yet.
621     * @return height that the view would be if it was visible
622     */
623    public int measureOverlay(View overlayView) {
624        measureOverlayView(overlayView);
625        return overlayView.getMeasuredHeight();
626    }
627
628    /**
629     * Copied/stolen from {@link ListView}.
630     */
631    private void measureOverlayView(View child) {
632        MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams();
633        if (p == null) {
634            p = (MarginLayoutParams) generateDefaultLayoutParams();
635        }
636
637        int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
638                getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width);
639        int lpHeight = p.height;
640        int childHeightSpec;
641        if (lpHeight > 0) {
642            childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
643        } else {
644            childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
645        }
646        child.measure(childWidthSpec, childHeightSpec);
647    }
648
649    private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay,
650            int overlayTop, int overlayBottom) {
651        // detach the view asynchronously, as scroll notification can happen during a draw, when
652        // it's not safe to remove children
653
654        // but immediately remove this view from the view set so future lookups don't find it
655        mOverlayViews.remove(adapterIndex);
656
657        post(new Runnable() {
658            @Override
659            public void run() {
660                detachOverlay(overlay);
661            }
662        });
663
664        // push it out of view immediately
665        // otherwise this scrolled-off header will continue to draw until the runnable runs
666        layoutOverlay(overlay.view, overlayTop, overlayBottom);
667    }
668
669    /**
670     * Returns an existing scrap view, if available. The view will already be removed from the view
671     * hierarchy. This method will not remove the view from the scrap heap.
672     *
673     */
674    public View getScrapView(int type) {
675        return mScrapViews.peek(type);
676    }
677
678    public void addScrapView(int type, View v) {
679        mScrapViews.add(type, v);
680    }
681
682    private void detachOverlay(OverlayView overlay) {
683        // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded
684        // because removing overlay views doesn't affect overall layout.
685        removeViewInLayout(overlay.view);
686        mScrapViews.add(overlay.itemType, overlay.view);
687        if (overlay.view instanceof DetachListener) {
688            ((DetachListener) overlay.view).onDetachedFromParent();
689        }
690    }
691
692    @Override
693    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
694        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
695        if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) {
696            LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s",
697                    MeasureSpec.toString(widthMeasureSpec),
698                    MeasureSpec.toString(heightMeasureSpec));
699        }
700
701        for (View nonScrollingChild : mNonScrollingChildren) {
702            if (nonScrollingChild.getVisibility() != GONE) {
703                measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */,
704                        heightMeasureSpec, 0 /* heightUsed */);
705            }
706        }
707        mWidthMeasureSpec = widthMeasureSpec;
708
709        // onLayout will re-measure and re-position overlays for the new container size, but the
710        // spacer offsets would still need to be updated to have them draw at their new locations.
711    }
712
713    @Override
714    protected void onLayout(boolean changed, int l, int t, int r, int b) {
715        LogUtils.d(TAG, "*** IN header container onLayout");
716
717        for (View nonScrollingChild : mNonScrollingChildren) {
718            if (nonScrollingChild.getVisibility() != GONE) {
719                final int w = nonScrollingChild.getMeasuredWidth();
720                final int h = nonScrollingChild.getMeasuredHeight();
721
722                final MarginLayoutParams lp =
723                        (MarginLayoutParams) nonScrollingChild.getLayoutParams();
724
725                final int childLeft = lp.leftMargin;
726                final int childTop = lp.topMargin;
727                nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h);
728            }
729        }
730
731        if (mOverlayAdapter != null) {
732            // being in a layout pass means overlay children may require measurement,
733            // so invalidate them
734            for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
735                mOverlayAdapter.getItem(i).invalidateMeasurement();
736            }
737        }
738
739        positionOverlays(0, mOffsetY);
740    }
741
742    @Override
743    protected void dispatchDraw(Canvas canvas) {
744        super.dispatchDraw(canvas);
745
746        if (mAttachedOverlaySinceLastDraw) {
747            drawChild(canvas, mTopMostOverlay, getDrawingTime());
748            mAttachedOverlaySinceLastDraw = false;
749        }
750    }
751
752    @Override
753    protected LayoutParams generateDefaultLayoutParams() {
754        return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
755    }
756
757    @Override
758    public LayoutParams generateLayoutParams(AttributeSet attrs) {
759        return new MarginLayoutParams(getContext(), attrs);
760    }
761
762    @Override
763    protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
764        return new MarginLayoutParams(p);
765    }
766
767    @Override
768    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
769        return p instanceof MarginLayoutParams;
770    }
771
772    private int getOverlayTop(int spacerIndex) {
773        return webPxToScreenPx(mOverlayPositions[spacerIndex].top);
774    }
775
776    private int getOverlayBottom(int spacerIndex) {
777        return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom);
778    }
779
780    private int webPxToScreenPx(int webPx) {
781        // TODO: round or truncate?
782        // TODO: refactor and unify with ConversationWebView.webPxToScreenPx()
783        return (int) (webPx * mScale);
784    }
785
786    private void positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY) {
787        final OverlayView overlay = mOverlayViews.get(adapterIndex);
788        final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex);
789
790        // save off the item's current top for later snap calculations
791        item.setTop(overlayTopY);
792
793        // is the overlay visible and does it have non-zero height?
794        if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY
795                && overlayTopY < mOffsetY + getHeight()) {
796            View overlayView = overlay != null ? overlay.view : null;
797            // show and/or move overlay
798            if (overlayView == null) {
799                overlayView = addOverlayView(adapterIndex);
800                measureOverlayView(overlayView);
801                item.markMeasurementValid();
802                traceLayout("show/measure overlay %d", adapterIndex);
803            } else {
804                traceLayout("move overlay %d", adapterIndex);
805                if (!item.isMeasurementValid()) {
806                    item.rebindView(overlayView);
807                    measureOverlayView(overlayView);
808                    item.markMeasurementValid();
809                    traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex,
810                            overlayView.getHeight(), overlayView.getMeasuredHeight());
811                }
812            }
813            traceLayout("laying out overlay %d with h=%d", adapterIndex,
814                    overlayView.getMeasuredHeight());
815            final int childBottom = overlayTopY + overlayView.getMeasuredHeight();
816            layoutOverlay(overlayView, overlayTopY, childBottom);
817            mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ?
818                    childBottom : mAdditionalBottomBorderOverlayTop;
819        } else {
820            // hide overlay
821            if (overlay != null) {
822                traceLayout("hide overlay %d", adapterIndex);
823                onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY);
824            } else {
825                traceLayout("ignore non-visible overlay %d", adapterIndex);
826            }
827            mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop)
828                    ? overlayBottomY : mAdditionalBottomBorderOverlayTop;
829        }
830
831        if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) {
832            if (mSnapIndex == -1) {
833                mSnapIndex = adapterIndex;
834            } else if (adapterIndex > mSnapIndex) {
835                mSnapIndex = adapterIndex;
836            }
837        }
838
839    }
840
841    // layout an existing view
842    // need its top offset into the conversation, its height, and the scroll offset
843    private void layoutOverlay(View child, int childTop, int childBottom) {
844        final int top = childTop - mOffsetY;
845        final int bottom = childBottom - mOffsetY;
846
847        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
848        final int childLeft = getPaddingLeft() + lp.leftMargin;
849
850        child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom);
851    }
852
853    private View addOverlayView(int adapterIndex) {
854        final int itemType = mOverlayAdapter.getItemViewType(adapterIndex);
855        final View convertView = mScrapViews.poll(itemType);
856
857        final View view = mOverlayAdapter.getView(adapterIndex, convertView, this);
858        mOverlayViews.put(adapterIndex, new OverlayView(view, itemType));
859
860        if (convertView == view) {
861            LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
862        } else {
863            LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
864        }
865
866        addViewInLayoutWrapper(view);
867
868        return view;
869    }
870
871    private void addViewInLayoutWrapper(View view) {
872        final int index = BOTTOM_LAYER_VIEW_IDS.length;
873        addViewInLayout(view, index, view.getLayoutParams(), true /* preventRequestLayout */);
874        mAttachedOverlaySinceLastDraw = true;
875    }
876
877    private boolean isSnapEnabled() {
878        if (mAccountController == null || mAccountController.getAccount() == null
879                || mAccountController.getAccount().settings == null) {
880            return true;
881        }
882        final int snap = mAccountController.getAccount().settings.snapHeaders;
883        return snap == UIProvider.SnapHeaderValue.ALWAYS ||
884                (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources()
885                    .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT);
886    }
887
888    // render and/or re-position snap header
889    private void positionSnapHeader(int snapIndex) {
890        ConversationOverlayItem snapItem = null;
891        if (mSnapEnabled && snapIndex != -1) {
892            final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex);
893            if (item.canBecomeSnapHeader()) {
894                snapItem = item;
895            }
896        }
897        if (snapItem == null) {
898            mSnapHeader.setVisibility(GONE);
899            mSnapHeader.unbind();
900            return;
901        }
902
903        snapItem.bindView(mSnapHeader, false /* measureOnly */);
904        mSnapHeader.setVisibility(VISIBLE);
905
906        // overlap is negative or zero; bump the snap header upwards by that much
907        int overlap = 0;
908
909        final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1);
910        if (next != null) {
911            overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY);
912
913            // disable overlap drawing past a certain speed
914            if (overlap < 0) {
915                final Float v = mVelocityTracker.getSmoothedVelocity();
916                if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) {
917                    overlap = 0;
918                }
919            }
920        }
921
922        mSnapHeader.setTranslationY(overlap);
923    }
924
925    // find the next header that can push the snap header up
926    private ConversationOverlayItem findNextPushingOverlay(int start) {
927        for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) {
928            final ConversationOverlayItem next = mOverlayAdapter.getItem(i);
929            if (next.canPushSnapHeader()) {
930                return next;
931            }
932        }
933        return null;
934    }
935
936    /**
937     * Return a collection of all currently visible overlay views, in no particular order.
938     * Please don't mess with them too badly (e.g. remove from parent).
939     *
940     */
941    public List<View> getOverlayViews() {
942        final List<View> views = Lists.newArrayList();
943        for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
944            views.add(mOverlayViews.valueAt(i).view);
945        }
946        return views;
947    }
948
949    /**
950     * Prevents any layouts from happening until the next time
951     * {@link #onGeometryChange(OverlayPosition[])} is
952     * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
953     * <p>
954     * If you call this, you must ensure that a followup call to
955     * {@link #onGeometryChange(OverlayPosition[])}
956     * is made later, when the HTML spacer coordinates are updated.
957     *
958     */
959    public void invalidateSpacerGeometry() {
960        mOverlayPositions = null;
961    }
962
963    public void onGeometryChange(OverlayPosition[] overlayPositions) {
964        traceLayout("*** got overlay spacer positions:");
965        for (OverlayPosition pos : overlayPositions) {
966            traceLayout("top=%d bottom=%d", pos.top, pos.bottom);
967        }
968
969        mOverlayPositions = overlayPositions;
970        positionOverlays(0, mOffsetY);
971    }
972
973    private void traceLayout(String msg, Object... params) {
974        if (mDisableLayoutTracing) {
975            return;
976        }
977        LogUtils.d(TAG, msg, params);
978    }
979
980    private class AdapterObserver extends DataSetObserver {
981        @Override
982        public void onChanged() {
983            onDataSetChanged();
984        }
985    }
986}
987