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