blob: e78c32829e5c0cd27f793d596eebabd907e63c49 [file] [log] [blame]
/*
* 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.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.Adapter;
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 java.util.List;
/**
* 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.
*
* <h5>INPUT HANDLING</h5>
* 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.
*
* <h5>VIEW RECYCLING</h5>
* 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.<br/><br/>
*
* 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.webview,
R.id.conversation_side_border_overlay
};
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<View> 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<Integer, View> mScrapViews = new DequeMap<Integer, View>();
/**
* The current set of overlay views in the view hierarchy. Looking through this map is faster
* than traversing the view hierarchy.
* <p>
* 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.
* <p>
* 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<OverlayView> 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<OverlayView>();
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.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 Adapter getOverlayAdapter() {
return mOverlayAdapter;
}
public void setAccountController(ConversationAccountController controller) {
mAccountController = controller;
mSnapEnabled = isSnapEnabled();
}
/**
* Re-bind any existing views that correspond to the given adapter positions.
*
*/
public void onOverlayModelUpdate(List<Integer> 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) {
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();
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) {
removeViewInLayout(mAdditionalBottomBorder);
mAdditionalBottomBorderAdded = false;
}
}
}
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);
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 {
public 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 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.
* <p>
* 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 */);
}
private void traceLayout(String msg, Object... params) {
if (mDisableLayoutTracing) {
return;
}
LogUtils.d(TAG, msg, params);
}
private class AdapterObserver extends DataSetObserver {
@Override
public void onChanged() {
onDataSetChanged();
}
}
}