/* * Copyright (C) 2012 Google Inc. * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.mail.browse; import android.content.Context; import android.content.res.Configuration; import android.database.DataSetObserver; import android.graphics.Rect; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.SparseArray; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.webkit.WebView; import android.widget.ListView; import android.widget.ScrollView; import com.android.mail.R; import com.android.mail.browse.ScrollNotifier.ScrollListener; import com.android.mail.providers.UIProvider; import com.android.mail.ui.ConversationViewFragment; import com.android.mail.utils.DequeMap; import com.android.mail.utils.InputSmoother; import com.android.mail.utils.LogUtils; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import java.util.List; import java.util.Set; /** * A specialized ViewGroup container for conversation view. It is designed to contain a single * {@link WebView} and a number of overlay views that draw on top of the WebView. In the Mail app, * the WebView contains all HTML message bodies in a conversation, and the overlay views are the * subject view, message headers, and attachment views. The WebView does all scroll handling, and * this container manages scrolling of the overlay views so that they move in tandem. * *
INPUT HANDLING
* Placing the WebView in the same container as the overlay views means we don't have to do a lot of * manual manipulation of touch events. We do have a * {@link #forwardFakeMotionEvent(MotionEvent, int)} method that deals with one WebView * idiosyncrasy: it doesn't react well when touch MOVE events stream in without a preceding DOWN. * *
VIEW RECYCLING
* Normally, it would make sense to put all overlay views into a {@link ListView}. But this view * sandwich has unique characteristics: the list items are scrolled based on an external controller, * and we happen to know all of the overlay positions up front. So it didn't make sense to shoehorn * a ListView in and instead, we rolled our own view recycler by borrowing key details from * ListView and AbsListView.

* * There is one additional constraint with the recycling: since scroll * notifications happen during the WebView's draw, we do not remove and re-add views for recycling. * Instead, we simply move the views off-screen and add them to our recycle cache. When the views * are reused, they are simply moved back on screen instead of added. This practice * circumvents the issues found when views are added or removed during draw (which results in * elements not being drawn and other visual oddities). See b/10994303 for more details. */ public class ConversationContainer extends ViewGroup implements ScrollListener { private static final String TAG = ConversationViewFragment.LAYOUT_TAG; private static final int[] BOTTOM_LAYER_VIEW_IDS = { R.id.conversation_webview }; private static final int[] TOP_LAYER_VIEW_IDS = { R.id.conversation_topmost_overlay }; /** * Maximum scroll speed (in dp/sec) at which the snap header animation will draw. * Anything faster than that, and drawing it creates visual artifacting (wagon-wheel effect). */ private static final float SNAP_HEADER_MAX_SCROLL_SPEED = 600f; private ConversationAccountController mAccountController; private ConversationViewAdapter mOverlayAdapter; private OverlayPosition[] mOverlayPositions; private ConversationWebView mWebView; private SnapHeader mSnapHeader; private final List mNonScrollingChildren = Lists.newArrayList(); /** * Current document zoom scale per {@link WebView#getScale()}. This is the ratio of actual * screen pixels to logical WebView HTML pixels. We use it to convert from one to the other. */ private float mScale; /** * Set to true upon receiving the first touch event. Used to help reject invalid WebView scale * values. */ private boolean mTouchInitialized; /** * System touch-slop distance per {@link ViewConfiguration#getScaledTouchSlop()}. */ private final int mTouchSlop; /** * Current scroll position, as dictated by the background {@link WebView}. */ private int mOffsetY; /** * Original pointer Y for slop calculation. */ private float mLastMotionY; /** * Original pointer ID for slop calculation. */ private int mActivePointerId; /** * Track pointer up/down state to know whether to send a make-up DOWN event to WebView. * WebView internal logic requires that a stream of {@link MotionEvent#ACTION_MOVE} events be * preceded by a {@link MotionEvent#ACTION_DOWN} event. */ private boolean mTouchIsDown = false; /** * Remember if touch interception was triggered on a {@link MotionEvent#ACTION_POINTER_DOWN}, * so we can send a make-up event in {@link #onTouchEvent(MotionEvent)}. */ private boolean mMissedPointerDown; /** * A recycler that holds removed scrap views, organized by integer item view type. All views * in this data structure should be removed from their view parent prior to insertion. */ private final DequeMap mScrapViews = new DequeMap(); /** * The current set of overlay views in the view hierarchy. Looking through this map is faster * than traversing the view hierarchy. *

* WebView sometimes notifies of scroll changes during a draw (or display list generation), when * it's not safe to detach view children because ViewGroup is in the middle of iterating over * its child array. So we remove any child from this list immediately and queue up a task to * detach it later. Since nobody other than the detach task references that view in the * meantime, we don't need any further checks or synchronization. *

* We keep {@link OverlayView} wrappers instead of bare views so that when it's time to dispose * of all views (on data set or adapter change), we can at least recycle them into the typed * scrap piles for later reuse. */ private final SparseArray mOverlayViews; private int mWidthMeasureSpec; private boolean mDisableLayoutTracing; private final InputSmoother mVelocityTracker; private final DataSetObserver mAdapterObserver = new AdapterObserver(); /** * The adapter index of the lowest overlay item that is above the top of the screen and reports * {@link ConversationOverlayItem#canPushSnapHeader()}. We calculate this after a pass through * {@link #positionOverlays}. * */ private int mSnapIndex; private boolean mSnapEnabled; /** * A View that fills the remaining vertical space when the overlays do not take * up the entire container. Otherwise, a card-like bottom white space appears. */ private View mAdditionalBottomBorder; /** * A flag denoting whether the fake bottom border has been added to the container. */ private boolean mAdditionalBottomBorderAdded; /** * An int containing the potential top value for the additional bottom border. * If this value is less than the height of the scroll container, the additional * bottom border will be drawn. */ private int mAdditionalBottomBorderOverlayTop; /** * Child views of this container should implement this interface to be notified when they are * being detached. */ public interface DetachListener { /** * Called on a child view when it is removed from its parent as part of * {@link ConversationContainer} view recycling. */ void onDetachedFromParent(); } public static class OverlayPosition { public final int top; public final int bottom; public OverlayPosition(int top, int bottom) { this.top = top; this.bottom = bottom; } } private static class OverlayView { public View view; int itemType; public OverlayView(View view, int itemType) { this.view = view; this.itemType = itemType; } } public ConversationContainer(Context c) { this(c, null); } public ConversationContainer(Context c, AttributeSet attrs) { super(c, attrs); mOverlayViews = new SparseArray(); mVelocityTracker = new InputSmoother(c); mTouchSlop = ViewConfiguration.get(c).getScaledTouchSlop(); // Disabling event splitting fixes pinch-zoom when the first pointer goes down on the // WebView and the second pointer goes down on an overlay view. // Intercepting ACTION_POINTER_DOWN events allows pinch-zoom to work when the first pointer // goes down on an overlay view. setMotionEventSplittingEnabled(false); } @Override protected void onFinishInflate() { super.onFinishInflate(); mWebView = (ConversationWebView) findViewById(R.id.conversation_webview); mWebView.addScrollListener(this); for (int id : BOTTOM_LAYER_VIEW_IDS) { mNonScrollingChildren.add(findViewById(id)); } for (int id : TOP_LAYER_VIEW_IDS) { mNonScrollingChildren.add(findViewById(id)); } } public void setupSnapHeader() { mSnapHeader = (SnapHeader) findViewById(R.id.snap_header); mSnapHeader.setSnappy(); } public SnapHeader getSnapHeader() { return mSnapHeader; } public void setOverlayAdapter(ConversationViewAdapter a) { if (mOverlayAdapter != null) { mOverlayAdapter.unregisterDataSetObserver(mAdapterObserver); clearOverlays(); } mOverlayAdapter = a; if (mOverlayAdapter != null) { mOverlayAdapter.registerDataSetObserver(mAdapterObserver); } } public void setAccountController(ConversationAccountController controller) { mAccountController = controller; // mSnapEnabled = isSnapEnabled(); mSnapEnabled = false; // TODO - re-enable when dogfooders howl } /** * Re-bind any existing views that correspond to the given adapter positions. * */ public void onOverlayModelUpdate(List affectedAdapterPositions) { for (Integer i : affectedAdapterPositions) { final ConversationOverlayItem item = mOverlayAdapter.getItem(i); final OverlayView overlay = mOverlayViews.get(i); if (overlay != null && overlay.view != null && item != null) { item.onModelUpdated(overlay.view); } // update the snap header too, but only it's showing if the current item if (i == mSnapIndex && mSnapHeader.isBoundTo(item)) { mSnapHeader.refresh(); } } } /** * Return an overlay view for the given adapter item, or null if no matching view is currently * visible. This can happen as you scroll away from an overlay view. * */ public View getViewForItem(ConversationOverlayItem item) { if (mOverlayAdapter == null) { return null; } View result = null; int adapterPos = -1; for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { if (mOverlayAdapter.getItem(i) == item) { adapterPos = i; break; } } if (adapterPos != -1) { final OverlayView overlay = mOverlayViews.get(adapterPos); if (overlay != null) { result = overlay.view; } } return result; } private void clearOverlays() { for (int i = 0, len = mOverlayViews.size(); i < len; i++) { detachOverlay(mOverlayViews.valueAt(i), true /* removeFromContainer */); } mOverlayViews.clear(); } private void onDataSetChanged() { // Recycle all views and re-bind them according to the current set of spacer coordinates. // This essentially resets the overlay views and re-renders them. // It's fast enough that it's okay to re-do all views on any small change, as long as // the change isn't too frequent (< ~1Hz). clearOverlays(); // also unbind the snap header view, so this "reset" causes the snap header to re-create // its view, just like all other headers mSnapHeader.unbind(); // also clear out the additional bottom border removeViewInLayout(mAdditionalBottomBorder); mAdditionalBottomBorderAdded = false; // mSnapEnabled = isSnapEnabled(); mSnapEnabled = false; // TODO - re-enable when dogfooders howl positionOverlays(mOffsetY, false /* postAddView */); } private void forwardFakeMotionEvent(MotionEvent original, int newAction) { MotionEvent newEvent = MotionEvent.obtain(original); newEvent.setAction(newAction); mWebView.onTouchEvent(newEvent); LogUtils.v(TAG, "in Container.OnTouch. fake: action=%d x/y=%f/%f pointers=%d", newEvent.getActionMasked(), newEvent.getX(), newEvent.getY(), newEvent.getPointerCount()); } /** * Touch slop code was copied from {@link ScrollView#onInterceptTouchEvent(MotionEvent)}. */ @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (!mTouchInitialized) { mTouchInitialized = true; } // no interception when WebView handles the first DOWN if (mWebView.isHandlingTouch()) { return false; } boolean intercept = false; switch (ev.getActionMasked()) { case MotionEvent.ACTION_POINTER_DOWN: LogUtils.d(TAG, "Container is intercepting non-primary touch!"); intercept = true; mMissedPointerDown = true; requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_DOWN: mLastMotionY = ev.getY(); mActivePointerId = ev.getPointerId(0); break; case MotionEvent.ACTION_MOVE: final int pointerIndex = ev.findPointerIndex(mActivePointerId); final float y = ev.getY(pointerIndex); final int yDiff = (int) Math.abs(y - mLastMotionY); if (yDiff > mTouchSlop) { mLastMotionY = y; intercept = true; } break; } // LogUtils.v(TAG, "in Container.InterceptTouch. action=%d x/y=%f/%f pointers=%d result=%s", // ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount(), intercept); return intercept; } @Override public boolean onTouchEvent(MotionEvent ev) { final int action = ev.getActionMasked(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { mTouchIsDown = false; } else if (!mTouchIsDown && (action == MotionEvent.ACTION_MOVE || action == MotionEvent.ACTION_POINTER_DOWN)) { forwardFakeMotionEvent(ev, MotionEvent.ACTION_DOWN); if (mMissedPointerDown) { forwardFakeMotionEvent(ev, MotionEvent.ACTION_POINTER_DOWN); mMissedPointerDown = false; } mTouchIsDown = true; } final boolean webViewResult = mWebView.onTouchEvent(ev); // LogUtils.v(TAG, "in Container.OnTouch. action=%d x/y=%f/%f pointers=%d", // ev.getActionMasked(), ev.getX(), ev.getY(), ev.getPointerCount()); return webViewResult; } @Override public void onNotifierScroll(final int y) { mVelocityTracker.onInput(y); mDisableLayoutTracing = true; positionOverlays(y, true /* postAddView */); // post the addView since we're in draw code mDisableLayoutTracing = false; } /** * Positions the overlays given an updated y position for the container. * @param y the current top position on screen * @param postAddView If {@code true}, posts all calls to * {@link #addViewInLayoutWrapper(android.view.View, boolean)} * to the UI thread rather than adding it immediately. If {@code false}, * calls {@link #addViewInLayoutWrapper(android.view.View, boolean)} * immediately. */ private void positionOverlays(int y, boolean postAddView) { mOffsetY = y; /* * The scale value that WebView reports is inaccurate when measured during WebView * initialization. This bug is present in ICS, so to work around it, we ignore all * reported values and use a calculated expected value from ConversationWebView instead. * Only when the user actually begins to touch the view (to, say, begin a zoom) do we begin * to pay attention to WebView-reported scale values. */ if (mTouchInitialized) { mScale = mWebView.getScale(); } else if (mScale == 0) { mScale = mWebView.getInitialScale(); } traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(), mScale); if (mOverlayPositions == null || mOverlayAdapter == null) { return; } // recycle scrolled-off views and add newly visible views // we want consecutive spacers/overlays to stack towards the bottom // so iterate from the bottom of the conversation up // starting with the last spacer bottom and the last adapter item, position adapter views // in a single stack until you encounter a non-contiguous expanded message header, // then decrement to the next spacer. traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayPositions.length, mOverlayAdapter.getCount()); mSnapIndex = -1; mAdditionalBottomBorderOverlayTop = 0; int adapterLoopIndex = mOverlayAdapter.getCount() - 1; int spacerIndex = mOverlayPositions.length - 1; while (spacerIndex >= 0 && adapterLoopIndex >= 0) { final int spacerTop = getOverlayTop(spacerIndex); final int spacerBottom = getOverlayBottom(spacerIndex); final boolean flip; final int flipOffset; final int forceGravity; // flip direction from bottom->top to top->bottom traversal on the very first spacer // to facilitate top-aligned headers at spacer index = 0 if (spacerIndex == 0) { flip = true; flipOffset = adapterLoopIndex; forceGravity = Gravity.TOP; } else { flip = false; flipOffset = 0; forceGravity = Gravity.NO_GRAVITY; } int adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; // always place at least one overlay per spacer ConversationOverlayItem adapterItem = mOverlayAdapter.getItem(adapterIndex); OverlayPosition itemPos = calculatePosition(adapterItem, spacerTop, spacerBottom, forceGravity); traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex, itemPos.top, itemPos.bottom, adapterItem); positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView); // and keep stacking overlays unconditionally if we are on the first spacer, or as long // as overlays are contiguous while (--adapterLoopIndex >= 0) { adapterIndex = flip ? flipOffset - adapterLoopIndex : adapterLoopIndex; adapterItem = mOverlayAdapter.getItem(adapterIndex); if (spacerIndex > 0 && !adapterItem.isContiguous()) { // advance to the next spacer, but stay on this adapter item break; } // place this overlay in the region of the spacer above or below the last item, // depending on direction of iteration final int regionTop = flip ? itemPos.bottom : spacerTop; final int regionBottom = flip ? spacerBottom : itemPos.top; itemPos = calculatePosition(adapterItem, regionTop, regionBottom, forceGravity); traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex, itemPos.top, itemPos.bottom, adapterItem); positionOverlay(adapterIndex, itemPos.top, itemPos.bottom, postAddView); } spacerIndex--; } positionSnapHeader(mSnapIndex); positionAdditionalBottomBorder(postAddView); } /** * Adds an additional bottom border to the overlay views in case * the overlays do not fill the entire screen. */ private void positionAdditionalBottomBorder(boolean postAddView) { final int lastBottom = mAdditionalBottomBorderOverlayTop; final int containerHeight = webPxToScreenPx(mWebView.getContentHeight()); final int speculativeHeight = containerHeight - lastBottom; if (speculativeHeight > 0) { if (mAdditionalBottomBorder == null) { mAdditionalBottomBorder = mOverlayAdapter.getLayoutInflater().inflate( R.layout.fake_bottom_border, this, false); } setAdditionalBottomBorderHeight(speculativeHeight); if (!mAdditionalBottomBorderAdded) { addViewInLayoutWrapper(mAdditionalBottomBorder, postAddView); mAdditionalBottomBorderAdded = true; } measureOverlayView(mAdditionalBottomBorder); layoutOverlay(mAdditionalBottomBorder, lastBottom, containerHeight); } else { if (mAdditionalBottomBorder != null && mAdditionalBottomBorderAdded) { if (postAddView) { post(mRemoveBorderRunnable); } else { mRemoveBorderRunnable.run(); } mAdditionalBottomBorderAdded = false; } } } private final RemoveBorderRunnable mRemoveBorderRunnable = new RemoveBorderRunnable(); private void setAdditionalBottomBorderHeight(int speculativeHeight) { LayoutParams params = mAdditionalBottomBorder.getLayoutParams(); params.height = speculativeHeight; mAdditionalBottomBorder.setLayoutParams(params); } private static OverlayPosition calculatePosition(final ConversationOverlayItem adapterItem, final int withinTop, final int withinBottom, final int forceGravity) { if (adapterItem.getHeight() == 0) { // "place" invisible items at the bottom of their region to stay consistent with the // stacking algorithm in positionOverlays(), unless gravity is forced to the top final int y = (forceGravity == Gravity.TOP) ? withinTop : withinBottom; return new OverlayPosition(y, y); } final int v = ((forceGravity != Gravity.NO_GRAVITY) ? forceGravity : adapterItem.getGravity()) & Gravity.VERTICAL_GRAVITY_MASK; switch (v) { case Gravity.BOTTOM: return new OverlayPosition(withinBottom - adapterItem.getHeight(), withinBottom); case Gravity.TOP: return new OverlayPosition(withinTop, withinTop + adapterItem.getHeight()); default: throw new UnsupportedOperationException("unsupported gravity: " + v); } } /** * Executes a measure pass over the specified child overlay view and returns the measured * height. The measurement uses whatever the current container's width measure spec is. * This method ignores view visibility and returns the height that the view would be if visible. * * @param overlayView an overlay view to measure. does not actually have to be attached yet. * @return height that the view would be if it was visible */ public int measureOverlay(View overlayView) { measureOverlayView(overlayView); return overlayView.getMeasuredHeight(); } /** * Copied/stolen from {@link ListView}. */ private void measureOverlayView(View child) { MarginLayoutParams p = (MarginLayoutParams) child.getLayoutParams(); if (p == null) { p = (MarginLayoutParams) generateDefaultLayoutParams(); } int childWidthSpec = ViewGroup.getChildMeasureSpec(mWidthMeasureSpec, getPaddingLeft() + getPaddingRight() + p.leftMargin + p.rightMargin, p.width); int lpHeight = p.height; int childHeightSpec; if (lpHeight > 0) { childHeightSpec = MeasureSpec.makeMeasureSpec(lpHeight, MeasureSpec.EXACTLY); } else { childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } child.measure(childWidthSpec, childHeightSpec); } private void onOverlayScrolledOff(final int adapterIndex, final OverlayView overlay, int overlayTop, int overlayBottom) { // immediately remove this view from the view set so future lookups don't find it mOverlayViews.remove(adapterIndex); // detach but don't actually remove from the view detachOverlay(overlay, false /* removeFromContainer */); // push it out of view immediately // otherwise this scrolled-off header will continue to draw until the runnable runs layoutOverlay(overlay.view, overlayTop, overlayBottom); } /** * Returns an existing scrap view, if available. The view will already be removed from the view * hierarchy. This method will not remove the view from the scrap heap. * */ public View getScrapView(int type) { return mScrapViews.peek(type); } public void addScrapView(int type, View v) { mScrapViews.add(type, v); addViewInLayoutWrapper(v, false /* postAddView */); } private void detachOverlay(OverlayView overlay, boolean removeFromContainer) { // Prefer removeViewInLayout over removeView. The typical followup layout pass is unneeded // because removing overlay views doesn't affect overall layout. if (removeFromContainer) { removeViewInLayout(overlay.view); } mScrapViews.add(overlay.itemType, overlay.view); if (overlay.view instanceof DetachListener) { ((DetachListener) overlay.view).onDetachedFromParent(); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (LogUtils.isLoggable(TAG, LogUtils.DEBUG)) { LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%s/%s", MeasureSpec.toString(widthMeasureSpec), MeasureSpec.toString(heightMeasureSpec)); } for (View nonScrollingChild : mNonScrollingChildren) { if (nonScrollingChild.getVisibility() != GONE) { measureChildWithMargins(nonScrollingChild, widthMeasureSpec, 0 /* widthUsed */, heightMeasureSpec, 0 /* heightUsed */); } } mWidthMeasureSpec = widthMeasureSpec; // onLayout will re-measure and re-position overlays for the new container size, but the // spacer offsets would still need to be updated to have them draw at their new locations. } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { LogUtils.d(TAG, "*** IN header container onLayout"); for (View nonScrollingChild : mNonScrollingChildren) { if (nonScrollingChild.getVisibility() != GONE) { final int w = nonScrollingChild.getMeasuredWidth(); final int h = nonScrollingChild.getMeasuredHeight(); final MarginLayoutParams lp = (MarginLayoutParams) nonScrollingChild.getLayoutParams(); final int childLeft = lp.leftMargin; final int childTop = lp.topMargin; nonScrollingChild.layout(childLeft, childTop, childLeft + w, childTop + h); } } if (mOverlayAdapter != null) { // being in a layout pass means overlay children may require measurement, // so invalidate them for (int i = 0, len = mOverlayAdapter.getCount(); i < len; i++) { mOverlayAdapter.getItem(i).invalidateMeasurement(); } } positionOverlays(mOffsetY, false /* postAddView */); } @Override protected LayoutParams generateDefaultLayoutParams() { return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); } @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); } @Override protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { return new MarginLayoutParams(p); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof MarginLayoutParams; } private int getOverlayTop(int spacerIndex) { return webPxToScreenPx(mOverlayPositions[spacerIndex].top); } private int getOverlayBottom(int spacerIndex) { return webPxToScreenPx(mOverlayPositions[spacerIndex].bottom); } private int webPxToScreenPx(int webPx) { // TODO: round or truncate? // TODO: refactor and unify with ConversationWebView.webPxToScreenPx() return (int) (webPx * mScale); } private void positionOverlay( int adapterIndex, int overlayTopY, int overlayBottomY, boolean postAddView) { final OverlayView overlay = mOverlayViews.get(adapterIndex); final ConversationOverlayItem item = mOverlayAdapter.getItem(adapterIndex); // save off the item's current top for later snap calculations item.setTop(overlayTopY); // is the overlay visible and does it have non-zero height? if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY && overlayTopY < mOffsetY + getHeight()) { View overlayView = overlay != null ? overlay.view : null; // show and/or move overlay if (overlayView == null) { overlayView = addOverlayView(adapterIndex, postAddView); ViewCompat.setLayoutDirection(overlayView, ViewCompat.getLayoutDirection(this)); measureOverlayView(overlayView); item.markMeasurementValid(); traceLayout("show/measure overlay %d", adapterIndex); } else { traceLayout("move overlay %d", adapterIndex); if (!item.isMeasurementValid()) { item.rebindView(overlayView); measureOverlayView(overlayView); item.markMeasurementValid(); traceLayout("and (re)measure overlay %d, old/new heights=%d/%d", adapterIndex, overlayView.getHeight(), overlayView.getMeasuredHeight()); } } traceLayout("laying out overlay %d with h=%d", adapterIndex, overlayView.getMeasuredHeight()); final int childBottom = overlayTopY + overlayView.getMeasuredHeight(); layoutOverlay(overlayView, overlayTopY, childBottom); mAdditionalBottomBorderOverlayTop = (childBottom > mAdditionalBottomBorderOverlayTop) ? childBottom : mAdditionalBottomBorderOverlayTop; } else { // hide overlay if (overlay != null) { traceLayout("hide overlay %d", adapterIndex); onOverlayScrolledOff(adapterIndex, overlay, overlayTopY, overlayBottomY); } else { traceLayout("ignore non-visible overlay %d", adapterIndex); } mAdditionalBottomBorderOverlayTop = (overlayBottomY > mAdditionalBottomBorderOverlayTop) ? overlayBottomY : mAdditionalBottomBorderOverlayTop; } if (overlayTopY <= mOffsetY && item.canPushSnapHeader()) { if (mSnapIndex == -1) { mSnapIndex = adapterIndex; } else if (adapterIndex > mSnapIndex) { mSnapIndex = adapterIndex; } } } // layout an existing view // need its top offset into the conversation, its height, and the scroll offset private void layoutOverlay(View child, int childTop, int childBottom) { final int top = childTop - mOffsetY; final int bottom = childBottom - mOffsetY; final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); final int childLeft = getPaddingLeft() + lp.leftMargin; child.layout(childLeft, top, childLeft + child.getMeasuredWidth(), bottom); } private View addOverlayView(int adapterIndex, boolean postAddView) { final int itemType = mOverlayAdapter.getItemViewType(adapterIndex); final View convertView = mScrapViews.poll(itemType); final View view = mOverlayAdapter.getView(adapterIndex, convertView, this); mOverlayViews.put(adapterIndex, new OverlayView(view, itemType)); if (convertView == view) { LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view); } else { LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view); } if (view.getParent() == null) { addViewInLayoutWrapper(view, postAddView); } else { // Need to call postInvalidate since the view is being moved back on // screen and we want to force it to draw the view. Without doing this, // the view may not draw itself when it comes back on screen. view.postInvalidate(); } return view; } private void addViewInLayoutWrapper(View view, boolean postAddView) { final AddViewRunnable addviewRunnable = new AddViewRunnable(view); if (postAddView) { post(addviewRunnable); } else { addviewRunnable.run(); } } private class AddViewRunnable implements Runnable { private final View mView; public AddViewRunnable(View view) { mView = view; } @Override public void run() { final int index = BOTTOM_LAYER_VIEW_IDS.length; addViewInLayout(mView, index, mView.getLayoutParams(), true /* preventRequestLayout */); } }; private class RemoveBorderRunnable implements Runnable { @Override public void run() { removeViewInLayout(mAdditionalBottomBorder); } } private boolean isSnapEnabled() { if (mAccountController == null || mAccountController.getAccount() == null || mAccountController.getAccount().settings == null) { return true; } final int snap = mAccountController.getAccount().settings.snapHeaders; return snap == UIProvider.SnapHeaderValue.ALWAYS || (snap == UIProvider.SnapHeaderValue.PORTRAIT_ONLY && getResources() .getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT); } // render and/or re-position snap header private void positionSnapHeader(int snapIndex) { ConversationOverlayItem snapItem = null; if (mSnapEnabled && snapIndex != -1) { final ConversationOverlayItem item = mOverlayAdapter.getItem(snapIndex); if (item.canBecomeSnapHeader()) { snapItem = item; } } if (snapItem == null) { mSnapHeader.setVisibility(GONE); mSnapHeader.unbind(); return; } snapItem.bindView(mSnapHeader, false /* measureOnly */); mSnapHeader.setVisibility(VISIBLE); // overlap is negative or zero; bump the snap header upwards by that much int overlap = 0; final ConversationOverlayItem next = findNextPushingOverlay(snapIndex + 1); if (next != null) { overlap = Math.min(0, next.getTop() - mSnapHeader.getHeight() - mOffsetY); // disable overlap drawing past a certain speed if (overlap < 0) { final Float v = mVelocityTracker.getSmoothedVelocity(); if (v != null && v > SNAP_HEADER_MAX_SCROLL_SPEED) { overlap = 0; } } } mSnapHeader.setTranslationY(overlap); } // find the next header that can push the snap header up private ConversationOverlayItem findNextPushingOverlay(int start) { for (int i = start, len = mOverlayAdapter.getCount(); i < len; i++) { final ConversationOverlayItem next = mOverlayAdapter.getItem(i); if (next.canPushSnapHeader()) { return next; } } return null; } /** * Prevents any layouts from happening until the next time * {@link #onGeometryChange(OverlayPosition[])} is * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items. *

* If you call this, you must ensure that a followup call to * {@link #onGeometryChange(OverlayPosition[])} * is made later, when the HTML spacer coordinates are updated. * */ public void invalidateSpacerGeometry() { mOverlayPositions = null; } public void onGeometryChange(OverlayPosition[] overlayPositions) { traceLayout("*** got overlay spacer positions:"); for (OverlayPosition pos : overlayPositions) { traceLayout("top=%d bottom=%d", pos.top, pos.bottom); } mOverlayPositions = overlayPositions; positionOverlays(mOffsetY, false /* postAddView */); } /** * Remove the view that corresponds to the item in the {@link ConversationViewAdapter} * at the specified index.

* * Note: the view is actually pushed off-screen and recycled * as though it were scrolled off. * @param adapterIndex The index for the view in the adapter. */ public void removeViewAtAdapterIndex(int adapterIndex) { // need to temporarily set the offset to 0 so that we can ensure we're pushing off-screen. final int offsetY = mOffsetY; mOffsetY = 0; final OverlayView overlay = mOverlayViews.get(adapterIndex); if (overlay != null) { final int height = getHeight(); onOverlayScrolledOff(adapterIndex, overlay, height, height + overlay.view.getHeight()); LogUtils.i(TAG, "footer scrolled off. container height=%s, measuredHeight=%s", height, getMeasuredHeight()); } else { LogUtils.i(TAG, "footer not found with adapterIndex=%s", adapterIndex); for (int i = 0, size = mOverlayViews.size(); i < size; i++) { final int index = mOverlayViews.keyAt(i); final OverlayView overlayView = mOverlayViews.valueAt(i); LogUtils.i(TAG, "OverlayView: adapterIndex=%s, itemType=%s, view=%s", index, overlayView.itemType, overlayView.view); } for (int i = 0, size = mOverlayAdapter.getCount(); i < size; i++) { final ConversationOverlayItem item = mOverlayAdapter.getItem(i); LogUtils.i(TAG, "adapter item: index=%s, item=%s", i, item); } } // restore the offset to its original value after the view has been moved off-screen. mOffsetY = offsetY; } private void traceLayout(String msg, Object... params) { if (mDisableLayoutTracing) { return; } LogUtils.d(TAG, msg, params); } @Override protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) { if (mOverlayAdapter != null) { return mOverlayAdapter.focusFirstMessageHeader(); } return false; } public void focusFirstMessageHeader() { mOverlayAdapter.focusFirstMessageHeader(); } public View getNextOverlayView(View curr, boolean isDown) { // Find the scraps that we should avoid when fetching the next view. final Set scraps = Sets.newHashSet(); mScrapViews.visitAll(new DequeMap.Visitor() { @Override public void visit(View item) { scraps.add(item); } }); return mOverlayAdapter.getNextOverlayView(curr, isDown, scraps); } private class AdapterObserver extends DataSetObserver { @Override public void onChanged() { onDataSetChanged(); } } }