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.Rect;
24 import android.support.v4.view.ViewCompat;
25 import android.util.AttributeSet;
26 import android.util.SparseArray;
27 import android.view.Gravity;
28 import android.view.MotionEvent;
29 import android.view.View;
30 import android.view.ViewConfiguration;
31 import android.view.ViewGroup;
32 import android.webkit.WebView;
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 import com.google.common.collect.Sets;
45 
46 import java.util.List;
47 import 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  */
76 public 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          */
onDetachedFromParent()207         void onDetachedFromParent();
208     }
209 
210     public static class OverlayPosition {
211         public final int top;
212         public final int bottom;
213 
OverlayPosition(int top, int bottom)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 
OverlayView(View view, int itemType)224         public OverlayView(View view, int itemType) {
225             this.view = view;
226             this.itemType = itemType;
227         }
228     }
229 
ConversationContainer(Context c)230     public ConversationContainer(Context c) {
231         this(c, null);
232     }
233 
ConversationContainer(Context c, AttributeSet attrs)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
onFinishInflate()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 
setupSnapHeader()265     public void setupSnapHeader() {
266         mSnapHeader = (SnapHeader) findViewById(R.id.snap_header);
267         mSnapHeader.setSnappy();
268     }
269 
getSnapHeader()270     public SnapHeader getSnapHeader() {
271         return mSnapHeader;
272     }
273 
setOverlayAdapter(ConversationViewAdapter a)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 
setAccountController(ConversationAccountController controller)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      */
onOverlayModelUpdate(List<Integer> affectedAdapterPositions)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      */
getViewForItem(ConversationOverlayItem item)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 
clearOverlays()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 
onDataSetChanged()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 
forwardFakeMotionEvent(MotionEvent original, int newAction)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
onInterceptTouchEvent(MotionEvent ev)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
onTouchEvent(MotionEvent ev)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
onNotifierScroll(final int y)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      */
positionOverlays(int y, boolean postAddView)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      */
positionAdditionalBottomBorder(boolean postAddView)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 
setAdditionalBottomBorderHeight(int speculativeHeight)594     private void setAdditionalBottomBorderHeight(int speculativeHeight) {
595         LayoutParams params = mAdditionalBottomBorder.getLayoutParams();
596         params.height = speculativeHeight;
597         mAdditionalBottomBorder.setLayoutParams(params);
598     }
599 
calculatePosition(final ConversationOverlayItem adapterItem, final int withinTop, final int withinBottom, final int forceGravity)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      */
measureOverlay(View overlayView)629     public int measureOverlay(View overlayView) {
630         measureOverlayView(overlayView);
631         return overlayView.getMeasuredHeight();
632     }
633 
634     /**
635      * Copied/stolen from {@link ListView}.
636      */
measureOverlayView(View child)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 
onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, int overlayTop, int overlayBottom)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      */
getScrapView(int type)673     public View getScrapView(int type) {
674         return mScrapViews.peek(type);
675     }
676 
addScrapView(int type, View v)677     public void addScrapView(int type, View v) {
678         mScrapViews.add(type, v);
679         addViewInLayoutWrapper(v, false /* postAddView */);
680     }
681 
detachOverlay(OverlayView overlay, boolean removeFromContainer)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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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
onLayout(boolean changed, int l, int t, int r, int b)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
generateDefaultLayoutParams()745     protected LayoutParams generateDefaultLayoutParams() {
746         return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
747     }
748 
749     @Override
generateLayoutParams(AttributeSet attrs)750     public LayoutParams generateLayoutParams(AttributeSet attrs) {
751         return new MarginLayoutParams(getContext(), attrs);
752     }
753 
754     @Override
generateLayoutParams(ViewGroup.LayoutParams p)755     protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
756         return new MarginLayoutParams(p);
757     }
758 
759     @Override
checkLayoutParams(ViewGroup.LayoutParams p)760     protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
761         return p instanceof MarginLayoutParams;
762     }
763 
getOverlayTop(int spacerIndex)764     private int getOverlayTop(int spacerIndex) {
765         return webPxToScreenPx(mOverlayPositions[spacerIndex].top);
766     }
767 
getOverlayBottom(int spacerIndex)768     private int getOverlayBottom(int spacerIndex) {
769         return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom);
770     }
771 
webPxToScreenPx(int webPx)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 
positionOverlay( int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView)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
layoutOverlay(View child, int childTop, int childBottom)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 
addOverlayView(int adapterIndex, boolean postAddView)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 
addViewInLayoutWrapper(View view, boolean postAddView)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 
AddViewRunnable(View view)884         public AddViewRunnable(View view) {
885             mView = view;
886         }
887 
888         @Override
run()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
run()897         public void run() {
898             removeViewInLayout(mAdditionalBottomBorder);
899         }
900     }
901 
isSnapEnabled()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
positionSnapHeader(int snapIndex)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
findNextPushingOverlay(int start)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      */
invalidateSpacerGeometry()971     public void invalidateSpacerGeometry() {
972         mOverlayPositions = null;
973     }
974 
onGeometryChange(OverlayPosition[] overlayPositions)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      */
removeViewAtAdapterIndex(int adapterIndex)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 
traceLayout(String msg, Object... params)1020     private void traceLayout(String msg, Object... params) {
1021         if (mDisableLayoutTracing) {
1022             return;
1023         }
1024         LogUtils.d(TAG, msg, params);
1025     }
1026 
1027     @Override
onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)1028     protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
1029         if (mOverlayAdapter != null) {
1030             return mOverlayAdapter.focusFirstMessageHeader();
1031         }
1032         return false;
1033     }
1034 
focusFirstMessageHeader()1035     public void focusFirstMessageHeader() {
1036         mOverlayAdapter.focusFirstMessageHeader();
1037     }
1038 
getNextOverlayView(View curr, boolean isDown)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
onChanged()1053         public void onChanged() {
1054             onDataSetChanged();
1055         }
1056     }
1057 }
1058