/**
* Copyright (c) 2017-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.litho;
import java.lang.ref.WeakReference;
import java.util.Deque;
import android.content.Context;
import android.graphics.Rect;
import android.support.annotation.VisibleForTesting;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityManagerCompat;
import android.support.v4.view.accessibility.AccessibilityManagerCompat.AccessibilityStateChangeListenerCompat;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import com.facebook.litho.config.ComponentsConfiguration;
import com.facebook.proguard.annotations.DoNotStrip;
import static android.content.Context.ACCESSIBILITY_SERVICE;
import static com.facebook.litho.AccessibilityUtils.isAccessibilityEnabled;
/**
* A {@link ViewGroup} that can host the mounted state of a {@link Component}.
*/
public class LithoView extends ComponentHost {
private ComponentTree mComponentTree;
private final MountState mMountState;
private boolean mIsAttached;
private final Rect mPreviousMountBounds = new Rect();
private final boolean mIncrementalMountOnOffsetOrTranslationChange;
private boolean mForceLayout;
private boolean mSuppressMeasureComponentTree;
private final AccessibilityManager mAccessibilityManager;
private final AccessibilityStateChangeListener mAccessibilityStateChangeListener =
new AccessibilityStateChangeListener(this);
private static final int[] sLayoutSize = new int[2];
// Keep ComponentTree when detached from this view in case the ComponentTree is shared between
// sticky header and RecyclerView's binder
// TODO T14859077 Replace with proper solution
private ComponentTree mTemporaryDetachedComponent;
/**
* Create a new {@link LithoView} instance and initialize it
* with the given {@link Component} root.
*
* @param context Android {@link Context}.
* @param component The root component to draw.
* @return {@link LithoView} able to render a {@link Component} hierarchy.
*/
public static LithoView create(Context context, Component component) {
return create(new ComponentContext(context), component);
}
/**
* Create a new {@link LithoView} instance and initialize it
* with the given {@link Component} root.
*
* @param context {@link ComponentContext}.
* @param component The root component to draw.
* @return {@link LithoView} able to render a {@link Component} hierarchy.
*/
public static LithoView create(ComponentContext context, Component component) {
final LithoView lithoView = new LithoView(context);
lithoView.setComponentTree(ComponentTree.create(context, component).build());
return lithoView;
}
public LithoView(Context context) {
this(context, null);
}
public LithoView(Context context, AttributeSet attrs) {
this(new ComponentContext(context), attrs);
}
public LithoView(ComponentContext context) {
this(context, null);
}
public LithoView(ComponentContext context, AttributeSet attrs) {
this(context, attrs, false);
}
public LithoView(
ComponentContext context,
AttributeSet attrs,
boolean incrementalMountOnOffsetOrTranslationChange) {
super(context, attrs);
mMountState = new MountState(this);
mAccessibilityManager = (AccessibilityManager) context.getSystemService(ACCESSIBILITY_SERVICE);
mIncrementalMountOnOffsetOrTranslationChange = incrementalMountOnOffsetOrTranslationChange;
}
private static void performLayoutOnChildrenIfNecessary(ComponentHost host) {
for (int i = 0, count = host.getChildCount(); i < count; i++) {
final View child = host.getChildAt(i);
if (child.isLayoutRequested()) {
// The hosting view doesn't allow children to change sizes dynamically as
// this would conflict with the component's own layout calculations.
child.measure(
MeasureSpec.makeMeasureSpec(child.getWidth(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(child.getHeight(), MeasureSpec.EXACTLY));
child.layout(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
}
if (child instanceof ComponentHost) {
performLayoutOnChildrenIfNecessary((ComponentHost) child);
}
}
}
void forceRelayout() {
mForceLayout = true;
requestLayout();
}
public void startTemporaryDetach() {
mTemporaryDetachedComponent = mComponentTree;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
onAttach();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
onDetach();
}
@Override
public void onStartTemporaryDetach() {
super.onStartTemporaryDetach();
onDetach();
}
@Override
public void onFinishTemporaryDetach() {
super.onFinishTemporaryDetach();
onAttach();
}
private void onAttach() {
if (!mIsAttached) {
mIsAttached = true;
if (mComponentTree != null) {
mComponentTree.attach();
}
refreshAccessibilityDelegatesIfNeeded(isAccessibilityEnabled(getContext()));
AccessibilityManagerCompat.addAccessibilityStateChangeListener(
mAccessibilityManager,
mAccessibilityStateChangeListener);
}
}
private void onDetach() {
if (mIsAttached) {
mIsAttached = false;
if (mComponentTree != null) {
mMountState.detach();
mComponentTree.detach();
}
AccessibilityManagerCompat.removeAccessibilityStateChangeListener(
mAccessibilityManager,
mAccessibilityStateChangeListener);
mSuppressMeasureComponentTree = false;
}
}
/**
* If set to true, the onMeasure(..) call won't measure the ComponentTree with the given
* measure specs, but it will just use them as measured dimensions.
*/
public void suppressMeasureComponentTree(boolean suppress) {
mSuppressMeasureComponentTree = suppress;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
if (mTemporaryDetachedComponent != null && mComponentTree == null) {
setComponentTree(mTemporaryDetachedComponent);
mTemporaryDetachedComponent = null;
}
if (mComponentTree != null && !mSuppressMeasureComponentTree) {
boolean forceRelayout = mForceLayout;
mForceLayout = false;
mComponentTree.measure(widthMeasureSpec, heightMeasureSpec, sLayoutSize, forceRelayout);
width = sLayoutSize[0];
height = sLayoutSize[1];
}
setMeasuredDimension(width, height);
}
@Override
protected void performLayout(boolean changed, int left, int top, int right, int bottom) {
if (mComponentTree != null) {
boolean wasMountTriggered = mComponentTree.layout();
final boolean isRectSame = mPreviousMountBounds != null
&& mPreviousMountBounds.left == left
&& mPreviousMountBounds.top == top
&& mPreviousMountBounds.right == right
&& mPreviousMountBounds.bottom == bottom;
// If this happens the LithoView might have moved on Screen without a scroll event
// triggering incremental mount. We trigger one here to be sure all the content is visible.
if (!wasMountTriggered
&& !isRectSame
&& isIncrementalMountEnabled()) {
performIncrementalMount();
}
if (!wasMountTriggered || shouldAlwaysLayoutChildren()) {
// If the layout() call on the component didn't trigger a mount step,
// we might need to perform an inner layout traversal on children that
// requested it as certain complex child views (e.g. ViewPager,
// RecyclerView, etc) rely on that.
performLayoutOnChildrenIfNecessary(this);
}
}
}
/**
* Indicates if the children of this view should be laid regardless to a mount step being
* triggered on layout. This step can be important when some of the children in the hierarchy
* are changed (e.g. resized) but the parent wasn't.
*
* Since the framework doesn't expect its children to resize after being mounted, this should be
* used only for extreme cases where the underline views are complex and need this behavior.
*
* @return boolean Returns true if the children of this view should be laid out even when a mount
* step was not needed.
*/
protected boolean shouldAlwaysLayoutChildren() {
return false;
}
/**
* @return {@link ComponentContext} associated with this LithoView. It's a wrapper on the
* {@link Context} originally used to create this LithoView itself.
*/
public ComponentContext getComponentContext() {
return (ComponentContext) getContext();
}
@Override
protected boolean shouldRequestLayout() {
// Don't bubble up layout requests while mounting.
if (mComponentTree != null && mComponentTree.isMounting()) {
return false;
}
return super.shouldRequestLayout();
}
public ComponentTree getComponentTree() {
return mComponentTree;
}
public void setComponentTree(ComponentTree componentTree) {
mTemporaryDetachedComponent = null;
if (mComponentTree == componentTree) {
if (mIsAttached) {
rebind();
}
return;
}
setMountStateDirty();
if (mComponentTree != null) {
if (mIsAttached) {
mComponentTree.detach();
}
mComponentTree.clearLithoView();
}
mComponentTree = componentTree;
if (mComponentTree != null) {
mComponentTree.setLithoView(this);
if (mIsAttached) {
mComponentTree.attach();
}
}
}
/**
* Change the root component synchronously.
*/
public void setComponent(Component component) {
if (mComponentTree == null) {
setComponentTree(ComponentTree.create(getComponentContext(), component).build());
} else {
mComponentTree.setRoot(component);
}
}
/**
* Change the root component measuring it on a background thread before updating the UI.
* If this {@link LithoView} doesn't have a ComponentTree initialized, the root will be
* computed synchronously.
*/
public void setComponentAsync(Component component) {
if (mComponentTree == null) {
setComponentTree(ComponentTree.create(getComponentContext(), component).build());
} else {
mComponentTree.setRootAsync(component);
}
}
public void rebind() {
mMountState.rebind();
}
/**
* To be called this when the LithoView is about to become inactive. This means that either
* the view is about to be recycled or moved off-screen.
*/
public void unbind() {
mMountState.unbind();
}
/**
* Called from the ComponentTree when a new view want to use the same ComponentTree.
*/
void clearComponentTree() {
if (mIsAttached) {
throw new IllegalStateException("Trying to clear the ComponentTree while attached.");
}
mComponentTree = null;
}
@Override
public void setHasTransientState(boolean hasTransientState) {
if (isIncrementalMountEnabled()) {
performIncrementalMount(null);
}
super.setHasTransientState(hasTransientState);
}
@Override
public void offsetTopAndBottom(int offset) {
super.offsetTopAndBottom(offset);
maybePerformIncrementalMountOnView();
}
@Override
public void offsetLeftAndRight(int offset) {
super.offsetLeftAndRight(offset);
maybePerformIncrementalMountOnView();
}
@Override
public void setTranslationX(float translationX) {
super.setTranslationX(translationX);
maybePerformIncrementalMountOnView();
}
@Override
public void setTranslationY(float translationY) {
super.setTranslationY(translationY);
maybePerformIncrementalMountOnView();
}
private void maybePerformIncrementalMountOnView() {
if (!mIncrementalMountOnOffsetOrTranslationChange &&
!ComponentsConfiguration.isIncrementalMountOnOffsetOrTranslationChangeEnabled) {
return;
}
if (!isIncrementalMountEnabled() || !(getParent() instanceof View)) {
return;
}
int parentWidth = ((View) getParent()).getWidth();
int parentHeight = ((View) getParent()).getHeight();
final int translationX = (int) getTranslationX();
final int translationY = (int) getTranslationY();
final int top = getTop() + translationY;
final int bottom = getBottom() + translationY;
final int left = getLeft() + translationX;
final int right = getRight() + translationX;
if (left >= 0 &&
top >= 0 &&
right <= parentWidth &&
bottom <= parentHeight &&
mPreviousMountBounds.width() == getWidth() &&
mPreviousMountBounds.height() == getHeight()) {
// View is fully visible, and has already been completely mounted.
return;
}
final Rect rect = ComponentsPools.acquireRect();
rect.set(
Math.max(0, -left),
Math.max(0, -top),
Math.min(right, parentWidth) - left,
Math.min(bottom, parentHeight) - top);
if (rect.isEmpty()) {
// View is not visible at all, nothing to do.
ComponentsPools.release(rect);
return;
}
performIncrementalMount(rect);
ComponentsPools.release(rect);
}
public void performIncrementalMount(Rect visibleRect) {
if (mComponentTree == null) {
return;
}
if (mComponentTree.isIncrementalMountEnabled()) {
mComponentTree.mountComponent(visibleRect);
} else {
throw new IllegalStateException("To perform incremental mounting, you need first to enable" +
" it when creating the ComponentTree.");
}
}
public void performIncrementalMount() {
if (mComponentTree == null) {
return;
}
if (mComponentTree.isIncrementalMountEnabled()) {
mComponentTree.incrementalMountComponent();
} else {
throw new IllegalStateException("To perform incremental mounting, you need first to enable" +
" it when creating the ComponentTree.");
}
}
public boolean isIncrementalMountEnabled() {
return (mComponentTree != null && mComponentTree.isIncrementalMountEnabled());
}
public void release() {
if (mComponentTree != null) {
mComponentTree.release();
mComponentTree = null;
}
}
void mount(LayoutState layoutState, Rect currentVisibleArea) {
if (isIncrementalMountEnabled() && ViewCompat.hasTransientState(this)) {
return;
}
if (currentVisibleArea == null) {
mPreviousMountBounds.setEmpty();
} else {
mPreviousMountBounds.set(currentVisibleArea);
}
mMountState.mount(layoutState, currentVisibleArea);
}
public Rect getPreviousMountBounds() {
return mPreviousMountBounds;
}
void setMountStateDirty() {
mMountState.setDirty();
mPreviousMountBounds.setEmpty();
}
boolean isMountStateDirty() {
return mMountState.isDirty();
}
MountState getMountState() {
return mMountState;
}
@DoNotStrip
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Deque<TestItem> findTestItems(String testKey) {
return mMountState.findTestItems(testKey);
}
private static class AccessibilityStateChangeListener extends
AccessibilityStateChangeListenerCompat {
private WeakReference<LithoView> mLithoView;
private AccessibilityStateChangeListener(LithoView lithoView) {
mLithoView = new WeakReference<>(lithoView);
}
@Override
public void onAccessibilityStateChanged(boolean enabled) {
final LithoView lithoView = mLithoView.get();
if (lithoView == null) {
return;
}
lithoView.refreshAccessibilityDelegatesIfNeeded(enabled);
lithoView.requestLayout();
}
}
}