• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
18 package com.android.mail.browse;
19 
20 import android.content.Context;
21 import android.content.res.Configuration;
22 import android.database.DataSetObserver;
23 import android.graphics.Canvas;
24 import android.util.AttributeSet;
25 import android.util.SparseArray;
26 import android.view.Gravity;
27 import android.view.MotionEvent;
28 import android.view.View;
29 import android.view.ViewConfiguration;
30 import android.view.ViewGroup;
31 import android.webkit.WebView;
32 import android.widget.Adapter;
33 import android.widget.ListView;
34 import android.widget.ScrollView;
35 
36 import com.android.mail.R;
37 import com.android.mail.browse.ScrollNotifier.ScrollListener;
38 import com.android.mail.providers.UIProvider;
39 import com.android.mail.ui.ConversationViewFragment;
40 import com.android.mail.utils.DequeMap;
41 import com.android.mail.utils.InputSmoother;
42 import com.android.mail.utils.LogUtils;
43 import com.google.common.collect.Lists;
44 
45 import 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  */
68 public class ConversationContainer extends ViewGroup implements ScrollListener {
69     private static final String TAG = ConversationViewFragment.LAYOUT_TAG;
70 
71     private static final int[] BOTTOM_LAYER_VIEW_IDS = {
72         R.id.webview,
73         R.id.conversation_side_border_overlay
74     };
75 
76     private static final int[] TOP_LAYER_VIEW_IDS = {
77         R.id.conversation_topmost_overlay
78     };
79 
80     /**
81      * Maximum scroll speed (in dp/sec) at which the snap header animation will draw.
82      * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect).
83      */
84     private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f;
85 
86     private ConversationAccountController mAccountController;
87     private ConversationViewAdapter mOverlayAdapter;
88     private OverlayPosition[] mOverlayPositions;
89     private ConversationWebView mWebView;
90     private MessageHeaderView mSnapHeader;
91     private View mTopMostOverlay;
92 
93     /**
94      * This is a hack.
95      *
96      * <p>Without this hack enabled, very fast scrolling can sometimes cause the top-most layers
97      * to skip being drawn for a frame or two. It happens specifically when overlay views are
98      * attached or added, and WebView happens to draw (on its own) immediately afterwards.
99      *
100      * <p>The workaround is to force an additional draw of the top-most overlay. Since the problem
101      * only occurs when scrolling overlays are added, restrict the additional draw to only occur
102      * if scrolling overlays were added since the last draw.
103      */
104     private boolean mAttachedOverlaySinceLastDraw;
105 
106     private final List<View> mNonScrollingChildren = Lists.newArrayList();
107 
108     /**
109      * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual
110      * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other.
111      */
112     private float mScale;
113     /**
114      * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale
115      * values.
116      */
117     private boolean mTouchInitialized;
118 
119     /**
120      * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}.
121      */
122     private final int mTouchSlop;
123     /**
124      * Current scroll position, as dictated by the background {@link WebView}.
125      */
126     private int mOffsetY;
127     /**
128      * Original pointer Y for slop calculation.
129      */
130     private float mLastMotionY;
131     /**
132      * Original pointer ID for slop calculation.
133      */
134     private int mActivePointerId;
135     /**
136      * Track pointer up/down state to know whether to send a make-up DOWN event to WebView.
137      * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be
138      * preceded by a {@link MotionEvent#ACTION_DOWN} event.
139      */
140     private boolean mTouchIsDown = false;
141     /**
142      * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN},
143      * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}.
144      */
145     private boolean mMissedPointerDown;
146 
147     /**
148      * A recycler that holds removed scrap views, organized by integer item view type. All views
149      * in this data structure should be removed from their view parent prior to insertion.
150      */
151     private final DequeMap<Integer, View> mScrapViews = new DequeMap<Integer, View>();
152 
153     /**
154      * The current set of overlay views in the view hierarchy. Looking through this map is faster
155      * than traversing the view hierarchy.
156      * <p>
157      * WebView sometimes notifies of scroll changes during a draw (or display list generation), when
158      * it's not safe to detach view children because ViewGroup is in the middle of iterating over
159      * its child array. So we remove any child from this list immediately and queue up a task to
160      * detach it later. Since nobody other than the detach task references that view in the
161      * meantime, we don't need any further checks or synchronization.
162      * <p>
163      * We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose
164      * of all views (on data set or adapter change), we can at least recycle them into the typed
165      * scrap piles for later reuse.
166      */
167     private final SparseArray<OverlayView> mOverlayViews;
168 
169     private int mWidthMeasureSpec;
170 
171     private boolean mDisableLayoutTracing;
172 
173     private final InputSmoother mVelocityTracker;
174 
175     private final DataSetObserver mAdapterObserver = new AdapterObserver();
176 
177     /**
178      * The adapter index of the lowest overlay item that is above the top of the screen and reports
179      * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through
180      * {@link #positionOverlays(int, int)}.
181      *
182      */
183     private int mSnapIndex;
184 
185     private boolean mSnapEnabled;
186 
187     /**
188      * A View that fills the remaining vertical space when the overlays do not take
189      * up the entire container. Otherwise, a card-like bottom white space appears.
190      */
191     private View mAdditionalBottomBorder;
192 
193     /**
194      * A flag denoting whether the fake bottom border has been added to the container.
195      */
196     private boolean mAdditionalBottomBorderAdded;
197 
198     /**
199      * An int containing the potential top value for the additional bottom border.
200      * If this value is less than the height of the scroll container, the additional
201      * bottom border will be drawn.
202      */
203     private int mAdditionalBottomBorderOverlayTop;
204 
205     /**
206      * Child views of this container should implement this interface to be notified when they are
207      * being detached.
208      *
209      */
210     public interface DetachListener {
211         /**
212          * Called on a child view when it is removed from its parent as part of
213          * {@link ConversationContainer} view recycling.
214          */
onDetachedFromParent()215         void onDetachedFromParent();
216     }
217 
218     public static class OverlayPosition {
219         public final int top;
220         public final int bottom;
221 
OverlayPosition(int top, int bottom)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 
OverlayView(View view, int itemType)232         public OverlayView(View view, int itemType) {
233             this.view = view;
234             this.itemType = itemType;
235         }
236     }
237 
ConversationContainer(Context c)238     public ConversationContainer(Context c) {
239         this(c, null);
240     }
241 
ConversationContainer(Context c, AttributeSet attrs)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
onFinishInflate()259     protected void onFinishInflate() {
260         super.onFinishInflate();
261 
262         mWebView = (ConversationWebView) findViewById(R.id.webview);
263         mWebView.addScrollListener(this);
264 
265         mTopMostOverlay = findViewById(R.id.conversation_topmost_overlay);
266 
267         mSnapHeader = (MessageHeaderView) findViewById(R.id.snap_header);
268         mSnapHeader.setSnappy(true);
269 
270         for (int id : BOTTOM_LAYER_VIEW_IDS) {
271             mNonScrollingChildren.add(findViewById(id));
272         }
273         for (int id : TOP_LAYER_VIEW_IDS) {
274             mNonScrollingChildren.add(findViewById(id));
275         }
276     }
277 
getSnapHeader()278     public MessageHeaderView getSnapHeader() {
279         return mSnapHeader;
280     }
281 
setOverlayAdapter(ConversationViewAdapter a)282     public void setOverlayAdapter(ConversationViewAdapter a) {
283         if (mOverlayAdapter != null) {
284             mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver);
285             clearOverlays();
286         }
287         mOverlayAdapter = a;
288         if (mOverlayAdapter != null) {
289             mOverlayAdapter.registerDataSetObserver(mAdapterObserver);
290         }
291     }
292 
getOverlayAdapter()293     public Adapter getOverlayAdapter() {
294         return mOverlayAdapter;
295     }
296 
setAccountController(ConversationAccountController controller)297     public void setAccountController(ConversationAccountController controller) {
298         mAccountController = controller;
299 
300         mSnapEnabled = isSnapEnabled();
301     }
302 
303     /**
304      * Re-bind any existing views that correspond to the given adapter positions.
305      *
306      */
onOverlayModelUpdate(List<Integer> affectedAdapterPositions)307     public void onOverlayModelUpdate(List<Integer> affectedAdapterPositions) {
308         for (Integer i : affectedAdapterPositions) {
309             final ConversationOverlayItem item = mOverlayAdapter.getItem(i);
310             final OverlayView overlay = mOverlayViews.get(i);
311             if (overlay != null && overlay.view != null && item != null) {
312                 item.onModelUpdated(overlay.view);
313             }
314             // update the snap header too, but only it's showing if the current item
315             if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) {
316                 mSnapHeader.refresh();
317             }
318         }
319     }
320 
321     /**
322      * Return an overlay view for the given adapter item, or null if no matching view is currently
323      * visible. This can happen as you scroll away from an overlay view.
324      *
325      */
getViewForItem(ConversationOverlayItem item)326     public View getViewForItem(ConversationOverlayItem item) {
327         View result = null;
328         int adapterPos = -1;
329         for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
330             if (mOverlayAdapter.getItem(i) == item) {
331                 adapterPos = i;
332                 break;
333             }
334         }
335         if (adapterPos != -1) {
336             final OverlayView overlay = mOverlayViews.get(adapterPos);
337             if (overlay != null) {
338                 result = overlay.view;
339             }
340         }
341         return result;
342     }
343 
clearOverlays()344     private void clearOverlays() {
345         for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
346             detachOverlay(mOverlayViews.valueAt(i));
347         }
348         mOverlayViews.clear();
349     }
350 
onDataSetChanged()351     private void onDataSetChanged() {
352         // Recycle all views and re-bind them according to the current set of spacer coordinates.
353         // This essentially resets the overlay views and re-renders them.
354         // It's fast enough that it's okay to re-do all views on any small change, as long as
355         // the change isn't too frequent (< ~1Hz).
356 
357         clearOverlays();
358         // also unbind the snap header view, so this "reset" causes the snap header to re-create
359         // its view, just like all other headers
360         mSnapHeader.unbind();
361 
362         // also clear out the additional bottom border
363         removeViewInLayout(mAdditionalBottomBorder);
364         mAdditionalBottomBorderAdded = false;
365 
366         mSnapEnabled = isSnapEnabled();
367         positionOverlays(0, mOffsetY);
368     }
369 
forwardFakeMotionEvent(MotionEvent original, int newAction)370     private void forwardFakeMotionEvent(MotionEvent original, int newAction) {
371         MotionEvent newEvent = MotionEvent.obtain(original);
372         newEvent.setAction(newAction);
373         mWebView.onTouchEvent(newEvent);
374         LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d",
375                 newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(),
376                 newEvent.getPointerCount());
377     }
378 
379     /**
380      * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}.
381      */
382     @Override
onInterceptTouchEvent(MotionEvent ev)383     public boolean onInterceptTouchEvent(MotionEvent ev) {
384 
385         if (!mTouchInitialized) {
386             mTouchInitialized = true;
387         }
388 
389         // no interception when WebView handles the first DOWN
390         if (mWebView.isHandlingTouch()) {
391             return false;
392         }
393 
394         boolean intercept = false;
395         switch (ev.getActionMasked()) {
396             case MotionEvent.ACTION_POINTER_DOWN:
397                 LogUtils.d(TAG, "Container is intercepting non-primary touch!");
398                 intercept = true;
399                 mMissedPointerDown = true;
400                 requestDisallowInterceptTouchEvent(true);
401                 break;
402 
403             case MotionEvent.ACTION_DOWN:
404                 mLastMotionY = ev.getY();
405                 mActivePointerId = ev.getPointerId(0);
406                 break;
407 
408             case MotionEvent.ACTION_MOVE:
409                 final int pointerIndex = ev.findPointerIndex(mActivePointerId);
410                 final float y = ev.getY(pointerIndex);
411                 final int yDiff = (int) Math.abs(y - mLastMotionY);
412                 if (yDiff > mTouchSlop) {
413                     mLastMotionY = y;
414                     intercept = true;
415                 }
416                 break;
417         }
418 
419 //        LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s",
420 //                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept);
421         return intercept;
422     }
423 
424     @Override
onTouchEvent(MotionEvent ev)425     public boolean onTouchEvent(MotionEvent ev) {
426         final int action = ev.getActionMasked();
427 
428         if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
429             mTouchIsDown = false;
430         } else if (!mTouchIsDown &&
431                 (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) {
432 
433             forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN);
434             if (mMissedPointerDown) {
435                 forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN);
436                 mMissedPointerDown = false;
437             }
438 
439             mTouchIsDown = true;
440         }
441 
442         final boolean webViewResult = mWebView.onTouchEvent(ev);
443 
444 //        LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d",
445 //                ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount());
446         return webViewResult;
447     }
448 
449     @Override
onNotifierScroll(final int x, final int y)450     public void onNotifierScroll(final int x, final int y) {
451         mVelocityTracker.onInput(y);
452         mDisableLayoutTracing = true;
453         positionOverlays(x, y);
454         mDisableLayoutTracing = false;
455     }
456 
positionOverlays(int x, int y)457     private void positionOverlays(int x, int y) {
458         mOffsetY = y;
459 
460         /*
461          * The scale value that WebView reports is inaccurate when measured during WebView
462          * initialization. This bug is present in ICS, so to work around it, we ignore all
463          * reported values and use a calculated expected value from ConversationWebView instead.
464          * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin
465          * to pay attention to WebView-reported scale values.
466          */
467         if (mTouchInitialized) {
468             mScale = mWebView.getScale();
469         } else if (mScale == 0) {
470             mScale = mWebView.getInitialScale();
471         }
472         traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(),
473                 mScale);
474 
475         if (mOverlayPositions == null || mOverlayAdapter == null) {
476             return;
477         }
478 
479         // recycle scrolled-off views and add newly visible views
480 
481         // we want consecutive spacers/overlays to stack towards the bottom
482         // so iterate from the bottom of the conversation up
483         // starting with the last spacer bottom and the last adapter item, position adapter views
484         // in a single stack until you encounter a non-contiguous expanded message header,
485         // then decrement to the next spacer.
486 
487         traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length,
488                 mOverlayAdapter.getCount());
489 
490         mSnapIndex = -1;
491         mAdditionalBottomBorderOverlayTop = 0;
492 
493         int adapterLoopIndex = mOverlayAdapter.getCount() - 1;
494         int spacerIndex = mOverlayPositions.length - 1;
495         while (spacerIndex >= 0 && adapterLoopIndex >= 0) {
496 
497             final int spacerTop = getOverlayTop(spacerIndex);
498             final int spacerBottom = getOverlayBottom(spacerIndex);
499 
500             final boolean flip;
501             final int flipOffset;
502             final int forceGravity;
503             // flip direction from bottom->top to top->bottom traversal on the very first spacer
504             // to facilitate top-aligned headers at spacer index = 0
505             if (spacerIndex == 0) {
506                 flip = true;
507                 flipOffset = adapterLoopIndex;
508                 forceGravity = Gravity.TOP;
509             } else {
510                 flip = false;
511                 flipOffset = 0;
512                 forceGravity = Gravity.NO_GRAVITY;
513             }
514 
515             int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;
516 
517             // always place at least one overlay per spacer
518             ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex);
519 
520             OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom,
521                     forceGravity);
522 
523             traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex,
524                     itemPos.top, itemPos.bottom, adapterItem);
525             positionOverlay(adapterIndex, itemPos.top, itemPos.bottom);
526 
527             // and keep stacking overlays unconditionally if we are on the first spacer, or as long
528             // as overlays are contiguous
529             while (--adapterLoopIndex >= 0) {
530                 adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex;
531                 adapterItem = mOverlayAdapter.getItem(adapterIndex);
532                 if (spacerIndex > 0 && !adapterItem.isContiguous()) {
533                     // advance to the next spacer, but stay on this adapter item
534                     break;
535                 }
536 
537                 // place this overlay in the region of the spacer above or below the last item,
538                 // depending on direction of iteration
539                 final int regionTop = flip ? itemPos.bottom : spacerTop;
540                 final int regionBottom = flip ? spacerBottom : itemPos.top;
541                 itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity);
542 
543                 traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex,
544                         adapterIndex, itemPos.top, itemPos.bottom, adapterItem);
545                 positionOverlay(adapterIndex, itemPos.top, itemPos.bottom);
546             }
547 
548             spacerIndex--;
549         }
550 
551         positionSnapHeader(mSnapIndex);
552         positionAdditionalBottomBorder();
553     }
554 
555     /**
556      * Adds an additional bottom border to the overlay views in case
557      * the overlays do not fill the entire screen.
558      */
positionAdditionalBottomBorder()559     private void positionAdditionalBottomBorder() {
560         final int lastBottom = mAdditionalBottomBorderOverlayTop;
561         final int containerHeight = webPxToScreenPx(mWebView.getContentHeight());
562         final int speculativeHeight = containerHeight - lastBottom;
563         if (speculativeHeight > 0) {
564             if (mAdditionalBottomBorder == null) {
565                 mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate(
566                         R.layout.fake_bottom_border, this, false);
567             }
568 
569             setAdditionalBottomBorderHeight(speculativeHeight);
570 
571             if (!mAdditionalBottomBorderAdded) {
572                 addViewInLayoutWrapper(mAdditionalBottomBorder);
573                 mAdditionalBottomBorderAdded = true;
574             }
575 
576             measureOverlayView(mAdditionalBottomBorder);
577             layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight);
578         } else {
579             if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) {
580                 removeViewInLayout(mAdditionalBottomBorder);
581                 mAdditionalBottomBorderAdded = false;
582             }
583         }
584     }
585 
setAdditionalBottomBorderHeight(int speculativeHeight)586     private void setAdditionalBottomBorderHeight(int speculativeHeight) {
587         LayoutParams params = mAdditionalBottomBorder.getLayoutParams();
588         params.height = speculativeHeight;
589         mAdditionalBottomBorder.setLayoutParams(params);
590     }
591 
calculatePosition(final ConversationOverlayItem adapterItem, final int withinTop, final int withinBottom, final int forceGravity)592     private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem,
593             final int withinTop, final int withinBottom, final int forceGravity) {
594         if (adapterItem.getHeight() == 0) {
595             // "place" invisible items at the bottom of their region to stay consistent with the
596             // stacking algorithm in positionOverlays(), unless gravity is forced to the top
597             final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom;
598             return new OverlayPosition(y, y);
599         }
600 
601         final int v = ((forceGravity != Gravity.NO_GRAVITY) ?
602                 forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK;
603         switch (v) {
604             case Gravity.BOTTOM:
605                 return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom);
606             case Gravity.TOP:
607                 return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight());
608             default:
609                 throw new UnsupportedOperationException("unsupported gravity: " + v);
610         }
611     }
612 
613     /**
614      * Executes a measure pass over the specified child overlay view and returns the measured
615      * height. The measurement uses whatever the current container's width measure spec is.
616      * This method ignores view visibility and returns the height that the view would be if visible.
617      *
618      * @param overlayView an overlay view to measure. does not actually have to be attached yet.
619      * @return height that the view would be if it was visible
620      */
measureOverlay(View overlayView)621     public int measureOverlay(View overlayView) {
622         measureOverlayView(overlayView);
623         return overlayView.getMeasuredHeight();
624     }
625 
626     /**
627      * Copied/stolen from {@link ListView}.
628      */
measureOverlayView(View child)629     private void measureOverlayView(View child) {
630         MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams();
631         if (p == null) {
632             p = (MarginLayoutParams) generateDefaultLayoutParams();
633         }
634 
635         int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec,
636                 getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width);
637         int lpHeight = p.height;
638         int childHeightSpec;
639         if (lpHeight > 0) {
640             childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY);
641         } else {
642             childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
643         }
644         child.measure(childWidthSpec, childHeightSpec);
645     }
646 
onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, int overlayTop, int overlayBottom)647     private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay,
648             int overlayTop, int overlayBottom) {
649         // detach the view asynchronously, as scroll notification can happen during a draw, when
650         // it's not safe to remove children
651 
652         // but immediately remove this view from the view set so future lookups don't find it
653         mOverlayViews.remove(adapterIndex);
654 
655         post(new Runnable() {
656             @Override
657             public void run() {
658                 detachOverlay(overlay);
659             }
660         });
661 
662         // push it out of view immediately
663         // otherwise this scrolled-off header will continue to draw until the runnable runs
664         layoutOverlay(overlay.view, overlayTop, overlayBottom);
665     }
666 
667     /**
668      * Returns an existing scrap view, if available. The view will already be removed from the view
669      * hierarchy. This method will not remove the view from the scrap heap.
670      *
671      */
getScrapView(int type)672     public View getScrapView(int type) {
673         return mScrapViews.peek(type);
674     }
675 
addScrapView(int type, View v)676     public void addScrapView(int type, View v) {
677         mScrapViews.add(type, v);
678     }
679 
detachOverlay(OverlayView overlay)680     private void detachOverlay(OverlayView overlay) {
681         // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded
682         // because removing overlay views doesn't affect overall layout.
683         removeViewInLayout(overlay.view);
684         mScrapViews.add(overlay.itemType, overlay.view);
685         if (overlay.view instanceof DetachListener) {
686             ((DetachListener) overlay.view).onDetachedFromParent();
687         }
688     }
689 
690     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)691     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
692         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
693         if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) {
694             LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s",
695                     MeasureSpec.toString(widthMeasureSpec),
696                     MeasureSpec.toString(heightMeasureSpec));
697         }
698 
699         for (View nonScrollingChild : mNonScrollingChildren) {
700             if (nonScrollingChild.getVisibility() != GONE) {
701                 measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */,
702                         heightMeasureSpec, 0 /* heightUsed */);
703             }
704         }
705         mWidthMeasureSpec = widthMeasureSpec;
706 
707         // onLayout will re-measure and re-position overlays for the new container size, but the
708         // spacer offsets would still need to be updated to have them draw at their new locations.
709     }
710 
711     @Override
onLayout(boolean changed, int l, int t, int r, int b)712     protected void onLayout(boolean changed, int l, int t, int r, int b) {
713         LogUtils.d(TAG, "*** IN header container onLayout");
714 
715         for (View nonScrollingChild : mNonScrollingChildren) {
716             if (nonScrollingChild.getVisibility() != GONE) {
717                 final int w = nonScrollingChild.getMeasuredWidth();
718                 final int h = nonScrollingChild.getMeasuredHeight();
719 
720                 final MarginLayoutParams lp =
721                         (MarginLayoutParams) nonScrollingChild.getLayoutParams();
722 
723                 final int childLeft = lp.leftMargin;
724                 final int childTop = lp.topMargin;
725                 nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h);
726             }
727         }
728 
729         if (mOverlayAdapter != null) {
730             // being in a layout pass means overlay children may require measurement,
731             // so invalidate them
732             for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) {
733                 mOverlayAdapter.getItem(i).invalidateMeasurement();
734             }
735         }
736 
737         positionOverlays(0, mOffsetY);
738     }
739 
740     @Override
dispatchDraw(Canvas canvas)741     protected void dispatchDraw(Canvas canvas) {
742         super.dispatchDraw(canvas);
743 
744         if (mAttachedOverlaySinceLastDraw) {
745             drawChild(canvas, mTopMostOverlay, getDrawingTime());
746             mAttachedOverlaySinceLastDraw = false;
747         }
748     }
749 
750     @Override
generateDefaultLayoutParams()751     protected LayoutParams generateDefaultLayoutParams() {
752         return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
753     }
754 
755     @Override
generateLayoutParams(AttributeSet attrs)756     public LayoutParams generateLayoutParams(AttributeSet attrs) {
757         return new MarginLayoutParams(getContext(), attrs);
758     }
759 
760     @Override
generateLayoutParams(ViewGroup.LayoutParams p)761     protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
762         return new MarginLayoutParams(p);
763     }
764 
765     @Override
checkLayoutParams(ViewGroup.LayoutParams p)766     protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
767         return p instanceof MarginLayoutParams;
768     }
769 
getOverlayTop(int spacerIndex)770     private int getOverlayTop(int spacerIndex) {
771         return webPxToScreenPx(mOverlayPositions[spacerIndex].top);
772     }
773 
getOverlayBottom(int spacerIndex)774     private int getOverlayBottom(int spacerIndex) {
775         return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom);
776     }
777 
webPxToScreenPx(int webPx)778     private int webPxToScreenPx(int webPx) {
779         // TODO: round or truncate?
780         // TODO: refactor and unify with ConversationWebView.webPxToScreenPx()
781         return (int) (webPx * mScale);
782     }
783 
positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY)784     private void positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY) {
785         final OverlayView overlay = mOverlayViews.get(adapterIndex);
786         final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex);
787 
788         // save off the item's current top for later snap calculations
789         item.setTop(overlayTopY);
790 
791         // is the overlay visible and does it have non-zero height?
792         if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY
793                 && overlayTopY < mOffsetY + getHeight()) {
794             View overlayView = overlay != null ? overlay.view : null;
795             // show and/or move overlay
796             if (overlayView == null) {
797                 overlayView = addOverlayView(adapterIndex);
798                 measureOverlayView(overlayView);
799                 item.markMeasurementValid();
800                 traceLayout("show/measure overlay %d", adapterIndex);
801             } else {
802                 traceLayout("move overlay %d", adapterIndex);
803                 if (!item.isMeasurementValid()) {
804                     item.rebindView(overlayView);
805                     measureOverlayView(overlayView);
806                     item.markMeasurementValid();
807                     traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex,
808                             overlayView.getHeight(), overlayView.getMeasuredHeight());
809                 }
810             }
811             traceLayout("laying out overlay %d with h=%d", adapterIndex,
812                     overlayView.getMeasuredHeight());
813             final int childBottom = overlayTopY + overlayView.getMeasuredHeight();
814             layoutOverlay(overlayView, overlayTopY, childBottom);
815             mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ?
816                     childBottom : mAdditionalBottomBorderOverlayTop;
817         } else {
818             // hide overlay
819             if (overlay != null) {
820                 traceLayout("hide overlay %d", adapterIndex);
821                 onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY);
822             } else {
823                 traceLayout("ignore non-visible overlay %d", adapterIndex);
824             }
825             mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop)
826                     ? overlayBottomY : mAdditionalBottomBorderOverlayTop;
827         }
828 
829         if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) {
830             if (mSnapIndex == -1) {
831                 mSnapIndex = adapterIndex;
832             } else if (adapterIndex > mSnapIndex) {
833                 mSnapIndex = adapterIndex;
834             }
835         }
836 
837     }
838 
839     // layout an existing view
840     // need its top offset into the conversation, its height, and the scroll offset
layoutOverlay(View child, int childTop, int childBottom)841     private void layoutOverlay(View child, int childTop, int childBottom) {
842         final int top = childTop - mOffsetY;
843         final int bottom = childBottom - mOffsetY;
844 
845         final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
846         final int childLeft = getPaddingLeft() + lp.leftMargin;
847 
848         child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom);
849     }
850 
addOverlayView(int adapterIndex)851     private View addOverlayView(int adapterIndex) {
852         final int itemType = mOverlayAdapter.getItemViewType(adapterIndex);
853         final View convertView = mScrapViews.poll(itemType);
854 
855         final View view = mOverlayAdapter.getView(adapterIndex, convertView, this);
856         mOverlayViews.put(adapterIndex, new OverlayView(view, itemType));
857 
858         if (convertView == view) {
859             LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
860         } else {
861             LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
862         }
863 
864         addViewInLayoutWrapper(view);
865 
866         return view;
867     }
868 
addViewInLayoutWrapper(View view)869     private void addViewInLayoutWrapper(View view) {
870         final int index = BOTTOM_LAYER_VIEW_IDS.length;
871         addViewInLayout(view, index, view.getLayoutParams(), true /* preventRequestLayout */);
872         mAttachedOverlaySinceLastDraw = true;
873     }
874 
isSnapEnabled()875     private boolean isSnapEnabled() {
876         if (mAccountController == null || mAccountController.getAccount() == null
877                 || mAccountController.getAccount().settings == null) {
878             return true;
879         }
880         final int snap = mAccountController.getAccount().settings.snapHeaders;
881         return snap == UIProvider.SnapHeaderValue.ALWAYS ||
882                 (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources()
883                     .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT);
884     }
885 
886     // render and/or re-position snap header
positionSnapHeader(int snapIndex)887     private void positionSnapHeader(int snapIndex) {
888         ConversationOverlayItem snapItem = null;
889         if (mSnapEnabled && snapIndex != -1) {
890             final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex);
891             if (item.canBecomeSnapHeader()) {
892                 snapItem = item;
893             }
894         }
895         if (snapItem == null) {
896             mSnapHeader.setVisibility(GONE);
897             mSnapHeader.unbind();
898             return;
899         }
900 
901         snapItem.bindView(mSnapHeader, false /* measureOnly */);
902         mSnapHeader.setVisibility(VISIBLE);
903 
904         // overlap is negative or zero; bump the snap header upwards by that much
905         int overlap = 0;
906 
907         final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1);
908         if (next != null) {
909             overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY);
910 
911             // disable overlap drawing past a certain speed
912             if (overlap < 0) {
913                 final Float v = mVelocityTracker.getSmoothedVelocity();
914                 if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) {
915                     overlap = 0;
916                 }
917             }
918         }
919 
920         mSnapHeader.setTranslationY(overlap);
921     }
922 
923     // find the next header that can push the snap header up
findNextPushingOverlay(int start)924     private ConversationOverlayItem findNextPushingOverlay(int start) {
925         for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) {
926             final ConversationOverlayItem next = mOverlayAdapter.getItem(i);
927             if (next.canPushSnapHeader()) {
928                 return next;
929             }
930         }
931         return null;
932     }
933 
934     /**
935      * Return a collection of all currently visible overlay views, in no particular order.
936      * Please don't mess with them too badly (e.g. remove from parent).
937      *
938      */
getOverlayViews()939     public List<View> getOverlayViews() {
940         final List<View> views = Lists.newArrayList();
941         for (int i = 0, len = mOverlayViews.size(); i < len; i++) {
942             views.add(mOverlayViews.valueAt(i).view);
943         }
944         return views;
945     }
946 
947     /**
948      * Prevents any layouts from happening until the next time
949      * {@link #onGeometryChange(OverlayPosition[])} is
950      * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
951      * <p>
952      * If you call this, you must ensure that a followup call to
953      * {@link #onGeometryChange(OverlayPosition[])}
954      * is made later, when the HTML spacer coordinates are updated.
955      *
956      */
invalidateSpacerGeometry()957     public void invalidateSpacerGeometry() {
958         mOverlayPositions = null;
959     }
960 
onGeometryChange(OverlayPosition[] overlayPositions)961     public void onGeometryChange(OverlayPosition[] overlayPositions) {
962         traceLayout("*** got overlay spacer positions:");
963         for (OverlayPosition pos : overlayPositions) {
964             traceLayout("top=%d bottom=%d", pos.top, pos.bottom);
965         }
966 
967         mOverlayPositions = overlayPositions;
968         positionOverlays(0, mOffsetY);
969     }
970 
traceLayout(String msg, Object... params)971     private void traceLayout(String msg, Object... params) {
972         if (mDisableLayoutTracing) {
973             return;
974         }
975         LogUtils.d(TAG, msg, params);
976     }
977 
978     private class AdapterObserver extends DataSetObserver {
979         @Override
onChanged()980         public void onChanged() {
981             onDataSetChanged();
982         }
983     }
984 }
985