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