/*
* Copyright 2023 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 androidx.wear.protolayout.renderer.impl;
import static androidx.core.util.Preconditions.checkNotNull;
import android.content.Context;
import android.content.res.Resources;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
import androidx.wear.protolayout.expression.pipeline.StateStore;
import androidx.wear.protolayout.expression.pipeline.sensor.SensorGateway;
import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
import androidx.wear.protolayout.proto.ResourceProto;
import androidx.wear.protolayout.proto.StateProto;
import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
import androidx.wear.protolayout.renderer.ProtoLayoutVisibilityState;
import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.InflateResult;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.ViewGroupMutation;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.ViewMutationException;
import androidx.wear.protolayout.renderer.inflater.ProtoLayoutThemeImpl;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata;
import androidx.wear.protolayout.renderer.inflater.ResourceResolvers;
import androidx.wear.protolayout.renderer.inflater.StandardResourceResolvers;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
/**
* A single attached instance of a ProtoLayout. This class will ensure that a ProtoLayout is
* inflated on a background thread, the first time it is attached to the carousel. As much of the
* inflation as possible will be done in the background, with only the final attachment of the
* generated layout to a parent container done on the UI thread.
*
*/
@RestrictTo(Scope.LIBRARY_GROUP_PREFIX)
public class ProtoLayoutViewInstance implements AutoCloseable {
/**
* Listener for clicks on Clickable objects that have an Action to (re)load the contents of a
* layout.
*/
public interface LoadActionListener {
/**
* Called when a Clickable that has a LoadAction is clicked.
*
* @param nextState The state that the next layout should be in.
*/
void onClick(@NonNull StateProto.State nextState);
}
private static final int DEFAULT_MAX_CONCURRENT_RUNNING_ANIMATIONS = 4;
@NonNull private static final String TAG = "ProtoLayoutViewInstance";
@NonNull private final Context mUiContext;
@NonNull private final Resources mRendererResources;
@NonNull private final ResourceResolversProvider mResourceResolversProvider;
@NonNull private final ProtoLayoutTheme mProtoLayoutTheme;
@Nullable private final ProtoLayoutDynamicDataPipeline mDataPipeline;
@NonNull private final LoadActionListener mLoadActionListener;
@NonNull private final ListeningExecutorService mUiExecutorService;
@NonNull private final ListeningExecutorService mBgExecutorService;
@NonNull private final String mClickableIdExtra;
private final boolean mAnimationEnabled;
private final boolean mAdaptiveUpdateRatesEnabled;
private boolean mWasFullyVisibleBefore;
/** This keeps track of the current inflated parent for the layout. */
@Nullable private ViewGroup mInflateParent = null;
/**
* This is simply a reference to the current parent for this layout instance (i.e. the last
* thing passed into "attach"). This is used because it is technically possible to attach the
* layout to a parent container, detach it again, then re-attach it before the render pass is
* complete. In this case, the listener attached to renderFuture will fire multiple time and try
* and attach the layout multiple times, leading to a crash.
*
* <p>This field is used inside of renderFuture's listener to ensure that the layout is still
* attached to the same object that it was when the listener was added, and hence the layout
* should be attached.
*
* <p>This field should only ever be accessed from the UI thread.
*/
@Nullable private ViewGroup mAttachParent = null;
/**
* This field is used to avoid unnecessary rendering when dealing with non-interactive layouts.
* For interactive layouts, the diffing should already handle this.
*/
@Nullable private Layout mPrevLayout = null;
/**
* This is used as the Future for the currently running inflation session. The first time
* "attach" is called, it should start the renderer. Subsequent attach calls should only ever
* re-attach "inflateParent".
*
* <p>If this is null, then nothing has yet called "attach", and hence "render" should be called
* on a background thread. If this is non-null but not done, then the inflation is in progress.
* If this is non-null and done, then the inflation is complete, and inflateParent can be safely
* accessed from the UI thread.
*
* <p>This field should only ever be accessed from the UI thread.
*/
@VisibleForTesting @Nullable ListenableFuture<RenderResult> mRenderFuture = null;
private boolean mCanReattachWithoutRendering = false;
/**
* This is used to provide a {@link ResourceResolvers} object to the {@link
* ProtoLayoutViewInstance} allowing it to query {@link ResourceProto.Resources} when needed.
*
*/
@RestrictTo(Scope.LIBRARY_GROUP)
public interface ResourceResolversProvider {
/** Provide a {@link ResourceResolvers} instance */
@Nullable
ResourceResolvers getResourceResolvers(
@NonNull Context context,
@NonNull ResourceProto.Resources resources,
@NonNull ListeningExecutorService listeningExecutorService,
boolean animationEnabled);
}
/** Data about a parent that a layout has been inflated into. */
static final class InflateParentData {
@Nullable final InflateResult mInflateResult;
InflateParentData(@Nullable InflateResult inflateResult) {
this.mInflateResult = inflateResult;
}
}
/** Base class for result of a {@link #renderOrComputeMutations} call. */
interface RenderResult {
/** If this result can be reused when attaching to a parent. */
boolean canReattachWithoutRendering();
/**
* Run any final inflation steps that need to be run on the Ui thread.
*
* @param isReattaching if True, this layout is being reattached and will skip content
* transition animations.
*/
@UiThread
@NonNull
ListenableFuture<Void> postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
boolean isReattaching);
}
/** Result of a {@link #renderOrComputeMutations} call when no changes are required. */
static final class UnchangedRenderResult implements RenderResult {
@Override
public boolean canReattachWithoutRendering() {
return false;
}
@NonNull
@Override
public ListenableFuture<Void> postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
boolean isReattaching) {
return Futures.immediateVoidFuture();
}
}
/** Result of a {@link #renderOrComputeMutations} call when a failure has happened. */
static final class FailedRenderResult implements RenderResult {
@Override
public boolean canReattachWithoutRendering() {
return false;
}
@NonNull
@Override
public ListenableFuture<Void> postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
boolean isReattaching) {
return Futures.immediateVoidFuture();
}
}
/**
* Result of a {@link #renderOrComputeMutations} call when the layout has been inflated into a
* new parent.
*/
static final class InflatedIntoNewParentRenderResult implements RenderResult {
@NonNull final InflateParentData mNewInflateParentData;
InflatedIntoNewParentRenderResult(@NonNull InflateParentData newInflateParentData) {
this.mNewInflateParentData = newInflateParentData;
}
@Override
public boolean canReattachWithoutRendering() {
return true;
}
@NonNull
@Override
@UiThread
public ListenableFuture<Void> postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
boolean isReattaching) {
checkNotNull(
mNewInflateParentData.mInflateResult,
TAG + " - inflated result was null, but inflating into new parent requested.")
.updateDynamicDataPipeline(isReattaching);
parent.removeAllViews();
parent.addView(
checkNotNull(mNewInflateParentData.mInflateResult).inflateParent,
new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
return Futures.immediateVoidFuture();
}
}
/**
* Result of a {@link #renderOrComputeMutations} call when the diffs have been computed and
* needs to be applied to the previous parent.
*/
static final class ApplyToPrevParentRenderResult implements RenderResult {
@NonNull final ProtoLayoutInflater mInflater;
@NonNull final ViewGroupMutation mMutation;
ApplyToPrevParentRenderResult(
@NonNull ProtoLayoutInflater inflater, @NonNull ViewGroupMutation mutation) {
this.mInflater = inflater;
this.mMutation = mutation;
}
@Override
public boolean canReattachWithoutRendering() {
return false;
}
@NonNull
@Override
@UiThread
public ListenableFuture<Void> postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
boolean isReattaching) {
return mInflater.applyMutation(checkNotNull(prevInflateParent), mMutation);
}
}
/** Config class for {@link ProtoLayoutViewInstance}. */
@RestrictTo(Scope.LIBRARY_GROUP_PREFIX)
public static final class Config {
@NonNull private final Context mUiContext;
@NonNull private final Resources mRendererResources;
@NonNull private final ResourceResolversProvider mResourceResolversProvider;
@NonNull private final ProtoLayoutTheme mProtoLayoutTheme;
@Nullable private final SensorGateway mSensorGateway;
@Nullable private final StateStore mStateStore;
@NonNull private final LoadActionListener mLoadActionListener;
@NonNull private final ListeningExecutorService mUiExecutorService;
@NonNull private final ListeningExecutorService mBgExecutorService;
@NonNull private final String mClickableIdExtra;
private final boolean mAnimationEnabled;
private final int mRunningAnimationsLimit;
private final boolean mUpdatesEnabled;
private final boolean mAdaptiveUpdateRatesEnabled;
private final boolean mIsViewFullyVisible;
Config(
@NonNull Context uiContext,
@NonNull Resources rendererResources,
@NonNull ResourceResolversProvider resourceResolversProvider,
@NonNull ProtoLayoutTheme protoLayoutTheme,
@Nullable SensorGateway sensorGateway,
@Nullable StateStore stateStore,
@NonNull LoadActionListener loadActionListener,
@NonNull ListeningExecutorService uiExecutorService,
@NonNull ListeningExecutorService bgExecutorService,
@NonNull String clickableIdExtra,
boolean animationEnabled,
int runningAnimationsLimit,
boolean updatesEnabled,
boolean adaptiveUpdateRatesEnabled,
boolean isViewFullyVisible) {
this.mUiContext = uiContext;
this.mRendererResources = rendererResources;
this.mResourceResolversProvider = resourceResolversProvider;
this.mProtoLayoutTheme = protoLayoutTheme;
this.mSensorGateway = sensorGateway;
this.mStateStore = stateStore;
this.mLoadActionListener = loadActionListener;
this.mUiExecutorService = uiExecutorService;
this.mBgExecutorService = bgExecutorService;
this.mClickableIdExtra = clickableIdExtra;
this.mAnimationEnabled = animationEnabled;
this.mRunningAnimationsLimit = runningAnimationsLimit;
this.mUpdatesEnabled = updatesEnabled;
this.mAdaptiveUpdateRatesEnabled = adaptiveUpdateRatesEnabled;
this.mIsViewFullyVisible = isViewFullyVisible;
}
/** Returns UI Context used for interacting with the UI. */
@NonNull
public Context getUiContext() {
return mUiContext;
}
/**
* Returns the Android Resources object for the renderer package.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Resources getRendererResources() {
return mRendererResources;
}
/**
* Returns provider for resource resolver.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public ResourceResolversProvider getResourceResolversProvider() {
return mResourceResolversProvider;
}
/** Returns theme used for this instance. */
@NonNull
ProtoLayoutTheme getProtoLayoutTheme() {
return mProtoLayoutTheme;
}
/** Returns gateway for sensor data. */
@Nullable
public SensorGateway getSensorGateway() {
return mSensorGateway;
}
/** Returns state store. */
@Nullable
public StateStore getStateStore() {
return mStateStore;
}
/**
* Returns listener for load actions.
*
*/
@NonNull
@RestrictTo(Scope.LIBRARY)
public LoadActionListener getLoadActionListener() {
return mLoadActionListener;
}
/** Returns ExecutorService for UI tasks. */
@NonNull
public ListeningExecutorService getUiExecutorService() {
return mUiExecutorService;
}
/** Returns ExecutorService for background tasks. */
@NonNull
public ListeningExecutorService getBgExecutorService() {
return mBgExecutorService;
}
/** Returns extra used for storing clickable id. */
@NonNull
public String getClickableIdExtra() {
return mClickableIdExtra;
}
/**
* Returns whether animations are enabled.
*
*/
@RestrictTo(Scope.LIBRARY)
public boolean getAnimationEnabled() {
return mAnimationEnabled;
}
/**
* Returns how many animations can be concurrently run.
*
*/
@RestrictTo(Scope.LIBRARY)
public int getRunningAnimationsLimit() {
return mRunningAnimationsLimit;
}
/**
* Returns whether updates are enabled.
*
*/
@RestrictTo(Scope.LIBRARY)
public boolean getUpdatesEnabled() {
return mUpdatesEnabled;
}
/**
* Returns whether adaptive updates are enabled.
*
*/
@RestrictTo(Scope.LIBRARY)
public boolean getAdaptiveUpdateRatesEnabled() {
return mAdaptiveUpdateRatesEnabled;
}
/**
* Returns whether view is fully visible.
*
*/
@RestrictTo(Scope.LIBRARY)
public boolean getIsViewFullyVisible() {
return mIsViewFullyVisible;
}
/**
* Builder for {@link Config}.
*
*/
@RestrictTo(Scope.LIBRARY)
public static final class Builder {
@NonNull private final Context mUiContext;
@Nullable private Resources mRendererResources;
@Nullable private ResourceResolversProvider mResourceResolversProvider;
@Nullable private ProtoLayoutTheme mProtoLayoutTheme;
@Nullable private SensorGateway mSensorGateway;
@Nullable private StateStore mStateStore;
@Nullable private LoadActionListener mLoadActionListener;
@NonNull private final ListeningExecutorService mUiExecutorService;
@NonNull private final ListeningExecutorService mBgExecutorService;
@NonNull private final String mClickableIdExtra;
private boolean mAnimationEnabled = true;
private int mRunningAnimationsLimit = DEFAULT_MAX_CONCURRENT_RUNNING_ANIMATIONS;
private boolean mUpdatesEnabled = true;
private boolean mAdaptiveUpdateRatesEnabled = true;
private boolean mIsViewFullyVisible = true;
/**
* Builder for the {@link Config} class.
*
* @param uiContext Context suitable for interacting with UI.
* @param uiExecutorService Executor for UI related tasks.
* @param bgExecutorService Executor for background tasks.
* @param clickableIdExtra String extra for storing clickable id.
*/
public Builder(
@NonNull Context uiContext,
@NonNull ListeningExecutorService uiExecutorService,
@NonNull ListeningExecutorService bgExecutorService,
@NonNull String clickableIdExtra) {
this.mUiContext = uiContext;
this.mUiExecutorService = uiExecutorService;
this.mBgExecutorService = bgExecutorService;
this.mClickableIdExtra = clickableIdExtra;
}
/**
* Sets provider for resolving resources.
*
*/
@NonNull
@RestrictTo(Scope.LIBRARY)
public Builder setResourceResolverProvider(
@NonNull ResourceResolversProvider resourceResolversProvider) {
this.mResourceResolversProvider = resourceResolversProvider;
return this;
}
/**
* Sets the Android Resources object for the renderer package. This can usually be
* retrieved with {@link
* android.content.pm.PackageManager#getResourcesForApplication(String)}. If not
* specified, this is retrieved from the Ui Context.
*
*/
@NonNull
@RestrictTo(Scope.LIBRARY)
public Builder setRendererResources(
@NonNull Resources rendererResources) {
this.mRendererResources = rendererResources;
return this;
}
/**
* Sets the gateway for accessing sensor data. If not set, sensor data won't be
* accessible.
*/
@NonNull
public Builder setSensorGateway(@NonNull SensorGateway sensorGateway) {
this.mSensorGateway = sensorGateway;
return this;
}
/** Sets the storage for state updates. */
@NonNull
public Builder setStateStore(@NonNull StateStore stateStore) {
this.mStateStore = stateStore;
return this;
}
/**
* Sets the listener for clicks that will cause contents to be reloaded. Defaults to
* no-op.
*
*/
@NonNull
@RestrictTo(Scope.LIBRARY)
public Builder setLoadActionListener(@NonNull LoadActionListener loadActionListener) {
this.mLoadActionListener = loadActionListener;
return this;
}
/**
* Sets whether animation are enabled. If disabled, none of the animation will be
* played.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Builder setAnimationEnabled(boolean animationEnabled) {
this.mAnimationEnabled = animationEnabled;
return this;
}
/**
* Sets the limit to how much concurrently running animations are allowed.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Builder setRunningAnimationsLimit(int runningAnimationsLimit) {
this.mRunningAnimationsLimit = runningAnimationsLimit;
return this;
}
/**
* Sets whether sending updates is enabled.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Builder setUpdatesEnabled(boolean updatesEnabled) {
this.mUpdatesEnabled = updatesEnabled;
return this;
}
/**
* Sets whether adaptive updates rates is enabled.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Builder setAdaptiveUpdateRatesEnabled(boolean adaptiveUpdateRatesEnabled) {
this.mAdaptiveUpdateRatesEnabled = adaptiveUpdateRatesEnabled;
return this;
}
/**
* Sets whether the view is fully visible.
*
*/
@RestrictTo(Scope.LIBRARY)
@NonNull
public Builder setIsViewFullyVisible(boolean isViewFullyVisible) {
this.mIsViewFullyVisible = isViewFullyVisible;
return this;
}
/** Builds {@link Config} object. */
@NonNull
public Config build() {
LoadActionListener loadActionListener = mLoadActionListener;
if (loadActionListener == null) {
loadActionListener = p -> {};
}
if (mProtoLayoutTheme == null) {
mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext);
}
if (mResourceResolversProvider == null) {
mResourceResolversProvider =
(context, resources, listeningExecutorService, animationEnabled) ->
StandardResourceResolvers.forLocalApp(
resources,
mUiContext,
listeningExecutorService,
mAnimationEnabled)
.build();
}
if (mRendererResources == null) {
this.mRendererResources = mUiContext.getResources();
}
return new Config(
mUiContext,
mRendererResources,
mResourceResolversProvider,
mProtoLayoutTheme,
mSensorGateway,
mStateStore,
loadActionListener,
mUiExecutorService,
mBgExecutorService,
mClickableIdExtra,
mAnimationEnabled,
mRunningAnimationsLimit,
mUpdatesEnabled,
mAdaptiveUpdateRatesEnabled,
mIsViewFullyVisible);
}
}
}
public ProtoLayoutViewInstance(@NonNull Config config) {
this.mUiContext = config.getUiContext();
this.mRendererResources = config.getRendererResources();
this.mResourceResolversProvider = config.getResourceResolversProvider();
this.mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext);
this.mLoadActionListener = config.getLoadActionListener();
this.mUiExecutorService = config.getUiExecutorService();
this.mBgExecutorService = config.getBgExecutorService();
this.mAnimationEnabled = config.getAnimationEnabled();
this.mClickableIdExtra = config.getClickableIdExtra();
this.mAdaptiveUpdateRatesEnabled = config.getAdaptiveUpdateRatesEnabled();
this.mWasFullyVisibleBefore = false;
StateStore stateStore = config.getStateStore();
if (stateStore != null) {
boolean updatesEnabled = config.getUpdatesEnabled();
mDataPipeline =
config.getAnimationEnabled()
? new ProtoLayoutDynamicDataPipeline(
updatesEnabled,
config.getSensorGateway(),
stateStore,
new FixedQuotaManagerImpl(config.getRunningAnimationsLimit()))
: new ProtoLayoutDynamicDataPipeline(
updatesEnabled, config.getSensorGateway(), stateStore);
mDataPipeline.setFullyVisible(config.getIsViewFullyVisible());
} else {
mDataPipeline = null;
}
}
@WorkerThread
@NonNull
private RenderResult renderOrComputeMutations(
@NonNull Layout layout,
@NonNull ResourceProto.Resources resources,
@Nullable RenderedMetadata prevRenderedMetadata) {
ResourceResolvers resolvers =
mResourceResolversProvider.getResourceResolvers(
mUiContext, resources, mUiExecutorService, mAnimationEnabled);
if (resolvers == null) {
Log.w(TAG, "Resource resolvers cannot be retrieved.");
return new FailedRenderResult();
}
ProtoLayoutInflater.Config.Builder inflaterConfigBuilder =
new ProtoLayoutInflater.Config.Builder(mUiContext, layout, resolvers)
.setLoadActionExecutor(mUiExecutorService)
.setLoadActionListener(mLoadActionListener::onClick)
.setRendererResources(mRendererResources)
.setProtoLayoutTheme(mProtoLayoutTheme)
.setAnimationEnabled(mAnimationEnabled)
.setClickableIdExtra(mClickableIdExtra)
.setAllowLayoutChangingBindsWithoutDefault(true);
if (mDataPipeline != null) {
inflaterConfigBuilder.setDynamicDataPipeline(mDataPipeline);
}
ProtoLayoutInflater inflater = new ProtoLayoutInflater(inflaterConfigBuilder.build());
// mark the view and skip doing diff update (to avoid doubling the work each time).
@Nullable ViewGroupMutation mutation = null;
if (mAdaptiveUpdateRatesEnabled && prevRenderedMetadata != null) {
// Compute the mutation here, but if there is a change, apply it in the UI thread.
try {
mutation = inflater.computeMutation(prevRenderedMetadata, layout);
} catch (UnsupportedOperationException ex) {
Log.w(TAG, "Error computing mutation.", ex);
}
}
if (mutation == null) {
// Couldn't compute mutation. Inflate from scratch.
InflateParentData inflateParentData = inflateIntoNewParent(mUiContext, inflater);
if (inflateParentData.mInflateResult == null) {
return new FailedRenderResult();
}
return new InflatedIntoNewParentRenderResult(inflateParentData);
} else if (mutation.isNoOp()) {
// New layout is the same. Nothing to do.
return new UnchangedRenderResult();
} else {
// We have a diff. Ask for it to be applied to the previously inflated parent, but in
// the UI thread.
checkNotNull(prevRenderedMetadata);
return new ApplyToPrevParentRenderResult(inflater, mutation);
}
}
// dereference of possibly-null reference childLp incompatible argument for parameter arg0 of
// setLayoutParams.
@SuppressWarnings({"nullness:dereference.of.nullable", "nullness:argument"})
@WorkerThread
@NonNull
private InflateParentData inflateIntoNewParent(
@NonNull Context uiContext, @NonNull ProtoLayoutInflater inflater) {
FrameLayout inflateParent;
int gravity;
inflateParent = new FrameLayout(uiContext);
gravity = Gravity.CENTER;
// Inflate the current timeline entry (passed above) into "inflateParent". This should, at
// most, add a single element into that container.
InflateResult result = inflater.inflate(inflateParent);
// The inflater will only ever add one child to the container. Set correct gravity on it to
// ensure that the inflated layout is centered within the inflation parent above.
if (inflateParent.getChildCount() > 0) {
View firstChild = inflateParent.getChildAt(0);
FrameLayout.LayoutParams childLp =
(FrameLayout.LayoutParams) firstChild.getLayoutParams();
childLp.gravity = gravity;
firstChild.setLayoutParams(childLp);
}
return new InflateParentData(result);
}
/**
* Render the layout for this layout and attach this layout instance to a parent container. Note
* that this method may clear all of {@code parent}'s children before attaching the layout, but
* only if it's not possible to update them in place.
*
* <p>If the layout has not yet been inflated, it will not be attached to the parent container
* immediately (nor will it remove all of {@code parent}'s children); it will instead inflate
* the layout in the background, then attach it at some point in the future once it has been
* inflated.
*
* <p>Note that it is safe to call {@link ProtoLayoutViewInstance#detach}, and subsequently,
* attach again while the layout is inflating; it will only attach to the last requested parent
* (or if detach was the last call, it will not be attached to anything).
*
* <p>Note also that this method must be called from the UI thread;
*/
@UiThread
@SuppressWarnings(
"ReferenceEquality") // layout == prevLayout is intentional (and enough in this case)
public void renderAndAttach(
@NonNull Layout layout,
@NonNull ResourceProto.Resources resources,
@NonNull ViewGroup parent) {
if (mAttachParent == parent && layout == mPrevLayout) {
return;
}
if (mAttachParent != null && mAttachParent != parent) {
throw new IllegalStateException("ProtoLayoutViewInstance is already attached!");
}
boolean isReattaching = false;
if (mRenderFuture != null) {
if (!mRenderFuture.isDone()) {
// There is an ongoing rendering operation. We'll skip this request as a missed
// frame.
Log.w(TAG, "Skipped layout update: previous layout update hasn't finished yet.");
return;
} else if (layout == mPrevLayout && mCanReattachWithoutRendering) {
isReattaching = true;
} else {
mRenderFuture = null;
}
}
@Nullable ViewGroup prevInflateParent = getOnlyChildViewGroup(parent);
@Nullable
RenderedMetadata prevRenderedMetadata =
prevInflateParent != null
? ProtoLayoutInflater.getRenderedMetadata(prevInflateParent)
: null;
mAttachParent = parent;
if (mRenderFuture == null) {
mPrevLayout = layout;
mRenderFuture =
mBgExecutorService.submit(
() ->
renderOrComputeMutations(
layout, resources, prevRenderedMetadata));
mCanReattachWithoutRendering = false;
}
if (!mRenderFuture.isDone()) {
mRenderFuture.addListener(
() -> {
// Ensure that this inflater is attached to the same parent as when this
// listener was created. If not, something has re-attached us in the
// time it took for the inflater to execute.
if (mAttachParent == parent) {
try {
postInflate(
parent,
prevInflateParent,
checkNotNull(mRenderFuture).get(),
/* isReattaching= */ false,
layout,
resources);
} catch (ExecutionException | InterruptedException e) {
Log.e(TAG, "Failed to render layout", e);
}
}
},
mUiExecutorService);
} else {
try {
postInflate(
parent,
prevInflateParent,
mRenderFuture.get(),
isReattaching,
layout,
resources);
} catch (ExecutionException | InterruptedException | CancellationException e) {
Log.e(TAG, "Failed to render layout", e);
}
}
}
@Nullable
private static ViewGroup getOnlyChildViewGroup(@NonNull ViewGroup parent) {
if (parent.getChildCount() == 1) {
View child = parent.getChildAt(0);
if (child instanceof ViewGroup) {
return (ViewGroup) child;
}
}
return null;
}
@UiThread
private void postInflate(
@NonNull ViewGroup parent,
@Nullable ViewGroup prevInflateParent,
@NonNull RenderResult renderResult,
boolean isReattaching,
@NonNull Layout layout,
@NonNull ResourceProto.Resources resources) {
mCanReattachWithoutRendering = renderResult.canReattachWithoutRendering();
if (renderResult instanceof InflatedIntoNewParentRenderResult) {
InflateParentData newInflateParentData =
((InflatedIntoNewParentRenderResult) renderResult).mNewInflateParentData;
mInflateParent = checkNotNull(
newInflateParentData.mInflateResult,
TAG + " - inflated result was null, but inflating was requested.")
.inflateParent;
}
ListenableFuture<Void> postInflateFuture =
renderResult.postInflate(parent, prevInflateParent, isReattaching);
if (!postInflateFuture.isDone()) {
postInflateFuture.addListener(
() -> {
try {
postInflateFuture.get();
} catch (ExecutionException | InterruptedException e) {
handlePostInflateFailure(
e, layout, resources, prevInflateParent, parent);
}
},
mUiExecutorService);
} else {
try {
postInflateFuture.get();
} catch (ExecutionException
| InterruptedException
| CancellationException
| ViewMutationException e) {
handlePostInflateFailure(e, layout, resources, prevInflateParent, parent);
}
}
}
@UiThread
@SuppressWarnings("ReferenceEquality") // layout == prevLayout is intentional
private void handlePostInflateFailure(
@NonNull Throwable error,
@NonNull Layout layout,
@NonNull ResourceProto.Resources resources,
@Nullable ViewGroup prevInflateParent,
@NonNull ViewGroup parent) {
// If a RuntimeError is thrown, it'll be wrapped in an UncheckedExecutionException
Throwable e = error.getCause();
if (e instanceof ViewMutationException) {
Log.w(TAG, "applyMutation failed." + e.getMessage());
if (mPrevLayout == layout && parent == mAttachParent) {
Log.w(TAG, "Retrying full inflation.");
// Clear rendering metadata and prevLayout to force a full reinflation.
ProtoLayoutInflater.clearRenderedMetadata(checkNotNull(prevInflateParent));
mPrevLayout = null;
renderAndAttach(layout, resources, parent);
}
} else {
Log.e(TAG, "postInflate failed.", error);
}
}
/**
* Detach this layout from a parent container. Note that it is safe to call this method while
* the layout is inflating; see the notes on {@link ProtoLayoutViewInstance#renderAndAttach} for
* more information.
*/
@UiThread
public void detach(@NonNull ViewGroup parent) {
if (mAttachParent != null && mAttachParent != parent) {
throw new IllegalStateException("Layout is not attached to parent " + parent);
}
detachInternal();
if (mInflateParent != null) {
parent.removeView(mInflateParent);
}
}
@UiThread
private void detachInternal() {
if (mRenderFuture != null && !mRenderFuture.isDone()) {
mRenderFuture.cancel(/* mayInterruptIfRunning= */ false);
}
setLayoutVisibility(ProtoLayoutVisibilityState.VISIBILITY_STATE_INVISIBLE);
mAttachParent = null;
}
/**
* Sets whether updates are enabled for this layout. When disabled, updates through the data
* pipeline (e.g. health updates) will be suppressed.
*
*/
@RestrictTo(Scope.LIBRARY)
@UiThread
@SuppressWarnings("RestrictTo")
public void setUpdatesEnabled(boolean updatesEnabled) {
if (mDataPipeline != null) {
mDataPipeline.setUpdatesEnabled(updatesEnabled);
}
}
/** Sets the visibility state for this layout.
*
*/
@RestrictTo(Scope.LIBRARY)
@UiThread
public void setLayoutVisibility(@ProtoLayoutVisibilityState int visibility) {
if (mAnimationEnabled && mDataPipeline != null) {
// Need to check here the previous layout visibility was not FULLY_VISIBLE, so that when
// the user swipes a little away from the layout, which will emit PARTIALLY_VISIBLE, but
// then go back to the current layout without entering another one, we do not want to
// restart the animation when FULLY_VISIBILITY is emitted in this situation.
if (visibility == ProtoLayoutVisibilityState.VISIBILITY_STATE_FULLY_VISIBLE
&& !mWasFullyVisibleBefore) {
mDataPipeline.setFullyVisible(true);
mWasFullyVisibleBefore = true;
} else if (visibility == ProtoLayoutVisibilityState.VISIBILITY_STATE_INVISIBLE) {
mDataPipeline.setFullyVisible(false);
mWasFullyVisibleBefore = false;
}
}
}
@Override
public void close() throws Exception {
detachInternal();
if (mDataPipeline != null) {
mDataPipeline.close();
}
}
}