ConversationContainer.java revision 24e052ad62145bfcb1189817e750e78b60c8645d
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.support.annotation.IdRes;
24import android.support.v4.view.ViewCompat;
25import android.util.AttributeSet;
26import android.util.SparseArray;
27import android.view.Gravity;
28import android.view.MotionEvent;
29import android.view.View;
30import android.view.ViewConfiguration;
31import android.view.ViewGroup;
32import android.webkit.WebView;
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.<br/><br/>
66 *
67 * There is one additional constraint with the recycling: since scroll
68 * notifications happen during the WebView's draw, we do not remove and re-add views for recycling.
69 * Instead, we simply move the views off-screen and add them to our recycle cache. When the views
70 * are reused, they are simply moved back on screen instead of added. This practice
71 * circumvents the issues found when views are added or removed during draw (which results in
72 * elements not being drawn and other visual oddities). See b/10994303 for more details.
73 */
74public class ConversationContainer extends ViewGroup implements ScrollListener {
75    private static final String TAG = ConversationViewFragment.LAYOUT_TAG;
76
77    private static final int[] BOTTOM_LAYER_VIEW_IDS = {
78        R.id.conversation_webview
79    };
80
81    private static final int[] TOP_LAYER_VIEW_IDS = {
82        R.id.conversation_topmost_overlay
83    };
84
85    /**
86     * Maximum scroll speed (in dp/sec) at which the snap header animation will draw.
87     * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect).
88     */
89    private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f;
90
91    private ConversationAccountController mAccountController;
92    private ConversationViewAdapter mOverlayAdapter;
93    private OverlayPosition[] mOverlayPositions;
94    private ConversationWebView mWebView;
95    private SnapHeader mSnapHeader;
96
97    private final List<View> mNonScrollingChildren = Lists.newArrayList();
98
99    /**
100     * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual
101     * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other.
102     */
103    private float mScale;
104    /**
105     * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale
106     * values.
107     */
108    private boolean mTouchInitialized;
109
110    /**
111     * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}.
112     */
113    private final int mTouchSlop;
114    /**
115     * Current scroll position, as dictated by the background {@link WebView}.
116     */
117    private int mOffsetY;
118    /**
119     * Original pointer Y for slop calculation.
120     */
121    private float mLastMotionY;
122    /**
123     * Original pointer ID for slop calculation.
124     */
125    private int mActivePointerId;
126    /**
127     * Track pointer up/down state to know whether to send a make-up DOWN event to WebView.
128     * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be
129     * preceded by a {@link MotionEvent#ACTION_DOWN} event.
130     */
131    private boolean mTouchIsDown = false;
132    /**
133     * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN},
134     * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}.
135     */
136    private boolean mMissedPointerDown;
137
138    /**
139     * A recycler that holds removed scrap views, organized by integer item view type. All views
140     * in this data structure should be removed from their view parent prior to insertion.
141     */
142    private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>();
143
144    /**
145     * The current set of overlay views in the view hierarchy. Looking through this map is faster
146     * than traversing the view hierarchy.
147     * <p>
148     * WebView sometimes notifies of scroll changes during a draw (or display list generation), when
149     * it's not safe to detach view children because ViewGroup is in the middle of iterating over
150     * its child array. So we remove any child from this list immediately and queue up a task to
151     * detach it later. Since nobody other than the detach task references that view in the
152     * meantime, we don't need any further checks or synchronization.
153     * <p>
154     * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose
155     * of all views (on data set or adapter change), we can at least recycle them into the typed
156     * scrap piles for later reuse.
157     */
158    private final SparseArray<OverlayView> mOverlayViews;
159
160    private int mWidthMeasureSpec;
161
162    private boolean mDisableLayoutTracing;
163
164    private final InputSmoother mVelocityTracker;
165
166    private final DataSetObserver mAdapterObserver = new AdapterObserver();
167
168    /**
169     * The adapter index of the lowest overlay item that is above the top of the screen and reports
170     * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through
171     * {@link #positionOverlays}.
172     *
173     */
174    private int mSnapIndex;
175
176    private boolean mSnapEnabled;
177
178    /**
179     * A View that fills the remaining vertical space when the overlays do not take
180     * up the entire container. Otherwise, a card-like bottom white space appears.
181     */
182    private View mAdditionalBottomBorder;
183
184    /**
185     * A flag denoting whether the fake bottom border has been added to the container.
186     */
187    private boolean mAdditionalBottomBorderAdded;
188
189    /**
190     * An int containing the potential top value for the additional bottom border.
191     * If this value is less than the height of the scroll container, the additional
192     * bottom border will be drawn.
193     */
194    private int mAdditionalBottomBorderOverlayTop;
195
196    /**
197     * Child views of this container should implement this interface to be notified when they are
198     * being detached.
199     */
200    public interface DetachListener {
201        /**
202         * Called on a child view when it is removed from its parent as part of
203         * {@link ConversationContainer} view recycling.
204         */
205        void onDetachedFromParent();
206    }
207
208    public static class OverlayPosition {
209        public final int top;
210        public final int bottom;
211
212        public OverlayPosition(int top, int bottom) {
213            this.top = top;
214            this.bottom = bottom;
215        }
216    }
217
218    private static class OverlayView {
219        public View view;
220        int itemType;
221
222        public OverlayView(View view, int itemType) {
223            this.view = view;
224            this.itemType = itemType;
225        }
226    }
227
228    public ConversationContainer(Context c) {
229        this(c, null);
230    }
231
232    public ConversationContainer(Context c, AttributeSet attrs) {
233        super(c, attrs);
234
235        mOverlayViews = new SparseArray<OverlayView>();
236
237        mVelocityTracker = new InputSmoother(c);
238
239        mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop();
240
241        // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the
242        // WebView and the second pointer goes down on an overlay view.
243        // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer
244        // goes down on an overlay view.
245        setMotionEventSplittingEnabled(false);
246    }
247
248    @Override
249    protected void onFinishInflate() {
250        super.onFinishInflate();
251
252        mWebView = (ConversationWebView) findViewById(R.id.conversation_webview);
253        mWebView.addScrollListener(this);
254
255        for (int id : BOTTOM_LAYER_VIEW_IDS) {
256            mNonScrollingChildren.add(findViewById(id));
257        }
258        for (int id : TOP_LAYER_VIEW_IDS) {
259            mNonScrollingChildren.add(findViewById(id));
260        }
261    }
262
263    public void setupSnapHeader() {
264        mSnapHeader = (SnapHeader) findViewById(R.id.snap_header);
265        mSnapHeader.setSnappy();
266    }
267
268    public SnapHeader getSnapHeader() {
269        return mSnapHeader;
270    }
271
272    public void setOverlayAdapter(ConversationViewAdapter a) {
273        if (mOverlayAdapter != null) {
274            mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver);
275            clearOverlays();
276        }
277        mOverlayAdapter = a;
278        if (mOverlayAdapter != null) {
279            mOverlayAdapter.registerDataSetObserver(mAdapterObserver);
280        }
281    }
282
283    public void setAccountController(ConversationAccountController controller) {
284        mAccountController = controller;
285
286//        mSnapEnabled = isSnapEnabled();
287        mSnapEnabled = false; // TODO - re-enable when dogfooders howl
288    }
289
290    /**
291     * Re-bind any existing views that correspond to the given adapter positions.
292     *
293     */
294    public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) {
295        for (Integer i : affectedAdapterPositions) {
296            final ConversationOverlayItem item = mOverlayAdapter.getItem(i);
297            final OverlayView overlay = mOverlayViews.get(i);
298            if (overlay != null && overlay.view != null && item != null) {
299                item.onModelUpdated(overlay.view);
300            }
301            // update the snap header too, but only it's showing if the current item
302            if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) {
303                mSnapHeader.refresh();
304            }
305        }
306    }
307
308    /**
309     * Return an overlay view for the given adapter item, or null if no matching view is currently
310     * visible. This can happen as you scroll away from an overlay view.
311     *
312     */
313    public View getViewForItem(ConversationOverlayItem item) {
314        if (mOverlayAdapter == null) {
315            return null;
316        }
317        View result = null;
318        int adapterPos = -1;
319        for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
320            if (mOverlayAdapter.getItem(i) == item) {
321                adapterPos = i;
322                break;
323            }
324        }
325        if (adapterPos != -1) {
326            final OverlayView overlay = mOverlayViews.get(adapterPos);
327            if (overlay != null) {
328                result = overlay.view;
329            }
330        }
331        return result;
332    }
333
334    private void clearOverlays() {
335        for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
336            detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */);
337        }
338        mOverlayViews.clear();
339    }
340
341    private void onDataSetChanged() {
342        // Recycle all views and re-bind them according to the current set of spacer coordinates.
343        // This essentially resets the overlay views and re-renders them.
344        // It's fast enough that it's okay to re-do all views on any small change, as long as
345        // the change isn't too frequent (< ~1Hz).
346
347        clearOverlays();
348        // also unbind the snap header view, so this "reset" causes the snap header to re-create
349        // its view, just like all other headers
350        mSnapHeader.unbind();
351
352        // also clear out the additional bottom border
353        removeViewInLayout(mAdditionalBottomBorder);
354        mAdditionalBottomBorderAdded = false;
355
356//        mSnapEnabled = isSnapEnabled();
357        mSnapEnabled = false; // TODO - re-enable when dogfooders howl
358        positionOverlays(mOffsetY, false /* postAddView */);
359    }
360
361    private void forwardFakeMotionEvent(MotionEvent original, int newAction) {
362        MotionEvent newEvent = MotionEvent.obtain(original);
363        newEvent.setAction(newAction);
364        mWebView.onTouchEvent(newEvent);
365        LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d",
366                newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(),
367                newEvent.getPointerCount());
368    }
369
370    /**
371     * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}.
372     */
373    @Override
374    public boolean onInterceptTouchEvent(MotionEvent ev) {
375
376        if (!mTouchInitialized) {
377            mTouchInitialized = true;
378        }
379
380        // no interception when WebView handles the first DOWN
381        if (mWebView.isHandlingTouch()) {
382            return false;
383        }
384
385        boolean intercept = false;
386        switch (ev.getActionMasked()) {
387            case MotionEvent.ACTION_POINTER_DOWN:
388                LogUtils.d(TAG, "Container is intercepting non-primary touch!");
389                intercept = true;
390                mMissedPointerDown = true;
391                requestDisallowInterceptTouchEvent(true);
392                break;
393
394            case MotionEvent.ACTION_DOWN:
395                mLastMotionY = ev.getY();
396                mActivePointerId = ev.getPointerId(0);
397                break;
398
399            case MotionEvent.ACTION_MOVE:
400                final int pointerIndex = ev.findPointerIndex(mActivePointerId);
401                final float y = ev.getY(pointerIndex);
402                final int yDiff = (int) Math.abs(y - mLastMotionY);
403                if (yDiff > mTouchSlop) {
404                    mLastMotionY = y;
405                    intercept = true;
406                }
407                break;
408        }
409
410//        LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s",
411//                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept);
412        return intercept;
413    }
414
415    @Override
416    public boolean onTouchEvent(MotionEvent ev) {
417        final int action = ev.getActionMasked();
418
419        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
420            mTouchIsDown = false;
421        } else if (!mTouchIsDown &&
422                (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) {
423
424            forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN);
425            if (mMissedPointerDown) {
426                forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN);
427                mMissedPointerDown = false;
428            }
429
430            mTouchIsDown = true;
431        }
432
433        final boolean webViewResult = mWebView.onTouchEvent(ev);
434
435//        LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d",
436//                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount());
437        return webViewResult;
438    }
439
440    @Override
441    public void onNotifierScroll(final int y) {
442        mVelocityTracker.onInput(y);
443        mDisableLayoutTracing = true;
444        positionOverlays(y, true /* postAddView */); // post the addView since we're in draw code
445        mDisableLayoutTracing = false;
446    }
447
448    /**
449     * Positions the overlays given an updated y position for the container.
450     * @param y the current top position on screen
451     * @param postAddView If {@code true}, posts all calls to
452     *                    {@link #addViewInLayoutWrapper(android.view.View, boolean)}
453     *                    to the UI thread rather than adding it immediately. If {@code false},
454     *                    calls {@link #addViewInLayoutWrapper(android.view.View, boolean)}
455     *                    immediately.
456     */
457    private void positionOverlays(int y, boolean postAddView) {
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, postAddView);
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, postAddView);
546            }
547
548            spacerIndex--;
549        }
550
551        positionSnapHeader(mSnapIndex);
552        positionAdditionalBottomBorder(postAddView);
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(boolean postAddView) {
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, postAddView);
573                mAdditionalBottomBorderAdded = true;
574            }
575
576            measureOverlayView(mAdditionalBottomBorder);
577            layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight);
578        } else {
579            if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) {
580                if (postAddView) {
581                    post(mRemoveAdditionalBottomBorderRunnable);
582                } else {
583                    mRemoveAdditionalBottomBorderRunnable.run();
584                }
585                mAdditionalBottomBorderAdded = false;
586            }
587        }
588    }
589
590    private final RemoveViewRunnable mRemoveAdditionalBottomBorderRunnable =
591            new RemoveViewRunnable(mAdditionalBottomBorder);
592
593    private void setAdditionalBottomBorderHeight(int speculativeHeight) {
594        LayoutParams params = mAdditionalBottomBorder.getLayoutParams();
595        params.height = speculativeHeight;
596        mAdditionalBottomBorder.setLayoutParams(params);
597    }
598
599    private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem,
600            final int withinTop, final int withinBottom, final int forceGravity) {
601        if (adapterItem.getHeight() == 0) {
602            // "place" invisible items at the bottom of their region to stay consistent with the
603            // stacking algorithm in positionOverlays(), unless gravity is forced to the top
604            final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom;
605            return new OverlayPosition(y, y);
606        }
607
608        final int v = ((forceGravity != Gravity.NO_GRAVITY) ?
609                forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK;
610        switch (v) {
611            case Gravity.BOTTOM:
612                return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom);
613            case Gravity.TOP:
614                return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight());
615            default:
616                throw new UnsupportedOperationException("unsupported gravity: " + v);
617        }
618    }
619
620    /**
621     * Executes a measure pass over the specified child overlay view and returns the measured
622     * height. The measurement uses whatever the current container's width measure spec is.
623     * This method ignores view visibility and returns the height that the view would be if visible.
624     *
625     * @param overlayView an overlay view to measure. does not actually have to be attached yet.
626     * @return height that the view would be if it was visible
627     */
628    public int measureOverlay(View overlayView) {
629        measureOverlayView(overlayView);
630        return overlayView.getMeasuredHeight();
631    }
632
633    /**
634     * Copied/stolen from {@link ListView}.
635     */
636    private void measureOverlayView(View child) {
637        MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams();
638        if (p == null) {
639            p = (MarginLayoutParams) generateDefaultLayoutParams();
640        }
641
642        int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
643                getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width);
644        int lpHeight = p.height;
645        int childHeightSpec;
646        if (lpHeight > 0) {
647            childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
648        } else {
649            childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
650        }
651        child.measure(childWidthSpec, childHeightSpec);
652    }
653
654    private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay,
655            int overlayTop, int overlayBottom) {
656        // immediately remove this view from the view set so future lookups don't find it
657        mOverlayViews.remove(adapterIndex);
658
659        // detach but don't actually remove from the view
660        detachOverlay(overlay, false /* removeFromContainer */);
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        addViewInLayoutWrapper(v, false /* postAddView */);
679    }
680
681    private void detachOverlay(OverlayView overlay, boolean removeFromContainer) {
682        // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded
683        // because removing overlay views doesn't affect overall layout.
684        if (removeFromContainer) {
685            removeViewInLayout(overlay.view);
686        }
687        mScrapViews.add(overlay.itemType, overlay.view);
688        if (overlay.view instanceof DetachListener) {
689            ((DetachListener) overlay.view).onDetachedFromParent();
690        }
691    }
692
693    @Override
694    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
695        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
696        if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) {
697            LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s",
698                    MeasureSpec.toString(widthMeasureSpec),
699                    MeasureSpec.toString(heightMeasureSpec));
700        }
701
702        for (View nonScrollingChild : mNonScrollingChildren) {
703            if (nonScrollingChild.getVisibility() != GONE) {
704                measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */,
705                        heightMeasureSpec, 0 /* heightUsed */);
706            }
707        }
708        mWidthMeasureSpec = widthMeasureSpec;
709
710        // onLayout will re-measure and re-position overlays for the new container size, but the
711        // spacer offsets would still need to be updated to have them draw at their new locations.
712    }
713
714    @Override
715    protected void onLayout(boolean changed, int l, int t, int r, int b) {
716        LogUtils.d(TAG, "*** IN header container onLayout");
717
718        for (View nonScrollingChild : mNonScrollingChildren) {
719            if (nonScrollingChild.getVisibility() != GONE) {
720                final int w = nonScrollingChild.getMeasuredWidth();
721                final int h = nonScrollingChild.getMeasuredHeight();
722
723                final MarginLayoutParams lp =
724                        (MarginLayoutParams) nonScrollingChild.getLayoutParams();
725
726                final int childLeft = lp.leftMargin;
727                final int childTop = lp.topMargin;
728                nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h);
729            }
730        }
731
732        if (mOverlayAdapter != null) {
733            // being in a layout pass means overlay children may require measurement,
734            // so invalidate them
735            for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
736                mOverlayAdapter.getItem(i).invalidateMeasurement();
737            }
738        }
739
740        positionOverlays(mOffsetY, false /* postAddView */);
741    }
742
743    @Override
744    protected LayoutParams generateDefaultLayoutParams() {
745        return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
746    }
747
748    @Override
749    public LayoutParams generateLayoutParams(AttributeSet attrs) {
750        return new MarginLayoutParams(getContext(), attrs);
751    }
752
753    @Override
754    protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
755        return new MarginLayoutParams(p);
756    }
757
758    @Override
759    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
760        return p instanceof MarginLayoutParams;
761    }
762
763    private int getOverlayTop(int spacerIndex) {
764        return webPxToScreenPx(mOverlayPositions[spacerIndex].top);
765    }
766
767    private int getOverlayBottom(int spacerIndex) {
768        return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom);
769    }
770
771    private int webPxToScreenPx(int webPx) {
772        // TODO: round or truncate?
773        // TODO: refactor and unify with ConversationWebView.webPxToScreenPx()
774        return (int) (webPx * mScale);
775    }
776
777    private void positionOverlay(
778            int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) {
779        final OverlayView overlay = mOverlayViews.get(adapterIndex);
780        final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex);
781
782        // save off the item's current top for later snap calculations
783        item.setTop(overlayTopY);
784
785        // is the overlay visible and does it have non-zero height?
786        if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY
787                && overlayTopY < mOffsetY + getHeight()) {
788            View overlayView = overlay != null ? overlay.view : null;
789            // show and/or move overlay
790            if (overlayView == null) {
791                overlayView = addOverlayView(adapterIndex, postAddView);
792                ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this));
793                measureOverlayView(overlayView);
794                item.markMeasurementValid();
795                traceLayout("show/measure overlay %d", adapterIndex);
796            } else {
797                traceLayout("move overlay %d", adapterIndex);
798                if (!item.isMeasurementValid()) {
799                    item.rebindView(overlayView);
800                    measureOverlayView(overlayView);
801                    item.markMeasurementValid();
802                    traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex,
803                            overlayView.getHeight(), overlayView.getMeasuredHeight());
804                }
805            }
806            traceLayout("laying out overlay %d with h=%d", adapterIndex,
807                    overlayView.getMeasuredHeight());
808            final int childBottom = overlayTopY + overlayView.getMeasuredHeight();
809            layoutOverlay(overlayView, overlayTopY, childBottom);
810            mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ?
811                    childBottom : mAdditionalBottomBorderOverlayTop;
812        } else {
813            // hide overlay
814            if (overlay != null) {
815                traceLayout("hide overlay %d", adapterIndex);
816                onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY);
817            } else {
818                traceLayout("ignore non-visible overlay %d", adapterIndex);
819            }
820            mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop)
821                    ? overlayBottomY : mAdditionalBottomBorderOverlayTop;
822        }
823
824        if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) {
825            if (mSnapIndex == -1) {
826                mSnapIndex = adapterIndex;
827            } else if (adapterIndex > mSnapIndex) {
828                mSnapIndex = adapterIndex;
829            }
830        }
831
832    }
833
834    // layout an existing view
835    // need its top offset into the conversation, its height, and the scroll offset
836    private void layoutOverlay(View child, int childTop, int childBottom) {
837        final int top = childTop - mOffsetY;
838        final int bottom = childBottom - mOffsetY;
839
840        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
841        final int childLeft = getPaddingLeft() + lp.leftMargin;
842
843        child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom);
844    }
845
846    private View addOverlayView(int adapterIndex, boolean postAddView) {
847        final int itemType = mOverlayAdapter.getItemViewType(adapterIndex);
848        final View convertView = mScrapViews.poll(itemType);
849
850        final View view = mOverlayAdapter.getView(adapterIndex, convertView, this);
851        mOverlayViews.put(adapterIndex, new OverlayView(view, itemType));
852
853        if (convertView == view) {
854            LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
855        } else {
856            LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
857        }
858
859        if (view.getParent() == null) {
860            addViewInLayoutWrapper(view, postAddView);
861        } else {
862            // Need to call postInvalidate since the view is being moved back on
863            // screen and we want to force it to draw the view. Without doing this,
864            // the view may not draw itself when it comes back on screen.
865            view.postInvalidate();
866        }
867
868        return view;
869    }
870
871    private void addViewInLayoutWrapper(View view, boolean postAddView) {
872        final AddViewRunnable addviewRunnable = new AddViewRunnable(view);
873        if (postAddView) {
874            post(addviewRunnable);
875        } else {
876            addviewRunnable.run();
877        }
878    }
879
880    private class AddViewRunnable implements Runnable {
881        private final View mView;
882
883        public AddViewRunnable(View view) {
884            mView = view;
885        }
886
887        @Override
888        public void run() {
889            final int index = BOTTOM_LAYER_VIEW_IDS.length;
890            addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */);
891        }
892    };
893
894    private class RemoveViewRunnable implements Runnable {
895        private final View mView;
896
897        private RemoveViewRunnable(View view) {
898            mView = view;
899        }
900
901        @Override
902        public void run() {
903            removeViewInLayout(mView);
904        }
905    }
906
907    private boolean isSnapEnabled() {
908        if (mAccountController == null || mAccountController.getAccount() == null
909                || mAccountController.getAccount().settings == null) {
910            return true;
911        }
912        final int snap = mAccountController.getAccount().settings.snapHeaders;
913        return snap == UIProvider.SnapHeaderValue.ALWAYS ||
914                (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources()
915                    .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT);
916    }
917
918    // render and/or re-position snap header
919    private void positionSnapHeader(int snapIndex) {
920        ConversationOverlayItem snapItem = null;
921        if (mSnapEnabled && snapIndex != -1) {
922            final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex);
923            if (item.canBecomeSnapHeader()) {
924                snapItem = item;
925            }
926        }
927        if (snapItem == null) {
928            mSnapHeader.setVisibility(GONE);
929            mSnapHeader.unbind();
930            return;
931        }
932
933        snapItem.bindView(mSnapHeader, false /* measureOnly */);
934        mSnapHeader.setVisibility(VISIBLE);
935
936        // overlap is negative or zero; bump the snap header upwards by that much
937        int overlap = 0;
938
939        final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1);
940        if (next != null) {
941            overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY);
942
943            // disable overlap drawing past a certain speed
944            if (overlap < 0) {
945                final Float v = mVelocityTracker.getSmoothedVelocity();
946                if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) {
947                    overlap = 0;
948                }
949            }
950        }
951
952        mSnapHeader.setTranslationY(overlap);
953    }
954
955    // find the next header that can push the snap header up
956    private ConversationOverlayItem findNextPushingOverlay(int start) {
957        for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) {
958            final ConversationOverlayItem next = mOverlayAdapter.getItem(i);
959            if (next.canPushSnapHeader()) {
960                return next;
961            }
962        }
963        return null;
964    }
965
966    /**
967     * Prevents any layouts from happening until the next time
968     * {@link #onGeometryChange(OverlayPosition[])} is
969     * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
970     * <p>
971     * If you call this, you must ensure that a followup call to
972     * {@link #onGeometryChange(OverlayPosition[])}
973     * is made later, when the HTML spacer coordinates are updated.
974     *
975     */
976    public void invalidateSpacerGeometry() {
977        mOverlayPositions = null;
978    }
979
980    public void onGeometryChange(OverlayPosition[] overlayPositions) {
981        traceLayout("*** got overlay spacer positions:");
982        for (OverlayPosition pos : overlayPositions) {
983            traceLayout("top=%d bottom=%d", pos.top, pos.bottom);
984        }
985
986        mOverlayPositions = overlayPositions;
987        positionOverlays(mOffsetY, false /* postAddView */);
988    }
989
990    /**
991     * Remove the view that corresponds to the item in the {@link ConversationViewAdapter}
992     * at the specified index.<p/>
993     *
994     * <b>Note:</b> the view is actually pushed off-screen and recycled
995     * as though it were scrolled off.
996     * @param adapterIndex The index for the view in the adapter.
997     */
998    public void removeViewAtAdapterIndex(int adapterIndex) {
999        // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen.
1000        final int offsetY = mOffsetY;
1001        mOffsetY = 0;
1002        final OverlayView overlay = mOverlayViews.get(adapterIndex);
1003        if (overlay != null) {
1004            final int height = getHeight();
1005            onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight());
1006        }
1007        // restore the offset to its original value after the view has been moved off-screen.
1008        mOffsetY = offsetY;
1009    }
1010
1011    private void traceLayout(String msg, Object... params) {
1012        if (mDisableLayoutTracing) {
1013            return;
1014        }
1015        LogUtils.d(TAG, msg, params);
1016    }
1017
1018    public void focusFirstMessageHeader() {
1019        mOverlayAdapter.focusFirstMessageHeader();
1020    }
1021
1022    public int getOverlayCount() {
1023        return mOverlayAdapter.getCount();
1024    }
1025
1026    public int getViewPosition(View v) {
1027        return mOverlayAdapter.getViewPosition(v);
1028    }
1029
1030    public View getNextOverlayView(int position, boolean isDown) {
1031        return mOverlayAdapter.getNextOverlayView(position, isDown);
1032    }
1033
1034    public boolean shouldInterceptLeftRightEvents(@IdRes int id, boolean isLeft, boolean isRight,
1035            boolean twoPaneLand) {
1036        return mOverlayAdapter.shouldInterceptLeftRightEvents(id, isLeft, isRight, twoPaneLand);
1037    }
1038
1039    public boolean shouldNavigateAway(@IdRes int id, boolean isLeft, boolean twoPaneLand) {
1040        return mOverlayAdapter.shouldNavigateAway(id, isLeft, twoPaneLand);
1041    }
1042
1043    private class AdapterObserver extends DataSetObserver {
1044        @Override
1045        public void onChanged() {
1046            onDataSetChanged();
1047        }
1048    }
1049}
1050