/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.react.flat;
import javax.annotation.Nullable;
import java.util.ArrayList;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.util.SparseArray;
import android.util.SparseIntArray;
import android.view.View;
import android.view.animation.Animation;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.uimanager.ReactClippingViewGroup;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
/**
* Abstract class for a {@link DrawCommandManager} with directional clipping. Allows support for
* vertical and horizontal clipping by implementing abstract methods.
*
* Uses two dynamic programming arrays to efficiently update which views and commands are onscreen,
* while not having to sort the incoming draw commands. The draw commands are loosely sorted, as
* they represent a flattening of the normal view hierarchy, and we use that information to quickly
* find children that should be considered onscreen. One array keeps track of, for each index, the
* maximum bottom position that occurs at or before that index; the other keeps track of the
* minimum top position that occurs at or after that index. Given the following children:
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* | | | |
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
*
* +-----------+ 80
* | 4 |
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* | |
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* The two arrays are:
* 0 1 2 3 4 5 6
* Max Bottom: [70, 70, 70, 70, 100, 130, 130]
* Min Top: [ 0, 0, 0, 0, 80, 90, 90]
*
* We can then binary search for the first max bottom that is above our rect, and the first min top
* that is below our rect.
*
* If the top and bottom of the rect are 55 and 85, respectively, we will start drawing at index 0
* and stop at index 4.
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* - -| -| - - - -| - - - - - - |- - -
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
*
* +-----------+ 80
* - - -| 4 - - - | - - - - - - - - -
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* | |
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* If the top and bottom are 75 and 105 respectively, we will start drawing at index 4 and stop at
* index 6.
*
* +---------------------------------+ 0 (Y coordinate)
* | 0 |
* | +-----------+ | 10
* | | 1 | |
* | | | +--------------+ | 20
* | | | | 3 | |
* | +-----------+ | | | 30
* | | | |
* | +-----------+ | | | 40
* | | 2 | | | |
* | | | +--------------+ | 50
* | | | |
* | +-----------+ | 60
* | |
* +---------------------------------+ 70
* - - - - - - - - - - - - - - - -
* +-----------+ 80
* | 4 |
* | | +--------------+ 90
* | | | 6 |
* +-----------+ | | 100
* - - - - - - - |- - - - - | - - -
* +-----------+ | | 110
* | 5 | | |
* | | +--------------+ 120
* | |
* +-----------+ 130
*
* While this doesn't map exactly to all of the commands that could be clipped, it means that
* children which contain other children (a pretty common case when flattening views) are clipped
* or unclipped as one logical unit. This has the side effect of minimizing the amount of
* invalidates coming from minor clipping rect adjustments. The underlying dynamic programming
* arrays can be calculated off the UI thread in O(n) time, requiring just two passes through the
* command array.
*
* We do a similar optimization when searching for matching node regions, as node regions are
* loosely sorted as well when clipping.
*/
/* package */ abstract class ClippingDrawCommandManager extends DrawCommandManager {
private final FlatViewGroup mFlatViewGroup;
private DrawCommand[] mDrawCommands = DrawCommand.EMPTY_ARRAY;
protected float[] mCommandMaxBottom = StateBuilder.EMPTY_FLOAT_ARRAY;
protected float[] mCommandMinTop = StateBuilder.EMPTY_FLOAT_ARRAY;
private NodeRegion[] mNodeRegions = NodeRegion.EMPTY_ARRAY;
protected float[] mRegionMaxBottom = StateBuilder.EMPTY_FLOAT_ARRAY;
protected float[] mRegionMinTop = StateBuilder.EMPTY_FLOAT_ARRAY;
// Onscreen bounds of draw command array.
private int mStart;
private int mStop;
// Mapping of ids to index position within the draw command array. O(log n) lookups should be
// less in our case because of the large constant overhead and auto boxing of the map.
private SparseIntArray mDrawViewIndexMap = StateBuilder.EMPTY_SPARSE_INT;
// Map of views that are currently clipped.
private final SparseArray<View> mClippedSubviews = new SparseArray<>();
protected final Rect mClippingRect = new Rect();
// Used in updating the clipping rect, as sometimes we want to detach all views, which means we
// need to temporarily store the views we are detaching and removing. These are always of size
// 0, except when used in update clipping rect.
private final SparseArray<View> mViewsToRemove = new SparseArray<>();
private final ArrayList<View> mViewsToKeep = new ArrayList<>();
// Currently clipping ViewGroups
private final ArrayList<ReactClippingViewGroup> mClippingViewGroups = new ArrayList<>();
ClippingDrawCommandManager(FlatViewGroup flatViewGroup, DrawCommand[] drawCommands) {
mFlatViewGroup = flatViewGroup;
initialSetup(drawCommands);
}
/**
* Initially setup this instance. Makes sure the draw commands are mounted, and that our
* clipping rect reflects our current bounds.
*
* @param drawCommands The list of current draw commands. In current implementations, this will
* always be DrawCommand.EMPTY_ARRAY
*/
private void initialSetup(DrawCommand[] drawCommands) {
mountDrawCommands(
drawCommands,
mDrawViewIndexMap,
mCommandMaxBottom,
mCommandMinTop,
true);
updateClippingRect();
}
/**
* @return index of the first command that could be on the screen.
*/
abstract int commandStartIndex();
/**
* @return index of the first command that is guaranteed to be off the screen, starting from the
* given start.
*/
abstract int commandStopIndex(int start);
/**
* @return index of the first region that is guaranteed to be outside of the bounds for touch.
*/
abstract int regionStopIndex(float touchX, float touchY);
/**
* Whether an index and all indices before it are guaranteed to be out of bounds for the current
* touch.
*
* @param index The region index to check.
* @param touchX X coordinate.
* @param touchY Y coordinate.
* @return true if the index and all before it are out of bounds.
*/
abstract boolean regionAboveTouch(int index, float touchX, float touchY);
@Override
public void mountDrawCommands(
DrawCommand[] drawCommands,
SparseIntArray drawViewIndexMap,
float[] maxBottom,
float[] minTop,
boolean willMountViews) {
mDrawCommands = drawCommands;
mCommandMaxBottom = maxBottom;
mCommandMinTop = minTop;
mDrawViewIndexMap = drawViewIndexMap;
if (mClippingRect.bottom != mClippingRect.top) {
mStart = commandStartIndex();
mStop = commandStopIndex(mStart);
if (!willMountViews) {
// If we are not mounting views, we still need to update view indices and positions. It is
// possible that a child changed size and we still need new clipping even though we are not
// mounting views.
updateClippingToCurrentRect();
}
}
}
@Override
public void mountNodeRegions(NodeRegion[] nodeRegions, float[] maxBottom, float[] minTop) {
mNodeRegions = nodeRegions;
mRegionMaxBottom = maxBottom;
mRegionMinTop = minTop;
}
@Override
public @Nullable NodeRegion virtualNodeRegionWithinBounds(float touchX, float touchY) {
int i = regionStopIndex(touchX, touchY);
while (i-- > 0) {
NodeRegion nodeRegion = mNodeRegions[i];
if (!nodeRegion.mIsVirtual) {
// only interested in virtual nodes
continue;
}
if (regionAboveTouch(i, touchX, touchY)) {
break;
}
if (nodeRegion.withinBounds(touchX, touchY)) {
return nodeRegion;
}
}
return null;
}
@Override
public @Nullable NodeRegion anyNodeRegionWithinBounds(float touchX, float touchY) {
int i = regionStopIndex(touchX, touchY);
while (i-- > 0) {
NodeRegion nodeRegion = mNodeRegions[i];
if (regionAboveTouch(i, touchX, touchY)) {
break;
}
if (nodeRegion.withinBounds(touchX, touchY)) {
return nodeRegion;
}
}
return null;
}
private void clip(int id, View view) {
mClippedSubviews.put(id, view);
}
private void unclip(int id) {
mClippedSubviews.remove(id);
}
private boolean isClipped(int id) {
return mClippedSubviews.get(id) != null;
}
private boolean isNotClipped(int id) {
return mClippedSubviews.get(id) == null;
}
@Override
void onClippedViewDropped(View view) {
unclip(view.getId());
mFlatViewGroup.removeDetachedView(view);
}
@Override
public void mountViews(ViewResolver viewResolver, int[] viewsToAdd, int[] viewsToDetach) {
mClippingViewGroups.clear();
for (int viewToAdd : viewsToAdd) {
// Views that are just temporarily detached are marked with a negative value.
boolean newView = viewToAdd > 0;
if (!newView) {
viewToAdd = -viewToAdd;
}
int commandArrayIndex = mDrawViewIndexMap.get(viewToAdd);
DrawView drawView = (DrawView) mDrawCommands[commandArrayIndex];
View view = viewResolver.getView(drawView.reactTag);
ensureViewHasNoParent(view);
// these views need to support recursive clipping of subviews
if (view instanceof ReactClippingViewGroup &&
((ReactClippingViewGroup) view).getRemoveClippedSubviews()) {
mClippingViewGroups.add((ReactClippingViewGroup) view);
}
if (newView) {
// This view was not previously attached to this parent.
drawView.mWasMounted = true;
if (animating(view) || withinBounds(commandArrayIndex)) {
// View should be drawn. This view can't currently be clipped because it wasn't
// previously attached to this parent.
mFlatViewGroup.addViewInLayout(view);
} else {
clip(drawView.reactTag, view);
}
} else {
// This view was previously attached, and just temporarily detached.
if (drawView.mWasMounted) {
// The DrawView has been mounted before.
if (isNotClipped(drawView.reactTag)) {
// The DrawView is not clipped. Attach it.
mFlatViewGroup.attachViewToParent(view);
}
// else The DrawView has been previously mounted and is clipped, so don't attach it.
} else {
// We are mounting it, so lets get this part out of the way.
drawView.mWasMounted = true;
// The DrawView has not been mounted before, which means the bounds changed and triggered
// a new DrawView when it was collected from the shadow node. We have a view with the
// same id temporarily detached, but its bounds have changed.
if (animating(view) || withinBounds(commandArrayIndex)) {
// View should be drawn.
if (isClipped(drawView.reactTag)) {
// View was clipped, so add it.
mFlatViewGroup.addViewInLayout(view);
unclip(drawView.reactTag);
} else {
// View was just temporarily removed, so attach it. We already know it isn't clipped,
// so no need to unclip it.
mFlatViewGroup.attachViewToParent(view);
}
} else {
// View should be clipped.
if (isNotClipped(drawView.reactTag)) {
// View was onscreen.
mFlatViewGroup.removeDetachedView(view);
clip(drawView.reactTag, view);
}
// else view is already clipped and not within bounds.
}
}
}
}
for (int viewToDetach : viewsToDetach) {
View view = viewResolver.getView(viewToDetach);
if (view.getParent() != null) {
throw new RuntimeException("Trying to remove view not owned by FlatViewGroup");
} else {
mFlatViewGroup.removeDetachedView(view);
}
// The view isn't clipped anymore, but gone entirely.
unclip(viewToDetach);
}
}
/**
* Returns true if a view is currently animating.
*/
private static boolean animating(View view) {
Animation animation = view.getAnimation();
return animation != null && !animation.hasEnded();
}
/**
* Returns true if a command index is currently onscreen.
*/
private boolean withinBounds(int i) {
return mStart <= i && i < mStop;
}
@Override
public boolean updateClippingRect() {
ReactClippingViewGroupHelper.calculateClippingRect(mFlatViewGroup, mClippingRect);
if (mFlatViewGroup.getParent() == null || mClippingRect.top == mClippingRect.bottom) {
// If we are unparented or are clipping to an empty rect, no op. Return false so we don't
// invalidate.
return false;
}
int start = commandStartIndex();
int stop = commandStopIndex(start);
if (mStart <= start && stop <= mStop) {
// We would only be removing children, don't invalidate and don't bother changing the
// attached children.
updateClippingRecursively();
return false;
}
mStart = start;
mStop = stop;
updateClippingToCurrentRect();
updateClippingRecursively();
return true;
}
private void updateClippingRecursively() {
for (int i = 0, children = mClippingViewGroups.size(); i < children; i++) {
ReactClippingViewGroup view = mClippingViewGroups.get(i);
if (isNotClipped(((View) view).getId())) {
view.updateClippingRect();
}
}
}
/**
* Used either after we have updated the current rect, or when we have mounted new commands and
* the rect hasn't changed. Updates the clipping after mStart and mStop have been set to the
* correct values. For draw commands, this is all it takes to update the command mounting, as
* draw commands are only attached in a conceptual sense, and don't rely on the android view
* hierarchy.
*
* For native children, we have to walk through our current views and remove any that are no
* longer on screen, and add those that are newly on screen. As an optimization for fling, if we
* are removing two or more native views we instead detachAllViews from the {@link FlatViewGroup}
* and re-attach or add as needed.
*
* This approximation is roughly correct, as we tend to add and remove the same amount of views,
* and each add and remove pair is O(n); detachAllViews and re-attach requires two passes, so
* using this once we are removing more than two native views is a good breakpoint.
*/
private void updateClippingToCurrentRect() {
for (int i = 0, size = mFlatViewGroup.getChildCount(); i < size; i++) {
View view = mFlatViewGroup.getChildAt(i);
int index = mDrawViewIndexMap.get(view.getId());
if (withinBounds(index) || animating(view)) {
mViewsToKeep.add(view);
} else {
mViewsToRemove.append(i, view);
clip(view.getId(), view);
}
}
int removeSize = mViewsToRemove.size();
boolean removeAll = removeSize > 2;
if (removeAll) {
// Detach all, as we are changing quite a few views, whether flinging or otherwise.
mFlatViewGroup.detachAllViewsFromParent();
for (int i = 0; i < removeSize; i++) {
mFlatViewGroup.removeDetachedView(mViewsToRemove.valueAt(i));
}
} else {
// Simple clipping sweep, as we are changing relatively few views.
while (removeSize-- > 0) {
mFlatViewGroup.removeViewsInLayout(mViewsToRemove.keyAt(removeSize), 1);
}
}
mViewsToRemove.clear();
int current = mStart;
int childIndex = 0;
for (int i = 0, size = mViewsToKeep.size(); i < size; i++) {
View view = mViewsToKeep.get(i);
int commandIndex = mDrawViewIndexMap.get(view.getId());
if (current <= commandIndex) {
while (current != commandIndex) {
if (mDrawCommands[current] instanceof DrawView) {
DrawView drawView = (DrawView) mDrawCommands[current];
mFlatViewGroup.addViewInLayout(
Assertions.assumeNotNull(mClippedSubviews.get(drawView.reactTag)),
childIndex++);
unclip(drawView.reactTag);
}
current++;
}
// We are currently at the command index, but we want to increment beyond it.
current++;
}
if (removeAll) {
mFlatViewGroup.attachViewToParent(view, childIndex);
}
// We want to make sure we increment the child index even if we didn't detach it to maintain
// order.
childIndex++;
}
mViewsToKeep.clear();
while (current < mStop) {
if (mDrawCommands[current] instanceof DrawView) {
DrawView drawView = (DrawView) mDrawCommands[current];
mFlatViewGroup.addViewInLayout(
Assertions.assumeNotNull(mClippedSubviews.get(drawView.reactTag)),
childIndex++);
unclip(drawView.reactTag);
}
current++;
}
}
@Override
public void getClippingRect(Rect outClippingRect) {
outClippingRect.set(mClippingRect);
}
@Override
public SparseArray<View> getDetachedViews() {
return mClippedSubviews;
}
/**
* Draws the unclipped commands on the given canvas. This would be much simpler if we didn't
* have to worry about animating views, as we could simply:
*
* for (int i = start; i < stop; i++) {
* drawCommands[i].draw(...);
* }
*
* This is complicated however by animating views, which may occur before or after the current
* clipping rect. Consider the following array:
*
* +--------------+
* | DrawView | 0
* | *animating* |
* +--------------+
* | DrawCommmand | 1
* | *clipped* |
* +--------------+
* | DrawCommand | 2 start
* | |
* +--------------+
* | DrawCommand | 3
* | |
* +--------------+
* | DrawView | 4
* | |
* +--------------+
* | DrawView | 5 stop
* | *clipped* |
* +--------------+
* | DrawView | 6
* | *animating* |
* +--------------+
*
* 2, 3, and 4 are onscreen according to bounds, while 0 and 6 are onscreen according to
* animation. We have to walk through the attached children making sure to draw any draw
* commands that should be drawn before that draw view, as well as making sure not to draw any
* draw commands that are out of bounds.
*
* @param canvas The canvas to draw on.
*/
@Override
public void draw(Canvas canvas) {
int commandIndex = mStart;
int size = mFlatViewGroup.getChildCount();
// Iterate through the children, making sure that we draw any draw commands we haven't drawn
// that should happen before the next draw view.
for (int i = 0; i < size; i++) {
// This is the command index of the next view that we need to draw. Since a view might be
// animating, this view is either before all the commands onscreen, onscreen, or after the
// onscreen commands.
int viewIndex = mDrawViewIndexMap.get(mFlatViewGroup.getChildAt(i).getId());
if (mStop < viewIndex) {
// The current view is outside of the viewport bounds. We want to draw all the commands
// up to the stop, then draw all the views outside the viewport bounds.
while (commandIndex < mStop) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
// We are now out of commands to draw, so we could just draw the remaining attached
// children, but the for loop logic will draw the rest anyway.
} else if (commandIndex <= viewIndex) {
// The viewIndex is within our onscreen bounds (or == stop). We want to draw all the
// commands from the current position to the current view, inclusive.
while (commandIndex < viewIndex) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
// Command index now == viewIndex, so increment beyond it.
commandIndex++;
}
mDrawCommands[viewIndex].draw(mFlatViewGroup, canvas);
}
// If we get here, it means we have drawn all the views, now just draw the remaining draw
// commands.
while (commandIndex < mStop) {
mDrawCommands[commandIndex++].draw(mFlatViewGroup, canvas);
}
}
@Override
void debugDraw(Canvas canvas) {
// Draws clipped draw commands, but does not draw clipped views.
for (DrawCommand drawCommand : mDrawCommands) {
if (drawCommand instanceof DrawView) {
if (isNotClipped(((DrawView) drawCommand).reactTag)) {
drawCommand.debugDraw(mFlatViewGroup, canvas);
}
// else, don't draw, and don't increment index
} else {
drawCommand.debugDraw(mFlatViewGroup, canvas);
}
}
}
}