| /* |
| * 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.inflater; |
| |
| import static android.util.TypedValue.COMPLEX_UNIT_SP; |
| |
| import static androidx.core.util.Preconditions.checkNotNull; |
| import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX; |
| import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID; |
| import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.getParentNodePosId; |
| |
| import static com.google.common.util.concurrent.Futures.immediateFailedFuture; |
| import static com.google.common.util.concurrent.Futures.immediateVoidFuture; |
| |
| import static java.lang.Math.max; |
| import static java.lang.Math.round; |
| |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.pm.ActivityInfo; |
| import android.content.res.ColorStateList; |
| import android.content.res.Resources; |
| import android.content.res.Resources.Theme; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Paint; |
| import android.graphics.Paint.Cap; |
| import android.graphics.PorterDuff.Mode; |
| import android.graphics.Rect; |
| import android.graphics.Typeface; |
| import android.graphics.drawable.AnimatedVectorDrawable; |
| import android.graphics.drawable.BitmapDrawable; |
| import android.graphics.drawable.ColorDrawable; |
| import android.graphics.drawable.Drawable; |
| import android.graphics.drawable.GradientDrawable; |
| import android.text.SpannableStringBuilder; |
| import android.text.Spanned; |
| import android.text.TextPaint; |
| import android.text.TextUtils.TruncateAt; |
| import android.text.method.LinkMovementMethod; |
| import android.text.style.AbsoluteSizeSpan; |
| import android.text.style.ClickableSpan; |
| import android.text.style.ForegroundColorSpan; |
| import android.text.style.ImageSpan; |
| import android.text.style.MetricAffectingSpan; |
| import android.text.style.StyleSpan; |
| import android.text.style.UnderlineSpan; |
| import android.util.Log; |
| import android.util.TypedValue; |
| import android.view.ContextThemeWrapper; |
| import android.view.Gravity; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewGroup.LayoutParams; |
| import android.view.ViewOutlineProvider; |
| import android.view.ViewParent; |
| import android.view.animation.AlphaAnimation; |
| import android.view.animation.AnimationSet; |
| import android.view.animation.TranslateAnimation; |
| import android.widget.FrameLayout; |
| import android.widget.ImageView; |
| import android.widget.ImageView.ScaleType; |
| import android.widget.LinearLayout; |
| import android.widget.Scroller; |
| import android.widget.Space; |
| import android.widget.TextView; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.UiThread; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.core.content.ContextCompat; |
| import androidx.core.view.AccessibilityDelegateCompat; |
| import androidx.core.view.ViewCompat; |
| import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; |
| import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable; |
| import androidx.wear.protolayout.expression.pipeline.AnimationsHelper; |
| import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec; |
| import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat; |
| import androidx.wear.protolayout.proto.ActionProto.Action; |
| import androidx.wear.protolayout.proto.ActionProto.AndroidActivity; |
| import androidx.wear.protolayout.proto.ActionProto.AndroidExtra; |
| import androidx.wear.protolayout.proto.ActionProto.LaunchAction; |
| import androidx.wear.protolayout.proto.ActionProto.LoadAction; |
| import androidx.wear.protolayout.proto.AlignmentProto.AngularAlignment; |
| import androidx.wear.protolayout.proto.AlignmentProto.ArcAnchorType; |
| import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignment; |
| import androidx.wear.protolayout.proto.AlignmentProto.TextAlignment; |
| import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignment; |
| import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignmentProp; |
| import androidx.wear.protolayout.proto.ColorProto.ColorProp; |
| import androidx.wear.protolayout.proto.DimensionProto.ArcLineLength; |
| import androidx.wear.protolayout.proto.DimensionProto.ArcSpacerLength; |
| import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension; |
| import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension.InnerCase; |
| import androidx.wear.protolayout.proto.DimensionProto.DegreesProp; |
| import androidx.wear.protolayout.proto.DimensionProto.DpProp; |
| import androidx.wear.protolayout.proto.DimensionProto.ExpandedAngularDimensionProp; |
| import androidx.wear.protolayout.proto.DimensionProto.ExpandedDimensionProp; |
| import androidx.wear.protolayout.proto.DimensionProto.ImageDimension; |
| import androidx.wear.protolayout.proto.DimensionProto.ProportionalDimensionProp; |
| import androidx.wear.protolayout.proto.DimensionProto.SpProp; |
| import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension; |
| import androidx.wear.protolayout.proto.DimensionProto.WrappedDimensionProp; |
| import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Arc; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ArcLine; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ArcSpacer; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ArcText; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Box; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Column; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ContentScaleMode; |
| import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement; |
| import androidx.wear.protolayout.proto.LayoutElementProto.FontStyle; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Image; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Layout; |
| import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement; |
| import androidx.wear.protolayout.proto.LayoutElementProto.MarqueeParameters; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Row; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Spacer; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Span; |
| import androidx.wear.protolayout.proto.LayoutElementProto.SpanImage; |
| import androidx.wear.protolayout.proto.LayoutElementProto.SpanText; |
| import androidx.wear.protolayout.proto.LayoutElementProto.SpanVerticalAlignmentProp; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Spannable; |
| import androidx.wear.protolayout.proto.LayoutElementProto.Text; |
| import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflow; |
| import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflowProp; |
| import androidx.wear.protolayout.proto.ModifiersProto.ArcModifiers; |
| import androidx.wear.protolayout.proto.ModifiersProto.Background; |
| import androidx.wear.protolayout.proto.ModifiersProto.Border; |
| import androidx.wear.protolayout.proto.ModifiersProto.Clickable; |
| import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.FadeInTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.FadeOutTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.Modifiers; |
| import androidx.wear.protolayout.proto.ModifiersProto.Padding; |
| import androidx.wear.protolayout.proto.ModifiersProto.Semantics; |
| import androidx.wear.protolayout.proto.ModifiersProto.SemanticsRole; |
| import androidx.wear.protolayout.proto.ModifiersProto.SlideDirection; |
| import androidx.wear.protolayout.proto.ModifiersProto.SlideInTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.SlideOutTransition; |
| import androidx.wear.protolayout.proto.ModifiersProto.SlideParentSnapOption; |
| import androidx.wear.protolayout.proto.ModifiersProto.SpanModifiers; |
| import androidx.wear.protolayout.proto.StateProto.State; |
| import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger; |
| import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger; |
| import androidx.wear.protolayout.proto.TriggerProto.Trigger; |
| import androidx.wear.protolayout.proto.TypesProto.StringProp; |
| import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider; |
| import androidx.wear.protolayout.renderer.ProtoLayoutTheme; |
| import androidx.wear.protolayout.renderer.ProtoLayoutTheme.FontSet; |
| import androidx.wear.protolayout.renderer.R; |
| import androidx.wear.protolayout.renderer.common.LoggingUtils; |
| import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer; |
| import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.LayoutDiff; |
| import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.TreeNodeWithChange; |
| import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline; |
| import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LayoutInfo; |
| import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LinearLayoutProperties; |
| import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingFrameLayoutParams; |
| import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingLayoutParams; |
| import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.ViewProperties; |
| import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.ResourceAccessException; |
| import androidx.wear.widget.ArcLayout; |
| import androidx.wear.widget.CurvedTextView; |
| |
| import com.google.common.util.concurrent.ListenableFuture; |
| import com.google.common.util.concurrent.SettableFuture; |
| |
| import java.lang.ref.WeakReference; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.concurrent.CancellationException; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.Future; |
| import java.util.function.Consumer; |
| |
| /** |
| * Renderer for ProtoLayout. |
| * |
| * <p>This variant uses Android views to represent the contents of the ProtoLayout. |
| */ |
| public final class ProtoLayoutInflater { |
| |
| private static final String TAG = "ProtoLayoutInflater"; |
| private static final char ZERO_WIDTH_JOINER = '\u200D'; |
| |
| // This will help debug potential layout issues that might be leading to full layout updates or |
| // poor performance in general. |
| private static final boolean DEBUG_DIFF_UPDATE_ENABLED = false; |
| |
| /** Target alpha for fade in animation. */ |
| private static final float FADE_IN_TARGET_ALPHA = 1; |
| |
| /** Initial alpha for fade out animation. */ |
| private static final float FADE_OUT_INITIAL_ALPHA = 1; |
| |
| /** The default trigger for animations set to onLoad. */ |
| private static final Trigger DEFAULT_ANIMATION_TRIGGER = |
| Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build(); |
| |
| /** |
| * Default maximum raw byte size for a bitmap drawable. |
| * |
| * @see <a |
| * href="https://cs.android.com/android/_/android/platform/frameworks/base/+/d01036ee5893357db577c961119fb85825247f03:graphics/java/android/graphics/RecordingCanvas.java;l=44;bpv=1;bpt=0;drc=00af5271dabd578397176eda0cd7a66c55fac59a"> |
| * The framework enforced max size</a> |
| */ |
| private static final int DEFAULT_MAX_BITMAP_RAW_SIZE = 20 * 1024 * 1024; |
| |
| private static final int HORIZONTAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_HORIZONTAL; |
| private static final int VERTICAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_VERTICAL; |
| private static final int TEXT_ALIGN_DEFAULT = Gravity.CENTER_HORIZONTAL; |
| private static final ScaleType IMAGE_DEFAULT_SCALE_TYPE = ScaleType.FIT_CENTER; |
| |
| @ArcLayout.LayoutParams.VerticalAlignment |
| private static final int ARC_VERTICAL_ALIGN_DEFAULT = |
| ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER; |
| |
| @SizedArcContainer.LayoutParams.AngularAlignment |
| private static final int ANGULAR_ALIGNMENT_DEFAULT = |
| SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER; |
| |
| private static final int SPAN_VERTICAL_ALIGN_DEFAULT = ImageSpan.ALIGN_BOTTOM; |
| |
| // This is pretty badly named; TruncateAt specifies where to place the ellipsis (or whether to |
| // marquee). Disabling truncation with null actually disables the _ellipsis_, but text will |
| // still be truncated. |
| @Nullable private static final TruncateAt TEXT_OVERFLOW_DEFAULT = null; |
| |
| private static final int TEXT_COLOR_DEFAULT = 0xFFFFFFFF; |
| private static final int TEXT_MAX_LINES_DEFAULT = 1; |
| @VisibleForTesting |
| static final int TEXT_AUTOSIZES_LIMIT = 10; |
| private static final int TEXT_MIN_LINES = 1; |
| |
| private static final ContainerDimension CONTAINER_DIMENSION_DEFAULT = |
| ContainerDimension.newBuilder() |
| .setWrappedDimension(WrappedDimensionProp.getDefaultInstance()) |
| .build(); |
| |
| @ArcLayout.AnchorType private static final int ARC_ANCHOR_DEFAULT = ArcLayout.ANCHOR_CENTER; |
| |
| // White |
| private static final int LINE_COLOR_DEFAULT = 0xFFFFFFFF; |
| |
| static final PendingLayoutParams NO_OP_PENDING_LAYOUT_PARAMS = layoutParams -> layoutParams; |
| |
| final Context mUiContext; |
| |
| // Context wrapped with the provided ProtoLayoutTheme. This context should be used for creating |
| // any Text Views, as it will apply text appearance attributes. |
| private final Context mProtoLayoutThemeContext; |
| |
| private final ProtoLayoutTheme mProtoLayoutTheme; |
| private final Layout mLayoutProto; |
| private final ResourceResolvers mLayoutResourceResolvers; |
| |
| private final Optional<ProtoLayoutDynamicDataPipeline> mDataPipeline; |
| |
| @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider; |
| |
| private final boolean mAllowLayoutChangingBindsWithoutDefault; |
| final String mClickableIdExtra; |
| |
| @Nullable private final LoggingUtils mLoggingUtils; |
| |
| @Nullable final Executor mLoadActionExecutor; |
| final LoadActionListener mLoadActionListener; |
| final boolean mAnimationEnabled; |
| |
| private boolean mApplyFontVariantBodyAsDefault = false; |
| |
| /** |
| * 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 State nextState); |
| } |
| |
| /** |
| * A one-off class to be returned from {@link ProtoLayoutInflater#inflate} containing top level |
| * parent, list of content transition animations to be run and a PipelineMaker with pending |
| * changes to the dynamic data pipeline. |
| */ |
| public static final class InflateResult { |
| public final ViewGroup inflateParent; |
| public final View firstChild; |
| private final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker; |
| |
| InflateResult( |
| ViewGroup inflateParent, |
| View firstChild, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| this.inflateParent = inflateParent; |
| this.firstChild = firstChild; |
| this.mPipelineMaker = pipelineMaker; |
| } |
| |
| /** |
| * Update the DynamicDataPipeline with new nodes that were stored during the layout update. |
| * |
| * @param isReattaching if True, this layout is being reattached and will skip content |
| * transition animations. |
| */ |
| @UiThread |
| public void updateDynamicDataPipeline(boolean isReattaching) { |
| mPipelineMaker.ifPresent( |
| pipe -> pipe.clearDataPipelineAndCommit(inflateParent, isReattaching)); |
| } |
| } |
| |
| /** A mutation that can be applied to a {@link ViewGroup}, using {@link #applyMutation}. */ |
| public static final class ViewGroupMutation { |
| final List<InflatedView> mInflatedViews; |
| final RenderedMetadata mRenderedMetadataAfterMutation; |
| final NodeFingerprint mPreMutationRootNodeFingerprint; |
| final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker; |
| |
| ViewGroupMutation( |
| List<InflatedView> inflatedViews, |
| RenderedMetadata renderedMetadataAfterMutation, |
| NodeFingerprint preMutationRootNodeFingerprint, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| this.mInflatedViews = inflatedViews; |
| this.mRenderedMetadataAfterMutation = renderedMetadataAfterMutation; |
| this.mPreMutationRootNodeFingerprint = preMutationRootNodeFingerprint; |
| this.mPipelineMaker = pipelineMaker; |
| } |
| |
| /** Returns true if this mutation has no effect. */ |
| public boolean isNoOp() { |
| return this.mInflatedViews.isEmpty(); |
| } |
| } |
| |
| private static final class InflatedView { |
| final View mView; |
| final LayoutParams mLayoutParams; |
| final PendingLayoutParams mChildLayoutParams; |
| private int mNumMissingChildren; |
| |
| /** |
| * @param view The {@link View} that has been inflated. |
| * @param layoutParams The {@link LayoutParams} that must be used when attaching the |
| * inflated view to a parent. |
| * @param childLayoutParams The {@link LayoutParams} that must be applied to children |
| * carried over from a previous layout. |
| * @param numMissingChildren Non-zero if {@code view} is a {@link ViewGroup} whose children |
| * have not been added. This means that before using this view in a layout, its children |
| * must be copied from the {@link ViewGroup} that represents the previous version of |
| * this layout element. |
| */ |
| InflatedView( |
| View view, |
| LayoutParams layoutParams, |
| PendingLayoutParams childLayoutParams, |
| int numMissingChildren) { |
| this.mView = view; |
| this.mLayoutParams = layoutParams; |
| this.mChildLayoutParams = childLayoutParams; |
| this.mNumMissingChildren = numMissingChildren; |
| } |
| |
| InflatedView(View view, LayoutParams layoutParams) { |
| this(view, layoutParams, NO_OP_PENDING_LAYOUT_PARAMS, /* numMissingChildren= */ 0); |
| } |
| |
| boolean addMissingChildrenFrom(View source) { |
| if (mNumMissingChildren == 0) { |
| // Nothing to do. |
| return true; |
| } |
| Object tagObj = source.getTag(); |
| String tag = (tagObj == null ? "unknown" : (String) tagObj); |
| if (!(mView instanceof ViewGroup)) { |
| Log.w(TAG, "Destination is not a group: " + tag); |
| return false; |
| } |
| ViewGroup destinationGroup = (ViewGroup) mView; |
| if (destinationGroup.getChildCount() > 0) { |
| Log.w(TAG, "Destination already has children: " + tag); |
| return false; |
| } |
| if (!(source instanceof ViewGroup)) { |
| Log.w(TAG, "Source is not a group: " + tag); |
| return false; |
| } |
| ViewGroup sourceGroup = (ViewGroup) source; |
| if (sourceGroup.getChildCount() != mNumMissingChildren) { |
| Log.w( |
| TAG, |
| String.format( |
| "Expected %d children in %s found %d", |
| mNumMissingChildren, tag, sourceGroup.getChildCount())); |
| return false; |
| } |
| List<View> children = new ArrayList<>(sourceGroup.getChildCount()); |
| for (int i = 0; i < mNumMissingChildren; i++) { |
| children.add(sourceGroup.getChildAt(i)); |
| } |
| sourceGroup.removeAllViews(); |
| |
| for (View child : children) { |
| destinationGroup.addView(child); |
| child.setLayoutParams( |
| mChildLayoutParams.apply(checkNotNull(child.getLayoutParams()))); |
| } |
| mNumMissingChildren = 0; |
| return true; |
| } |
| |
| @Nullable |
| String getTag() { |
| return (String) mView.getTag(); |
| } |
| } |
| |
| /** |
| * A one-of class to pass either a real {@link ViewGroup} or only its needed properties through |
| * the renderer. |
| */ |
| private static final class ParentViewWrapper { |
| @Nullable private final ViewGroup mParent; |
| private final ViewProperties mParentProps; |
| |
| ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams) { |
| this.mParent = parent; |
| this.mParentProps = |
| ViewProperties.fromViewGroup( |
| parent, parentLayoutParams, NO_OP_PENDING_LAYOUT_PARAMS); |
| } |
| |
| ParentViewWrapper( |
| ViewGroup parent, |
| LayoutParams parentLayoutParams, |
| PendingLayoutParams childLayoutParams) { |
| this.mParent = parent; |
| this.mParentProps = |
| ViewProperties.fromViewGroup(parent, parentLayoutParams, childLayoutParams); |
| } |
| |
| ParentViewWrapper(ViewProperties parentProps) { |
| this.mParent = null; |
| this.mParentProps = parentProps; |
| } |
| |
| ViewProperties getParentProperties() { |
| return mParentProps; |
| } |
| |
| /** If this class holds a {@link ViewGroup}, add {@code child} to it. */ |
| void maybeAddView(View child, LayoutParams layoutParams) { |
| if (mParent != null) { |
| mParent.addView(child, layoutParams); |
| } |
| } |
| } |
| |
| /** Exception that will be thrown when applying a mutation to a {@link View} fails. */ |
| public static class ViewMutationException extends RuntimeException { |
| public ViewMutationException(@NonNull String message) { |
| super(message); |
| } |
| } |
| |
| /** Config class for ProtoLayoutInflater */ |
| public static final class Config { |
| @NonNull private final Context mUiContext; |
| @NonNull private final Layout mLayout; |
| @NonNull private final ResourceResolvers mLayoutResourceResolvers; |
| @Nullable private final Executor mLoadActionExecutor; |
| @NonNull private final LoadActionListener mLoadActionListener; |
| @NonNull private final Resources mRendererResources; |
| @NonNull private final ProtoLayoutTheme mProtoLayoutTheme; |
| @Nullable private final ProtoLayoutDynamicDataPipeline mDataPipeline; |
| @NonNull private final String mClickableIdExtra; |
| |
| @Nullable private final LoggingUtils mLoggingUtils; |
| @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider; |
| private final boolean mAnimationEnabled; |
| private final boolean mAllowLayoutChangingBindsWithoutDefault; |
| |
| private final boolean mApplyFontVarianBodyAsDefault; |
| |
| Config( |
| @NonNull Context uiContext, |
| @NonNull Layout layout, |
| @NonNull ResourceResolvers layoutResourceResolvers, |
| @Nullable Executor loadActionExecutor, |
| @NonNull LoadActionListener loadActionListener, |
| @NonNull Resources rendererResources, |
| @NonNull ProtoLayoutTheme protoLayoutTheme, |
| @Nullable ProtoLayoutDynamicDataPipeline dataPipeline, |
| @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider, |
| @NonNull String clickableIdExtra, |
| @Nullable LoggingUtils loggingUtils, |
| boolean animationEnabled, |
| boolean allowLayoutChangingBindsWithoutDefault, |
| boolean applyFontVarianBodyAsDefault) { |
| this.mUiContext = uiContext; |
| this.mLayout = layout; |
| this.mLayoutResourceResolvers = layoutResourceResolvers; |
| this.mLoadActionExecutor = loadActionExecutor; |
| this.mLoadActionListener = loadActionListener; |
| this.mRendererResources = rendererResources; |
| this.mProtoLayoutTheme = protoLayoutTheme; |
| this.mDataPipeline = dataPipeline; |
| this.mAnimationEnabled = animationEnabled; |
| this.mAllowLayoutChangingBindsWithoutDefault = allowLayoutChangingBindsWithoutDefault; |
| this.mClickableIdExtra = clickableIdExtra; |
| this.mLoggingUtils = loggingUtils; |
| this.mExtensionViewProvider = extensionViewProvider; |
| this.mApplyFontVarianBodyAsDefault = applyFontVarianBodyAsDefault; |
| } |
| |
| /** A {@link Context} suitable for interacting with UI. */ |
| @NonNull |
| public Context getUiContext() { |
| return mUiContext; |
| } |
| |
| /** The layout to be rendered. */ |
| @NonNull |
| public Layout getLayout() { |
| return mLayout; |
| } |
| |
| /** Resolvers for the resources used for rendering this layout. */ |
| @NonNull |
| public ResourceResolvers getLayoutResourceResolvers() { |
| return mLayoutResourceResolvers; |
| } |
| |
| /** Executor to dispatch loadActionListener on. */ |
| @Nullable |
| public Executor getLoadActionExecutor() { |
| return mLoadActionExecutor; |
| } |
| |
| /** Listener for clicks that will cause contents to be reloaded. */ |
| @NonNull |
| public LoadActionListener getLoadActionListener() { |
| return mLoadActionListener; |
| } |
| |
| /** |
| * Renderer internal resources. This Resources object can be used to resolve Renderer's |
| * resources. |
| */ |
| @NonNull |
| public Resources getRendererResources() { |
| return mRendererResources; |
| } |
| |
| /** |
| * Theme to use for this ProtoLayoutInflater instance. This can be used to customise things |
| * like the default font family. |
| */ |
| @NonNull |
| public ProtoLayoutTheme getProtoLayoutTheme() { |
| return mProtoLayoutTheme; |
| } |
| |
| /** |
| * Pipeline for dynamic data. If null, the dynamic properties would not be registered for |
| * update. |
| */ |
| @Nullable |
| public ProtoLayoutDynamicDataPipeline getDynamicDataPipeline() { |
| return mDataPipeline; |
| } |
| |
| /** ID for the Intent extra containing the ID of a Clickable. */ |
| @NonNull |
| public String getClickableIdExtra() { |
| return mClickableIdExtra; |
| } |
| |
| /** Debug logger used to log debug messages. */ |
| @Nullable |
| public LoggingUtils getLoggingUtils() { |
| return mLoggingUtils; |
| } |
| |
| /** View provider for the renderer extension. */ |
| @Nullable |
| public ProtoLayoutExtensionViewProvider getExtensionViewProvider() { |
| return mExtensionViewProvider; |
| } |
| |
| /** Whether animation is enabled, which decides whether to load contentUpdateAnimations. */ |
| public boolean getAnimationEnabled() { |
| return mAnimationEnabled; |
| } |
| |
| /** |
| * Whether a "layout changing" data bind can be applied without the "value_for_layout" field |
| * being filled in. This is to support legacy apps which use layout-changing data binds |
| * before the full support was built. |
| */ |
| public boolean getAllowLayoutChangingBindsWithoutDefault() { |
| return mAllowLayoutChangingBindsWithoutDefault; |
| } |
| |
| /** Whether to apply FONT_VARIANT_BODY as default variant. */ |
| public boolean getApplyFontVariantBodyAsDefault() { |
| return mApplyFontVarianBodyAsDefault; |
| } |
| |
| /** Builder for the Config class. */ |
| public static final class Builder { |
| @NonNull private final Context mUiContext; |
| @NonNull private final Layout mLayout; |
| @NonNull private final ResourceResolvers mLayoutResourceResolvers; |
| @Nullable private Executor mLoadActionExecutor; |
| @Nullable private LoadActionListener mLoadActionListener; |
| @NonNull private Resources mRendererResources; |
| @Nullable private ProtoLayoutTheme mProtoLayoutTheme; |
| @Nullable private ProtoLayoutDynamicDataPipeline mDataPipeline = null; |
| private boolean mAnimationEnabled = true; |
| private boolean mAllowLayoutChangingBindsWithoutDefault = false; |
| @Nullable private String mClickableIdExtra; |
| |
| @Nullable private LoggingUtils mLoggingUtils; |
| |
| @Nullable private ProtoLayoutExtensionViewProvider mExtensionViewProvider = null; |
| |
| private boolean mApplyFontVariantBodyAsDefault = false; |
| |
| /** |
| * @param uiContext A {@link Context} suitable for interacting with UI with. |
| * @param layout The layout to be rendered. |
| * @param layoutResourceResolvers Resolvers for the resources used for rendering this |
| * layout. |
| */ |
| public Builder( |
| @NonNull Context uiContext, |
| @NonNull Layout layout, |
| @NonNull ResourceResolvers layoutResourceResolvers) { |
| this.mUiContext = uiContext; |
| this.mRendererResources = uiContext.getResources(); |
| this.mLayout = layout; |
| this.mLayoutResourceResolvers = layoutResourceResolvers; |
| } |
| |
| /** |
| * Sets the Executor to dispatch loadActionListener on. This is required when setting |
| * {@link Builder#setLoadActionListener}. |
| */ |
| @NonNull |
| public Builder setLoadActionExecutor(@NonNull Executor loadActionExecutor) { |
| this.mLoadActionExecutor = loadActionExecutor; |
| return this; |
| } |
| |
| /** |
| * Sets the listener for clicks that will cause contents to be reloaded. Defaults to |
| * no-op. This is required if the given layout contains a load action. When this is set, |
| * it's also required to set an executor with {@link Builder#setLoadActionExecutor}. |
| */ |
| @NonNull |
| public Builder setLoadActionListener(@NonNull LoadActionListener loadActionListener) { |
| this.mLoadActionListener = loadActionListener; |
| return this; |
| } |
| |
| /** |
| * Sets the Renderer internal Resources object. This should be specified when loading |
| * the renderer from a separate APK. This can usually be retrieved with {@link |
| * android.content.pm.PackageManager#getResourcesForApplication(String)}. If not |
| * specified, this is retrieved from the Ui Context. |
| */ |
| @NonNull |
| public Builder setRendererResources(@NonNull Resources rendererResources) { |
| this.mRendererResources = rendererResources; |
| return this; |
| } |
| |
| /** |
| * Sets the theme to use for this ProtoLayoutInflater instance. This can be used to |
| * customise things like the default font family. If not set, the default theme is used. |
| */ |
| @NonNull |
| public Builder setProtoLayoutTheme(@NonNull ProtoLayoutTheme protoLayoutTheme) { |
| this.mProtoLayoutTheme = protoLayoutTheme; |
| return this; |
| } |
| |
| /** |
| * Sets the pipeline for dynamic data. If null, the dynamic properties would not be |
| * registered for update. |
| */ |
| @NonNull |
| public Builder setDynamicDataPipeline( |
| @NonNull ProtoLayoutDynamicDataPipeline dataPipeline) { |
| this.mDataPipeline = dataPipeline; |
| return this; |
| } |
| |
| /** Sets the view provider for the renderer extension. */ |
| @NonNull |
| public Builder setExtensionViewProvider( |
| @NonNull ProtoLayoutExtensionViewProvider extensionViewProvider) { |
| this.mExtensionViewProvider = extensionViewProvider; |
| return this; |
| } |
| |
| /** |
| * Sets whether animation is enabled, which decides whether to load |
| * contentUpdateAnimations. Defaults to true. |
| */ |
| @NonNull |
| public Builder setAnimationEnabled(boolean animationEnabled) { |
| this.mAnimationEnabled = animationEnabled; |
| return this; |
| } |
| |
| /** Sets the ID for the Intent extra containing the ID of a Clickable. */ |
| @NonNull |
| public Builder setClickableIdExtra(@NonNull String clickableIdExtra) { |
| this.mClickableIdExtra = clickableIdExtra; |
| return this; |
| } |
| |
| /** Sets the debug logger used for extensive logging. */ |
| @NonNull |
| public Builder setLoggingUtils(@NonNull LoggingUtils loggingUtils) { |
| this.mLoggingUtils = loggingUtils; |
| return this; |
| } |
| |
| /** |
| * Sets whether a "layout changing" data bind can be applied without the |
| * "value_for_layout" field being filled in. This is to support legacy apps which use |
| * layout-changing data binds before the full support was built. Defaults to false. |
| */ |
| @NonNull |
| public Builder setAllowLayoutChangingBindsWithoutDefault( |
| boolean allowLayoutChangingBindsWithoutDefault) { |
| this.mAllowLayoutChangingBindsWithoutDefault = |
| allowLayoutChangingBindsWithoutDefault; |
| return this; |
| } |
| |
| /** Apply FONT_VARIANT_BODY as default variant. */ |
| @NonNull |
| public Builder setApplyFontVariantBodyAsDefault(boolean applyFontVariantBodyAsDefault) { |
| this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault; |
| return this; |
| } |
| |
| /** Builds a Config instance. */ |
| @NonNull |
| public Config build() { |
| if (mLoadActionListener != null && mLoadActionExecutor == null) { |
| throw new IllegalArgumentException( |
| "A loadActionExecutor should always be set if setting a" |
| + " loadActionListener."); |
| } else if (mLoadActionListener == null && mLoadActionExecutor != null) { |
| throw new IllegalArgumentException( |
| "A loadActionExecutor has been provided but no loadActionListener was" |
| + " set."); |
| } |
| |
| if (mLoadActionListener == null) { |
| mLoadActionListener = p -> {}; |
| } |
| if (mProtoLayoutTheme == null) { |
| this.mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext); |
| } |
| |
| return new Config( |
| mUiContext, |
| mLayout, |
| mLayoutResourceResolvers, |
| mLoadActionExecutor, |
| checkNotNull(mLoadActionListener), |
| mRendererResources, |
| checkNotNull(mProtoLayoutTheme), |
| mDataPipeline, |
| mExtensionViewProvider, |
| checkNotNull(mClickableIdExtra), |
| mLoggingUtils, |
| mAnimationEnabled, |
| mAllowLayoutChangingBindsWithoutDefault, |
| mApplyFontVariantBodyAsDefault); |
| } |
| } |
| } |
| |
| public ProtoLayoutInflater(@NonNull Config config) { |
| // Wrap the Ui Context with a Theme from rendererResources, so that any implicit resource |
| // reads using the R class from this package are successful. |
| Theme rendererTheme = config.getRendererResources().newTheme(); |
| rendererTheme.setTo(config.getUiContext().getTheme()); |
| this.mUiContext = new ContextThemeWrapper(config.getUiContext(), rendererTheme); |
| this.mProtoLayoutTheme = config.getProtoLayoutTheme(); |
| this.mProtoLayoutThemeContext = |
| new ContextThemeWrapper(mUiContext, mProtoLayoutTheme.getTheme()); |
| this.mLayoutProto = config.getLayout(); |
| this.mLayoutResourceResolvers = config.getLayoutResourceResolvers(); |
| this.mLoadActionExecutor = config.getLoadActionExecutor(); |
| this.mLoadActionListener = config.getLoadActionListener(); |
| this.mDataPipeline = Optional.ofNullable(config.getDynamicDataPipeline()); |
| this.mAnimationEnabled = config.getAnimationEnabled(); |
| this.mAllowLayoutChangingBindsWithoutDefault = |
| config.getAllowLayoutChangingBindsWithoutDefault(); |
| this.mClickableIdExtra = config.getClickableIdExtra(); |
| this.mLoggingUtils = config.getLoggingUtils(); |
| this.mExtensionViewProvider = config.getExtensionViewProvider(); |
| this.mApplyFontVariantBodyAsDefault = config.getApplyFontVariantBodyAsDefault(); |
| } |
| |
| private int safeDpToPx(float dp) { |
| return round(max(0, dp) * mUiContext.getResources().getDisplayMetrics().density); |
| } |
| |
| private int safeDpToPx(DpProp dpProp) { |
| return safeDpToPx(dpProp.getValue()); |
| } |
| |
| @Nullable |
| private static Float safeAspectRatioOrNull( |
| ProportionalDimensionProp proportionalDimensionProp) { |
| final int dividend = proportionalDimensionProp.getAspectRatioWidth(); |
| final int divisor = proportionalDimensionProp.getAspectRatioHeight(); |
| |
| if (dividend <= 0 || divisor <= 0) { |
| return null; |
| } |
| return (float) dividend / divisor; |
| } |
| |
| private static Rect getSourceBounds(View v) { |
| final int[] pos = new int[2]; |
| v.getLocationOnScreen(pos); |
| |
| return new Rect( |
| /* left= */ pos[0], |
| /* top= */ pos[1], |
| /* right= */ pos[0] + v.getWidth(), |
| /* bottom= */ pos[1] + v.getHeight()); |
| } |
| |
| /** |
| * Generates a generic LayoutParameters for use by all components. This just defaults to setting |
| * the width/height to WRAP_CONTENT. |
| * |
| * @return The default layout parameters. |
| */ |
| private static LayoutParams generateDefaultLayoutParams() { |
| return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); |
| } |
| |
| // dereference of possibly-null reference parent.getLayoutParams() |
| @SuppressWarnings("nullness:dereference.of.nullable") |
| private LayoutParams updateLayoutParamsInLinearLayout( |
| LinearLayoutProperties linearLayoutProperties, |
| LayoutParams layoutParams, |
| ContainerDimension width, |
| ContainerDimension height) { |
| // This is a little bit fun. ProtoLayout's semantics is that dimension = expand should eat |
| // all remaining space in that dimension, but not grow the parent. This is easy for standard |
| // containers, but a little trickier in rows and columns on Android. |
| // |
| // A Row (LinearLayout) supports this with width=0 and weight>0. After doing a layout pass, |
| // it will assign all remaining space to elements with width=0 and weight>0, biased by the |
| // weight. This causes problems if there are two (or more) "expand" elements in a row, which |
| // is itself set to WRAP_CONTENTS, and one of those elements has a measured width (e.g. |
| // Text). In that case, the LinearLayout will measure the text, then ensure that all |
| // elements with a weight set have their widths set according to the weight. For us, that |
| // means that _all_ elements with expand=true will size themselves to the same width as the |
| // Text, pushing out the bounds of the parent row. This happens on columns too, but of |
| // course regarding height. |
| // |
| // To get around this, if an element with expand=true is added to a row that is WRAP_CONTENT |
| // (e.g. a row with no explicit width, that is not expanded), we ignore the expand=true, and |
| // set the inner element's width to WRAP_CONTENT too. |
| |
| LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(layoutParams); |
| LayoutParams parentLayoutParams = linearLayoutProperties.getRawLayoutParams(); |
| |
| // Handle the width |
| if (linearLayoutProperties.getOrientation() == LinearLayout.HORIZONTAL |
| && width.getInnerCase() == InnerCase.EXPANDED_DIMENSION) { |
| // If the parent container would not normally have "remaining space", ignore the |
| // expand=true. |
| if (parentLayoutParams.width == LayoutParams.WRAP_CONTENT) { |
| linearLayoutParams.width = LayoutParams.WRAP_CONTENT; |
| } else { |
| linearLayoutParams.width = 0; |
| float weight = width.getExpandedDimension().getLayoutWeight().getValue(); |
| linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f; |
| } |
| } else { |
| linearLayoutParams.width = dimensionToPx(width); |
| } |
| |
| // And the height |
| if (linearLayoutProperties.getOrientation() == LinearLayout.VERTICAL |
| && height.getInnerCase() == InnerCase.EXPANDED_DIMENSION) { |
| // If the parent container would not normally have "remaining space", ignore the |
| // expand=true. |
| if (parentLayoutParams.height == LayoutParams.WRAP_CONTENT) { |
| linearLayoutParams.height = LayoutParams.WRAP_CONTENT; |
| } else { |
| linearLayoutParams.height = 0; |
| float weight = height.getExpandedDimension().getLayoutWeight().getValue(); |
| linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f; |
| } |
| } else { |
| linearLayoutParams.height = dimensionToPx(height); |
| } |
| |
| return linearLayoutParams; |
| } |
| |
| private LayoutParams updateLayoutParams( |
| ViewProperties viewProperties, |
| LayoutParams layoutParams, |
| ContainerDimension width, |
| ContainerDimension height) { |
| if (viewProperties instanceof LinearLayoutProperties) { |
| // LinearLayouts have a bunch of messy caveats in ProtoLayout when their children can be |
| // expanded; factor that case out to keep this clean. |
| return updateLayoutParamsInLinearLayout( |
| (LinearLayoutProperties) viewProperties, layoutParams, width, height); |
| } else { |
| layoutParams.width = dimensionToPx(width); |
| layoutParams.height = dimensionToPx(height); |
| } |
| |
| return layoutParams; |
| } |
| |
| private void resolveMinimumDimensions( |
| View view, ContainerDimension width, ContainerDimension height) { |
| if (width.getWrappedDimension().hasMinimumSize()) { |
| view.setMinimumWidth(safeDpToPx(width.getWrappedDimension().getMinimumSize())); |
| } |
| |
| if (height.getWrappedDimension().hasMinimumSize()) { |
| view.setMinimumHeight(safeDpToPx(height.getWrappedDimension().getMinimumSize())); |
| } |
| } |
| |
| @VisibleForTesting() |
| static int getFrameLayoutGravity( |
| HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) { |
| return horizontalAlignmentToGravity(horizontalAlignment) |
| | verticalAlignmentToGravity(verticalAlignment); |
| } |
| |
| @SuppressLint("RtlHardcoded") |
| private static int horizontalAlignmentToGravity(HorizontalAlignment alignment) { |
| switch (alignment) { |
| case HORIZONTAL_ALIGN_START: |
| return Gravity.START; |
| case HORIZONTAL_ALIGN_CENTER: |
| return Gravity.CENTER_HORIZONTAL; |
| case HORIZONTAL_ALIGN_END: |
| return Gravity.END; |
| case HORIZONTAL_ALIGN_LEFT: |
| return Gravity.LEFT; |
| case HORIZONTAL_ALIGN_RIGHT: |
| return Gravity.RIGHT; |
| case UNRECOGNIZED: |
| case HORIZONTAL_ALIGN_UNDEFINED: |
| return HORIZONTAL_ALIGN_DEFAULT_GRAVITY; |
| } |
| |
| return HORIZONTAL_ALIGN_DEFAULT_GRAVITY; |
| } |
| |
| private static int verticalAlignmentToGravity(VerticalAlignment alignment) { |
| switch (alignment) { |
| case VERTICAL_ALIGN_TOP: |
| return Gravity.TOP; |
| case VERTICAL_ALIGN_CENTER: |
| return Gravity.CENTER_VERTICAL; |
| case VERTICAL_ALIGN_BOTTOM: |
| return Gravity.BOTTOM; |
| case UNRECOGNIZED: |
| case VERTICAL_ALIGN_UNDEFINED: |
| return VERTICAL_ALIGN_DEFAULT_GRAVITY; |
| } |
| |
| return VERTICAL_ALIGN_DEFAULT_GRAVITY; |
| } |
| |
| @ArcLayout.LayoutParams.VerticalAlignment |
| private static int verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment) { |
| switch (alignment.getValue()) { |
| case VERTICAL_ALIGN_TOP: |
| return ArcLayout.LayoutParams.VERTICAL_ALIGN_OUTER; |
| case VERTICAL_ALIGN_CENTER: |
| return ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER; |
| case VERTICAL_ALIGN_BOTTOM: |
| return ArcLayout.LayoutParams.VERTICAL_ALIGN_INNER; |
| case UNRECOGNIZED: |
| case VERTICAL_ALIGN_UNDEFINED: |
| return ARC_VERTICAL_ALIGN_DEFAULT; |
| } |
| |
| return ARC_VERTICAL_ALIGN_DEFAULT; |
| } |
| |
| private static ScaleType contentScaleModeToScaleType(ContentScaleMode contentScaleMode) { |
| switch (contentScaleMode) { |
| case CONTENT_SCALE_MODE_FIT: |
| return ScaleType.FIT_CENTER; |
| case CONTENT_SCALE_MODE_CROP: |
| return ScaleType.CENTER_CROP; |
| case CONTENT_SCALE_MODE_FILL_BOUNDS: |
| return ScaleType.FIT_XY; |
| case CONTENT_SCALE_MODE_UNDEFINED: |
| case UNRECOGNIZED: |
| return IMAGE_DEFAULT_SCALE_TYPE; |
| } |
| |
| return IMAGE_DEFAULT_SCALE_TYPE; |
| } |
| |
| private static int spanVerticalAlignmentToImgSpanAlignment( |
| SpanVerticalAlignmentProp alignment) { |
| switch (alignment.getValue()) { |
| case SPAN_VERTICAL_ALIGN_TEXT_BASELINE: |
| return ImageSpan.ALIGN_BASELINE; |
| case SPAN_VERTICAL_ALIGN_BOTTOM: |
| return ImageSpan.ALIGN_BOTTOM; |
| case SPAN_VERTICAL_ALIGN_UNDEFINED: |
| case UNRECOGNIZED: |
| return SPAN_VERTICAL_ALIGN_DEFAULT; |
| } |
| |
| return SPAN_VERTICAL_ALIGN_DEFAULT; |
| } |
| |
| /** |
| * Whether a font style is bold or not (has weight > 700). Note that this check is required, |
| * even if you are using an explicitly bold font (e.g. Roboto-Bold), as Typeface still needs to |
| * bold bit set to render properly. |
| */ |
| private static boolean isBold(FontStyle fontStyle) { |
| // Although this method could be a simple equality check against FONT_WEIGHT_BOLD, we list |
| // all current cases here so that this will become a compile time error as soon as a new |
| // FontWeight value is added to the schema. If this fails to build, then this means that an |
| // int typeface style is no longer enough to represent all FontWeight values and a |
| // customizable, per-weight text style must be introduced to ProtoLayoutInflater to handle |
| // this. See b/176980535 |
| switch (fontStyle.getWeight().getValue()) { |
| case FONT_WEIGHT_BOLD: |
| return true; |
| case FONT_WEIGHT_NORMAL: |
| case FONT_WEIGHT_MEDIUM: |
| case FONT_WEIGHT_UNDEFINED: |
| case UNRECOGNIZED: |
| return false; |
| } |
| |
| return false; |
| } |
| |
| private Typeface fontStyleToTypeface(FontStyle fontStyle) { |
| FontSet fonts = mProtoLayoutTheme.getFontSet(fontStyle.getVariant().getValue().getNumber()); |
| |
| switch (fontStyle.getWeight().getValue()) { |
| case FONT_WEIGHT_BOLD: |
| return fonts.getBoldFont(); |
| case FONT_WEIGHT_MEDIUM: |
| return fonts.getMediumFont(); |
| case FONT_WEIGHT_NORMAL: |
| case FONT_WEIGHT_UNDEFINED: |
| case UNRECOGNIZED: |
| return fonts.getNormalFont(); |
| } |
| |
| return fonts.getNormalFont(); |
| } |
| |
| private static int fontStyleToTypefaceStyle(FontStyle fontStyle) { |
| final boolean isBold = isBold(fontStyle); |
| final boolean isItalic = fontStyle.getItalic().getValue(); |
| |
| if (isBold && isItalic) { |
| return Typeface.BOLD_ITALIC; |
| } else if (isBold) { |
| return Typeface.BOLD; |
| } else if (isItalic) { |
| return Typeface.ITALIC; |
| } else { |
| return Typeface.NORMAL; |
| } |
| } |
| |
| @SuppressWarnings("nullness") |
| private Typeface createTypeface(FontStyle fontStyle) { |
| return Typeface.create(fontStyleToTypeface(fontStyle), fontStyleToTypefaceStyle(fontStyle)); |
| } |
| |
| private static MetricAffectingSpan createTypefaceSpan(FontStyle fontStyle) { |
| return new StyleSpan(fontStyleToTypefaceStyle(fontStyle)); |
| } |
| |
| /** |
| * Returns whether or not the default style bits in Typeface can be used, or if we need to add |
| * bold/italic flags there. |
| */ |
| private static boolean hasDefaultTypefaceStyle(FontStyle fontStyle) { |
| return !fontStyle.getItalic().getValue() && !isBold(fontStyle); |
| } |
| |
| private float toPx(SpProp spField) { |
| return TypedValue.applyDimension( |
| COMPLEX_UNIT_SP, |
| spField.getValue(), |
| mUiContext.getResources().getDisplayMetrics()); |
| } |
| |
| private void applyFontStyle( |
| FontStyle style, |
| TextView textView, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker, |
| boolean isAutoSizeAllowed) { |
| // Note: Underline must be applied as a Span to work correctly (as opposed to using |
| // TextPaint#setTextUnderline). This is applied in the caller instead. |
| |
| // Need to supply typefaceStyle when creating the typeface (will select specialist |
| // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic |
| // flags in Paint if they're not supported by the given typeface). |
| textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style)); |
| |
| if (fontStyleHasSize(style)) { |
| List<SpProp> sizes = style.getSizeList(); |
| int sizesCnt = sizes.size(); |
| |
| if (sizesCnt == 1) { |
| // No autosizing needed. |
| textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(0).getValue()); |
| } else if (isAutoSizeAllowed && sizesCnt <= TEXT_AUTOSIZES_LIMIT) { |
| // We need to check values so that we are certain that there's at least 1 non zero |
| // value. |
| boolean atLeastOneCorrectSize = |
| sizes.stream() |
| .mapToInt(sp -> (int) sp.getValue()) |
| .filter(sp -> sp > 0) |
| .distinct() |
| .count() |
| > 0; |
| |
| if (atLeastOneCorrectSize) { |
| // Max size is needed so that TextView leaves enough space for it. Otherwise, |
| // the text won't be able to grow. |
| int maxSize = |
| sizes.stream().mapToInt(sp -> (int) sp.getValue()).max().getAsInt(); |
| textView.setTextSize(COMPLEX_UNIT_SP, maxSize); |
| |
| // No need for sorting, TextView does that. |
| textView.setAutoSizeTextTypeUniformWithPresetSizes( |
| sizes.stream().mapToInt(spProp -> (int) spProp.getValue()).toArray(), |
| COMPLEX_UNIT_SP); |
| } else { |
| Log.w( |
| TAG, |
| "Trying to autosize text but no valid font sizes has been specified."); |
| } |
| } else { |
| // Fallback where multiple values can't be used and the last value would be used. |
| // This can happen in two cases. |
| if (!isAutoSizeAllowed) { |
| // There is more than 1 size specified, but autosizing is not allowed. |
| Log.w( |
| TAG, |
| "Trying to autosize text with multiple font sizes where it's not " |
| + "allowed. Ignoring all other sizes and using the last one."); |
| } else { |
| Log.w( |
| TAG, |
| "More than " + TEXT_AUTOSIZES_LIMIT + " sizes has been added for the " |
| + "text autosizing. Ignoring all other sizes and using the last" |
| + "one."); |
| } |
| |
| textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(sizesCnt - 1).getValue()); |
| } |
| } |
| |
| if (style.hasLetterSpacing()) { |
| textView.setLetterSpacing(style.getLetterSpacing().getValue()); |
| } |
| |
| if (style.hasColor()) { |
| handleProp(style.getColor(), textView::setTextColor, posId, pipelineMaker); |
| } else { |
| textView.setTextColor(TEXT_COLOR_DEFAULT); |
| } |
| } |
| |
| private void applyFontStyle(FontStyle style, CurvedTextView textView) { |
| // Need to supply typefaceStyle when creating the typeface (will select specialist |
| // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic |
| // flags in Paint if they're not supported by the given typeface). |
| textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style)); |
| |
| if (fontStyleHasSize(style)) { |
| // We are using the last added size in the FontStyle because ArcText doesn't support |
| // autosizing. This is the same behaviour as it was before size has made repeated. |
| if (style.getSizeList().size() > 1) { |
| Log.w( |
| TAG, |
| "Font size with multiple values has been used on Arc Text. Ignoring " |
| + "all size except the first one."); |
| } |
| textView.setTextSize(toPx(style.getSize(style.getSizeCount() - 1))); |
| } |
| } |
| |
| void dispatchLaunchActionIntent(Intent i) { |
| ActivityInfo ai = i.resolveActivityInfo(mUiContext.getPackageManager(), /* flags= */ 0); |
| |
| if (ai != null && ai.exported && (ai.permission == null || ai.permission.isEmpty())) { |
| mUiContext.startActivity(i); |
| } |
| } |
| |
| private void applyClickable(View view, Clickable clickable) { |
| view.setTag(R.id.clickable_id_tag, clickable.getId()); |
| |
| boolean hasAction = false; |
| switch (clickable.getOnClick().getValueCase()) { |
| case LAUNCH_ACTION: |
| Intent i = |
| buildLaunchActionIntent( |
| clickable.getOnClick().getLaunchAction(), |
| clickable.getId(), |
| mClickableIdExtra); |
| if (i != null) { |
| hasAction = true; |
| view.setOnClickListener( |
| v -> { |
| i.setSourceBounds(getSourceBounds(view)); |
| dispatchLaunchActionIntent(i); |
| }); |
| } |
| break; |
| case LOAD_ACTION: |
| hasAction = true; |
| if (mLoadActionExecutor == null) { |
| Log.w(TAG, "Ignoring load action since an executor has not been provided."); |
| break; |
| } |
| view.setOnClickListener( |
| v -> |
| checkNotNull(mLoadActionExecutor) |
| .execute( |
| () -> |
| mLoadActionListener.onClick( |
| buildState( |
| clickable |
| .getOnClick() |
| .getLoadAction(), |
| clickable.getId())))); |
| break; |
| case VALUE_NOT_SET: |
| break; |
| } |
| |
| if (hasAction) { |
| // Apply ripple effect Resolve selectableItemBackground against the mUiContext theme, |
| // which provides the drawable. Note that this is not customizable by the |
| // ProtoLayoutTheme. |
| TypedValue outValue = new TypedValue(); |
| boolean isValid = |
| mUiContext |
| .getTheme() |
| .resolveAttribute( |
| android.R.attr.selectableItemBackground, |
| outValue, |
| /* resolveRefs= */ true); |
| if (isValid) { |
| view.setForeground(mUiContext.getDrawable(outValue.resourceId)); |
| } else { |
| Log.e( |
| TAG, |
| "Could not resolve android.R.attr.selectableItemBackground from Ui" |
| + " Context."); |
| } |
| } |
| } |
| |
| private void applyPadding(View view, Padding padding) { |
| if (padding.getRtlAware().getValue()) { |
| view.setPaddingRelative( |
| safeDpToPx(padding.getStart()), |
| safeDpToPx(padding.getTop()), |
| safeDpToPx(padding.getEnd()), |
| safeDpToPx(padding.getBottom())); |
| } else { |
| view.setPadding( |
| safeDpToPx(padding.getStart()), |
| safeDpToPx(padding.getTop()), |
| safeDpToPx(padding.getEnd()), |
| safeDpToPx(padding.getBottom())); |
| } |
| } |
| |
| private GradientDrawable applyBackground( |
| View view, |
| Background background, |
| @Nullable GradientDrawable drawable, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (drawable == null) { |
| drawable = new GradientDrawable(); |
| } |
| |
| if (background.hasColor()) { |
| handleProp(background.getColor(), drawable::setColor, posId, pipelineMaker); |
| } |
| |
| if (background.hasCorner()) { |
| final int radiusPx = safeDpToPx(background.getCorner().getRadius()); |
| if (radiusPx != 0) { |
| drawable.setCornerRadius(radiusPx); |
| view.setClipToOutline(true); |
| view.setOutlineProvider(ViewOutlineProvider.BACKGROUND); |
| } |
| } |
| |
| return drawable; |
| } |
| |
| private GradientDrawable applyBorder( |
| Border border, |
| @Nullable GradientDrawable drawable, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (drawable == null) { |
| drawable = new GradientDrawable(); |
| } |
| |
| GradientDrawable finalDrawable = drawable; |
| int width = safeDpToPx(border.getWidth()); |
| handleProp( |
| border.getColor(), |
| borderColor -> finalDrawable.setStroke(width, borderColor), |
| posId, |
| pipelineMaker); |
| |
| return drawable; |
| } |
| |
| private View applyModifiers( |
| View view, |
| Modifiers modifiers, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (modifiers.hasClickable()) { |
| applyClickable(view, modifiers.getClickable()); |
| } |
| |
| if (modifiers.hasSemantics()) { |
| applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker); |
| } |
| |
| if (modifiers.hasPadding()) { |
| applyPadding(view, modifiers.getPadding()); |
| } |
| |
| GradientDrawable backgroundDrawable = null; |
| |
| if (modifiers.hasBackground()) { |
| backgroundDrawable = |
| applyBackground( |
| view, |
| modifiers.getBackground(), |
| backgroundDrawable, |
| posId, |
| pipelineMaker); |
| } |
| |
| if (modifiers.hasBorder()) { |
| backgroundDrawable = |
| applyBorder(modifiers.getBorder(), backgroundDrawable, posId, pipelineMaker); |
| } |
| |
| if (backgroundDrawable != null) { |
| view.setBackground(backgroundDrawable); |
| } |
| |
| if (mAnimationEnabled && modifiers.hasContentUpdateAnimation()) { |
| pipelineMaker.ifPresent( |
| p -> |
| p.storeAnimatedVisibilityFor( |
| posId, modifiers.getContentUpdateAnimation())); |
| } |
| |
| return view; |
| } |
| |
| @SuppressWarnings("RestrictTo") |
| static AnimationSet getEnterAnimations( |
| @NonNull EnterTransition enterTransition, @NonNull View view) { |
| AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false); |
| if (enterTransition.hasFadeIn()) { |
| FadeInTransition fadeIn = enterTransition.getFadeIn(); |
| AlphaAnimation alphaAnimation = |
| new AlphaAnimation(fadeIn.getInitialAlpha(), FADE_IN_TARGET_ALPHA); |
| |
| // If it doesn't exist, this will be default object. |
| AnimationSpec spec = fadeIn.getAnimationSpec(); |
| |
| AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec); |
| animations.addAnimation(alphaAnimation); |
| } |
| |
| if (enterTransition.hasSlideIn()) { |
| SlideInTransition slideIn = enterTransition.getSlideIn(); |
| |
| // If it doesn't exist, this will be default object. |
| AnimationSpec spec = slideIn.getAnimationSpec(); |
| |
| float fromXDelta = 0; |
| float toXDelta = 0; |
| float fromYDelta = 0; |
| float toYDelta = 0; |
| |
| switch (slideIn.getDirectionValue()) { |
| case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE: |
| // Do the same as for horizontal as that is default. |
| case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE: |
| case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE: |
| fromXDelta = getInitialOffsetOrDefaultX(slideIn, view); |
| break; |
| case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE: |
| case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE: |
| fromYDelta = getInitialOffsetOrDefaultY(slideIn, view); |
| break; |
| default: |
| break; |
| } |
| |
| TranslateAnimation translateAnimation = |
| new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta); |
| AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec); |
| animations.addAnimation(translateAnimation); |
| } |
| return animations; |
| } |
| |
| @SuppressWarnings("RestrictTo") |
| static AnimationSet getExitAnimations( |
| @NonNull ExitTransition exitTransition, @NonNull View view) { |
| AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false); |
| if (exitTransition.hasFadeOut()) { |
| FadeOutTransition fadeOut = exitTransition.getFadeOut(); |
| AlphaAnimation alphaAnimation = |
| new AlphaAnimation(FADE_OUT_INITIAL_ALPHA, fadeOut.getTargetAlpha()); |
| |
| // If it doesn't exist, this will be default object. |
| AnimationSpec spec = fadeOut.getAnimationSpec(); |
| |
| // Indefinite Exit animations aren't allowed. |
| if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) { |
| AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec); |
| animations.addAnimation(alphaAnimation); |
| } |
| } |
| |
| if (exitTransition.hasSlideOut()) { |
| SlideOutTransition slideOut = exitTransition.getSlideOut(); |
| |
| // If it doesn't exist, this will be default object. |
| AnimationSpec spec = slideOut.getAnimationSpec(); |
| // Indefinite Exit animations aren't allowed. |
| if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) { |
| float fromXDelta = 0; |
| float toXDelta = 0; |
| float fromYDelta = 0; |
| float toYDelta = 0; |
| |
| switch (slideOut.getDirectionValue()) { |
| case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE: |
| // Do the same as for horizontal as that is default. |
| case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE: |
| case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE: |
| toXDelta = getTargetOffsetOrDefaultX(slideOut, view); |
| break; |
| case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE: |
| case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE: |
| toYDelta = getTargetOffsetOrDefaultY(slideOut, view); |
| break; |
| default: |
| break; |
| } |
| |
| TranslateAnimation translateAnimation = |
| new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta); |
| AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec); |
| animations.addAnimation(translateAnimation); |
| } |
| } |
| return animations; |
| } |
| |
| /** |
| * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which |
| * * is sliding to the left or right parent edge, depending on the direction. |
| */ |
| private static float getInitialOffsetOrDefaultX( |
| @NonNull SlideInTransition slideIn, @NonNull View view) { |
| int sign = |
| slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE |
| ? -1 |
| : 1; |
| if (slideIn.hasInitialSlideBound()) { |
| |
| switch (slideIn.getInitialSlideBound().getInnerCase()) { |
| case LINEAR_BOUND: |
| return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign; |
| case PARENT_BOUND: |
| if (slideIn.getInitialSlideBound().getParentBound().getSnapTo() |
| == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { |
| return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight()) |
| * sign; |
| } |
| // fall through |
| case INNER_NOT_SET: |
| break; |
| } |
| } |
| return (sign == -1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign; |
| } |
| |
| /** |
| * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which |
| * * is sliding to the left or right parent edge, depending on the direction. |
| */ |
| private static float getInitialOffsetOrDefaultY( |
| @NonNull SlideInTransition slideIn, @NonNull View view) { |
| int sign = |
| slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE |
| ? -1 |
| : 1; |
| if (slideIn.hasInitialSlideBound()) { |
| |
| switch (slideIn.getInitialSlideBound().getInnerCase()) { |
| case LINEAR_BOUND: |
| return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign; |
| case PARENT_BOUND: |
| if (slideIn.getInitialSlideBound().getParentBound().getSnapTo() |
| == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { |
| return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom()) |
| * sign; |
| } |
| // fall through |
| case INNER_NOT_SET: |
| break; |
| } |
| } |
| return (sign == -1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign; |
| } |
| |
| /** |
| * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value |
| * which is sliding to the left or right parent edge, depending on the direction. |
| */ |
| private static float getTargetOffsetOrDefaultX( |
| @NonNull SlideOutTransition slideOut, @NonNull View view) { |
| int sign = |
| slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE |
| ? 1 |
| : -1; |
| if (slideOut.hasTargetSlideBound()) { |
| |
| switch (slideOut.getTargetSlideBound().getInnerCase()) { |
| case LINEAR_BOUND: |
| return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign; |
| case PARENT_BOUND: |
| if (slideOut.getTargetSlideBound().getParentBound().getSnapTo() |
| == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { |
| return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight()) |
| * sign; |
| } |
| // fall through |
| case INNER_NOT_SET: |
| break; |
| } |
| } |
| return (sign == 1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign; |
| } |
| |
| /** |
| * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value |
| * which is sliding to the top or bottom parent edge, depending on the direction. |
| */ |
| private static float getTargetOffsetOrDefaultY( |
| @NonNull SlideOutTransition slideOut, @NonNull View view) { |
| int sign = |
| slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE |
| ? 1 |
| : -1; |
| if (slideOut.hasTargetSlideBound()) { |
| |
| switch (slideOut.getTargetSlideBound().getInnerCase()) { |
| case LINEAR_BOUND: |
| return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign; |
| case PARENT_BOUND: |
| if (slideOut.getTargetSlideBound().getParentBound().getSnapTo() |
| == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { |
| return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom()) |
| * sign; |
| } |
| // fall through |
| case INNER_NOT_SET: |
| break; |
| } |
| } |
| return (sign == 1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign; |
| } |
| |
| // This is a little nasty; ArcLayout.Widget is just an interface, so we have no guarantee that |
| // the instance also extends View (as it should). Instead, just take a View in and rename this, |
| // and check that it's an ArcLayout.Widget internally. |
| private View applyModifiersToArcLayoutView( |
| View view, |
| ArcModifiers modifiers, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (!(view instanceof ArcLayout.Widget)) { |
| Log.e( |
| TAG, |
| "applyModifiersToArcLayoutView should only be called with an ArcLayout.Widget"); |
| return view; |
| } |
| |
| if (modifiers.hasClickable()) { |
| applyClickable(view, modifiers.getClickable()); |
| } |
| |
| if (modifiers.hasSemantics()) { |
| applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker); |
| } |
| |
| return view; |
| } |
| |
| private static int textAlignToAndroidGravity(TextAlignment alignment) { |
| switch (alignment) { |
| case TEXT_ALIGN_START: |
| return Gravity.START; |
| case TEXT_ALIGN_CENTER: |
| return Gravity.CENTER_HORIZONTAL; |
| case TEXT_ALIGN_END: |
| return Gravity.END; |
| case TEXT_ALIGN_UNDEFINED: |
| case UNRECOGNIZED: |
| return TEXT_ALIGN_DEFAULT; |
| } |
| |
| return TEXT_ALIGN_DEFAULT; |
| } |
| |
| @Nullable |
| private static TruncateAt textTruncationToEllipsize(TextOverflow overflowValue) { |
| switch (overflowValue) { |
| case TEXT_OVERFLOW_TRUNCATE: |
| // A null TruncateAt disables adding an ellipsis. |
| return null; |
| case TEXT_OVERFLOW_ELLIPSIZE_END: |
| return TruncateAt.END; |
| case TEXT_OVERFLOW_MARQUEE: |
| return TruncateAt.MARQUEE; |
| case TEXT_OVERFLOW_UNDEFINED: |
| case UNRECOGNIZED: |
| return TEXT_OVERFLOW_DEFAULT; |
| } |
| |
| return TEXT_OVERFLOW_DEFAULT; |
| } |
| |
| @ArcLayout.AnchorType |
| private static int anchorTypeToAnchorPos(ArcAnchorType type) { |
| switch (type) { |
| case ARC_ANCHOR_START: |
| return ArcLayout.ANCHOR_START; |
| case ARC_ANCHOR_CENTER: |
| return ArcLayout.ANCHOR_CENTER; |
| case ARC_ANCHOR_END: |
| return ArcLayout.ANCHOR_END; |
| case ARC_ANCHOR_UNDEFINED: |
| case UNRECOGNIZED: |
| return ARC_ANCHOR_DEFAULT; |
| } |
| |
| return ARC_ANCHOR_DEFAULT; |
| } |
| |
| @SizedArcContainer.LayoutParams.AngularAlignment |
| private static int angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment) { |
| switch (angularAlignment) { |
| case ANGULAR_ALIGNMENT_START: |
| return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_START; |
| case ANGULAR_ALIGNMENT_CENTER: |
| return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER; |
| case ANGULAR_ALIGNMENT_END: |
| return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_END; |
| case ANGULAR_ALIGNMENT_UNDEFINED: |
| case UNRECOGNIZED: |
| return ANGULAR_ALIGNMENT_DEFAULT; |
| } |
| |
| return ANGULAR_ALIGNMENT_DEFAULT; |
| } |
| |
| private int dimensionToPx(ContainerDimension containerDimension) { |
| switch (containerDimension.getInnerCase()) { |
| case LINEAR_DIMENSION: |
| return safeDpToPx(containerDimension.getLinearDimension()); |
| case EXPANDED_DIMENSION: |
| return LayoutParams.MATCH_PARENT; |
| case WRAPPED_DIMENSION: |
| return LayoutParams.WRAP_CONTENT; |
| case INNER_NOT_SET: |
| return dimensionToPx(CONTAINER_DIMENSION_DEFAULT); |
| } |
| |
| return dimensionToPx(CONTAINER_DIMENSION_DEFAULT); |
| } |
| |
| private static int extractTextColorArgb(FontStyle fontStyle) { |
| if (fontStyle.hasColor()) { |
| return fontStyle.getColor().getArgb(); |
| } else { |
| return TEXT_COLOR_DEFAULT; |
| } |
| } |
| |
| /** |
| * Returns an Android {@link Intent} that can perform the action defined in the given layout |
| * {@link LaunchAction}. |
| */ |
| @Nullable |
| public static Intent buildLaunchActionIntent( |
| @NonNull LaunchAction launchAction, |
| @NonNull String clickableId, |
| @NonNull String clickableIdExtra) { |
| if (launchAction.hasAndroidActivity()) { |
| AndroidActivity activity = launchAction.getAndroidActivity(); |
| Intent i = |
| new Intent().setClassName(activity.getPackageName(), activity.getClassName()); |
| i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| |
| if (!clickableId.isEmpty() && !clickableIdExtra.isEmpty()) { |
| i.putExtra(clickableIdExtra, clickableId); |
| } |
| |
| for (Map.Entry<String, AndroidExtra> entry : activity.getKeyToExtraMap().entrySet()) { |
| if (entry.getValue().hasStringVal()) { |
| i.putExtra(entry.getKey(), entry.getValue().getStringVal().getValue()); |
| } else if (entry.getValue().hasIntVal()) { |
| i.putExtra(entry.getKey(), entry.getValue().getIntVal().getValue()); |
| } else if (entry.getValue().hasLongVal()) { |
| i.putExtra(entry.getKey(), entry.getValue().getLongVal().getValue()); |
| } else if (entry.getValue().hasDoubleVal()) { |
| i.putExtra(entry.getKey(), entry.getValue().getDoubleVal().getValue()); |
| } else if (entry.getValue().hasBooleanVal()) { |
| i.putExtra(entry.getKey(), entry.getValue().getBooleanVal().getValue()); |
| } |
| } |
| |
| return i; |
| } |
| |
| return null; |
| } |
| |
| static State buildState(LoadAction loadAction, String clickableId) { |
| // Get the state specified by the provider and add the last clicked clickable's ID to it. |
| return loadAction.getRequestState().toBuilder().setLastClickableId(clickableId).build(); |
| } |
| |
| @Nullable |
| private InflatedView inflateColumn( |
| ParentViewWrapper parentViewWrapper, |
| Column column, |
| String columnPosId, |
| boolean includeChildren, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| ContainerDimension width = |
| column.hasWidth() ? column.getWidth() : CONTAINER_DIMENSION_DEFAULT; |
| ContainerDimension height = |
| column.hasHeight() ? column.getHeight() : CONTAINER_DIMENSION_DEFAULT; |
| |
| if (!canMeasureContainer(width, height, column.getContentsList())) { |
| Log.w(TAG, "Column set to wrap but contents are unmeasurable. Ignoring."); |
| return null; |
| } |
| |
| LinearLayout linearLayout = new LinearLayout(mUiContext); |
| linearLayout.setOrientation(LinearLayout.VERTICAL); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| linearLayout.setGravity( |
| horizontalAlignmentToGravity(column.getHorizontalAlignment().getValue())); |
| |
| layoutParams = |
| updateLayoutParams( |
| parentViewWrapper.getParentProperties(), layoutParams, width, height); |
| resolveMinimumDimensions(linearLayout, width, height); |
| |
| View wrappedView = |
| applyModifiers(linearLayout, column.getModifiers(), columnPosId, pipelineMaker); |
| |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| if (includeChildren) { |
| inflateChildElements( |
| linearLayout, |
| layoutParams, |
| NO_OP_PENDING_LAYOUT_PARAMS, |
| column.getContentsList(), |
| columnPosId, |
| layoutInfoBuilder, |
| pipelineMaker); |
| layoutInfoBuilder.removeSubtree(columnPosId); |
| } |
| |
| int numMissingChildren = includeChildren ? 0 : column.getContentsCount(); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), |
| NO_OP_PENDING_LAYOUT_PARAMS, |
| numMissingChildren); |
| } |
| |
| @Nullable |
| private InflatedView inflateRow( |
| ParentViewWrapper parentViewWrapper, |
| Row row, |
| String rowPosId, |
| boolean includeChildren, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| ContainerDimension width = row.hasWidth() ? row.getWidth() : CONTAINER_DIMENSION_DEFAULT; |
| ContainerDimension height = row.hasHeight() ? row.getHeight() : CONTAINER_DIMENSION_DEFAULT; |
| |
| if (!canMeasureContainer(width, height, row.getContentsList())) { |
| Log.w(TAG, "Row set to wrap but contents are unmeasurable. Ignoring."); |
| return null; |
| } |
| |
| LinearLayout linearLayout = new LinearLayout(mUiContext); |
| linearLayout.setOrientation(LinearLayout.HORIZONTAL); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| linearLayout.setGravity(verticalAlignmentToGravity(row.getVerticalAlignment().getValue())); |
| |
| layoutParams = |
| updateLayoutParams( |
| parentViewWrapper.getParentProperties(), layoutParams, width, height); |
| resolveMinimumDimensions(linearLayout, width, height); |
| |
| View wrappedView = |
| applyModifiers(linearLayout, row.getModifiers(), rowPosId, pipelineMaker); |
| |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| if (includeChildren) { |
| inflateChildElements( |
| linearLayout, |
| layoutParams, |
| NO_OP_PENDING_LAYOUT_PARAMS, |
| row.getContentsList(), |
| rowPosId, |
| layoutInfoBuilder, |
| pipelineMaker); |
| layoutInfoBuilder.removeSubtree(rowPosId); |
| } |
| |
| int numMissingChildren = includeChildren ? 0 : row.getContentsCount(); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), |
| NO_OP_PENDING_LAYOUT_PARAMS, |
| numMissingChildren); |
| } |
| |
| // dereference of possibly-null reference lp |
| @SuppressWarnings("nullness:dereference.of.nullable") |
| @Nullable |
| private InflatedView inflateBox( |
| ParentViewWrapper parentViewWrapper, |
| Box box, |
| String boxPosId, |
| boolean includeChildren, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| ContainerDimension width = box.hasWidth() ? box.getWidth() : CONTAINER_DIMENSION_DEFAULT; |
| ContainerDimension height = box.hasHeight() ? box.getHeight() : CONTAINER_DIMENSION_DEFAULT; |
| |
| if (!canMeasureContainer(width, height, box.getContentsList())) { |
| Log.w(TAG, "Box set to wrap but contents are unmeasurable. Ignoring."); |
| return null; |
| } |
| |
| FrameLayout frame = new FrameLayout(mUiContext); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| layoutParams = |
| updateLayoutParams( |
| parentViewWrapper.getParentProperties(), layoutParams, width, height); |
| resolveMinimumDimensions(frame, width, height); |
| |
| int gravity = |
| getFrameLayoutGravity( |
| box.getHorizontalAlignment().getValue(), |
| box.getVerticalAlignment().getValue()); |
| PendingFrameLayoutParams childLayoutParams = new PendingFrameLayoutParams(gravity); |
| |
| View wrappedView = applyModifiers(frame, box.getModifiers(), boxPosId, pipelineMaker); |
| |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| if (includeChildren) { |
| inflateChildElements( |
| frame, |
| layoutParams, |
| childLayoutParams, |
| box.getContentsList(), |
| boxPosId, |
| layoutInfoBuilder, |
| pipelineMaker); |
| layoutInfoBuilder.removeSubtree(boxPosId); |
| } |
| |
| // We can't set layout gravity to a FrameLayout ahead of time (and foregroundGravity only |
| // sets the gravity of the foreground Drawable). Go and apply gravity to the child. |
| try { |
| applyGravityToFrameLayoutChildren(frame, gravity); |
| } catch (IllegalStateException ex) { |
| Log.e(TAG, "Error applying Gravity to FrameLayout children.", ex); |
| } |
| |
| // HACK: FrameLayout has a bug in it. If we add one WRAP_CONTENT child, and one MATCH_PARENT |
| // child, the expected behaviour is that the FrameLayout sizes itself to fit the |
| // WRAP_CONTENT child (e.g. a TextView), then the MATCH_PARENT child is forced to the same |
| // size as the outer FrameLayout (and hence, the size of the TextView, after accounting for |
| // padding etc). Because of a bug though, this doesn't happen; instead, the MATCH_PARENT |
| // child will just keep its intrinsic size. This is because FrameLayout only forces |
| // MATCH_PARENT children to a given size if there are _more than one_ of them (see the |
| // bottom of FrameLayout#onMeasure). |
| // |
| // To work around this (without copying the whole of FrameLayout just to change a "1" to |
| // "0"), |
| // we add a Space element in if there is one MATCH_PARENT child. This has a tiny cost to the |
| // measure pass, and negligible cost to layout/draw (since it doesn't take part in those |
| // passes). |
| int numMatchParentChildren = 0; |
| for (int i = 0; i < frame.getChildCount(); i++) { |
| LayoutParams lp = frame.getChildAt(i).getLayoutParams(); |
| if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) { |
| numMatchParentChildren++; |
| } |
| } |
| |
| if (numMatchParentChildren == 1) { |
| Space hackSpace = new Space(mUiContext); |
| LayoutParams hackSpaceLp = |
| new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); |
| frame.addView(hackSpace, hackSpaceLp); |
| } |
| |
| int numMissingChildren = includeChildren ? 0 : box.getContentsCount(); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), |
| childLayoutParams, |
| numMissingChildren); |
| } |
| |
| @Nullable |
| private InflatedView inflateSpacer( |
| ParentViewWrapper parentViewWrapper, |
| Spacer spacer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| // Initialize the size wrapper here, if needed. This simplifies the logic below when |
| // creating the actual Spacer and adding it to its parent... |
| FrameLayout sizeWrapper = null; |
| @Nullable Float widthForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getWidth()); |
| @Nullable Float heightForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getHeight()); |
| |
| if (widthForLayoutDp != null || heightForLayoutDp != null) { |
| sizeWrapper = new FrameLayout(mUiContext); |
| LayoutParams spaceWrapperLayoutParams = generateDefaultLayoutParams(); |
| spaceWrapperLayoutParams.width = LayoutParams.WRAP_CONTENT; |
| spaceWrapperLayoutParams.height = LayoutParams.WRAP_CONTENT; |
| |
| if (widthForLayoutDp != null) { |
| if (widthForLayoutDp <= 0f) { |
| Log.w( |
| TAG, |
| "Spacer width's value_for_layout is not a positive value. Element won't" |
| + " be visible."); |
| } |
| spaceWrapperLayoutParams.width = safeDpToPx(widthForLayoutDp); |
| } |
| |
| if (heightForLayoutDp != null) { |
| if (heightForLayoutDp <= 0f) { |
| Log.w( |
| TAG, |
| "Spacer height's value_for_layout is not a positive value. Element" |
| + " won't be visible."); |
| } |
| spaceWrapperLayoutParams.height = safeDpToPx(heightForLayoutDp); |
| } |
| |
| int gravity = |
| horizontalAlignmentToGravity( |
| spacer.getWidth() |
| .getLinearDimension() |
| .getHorizontalAlignmentForLayout()) |
| | verticalAlignmentToGravity( |
| spacer.getHeight() |
| .getLinearDimension() |
| .getVerticalAlignmentForLayout()); |
| FrameLayout.LayoutParams frameLayoutLayoutParams = |
| new FrameLayout.LayoutParams(layoutParams); |
| frameLayoutLayoutParams.gravity = gravity; |
| layoutParams = frameLayoutLayoutParams; |
| |
| parentViewWrapper.maybeAddView(sizeWrapper, spaceWrapperLayoutParams); |
| |
| parentViewWrapper = new ParentViewWrapper(sizeWrapper, spaceWrapperLayoutParams); |
| } |
| |
| // Modifiers cannot be applied to android's Space, so use a plain View if this Spacer has |
| // modifiers. |
| View view; |
| if (spacer.hasModifiers()) { |
| view = |
| applyModifiers( |
| new View(mUiContext), spacer.getModifiers(), posId, pipelineMaker); |
| |
| // Currently, a spacer can only have a known size, not wrap or expand. Because of that, |
| // we don't need to use updateLayoutParams (it only exists to special-case expand() in a |
| // linear layout). Just go and set the LayoutParams directly here. First though, init |
| // the layout params to 0 (so we don't get strange behaviour before the first data |
| // pipeline update). |
| layoutParams.width = 0; |
| layoutParams.height = 0; |
| |
| // The View needs to be added before any of the *Prop messages are wired up. |
| // View#getLayoutParams will return null if the View has not been added to a container |
| // yet |
| // (since the LayoutParams are technically managed by the parent). |
| parentViewWrapper.maybeAddView(view, layoutParams); |
| |
| handleProp( |
| spacer.getWidth().getLinearDimension(), |
| width -> { |
| LayoutParams lp = view.getLayoutParams(); |
| if (lp == null) { |
| Log.e(TAG, "LayoutParams was null when updating spacer width"); |
| return; |
| } |
| |
| lp.width = safeDpToPx(width); |
| view.requestLayout(); |
| }, |
| posId, |
| pipelineMaker); |
| |
| handleProp( |
| spacer.getHeight().getLinearDimension(), |
| height -> { |
| LayoutParams lp = view.getLayoutParams(); |
| if (lp == null) { |
| Log.e(TAG, "LayoutParams was null when updating spacer height"); |
| return; |
| } |
| |
| lp.height = safeDpToPx(height); |
| view.requestLayout(); |
| }, |
| posId, |
| pipelineMaker); |
| } else { |
| view = new Space(mUiContext); |
| handleProp( |
| spacer.getWidth().getLinearDimension(), |
| width -> view.setMinimumWidth(safeDpToPx(width)), |
| posId, |
| pipelineMaker); |
| handleProp( |
| spacer.getHeight().getLinearDimension(), |
| height -> view.setMinimumHeight(safeDpToPx(height)), |
| posId, |
| pipelineMaker); |
| parentViewWrapper.maybeAddView(view, layoutParams); |
| } |
| |
| if (sizeWrapper != null) { |
| return new InflatedView( |
| sizeWrapper, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } else { |
| return new InflatedView( |
| view, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| } |
| |
| @Nullable |
| private InflatedView inflateArcSpacer( |
| ParentViewWrapper parentViewWrapper, |
| ArcSpacer spacer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| float lengthDegrees = 0; |
| int thicknessPx = safeDpToPx(spacer.getThickness()); |
| WearCurvedSpacer space = new WearCurvedSpacer(mUiContext); |
| ArcLayout.LayoutParams layoutParams = |
| new ArcLayout.LayoutParams(generateDefaultLayoutParams()); |
| |
| if (spacer.hasAngularLength()) { |
| final ArcSpacerLength angularLength = spacer.getAngularLength(); |
| switch (angularLength.getInnerCase()) { |
| case DEGREES: |
| lengthDegrees = max(0, angularLength.getDegrees().getValue()); |
| break; |
| |
| case EXPANDED_ANGULAR_DIMENSION: |
| { |
| float weight = |
| angularLength |
| .getExpandedAngularDimension() |
| .getLayoutWeight() |
| .getValue(); |
| if (weight == 0 && thicknessPx == 0) { |
| return null; |
| } |
| layoutParams.setWeight(weight); |
| |
| space.setThickness(thicknessPx); |
| |
| View wrappedView = |
| applyModifiersToArcLayoutView( |
| space, spacer.getModifiers(), posId, pipelineMaker); |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| |
| case INNER_NOT_SET: |
| break; |
| } |
| } else { |
| lengthDegrees = max(0, spacer.getLength().getValue()); |
| } |
| |
| if (lengthDegrees == 0 && thicknessPx == 0) { |
| return null; |
| } |
| space.setSweepAngleDegrees(lengthDegrees); |
| space.setThickness(thicknessPx); |
| |
| View wrappedView = |
| applyModifiersToArcLayoutView(space, spacer.getModifiers(), posId, pipelineMaker); |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| |
| private void applyTextOverflow( |
| TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters) { |
| TextOverflow overflowValue = overflow.getValue(); |
| if (!mAnimationEnabled && overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE) { |
| overflowValue = TextOverflow.TEXT_OVERFLOW_UNDEFINED; |
| } |
| |
| textView.setEllipsize(textTruncationToEllipsize(overflowValue)); |
| if (overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE && textView.getMaxLines() == 1) { |
| int marqueeIterations = |
| marqueeParameters.hasIterations() |
| ? marqueeParameters.getIterations() |
| : -1; // Defaults to repeat indefinitely (-1). |
| textView.setMarqueeRepeatLimit(marqueeIterations); |
| textView.setSelected(true); |
| textView.setSingleLine(); |
| textView.setHorizontalFadingEdgeEnabled(true); |
| } |
| } |
| |
| private InflatedView inflateText( |
| ParentViewWrapper parentViewWrapper, |
| Text text, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| TextView textView = newThemedTextView(); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| handleProp( |
| text.getText(), |
| t -> { |
| // Underlines are applied using a Spannable here, rather than setting paint bits |
| // (or |
| // using Paint#setTextUnderline). When multiple fonts are mixed on the same line |
| // (especially when mixing anything with NotoSans-CJK), multiple underlines can |
| // appear. Using UnderlineSpan instead though causes the correct behaviour to |
| // happen |
| // (only a |
| // single underline). |
| SpannableStringBuilder ssb = new SpannableStringBuilder(); |
| ssb.append(t); |
| |
| if (text.getFontStyle().getUnderline().getValue()) { |
| ssb.setSpan(new UnderlineSpan(), 0, ssb.length(), Spanned.SPAN_MARK_MARK); |
| } |
| |
| textView.setText(ssb); |
| }, |
| posId, |
| pipelineMaker); |
| |
| textView.setGravity(textAlignToAndroidGravity(text.getMultilineAlignment().getValue())); |
| |
| @Nullable String valueForLayout = resolveValueForLayoutIfNeeded(text.getText()); |
| |
| // Use valueForLayout as a proxy for "has a dynamic size". If there's a dynamic binding |
| // for the text element, then it can only have a single line of text. |
| if (text.hasMaxLines() && valueForLayout == null) { |
| textView.setMaxLines(max(TEXT_MIN_LINES, text.getMaxLines().getValue())); |
| } else { |
| textView.setMaxLines(TEXT_MAX_LINES_DEFAULT); |
| } |
| applyTextOverflow(textView, text.getOverflow(), text.getMarqueeParameters()); |
| |
| // Text auto size is not supported for dynamic text. |
| boolean isAutoSizeAllowed = !(text.hasText() && text.getText().hasDynamicValue()); |
| // Setting colours **must** go after setting the Text Appearance, otherwise it will get |
| // immediately overridden. |
| if (text.hasFontStyle()) { |
| applyFontStyle( |
| text.getFontStyle(), |
| textView, |
| posId, |
| pipelineMaker, |
| isAutoSizeAllowed); |
| } else { |
| applyFontStyle( |
| FontStyle.getDefaultInstance(), |
| textView, |
| posId, |
| pipelineMaker, |
| isAutoSizeAllowed); |
| } |
| |
| boolean excludeFontPadding = false; |
| |
| if (text.hasAndroidTextStyle()) { |
| excludeFontPadding = text.getAndroidTextStyle().getExcludeFontPadding(); |
| } |
| applyExcludeFontPadding(textView, excludeFontPadding); |
| |
| if (text.hasLineHeight()) { |
| float lineHeightPx = toPx(text.getLineHeight()); |
| final float fontHeightPx = textView.getPaint().getFontSpacing(); |
| if (lineHeightPx != fontHeightPx) { |
| textView.setLineSpacing(lineHeightPx - fontHeightPx, 1f); |
| } |
| } |
| |
| // We don't want the text to be screen-reader focusable, unless wrapped in a Spannable |
| // modifier. This prevents automatically reading out partial text (e.g. text in a row) etc. |
| // |
| // This **must** be done before applying modifiers; applying a Semantics modifier will set |
| // importantForAccessibility, so we don't want to override it after applying modifiers. |
| textView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); |
| |
| View wrappedView = applyModifiers(textView, text.getModifiers(), posId, pipelineMaker); |
| |
| if (valueForLayout != null) { |
| if (valueForLayout.isEmpty()) { |
| Log.w(TAG, "Text's value_for_layout is empty. Element won't be visible."); |
| } |
| |
| // Now create a "container" element, with that size, to hold the text. |
| FrameLayout sizeChangingTextWrapper = new FrameLayout(mUiContext); |
| LayoutParams sizeChangingTextWrapperLayoutParams = generateDefaultLayoutParams(); |
| // Use the actual TextView to measure the text width. |
| sizeChangingTextWrapperLayoutParams.width = |
| (int) textView.getPaint().measureText(valueForLayout); |
| sizeChangingTextWrapperLayoutParams.height = LayoutParams.WRAP_CONTENT; |
| |
| // Set horizontal gravity on the wrapper to reflect alignment. |
| int gravity = textAlignToAndroidGravity(text.getText().getTextAlignmentForLayout()); |
| FrameLayout.LayoutParams frameLayoutLayoutParams = |
| new FrameLayout.LayoutParams(layoutParams); |
| frameLayoutLayoutParams.gravity = gravity; |
| layoutParams = frameLayoutLayoutParams; |
| |
| sizeChangingTextWrapper.addView(wrappedView, layoutParams); |
| parentViewWrapper.maybeAddView( |
| sizeChangingTextWrapper, sizeChangingTextWrapperLayoutParams); |
| return new InflatedView( |
| sizeChangingTextWrapper, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(sizeChangingTextWrapperLayoutParams)); |
| } else { |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| } |
| |
| /** |
| * Sets whether the padding is included or not. If font padding is not included, sets the |
| * correct padding to the TextView to avoid clipping taller languages. |
| */ |
| private void applyExcludeFontPadding(TextView textView, boolean excludeFontPadding) { |
| // Reversed value, since TextView sets padding to be included, while our protos are for |
| // excluding it. |
| textView.setIncludeFontPadding(!excludeFontPadding); |
| |
| // We need to update padding in the TextView if font's padding is not used, to avoid |
| // clipping of taller languages. |
| if (!excludeFontPadding) { |
| return; |
| } |
| |
| float ascent = textView.getPaint().getFontMetrics().ascent; |
| float descent = textView.getPaint().getFontMetrics().descent; |
| String text = textView.getText().toString(); |
| Rect bounds = new Rect(); |
| |
| textView.getPaint().getTextBounds(text, 0, max(0, text.length() - 1), bounds); |
| |
| int topPadding = textView.getPaddingTop(); |
| int bottomPadding = textView.getPaddingBottom(); |
| |
| if (ascent > bounds.top) { |
| topPadding = (int) (ascent - bounds.top); |
| } |
| |
| if (descent < bounds.bottom) { |
| bottomPadding = (int) (bounds.bottom - descent); |
| } |
| |
| textView.setPadding( |
| textView.getPaddingLeft(), topPadding, textView.getPaddingRight(), bottomPadding); |
| } |
| |
| private InflatedView inflateArcText( |
| ParentViewWrapper parentViewWrapper, |
| ArcText text, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| CurvedTextView textView = newThemedCurvedTextView(); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| layoutParams.width = LayoutParams.MATCH_PARENT; |
| layoutParams.height = LayoutParams.MATCH_PARENT; |
| |
| textView.setText(text.getText().getValue()); |
| |
| if (text.hasFontStyle()) { |
| applyFontStyle(text.getFontStyle(), textView); |
| } else if (mApplyFontVariantBodyAsDefault) { |
| applyFontStyle(FontStyle.getDefaultInstance(), textView); |
| } |
| |
| // Setting colours **must** go after setting the Text Appearance, otherwise it will get |
| // immediately overridden. |
| textView.setTextColor(extractTextColorArgb(text.getFontStyle())); |
| |
| View wrappedView = |
| applyModifiersToArcLayoutView(textView, text.getModifiers(), posId, pipelineMaker); |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| |
| private static boolean isZeroLengthImageDimension(ImageDimension dimension) { |
| return dimension.getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION |
| && dimension.getLinearDimension().getValue() == 0; |
| } |
| |
| private static ContainerDimension imageDimensionToContainerDimension(ImageDimension dimension) { |
| switch (dimension.getInnerCase()) { |
| case LINEAR_DIMENSION: |
| return ContainerDimension.newBuilder() |
| .setLinearDimension(dimension.getLinearDimension()) |
| .build(); |
| case EXPANDED_DIMENSION: |
| return ContainerDimension.newBuilder() |
| .setExpandedDimension(ExpandedDimensionProp.getDefaultInstance()) |
| .build(); |
| case PROPORTIONAL_DIMENSION: |
| // A ratio size should be translated to a WRAP_CONTENT; the RatioViewWrapper will |
| // deal with the sizing of that. |
| return ContainerDimension.newBuilder() |
| .setWrappedDimension(WrappedDimensionProp.getDefaultInstance()) |
| .build(); |
| case INNER_NOT_SET: |
| break; |
| } |
| // Caller should have already checked for this. |
| throw new IllegalArgumentException( |
| "ImageDimension has an unknown dimension type: " + dimension.getInnerCase().name()); |
| } |
| |
| @SuppressWarnings("ExecutorTaskName") |
| @Nullable |
| private InflatedView inflateImage( |
| ParentViewWrapper parentViewWrapper, |
| Image image, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| String protoResId = image.getResourceId().getValue(); |
| |
| // If either width or height isn't set, abort. |
| if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET |
| || image.getHeight().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET) { |
| Log.w(TAG, "One of width and height not set on image " + protoResId); |
| return null; |
| } |
| |
| // The image must occupy _some_ space. |
| if (isZeroLengthImageDimension(image.getWidth()) |
| || isZeroLengthImageDimension(image.getHeight())) { |
| Log.w(TAG, "One of width and height was zero on image " + protoResId); |
| return null; |
| } |
| |
| // Both dimensions can't be ratios. |
| if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION |
| && image.getHeight().getInnerCase() |
| == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { |
| Log.w(TAG, "Both width and height were proportional for image " + protoResId); |
| return null; |
| } |
| |
| // Pull the ratio for the RatioViewWrapper. Was either argument a proportional dimension? |
| @Nullable Float ratio = RatioViewWrapper.UNDEFINED_ASPECT_RATIO; |
| |
| if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { |
| ratio = safeAspectRatioOrNull(image.getWidth().getProportionalDimension()); |
| } |
| |
| if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { |
| ratio = safeAspectRatioOrNull(image.getHeight().getProportionalDimension()); |
| } |
| |
| if (ratio == null) { |
| Log.w(TAG, "Invalid aspect ratio for image " + protoResId); |
| return null; |
| } |
| |
| ImageViewWithoutIntrinsicSizes imageView = new ImageViewWithoutIntrinsicSizes(mUiContext); |
| |
| if (image.hasContentScaleMode()) { |
| imageView.setScaleType( |
| contentScaleModeToScaleType(image.getContentScaleMode().getValue())); |
| } |
| |
| if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) { |
| imageView.setMinimumWidth(safeDpToPx(image.getWidth().getLinearDimension())); |
| } |
| |
| if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) { |
| imageView.setMinimumHeight(safeDpToPx(image.getHeight().getLinearDimension())); |
| } |
| |
| // We need to sort out the sizing of the widget now, so we can pass the correct params to |
| // RatioViewWrapper. First, translate the ImageSize to a ContainerSize. A ratio size should |
| // be translated to a WRAP_CONTENT; the RatioViewWrapper will deal with the sizing of that. |
| LayoutParams ratioWrapperLayoutParams = generateDefaultLayoutParams(); |
| ratioWrapperLayoutParams = |
| updateLayoutParams( |
| parentViewWrapper.getParentProperties(), |
| ratioWrapperLayoutParams, |
| imageDimensionToContainerDimension(image.getWidth()), |
| imageDimensionToContainerDimension(image.getHeight())); |
| |
| // Apply the modifiers to the ImageView, **not** the RatioViewWrapper. |
| // |
| // RatioViewWrapper doesn't do any custom drawing, it only exists to force dimensions during |
| // the measure/layout passes, so it doesn't matter which element any border/background |
| // modifiers get applied to. Applying modifiers to the ImageView is important for Semantics |
| // though; screen readers try and pick up on the type of element being read, so in the case |
| // of an image would read "image, <description>" (where the location of "image" can move |
| // depending on user settings). If we apply the modifiers to RatioViewWrapper though, screen |
| // readers will not realise that this is an image, and will read the incorrect description. |
| View wrappedImageView = |
| applyModifiers(imageView, image.getModifiers(), posId, pipelineMaker); |
| |
| RatioViewWrapper ratioViewWrapper = new RatioViewWrapper(mUiContext); |
| ratioViewWrapper.setAspectRatio(ratio); |
| ratioViewWrapper.addView(wrappedImageView); |
| |
| parentViewWrapper.maybeAddView(ratioViewWrapper, ratioWrapperLayoutParams); |
| |
| ListenableFuture<Drawable> drawableFuture = |
| mLayoutResourceResolvers.getDrawable(protoResId); |
| Drawable immediatelySetDrawable = null; |
| if (drawableFuture.isDone() && !drawableFuture.isCancelled()) { |
| // If the future is done, immediately draw. |
| immediatelySetDrawable = setImageDrawable(imageView, drawableFuture, protoResId); |
| } |
| |
| if (immediatelySetDrawable != null && pipelineMaker.isPresent()) { |
| if (immediatelySetDrawable instanceof AnimatedVectorDrawable) { |
| AnimatedVectorDrawable avd = (AnimatedVectorDrawable) immediatelySetDrawable; |
| try { |
| Trigger trigger = mLayoutResourceResolvers.getAnimationTrigger(protoResId); |
| |
| if (trigger != null |
| && trigger.getInnerCase() |
| == Trigger.InnerCase.ON_CONDITION_MET_TRIGGER) { |
| OnConditionMetTrigger conditionTrigger = trigger.getOnConditionMetTrigger(); |
| pipelineMaker |
| .get() |
| .addResolvedAnimatedImageWithBoolTrigger( |
| avd, trigger, posId, conditionTrigger.getCondition()); |
| } else { |
| // Use default trigger if it's not set. |
| if (trigger == null |
| || trigger.getInnerCase() == Trigger.InnerCase.INNER_NOT_SET) { |
| trigger = DEFAULT_ANIMATION_TRIGGER; |
| } |
| pipelineMaker.get().addResolvedAnimatedImage(avd, trigger, posId); |
| } |
| } catch (RuntimeException ex) { |
| Log.e(TAG, "Error setting up animation trigger", ex); |
| } |
| } else if (immediatelySetDrawable instanceof SeekableAnimatedVectorDrawable) { |
| SeekableAnimatedVectorDrawable seekableAvd = |
| (SeekableAnimatedVectorDrawable) immediatelySetDrawable; |
| try { |
| DynamicFloat progress = mLayoutResourceResolvers.getBoundProgress(protoResId); |
| if (progress != null) { |
| pipelineMaker |
| .get() |
| .addResolvedSeekableAnimatedImage(seekableAvd, progress, posId); |
| } |
| } catch (IllegalArgumentException ex) { |
| Log.e(TAG, "Error setting up seekable animated image", ex); |
| } |
| } |
| } else { |
| // Is there a placeholder to use in the meantime? |
| try { |
| if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) { |
| if (setImageDrawable( |
| imageView, |
| mLayoutResourceResolvers.getPlaceholderDrawableOrThrow( |
| protoResId), |
| protoResId) |
| == null) { |
| Log.w(TAG, "Failed to set the placeholder for " + protoResId); |
| } |
| } |
| } catch (ResourceAccessException | IllegalArgumentException ex) { |
| Log.e(TAG, "Exception loading placeholder for resource " + protoResId, ex); |
| } |
| |
| // Otherwise, handle the result on the UI thread. |
| drawableFuture.addListener( |
| () -> setImageDrawable(imageView, drawableFuture, protoResId), |
| ContextCompat.getMainExecutor(mUiContext)); |
| } |
| |
| boolean canImageBeTinted = false; |
| |
| try { |
| canImageBeTinted = mLayoutResourceResolvers.canImageBeTinted(protoResId); |
| } catch (IllegalArgumentException ex) { |
| Log.e(TAG, "Exception tinting image " + protoResId, ex); |
| } |
| |
| if (image.getColorFilter().hasTint() && canImageBeTinted) { |
| // Only allow tinting for Android images.f |
| handleProp( |
| image.getColorFilter().getTint(), |
| tintColor -> { |
| ColorStateList tint = ColorStateList.valueOf(tintColor); |
| imageView.setImageTintList(tint); |
| |
| // SRC_IN throws away the colours in the drawable that we're tinting. |
| // Effectively, the drawable being tinted is only a mask to apply the colour |
| // to. |
| imageView.setImageTintMode(Mode.SRC_IN); |
| }, |
| posId, |
| pipelineMaker); |
| } |
| |
| return new InflatedView( |
| ratioViewWrapper, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(ratioWrapperLayoutParams)); |
| } |
| |
| /** |
| * Set drawable to the image view. |
| * |
| * @return Returns the drawable if it is successfully retrieved from the drawable future and set |
| * to the image view; otherwise returns null to indicate the failure of setting drawable. |
| */ |
| @Nullable |
| private static Drawable setImageDrawable( |
| ImageView imageView, Future<Drawable> drawableFuture, String protoResId) { |
| try { |
| return setImageDrawable(imageView, drawableFuture.get(), protoResId); |
| } catch (ExecutionException | InterruptedException | CancellationException e) { |
| Log.w(TAG, "Could not get drawable for image " + protoResId, e); |
| } |
| return null; |
| } |
| |
| /** |
| * Set drawable to the image view. |
| * |
| * @return Returns the drawable if it is successfully set to the image view; otherwise returns |
| * null to indicate the failure of setting drawable. |
| */ |
| @Nullable |
| private static Drawable setImageDrawable( |
| ImageView imageView, Drawable drawable, String protoResId) { |
| if (drawable instanceof BitmapDrawable |
| && ((BitmapDrawable) drawable).getBitmap().getByteCount() |
| > DEFAULT_MAX_BITMAP_RAW_SIZE) { |
| Log.w(TAG, "Ignoring image " + protoResId + " as it's too large."); |
| return null; |
| } |
| imageView.setImageDrawable(drawable); |
| return drawable; |
| } |
| |
| @Nullable |
| private InflatedView inflateArcLine( |
| ParentViewWrapper parentViewWrapper, |
| ArcLine line, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| float lengthDegrees = 0; |
| if (line.hasAngularLength()) { |
| if (line.getAngularLength().getInnerCase() == ArcLineLength.InnerCase.DEGREES) { |
| lengthDegrees = max(0, line.getAngularLength().getDegrees().getValue()); |
| } |
| } else { |
| lengthDegrees = max(0, line.getLength().getValue()); |
| } |
| |
| int thicknessPx = safeDpToPx(line.getThickness()); |
| |
| if (lengthDegrees == 0 && thicknessPx == 0) { |
| return null; |
| } |
| |
| WearCurvedLineView lineView = new WearCurvedLineView(mUiContext); |
| |
| // A ArcLineView must always be the same width/height as its parent, so it can draw the line |
| // properly inside of those bounds. |
| ArcLayout.LayoutParams layoutParams = |
| new ArcLayout.LayoutParams(generateDefaultLayoutParams()); |
| layoutParams.width = LayoutParams.MATCH_PARENT; |
| layoutParams.height = LayoutParams.MATCH_PARENT; |
| |
| if (line.hasColor()) { |
| handleProp(line.getColor(), lineView::setColor, posId, pipelineMaker); |
| } else { |
| lineView.setColor(LINE_COLOR_DEFAULT); |
| } |
| |
| if (line.hasStrokeCap()) { |
| switch (line.getStrokeCap().getValue()) { |
| case STROKE_CAP_BUTT: |
| lineView.setStrokeCap(Cap.BUTT); |
| break; |
| case STROKE_CAP_ROUND: |
| lineView.setStrokeCap(Cap.ROUND); |
| break; |
| case STROKE_CAP_SQUARE: |
| lineView.setStrokeCap(Cap.SQUARE); |
| break; |
| case UNRECOGNIZED: |
| case STROKE_CAP_UNDEFINED: |
| Log.w(TAG, "Undefined StrokeCap value."); |
| break; |
| } |
| } |
| |
| lineView.setThickness(thicknessPx); |
| |
| DegreesProp length; |
| if (line.hasAngularLength()) { |
| final ArcLineLength angularLength = line.getAngularLength(); |
| switch (angularLength.getInnerCase()) { |
| case DEGREES: |
| length = line.getAngularLength().getDegrees(); |
| handleProp(length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker); |
| break; |
| |
| case EXPANDED_ANGULAR_DIMENSION: |
| { |
| ExpandedAngularDimensionProp expandedAngularDimension = |
| angularLength.getExpandedAngularDimension(); |
| layoutParams.setWeight( |
| expandedAngularDimension.hasLayoutWeight() |
| ? expandedAngularDimension.getLayoutWeight().getValue() |
| : 1.0f); |
| length = DegreesProp.getDefaultInstance(); |
| break; |
| } |
| |
| default: |
| length = DegreesProp.getDefaultInstance(); |
| break; |
| } |
| } else { |
| length = line.getLength(); |
| handleProp(length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker); |
| } |
| |
| SizedArcContainer sizeWrapper = null; |
| SizedArcContainer.LayoutParams sizedLp = |
| new SizedArcContainer.LayoutParams( |
| LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); |
| @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length); |
| if (sizeForLayout != null) { |
| sizeWrapper = new SizedArcContainer(mUiContext); |
| if (sizeForLayout <= 0f) { |
| Log.w( |
| TAG, |
| "ArcLine length's value_for_layout is not a positive value. Element won't" |
| + " be visible."); |
| } |
| sizeWrapper.setSweepAngleDegrees(sizeForLayout); |
| sizedLp.setAngularAlignment( |
| angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout())); |
| |
| // Also clamp the line to that angle... |
| lineView.setMaxSweepAngleDegrees(sizeForLayout); |
| } |
| |
| View wrappedView = |
| applyModifiersToArcLayoutView(lineView, line.getModifiers(), posId, pipelineMaker); |
| |
| if (sizeWrapper != null) { |
| sizeWrapper.addView(wrappedView, sizedLp); |
| parentViewWrapper.maybeAddView(sizeWrapper, layoutParams); |
| return new InflatedView( |
| sizeWrapper, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } else { |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| } |
| |
| // dereference of possibly-null reference childLayoutParams |
| @SuppressWarnings("nullness:dereference.of.nullable") |
| @Nullable |
| private InflatedView inflateArc( |
| ParentViewWrapper parentViewWrapper, |
| Arc arc, |
| String arcPosId, |
| boolean includeChildren, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| ArcLayout arcLayout = new ArcLayout(mUiContext); |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| layoutParams.width = LayoutParams.MATCH_PARENT; |
| layoutParams.height = LayoutParams.MATCH_PARENT; |
| |
| handleProp( |
| arc.getAnchorAngle(), |
| angle -> { |
| arcLayout.setAnchorAngleDegrees(angle); |
| // Invalidating arcLayout isn't enough. AnchorAngleDegrees change should trigger |
| // child requestLayout. |
| arcLayout.requestLayout(); |
| }, |
| arcPosId, |
| pipelineMaker); |
| arcLayout.setAnchorAngleDegrees(arc.getAnchorAngle().getValue()); |
| arcLayout.setAnchorType(anchorTypeToAnchorPos(arc.getAnchorType().getValue())); |
| |
| if (arc.hasMaxAngle()) { |
| arcLayout.setMaxAngleDegrees(arc.getMaxAngle().getValue()); |
| } |
| |
| // Add all children. |
| if (includeChildren) { |
| int index = FIRST_CHILD_INDEX; |
| for (ArcLayoutElement child : arc.getContentsList()) { |
| String childPosId = ProtoLayoutDiffer.createNodePosId(arcPosId, index++); |
| @Nullable |
| InflatedView childView = |
| inflateArcLayoutElement( |
| new ParentViewWrapper(arcLayout, layoutParams), |
| child, |
| childPosId, |
| layoutInfoBuilder, |
| pipelineMaker); |
| if (childView != null) { |
| ArcLayout.LayoutParams childLayoutParams = |
| (ArcLayout.LayoutParams) childView.mView.getLayoutParams(); |
| boolean rotate = false; |
| if (child.hasAdapter()) { |
| rotate = child.getAdapter().getRotateContents().getValue(); |
| } |
| |
| // Apply rotation and gravity. |
| childLayoutParams.setRotated(rotate); |
| childLayoutParams.setVerticalAlignment( |
| verticalAlignmentToArcVAlign(arc.getVerticalAlign())); |
| } |
| } |
| layoutInfoBuilder.removeSubtree(arcPosId); |
| } |
| |
| View wrappedView = applyModifiers(arcLayout, arc.getModifiers(), arcPosId, pipelineMaker); |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| int numMissingChildren = includeChildren ? 0 : arc.getContentsCount(); |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), |
| NO_OP_PENDING_LAYOUT_PARAMS, |
| numMissingChildren); |
| } |
| |
| private void applyStylesToSpan( |
| SpannableStringBuilder builder, int start, int end, FontStyle fontStyle) { |
| if (fontStyleHasSize(fontStyle)) { |
| // We are using the last added size in the FontStyle because ArcText doesn't support |
| // autosizing. This is the same behaviour as it was before size has made repeated. |
| if (fontStyle.getSizeList().size() > 1) { |
| Log.w( |
| TAG, |
| "Font size with multiple values has been used on Span Text. Ignoring " |
| + "all size except the first one."); |
| } |
| AbsoluteSizeSpan span = new AbsoluteSizeSpan(round(toPx( |
| fontStyle.getSize(fontStyle.getSizeCount() - 1)))); |
| builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| if (fontStyle.hasWeight() || fontStyle.hasVariant()) { |
| CustomTypefaceSpan span = new CustomTypefaceSpan(fontStyleToTypeface(fontStyle)); |
| builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| if (!hasDefaultTypefaceStyle(fontStyle)) { |
| MetricAffectingSpan span = createTypefaceSpan(fontStyle); |
| builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| if (fontStyle.getUnderline().getValue()) { |
| UnderlineSpan span = new UnderlineSpan(); |
| builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| if (fontStyle.hasLetterSpacing()) { |
| LetterSpacingSpan span = new LetterSpacingSpan(fontStyle.getLetterSpacing().getValue()); |
| builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| ForegroundColorSpan colorSpan = new ForegroundColorSpan(extractTextColorArgb(fontStyle)); |
| |
| builder.setSpan(colorSpan, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| |
| private static boolean fontStyleHasSize(FontStyle fontStyle) { |
| return !fontStyle.getSizeList().isEmpty(); |
| } |
| |
| private void applyModifiersToSpan( |
| SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers) { |
| if (modifiers.hasClickable()) { |
| ClickableSpan clickableSpan = new ProtoLayoutClickableSpan(modifiers.getClickable()); |
| |
| builder.setSpan(clickableSpan, start, end, Spanned.SPAN_MARK_MARK); |
| } |
| } |
| |
| private SpannableStringBuilder inflateTextInSpannable( |
| SpannableStringBuilder builder, SpanText text) { |
| int currentPos = builder.length(); |
| int lastPos = currentPos + text.getText().getValue().length(); |
| |
| builder.append(text.getText().getValue()); |
| |
| applyStylesToSpan(builder, currentPos, lastPos, text.getFontStyle()); |
| applyModifiersToSpan(builder, currentPos, lastPos, text.getModifiers()); |
| |
| return builder; |
| } |
| |
| @SuppressWarnings("ExecutorTaskName") |
| private SpannableStringBuilder inflateImageInSpannable( |
| SpannableStringBuilder builder, SpanImage protoImage, TextView textView) { |
| String protoResId = protoImage.getResourceId().getValue(); |
| |
| if (protoImage.getWidth().getValue() == 0 || protoImage.getHeight().getValue() == 0) { |
| Log.w(TAG, "One of width and height was zero on image " + protoResId); |
| return builder; |
| } |
| |
| ListenableFuture<Drawable> drawableFuture = |
| mLayoutResourceResolvers.getDrawable(protoResId); |
| if (drawableFuture.isDone()) { |
| // If the future is done, immediately add drawable to builder. |
| try { |
| Drawable drawable = drawableFuture.get(); |
| appendSpanDrawable(builder, drawable, protoImage); |
| } catch (ExecutionException | InterruptedException e) { |
| Log.w( |
| TAG, |
| "Could not get drawable for image " |
| + protoImage.getResourceId().getValue()); |
| } |
| } else { |
| // If the future is not done, add an empty drawable to builder as a placeholder. |
| @Nullable Drawable placeholderDrawable = null; |
| |
| try { |
| if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) { |
| placeholderDrawable = |
| mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(protoResId); |
| } |
| } catch (ResourceAccessException | IllegalArgumentException ex) { |
| Log.e(TAG, "Could not get placeholder for image " + protoResId, ex); |
| } |
| |
| if (placeholderDrawable == null) { |
| placeholderDrawable = new ColorDrawable(Color.TRANSPARENT); |
| } |
| |
| int startInclusive = builder.length(); |
| FixedImageSpan placeholderDrawableSpan = |
| appendSpanDrawable(builder, placeholderDrawable, protoImage); |
| int endExclusive = builder.length(); |
| |
| // When the future is done, replace the empty drawable with the received one. |
| drawableFuture.addListener( |
| () -> { |
| // Remove the placeholder. This should be safe, even with other modifiers |
| // applied. This just removes the single drawable span, and should leave |
| // other spans in place. |
| builder.removeSpan(placeholderDrawableSpan); |
| // Add the new drawable to the same range. |
| setSpanDrawable( |
| builder, drawableFuture, startInclusive, endExclusive, protoImage); |
| // Update the TextView. |
| textView.setText(builder); |
| }, |
| ContextCompat.getMainExecutor(mUiContext)); |
| } |
| |
| return builder; |
| } |
| |
| private FixedImageSpan appendSpanDrawable( |
| SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage) { |
| drawable.setBounds( |
| 0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight())); |
| FixedImageSpan imgSpan = |
| new FixedImageSpan( |
| drawable, |
| spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment())); |
| |
| int startPos = builder.length(); |
| |
| // Adding NBSP around the space to prevent it from being trimmed. |
| builder.append( |
| ZERO_WIDTH_JOINER + " " + ZERO_WIDTH_JOINER, imgSpan, Spanned.SPAN_MARK_MARK); |
| int endPos = builder.length(); |
| |
| applyModifiersToSpan(builder, startPos, endPos, protoImage.getModifiers()); |
| |
| return imgSpan; |
| } |
| |
| private void setSpanDrawable( |
| SpannableStringBuilder builder, |
| ListenableFuture<Drawable> drawableFuture, |
| int startInclusive, |
| int endExclusive, |
| SpanImage protoImage) { |
| final String protoResourceId = protoImage.getResourceId().getValue(); |
| |
| try { |
| // Add the image span to the same range occupied by the placeholder. |
| Drawable drawable = drawableFuture.get(); |
| drawable.setBounds( |
| 0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight())); |
| FixedImageSpan imgSpan = |
| new FixedImageSpan( |
| drawable, |
| spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment())); |
| builder.setSpan( |
| imgSpan, |
| startInclusive, |
| endExclusive, |
| android.text.Spannable.SPAN_INCLUSIVE_EXCLUSIVE); |
| } catch (ExecutionException | InterruptedException | CancellationException e) { |
| Log.w(TAG, "Could not get drawable for image " + protoResourceId); |
| } |
| } |
| |
| private InflatedView inflateSpannable( |
| ParentViewWrapper parentViewWrapper, |
| Spannable spannable, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| TextView tv = newThemedTextView(); |
| |
| // Setting colours **must** go after setting the Text Appearance, otherwise it will get |
| // immediately overridden. |
| if (mApplyFontVariantBodyAsDefault) { |
| applyFontStyle( |
| FontStyle.getDefaultInstance(), |
| tv, |
| posId, |
| pipelineMaker, |
| /* isAutoSizeAllowed= */ false); |
| } |
| |
| LayoutParams layoutParams = generateDefaultLayoutParams(); |
| |
| SpannableStringBuilder builder = new SpannableStringBuilder(); |
| |
| boolean isAnySpanClickable = false; |
| |
| boolean excludeFontPadding = false; |
| |
| for (Span element : spannable.getSpansList()) { |
| switch (element.getInnerCase()) { |
| case IMAGE: |
| SpanImage protoImage = element.getImage(); |
| builder = inflateImageInSpannable(builder, protoImage, tv); |
| |
| if (protoImage.getModifiers().hasClickable()) { |
| isAnySpanClickable = true; |
| } |
| |
| break; |
| case TEXT: |
| SpanText protoText = element.getText(); |
| builder = inflateTextInSpannable(builder, protoText); |
| |
| if (protoText.getModifiers().hasClickable()) { |
| isAnySpanClickable = true; |
| } |
| |
| if (protoText.hasAndroidTextStyle() |
| && protoText.getAndroidTextStyle().getExcludeFontPadding()) { |
| excludeFontPadding = true; |
| } |
| break; |
| default: |
| Log.w(TAG, "Unknown Span child type."); |
| break; |
| } |
| } |
| |
| tv.setGravity(horizontalAlignmentToGravity(spannable.getMultilineAlignment().getValue())); |
| |
| if (spannable.hasMaxLines()) { |
| tv.setMaxLines(max(TEXT_MIN_LINES, spannable.getMaxLines().getValue())); |
| } else { |
| tv.setMaxLines(TEXT_MAX_LINES_DEFAULT); |
| } |
| applyTextOverflow(tv, spannable.getOverflow(), spannable.getMarqueeParameters()); |
| |
| if (spannable.hasLineHeight()) { |
| // We use a Span here instead of just calling TextViewCompat#setLineHeight. |
| // setLineHeight is implemented by taking the difference between the current font height |
| // (via the font metrics, not just the size in SP), subtracting that from the desired |
| // line height, and setting that as the inter-line spacing. This doesn't work for our |
| // Spannables; we don't use a default height, yet TextView still has a default font (and |
| // size) that it tries to base the requested line height on, despite that never actually |
| // being used. The end result is that the line height never actually drops out as |
| // expected. |
| // |
| // Instead, wrap the whole thing in a LineHeightSpan with the desired line height. This |
| // gets calculated properly as the TextView is calculating its per-line font metrics, |
| // and will actually work correctly. |
| StandardLineHeightSpan span = |
| new StandardLineHeightSpan((int) toPx(spannable.getLineHeight())); |
| builder.setSpan( |
| span, |
| /* start= */ 0, |
| /* end= */ builder.length(), |
| Spanned.SPAN_INCLUSIVE_EXCLUSIVE); |
| } else if (spannable.hasLineSpacing()) { |
| tv.setLineSpacing(toPx(spannable.getLineSpacing()), 1f); |
| } |
| |
| tv.setText(builder); |
| |
| applyExcludeFontPadding(tv, excludeFontPadding); |
| |
| if (isAnySpanClickable) { |
| // For any ClickableSpans to work, the MovementMethod must be set to LinkMovementMethod. |
| tv.setMovementMethod(LinkMovementMethod.getInstance()); |
| |
| // Disable the highlight color; if we don't do this, the clicked span will get |
| // highlighted, which will be cleared half a second later if using LoadAction as the |
| // next layout will be delivered, which recreates the elements and clears the highlight. |
| tv.setHighlightColor(Color.TRANSPARENT); |
| |
| // Use InhibitingScroller to prevent the text from scrolling when tapped. Setting a |
| // MovementMethod on a TextView (e.g. for clickables in a Spannable) then cause the |
| // TextView to be scrollable, and to jump to the end when tapped. |
| tv.setScroller(new InhibitingScroller(mUiContext)); |
| } |
| |
| View wrappedView = applyModifiers(tv, spannable.getModifiers(), posId, pipelineMaker); |
| parentViewWrapper.maybeAddView(wrappedView, layoutParams); |
| |
| return new InflatedView( |
| wrappedView, |
| parentViewWrapper |
| .getParentProperties() |
| .applyPendingChildLayoutParams(layoutParams)); |
| } |
| |
| @Nullable |
| private InflatedView inflateArcLayoutElement( |
| ParentViewWrapper parentViewWrapper, |
| ArcLayoutElement element, |
| String nodePosId, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| InflatedView inflatedView = null; |
| |
| switch (element.getInnerCase()) { |
| case ADAPTER: |
| // Fall back to the normal inflater. |
| inflatedView = |
| inflateLayoutElement( |
| parentViewWrapper, |
| element.getAdapter().getContent(), |
| nodePosId, |
| /* includeChildren= */ true, |
| layoutInfoBuilder, |
| pipelineMaker); |
| break; |
| |
| case SPACER: |
| inflatedView = |
| inflateArcSpacer( |
| parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker); |
| break; |
| |
| case LINE: |
| inflatedView = |
| inflateArcLine( |
| parentViewWrapper, element.getLine(), nodePosId, pipelineMaker); |
| break; |
| |
| case TEXT: |
| inflatedView = |
| inflateArcText( |
| parentViewWrapper, element.getText(), nodePosId, pipelineMaker); |
| break; |
| |
| case INNER_NOT_SET: |
| break; |
| } |
| |
| if (inflatedView == null) { |
| // Covers null (returned when the childCase in the proto isn't known). Sadly, ProtoLite |
| // doesn't give us a way to access childCase's underlying tag, so we can't give any |
| // smarter error message here. |
| Log.w(TAG, "Unknown child type"); |
| } else if (nodePosId.isEmpty()) { |
| Log.w(TAG, "No node ID for " + element.getInnerCase().name()); |
| } else { |
| // Set the view's tag to a known, position-based ID so that it can be looked up to apply |
| // mutations. |
| inflatedView.mView.setTag(nodePosId); |
| if (inflatedView.mView instanceof ViewGroup) { |
| layoutInfoBuilder.add( |
| nodePosId, |
| ViewProperties.fromViewGroup( |
| (ViewGroup) inflatedView.mView, |
| inflatedView.mLayoutParams, |
| inflatedView.mChildLayoutParams)); |
| } |
| pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId)); |
| } |
| return inflatedView; |
| } |
| |
| @Nullable |
| private InflatedView inflateLayoutElement( |
| ParentViewWrapper parentViewWrapper, |
| LayoutElement element, |
| String nodePosId, |
| boolean includeChildren, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| InflatedView inflatedView = null; |
| // What is it? |
| switch (element.getInnerCase()) { |
| case COLUMN: |
| inflatedView = |
| inflateColumn( |
| parentViewWrapper, |
| element.getColumn(), |
| nodePosId, |
| includeChildren, |
| layoutInfoBuilder, |
| pipelineMaker); |
| break; |
| case ROW: |
| inflatedView = |
| inflateRow( |
| parentViewWrapper, |
| element.getRow(), |
| nodePosId, |
| includeChildren, |
| layoutInfoBuilder, |
| pipelineMaker); |
| break; |
| case BOX: |
| inflatedView = |
| inflateBox( |
| parentViewWrapper, |
| element.getBox(), |
| nodePosId, |
| includeChildren, |
| layoutInfoBuilder, |
| pipelineMaker); |
| break; |
| case SPACER: |
| inflatedView = |
| inflateSpacer( |
| parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker); |
| break; |
| case TEXT: |
| inflatedView = |
| inflateText(parentViewWrapper, element.getText(), nodePosId, pipelineMaker); |
| break; |
| case IMAGE: |
| inflatedView = |
| inflateImage( |
| parentViewWrapper, element.getImage(), nodePosId, pipelineMaker); |
| break; |
| case ARC: |
| inflatedView = |
| inflateArc( |
| parentViewWrapper, |
| element.getArc(), |
| nodePosId, |
| includeChildren, |
| layoutInfoBuilder, |
| pipelineMaker); |
| break; |
| case SPANNABLE: |
| inflatedView = |
| inflateSpannable( |
| parentViewWrapper, |
| element.getSpannable(), |
| nodePosId, |
| pipelineMaker); |
| break; |
| case EXTENSION: |
| try { |
| inflatedView = inflateExtension(parentViewWrapper, element.getExtension()); |
| } catch (IllegalStateException ex) { |
| Log.w(TAG, "Error inflating Extension.", ex); |
| } |
| break; |
| case INNER_NOT_SET: |
| Log.w(TAG, "Unknown child type: " + element.getInnerCase().name()); |
| break; |
| } |
| |
| if (inflatedView == null) { |
| Log.w(TAG, "Error inflating " + element.getInnerCase().name()); |
| } else if (nodePosId.isEmpty()) { |
| Log.w(TAG, "No node ID for " + element.getInnerCase().name()); |
| } else { |
| // Set the view's tag to a known, position-based ID so that it can be looked up to apply |
| // mutations. |
| inflatedView.mView.setTag(nodePosId); |
| if (inflatedView.mView instanceof ViewGroup) { |
| layoutInfoBuilder.add( |
| nodePosId, |
| ViewProperties.fromViewGroup( |
| (ViewGroup) inflatedView.mView, |
| inflatedView.mLayoutParams, |
| inflatedView.mChildLayoutParams)); |
| } |
| pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId)); |
| } |
| return inflatedView; |
| } |
| |
| @Nullable |
| private InflatedView inflateExtension( |
| ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) { |
| int widthPx = safeDpToPx(element.getWidth().getLinearDimension()); |
| int heightPx = safeDpToPx(element.getHeight().getLinearDimension()); |
| |
| if (widthPx == 0 && heightPx == 0) { |
| return null; |
| } |
| |
| if (mExtensionViewProvider == null) { |
| Log.e(TAG, "Layout has extension payload, but no extension provider is available."); |
| return inflateFailedExtension(parentViewWrapper, element); |
| } |
| |
| View view = |
| mExtensionViewProvider.provideView( |
| element.getPayload().toByteArray(), element.getExtensionId()); |
| |
| if (view == null) { |
| Log.w(TAG, "Extension view provider returned null."); |
| // A failed extension should still occupy space. |
| return inflateFailedExtension(parentViewWrapper, element); |
| } |
| |
| if (view.getTag() != null) { |
| throw new IllegalStateException("Extension must not set View's default tag"); |
| } |
| |
| LayoutParams lp = new LayoutParams(widthPx, heightPx); |
| parentViewWrapper.maybeAddView(view, lp); |
| |
| return new InflatedView( |
| view, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp)); |
| } |
| |
| private InflatedView inflateFailedExtension( |
| ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) { |
| int widthPx = safeDpToPx(element.getWidth().getLinearDimension()); |
| int heightPx = safeDpToPx(element.getHeight().getLinearDimension()); |
| |
| Space space = new Space(mUiContext); |
| |
| LayoutParams lp = new LayoutParams(widthPx, heightPx); |
| parentViewWrapper.maybeAddView(space, lp); |
| |
| return new InflatedView( |
| space, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp)); |
| } |
| |
| /** |
| * Either yield the constant value stored in stringProp, or register for updates if it is |
| * dynamic property. |
| * |
| * <p>If both are set, this routine will yield the constant value if and only if this renderer |
| * has a dynamic pipeline (i.e. {code mDataPipeline} is non-null), otherwise it will only |
| * subscribe for dynamic updates. If the dynamic pipeline ever yields an invalid value (via |
| * {@code onStateInvalid}), then stringProp's static valid will be used instead. |
| */ |
| private void handleProp( |
| StringProp stringProp, |
| Consumer<String> consumer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (stringProp.hasDynamicValue() && pipelineMaker.isPresent()) { |
| try { |
| pipelineMaker |
| .get() |
| .addPipelineFor( |
| stringProp.getDynamicValue(), |
| stringProp.getValue(), |
| mUiContext.getResources().getConfiguration().getLocales().get(0), |
| posId, |
| consumer); |
| } catch (RuntimeException ex) { |
| Log.e(TAG, "Error building pipeline", ex); |
| consumer.accept(stringProp.getValue()); |
| } |
| } else { |
| consumer.accept(stringProp.getValue()); |
| } |
| } |
| |
| private void handleProp( |
| DegreesProp degreesProp, |
| Consumer<Float> consumer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (degreesProp.hasDynamicValue() && pipelineMaker.isPresent()) { |
| try { |
| pipelineMaker |
| .get() |
| .addPipelineFor(degreesProp, degreesProp.getValue(), posId, consumer); |
| } catch (RuntimeException ex) { |
| Log.e(TAG, "Error building pipeline", ex); |
| consumer.accept(degreesProp.getValue()); |
| } |
| } else { |
| consumer.accept(degreesProp.getValue()); |
| } |
| } |
| |
| private void handleProp( |
| DpProp dpProp, |
| Consumer<Float> consumer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (dpProp.hasDynamicValue() && pipelineMaker.isPresent()) { |
| try { |
| pipelineMaker.get().addPipelineFor(dpProp, dpProp.getValue(), posId, consumer); |
| } catch (RuntimeException ex) { |
| Log.e(TAG, "Error building pipeline", ex); |
| consumer.accept(dpProp.getValue()); |
| } |
| } else { |
| consumer.accept(dpProp.getValue()); |
| } |
| } |
| |
| private void handleProp( |
| ColorProp colorProp, |
| Consumer<Integer> consumer, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| if (colorProp.hasDynamicValue() && pipelineMaker.isPresent()) { |
| try { |
| pipelineMaker.get().addPipelineFor(colorProp, colorProp.getArgb(), posId, consumer); |
| } catch (RuntimeException ex) { |
| Log.e(TAG, "Error building pipeline", ex); |
| consumer.accept(colorProp.getArgb()); |
| } |
| } else { |
| consumer.accept(colorProp.getArgb()); |
| } |
| } |
| |
| /** |
| * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic |
| * values. Returns null if no size wrapper is needed. |
| */ |
| @Nullable |
| private String resolveValueForLayoutIfNeeded(StringProp stringProp) { |
| if (!stringProp.hasDynamicValue() || !mDataPipeline.isPresent()) { |
| return null; |
| } |
| |
| // If value_for_layout is set to non-zero, always use it. |
| if (!stringProp.getValueForLayout().isEmpty()) { |
| return stringProp.getValueForLayout(); |
| } |
| |
| return mAllowLayoutChangingBindsWithoutDefault ? null : ""; |
| } |
| |
| /** |
| * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic |
| * values. Returns null if no size wrapper is needed. |
| */ |
| @Nullable |
| private Float resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension) { |
| DpProp dimension = spacerDimension.getLinearDimension(); |
| if (!dimension.hasDynamicValue() || !mDataPipeline.isPresent()) { |
| return null; |
| } |
| |
| if (dimension.getValueForLayout() > 0f) { |
| return dimension.getValueForLayout(); |
| } |
| |
| return mAllowLayoutChangingBindsWithoutDefault ? null : 0f; |
| } |
| |
| /** |
| * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic |
| * values. Returns null if no size wrapper is needed. |
| */ |
| @Nullable |
| private Float resolveSizeForLayoutIfNeeded(DegreesProp degreesProp) { |
| if (!degreesProp.hasDynamicValue() || !mDataPipeline.isPresent()) { |
| return null; |
| } |
| |
| // If value_for_layout is set to non-zero, always use it |
| if (degreesProp.getValueForLayout() > 0f) { |
| return degreesProp.getValueForLayout(); |
| } |
| |
| return mAllowLayoutChangingBindsWithoutDefault ? null : 0f; |
| } |
| |
| private boolean canMeasureContainer( |
| ContainerDimension containerWidth, |
| ContainerDimension containerHeight, |
| List<LayoutElement> elements) { |
| // We can't measure a container if it's set to wrap-contents but all of its contents are set |
| // to expand-to-parent. Such containers must not be displayed. |
| if (containerWidth.hasWrappedDimension() |
| && !containsMeasurableWidth(containerHeight, elements)) { |
| return false; |
| } |
| if (containerHeight.hasWrappedDimension() |
| && !containsMeasurableHeight(containerWidth, elements)) { |
| return false; |
| } |
| return true; |
| } |
| |
| private boolean containsMeasurableWidth( |
| ContainerDimension containerHeight, List<LayoutElement> elements) { |
| for (LayoutElement element : elements) { |
| if (isWidthMeasurable(element, containerHeight)) { |
| // Enough to find a single element that is measurable. |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private boolean containsMeasurableHeight( |
| ContainerDimension containerWidth, List<LayoutElement> elements) { |
| for (LayoutElement element : elements) { |
| if (isHeightMeasurable(element, containerWidth)) { |
| // Enough to find a single element that is measurable. |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private boolean isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight) { |
| switch (element.getInnerCase()) { |
| case COLUMN: |
| return isMeasurable(element.getColumn().getWidth()); |
| case ROW: |
| return isMeasurable(element.getRow().getWidth()); |
| case BOX: |
| return isMeasurable(element.getBox().getWidth()); |
| case SPACER: |
| return isMeasurable(element.getSpacer().getWidth()); |
| case IMAGE: |
| // Special-case. If the image width is proportional, then the height must be |
| // measurable. This means either a fixed size, or expanded where we know the parent |
| // dimension. |
| Image img = element.getImage(); |
| if (img.getWidth().hasProportionalDimension()) { |
| boolean isContainerHeightKnown = |
| (containerHeight.hasExpandedDimension() |
| || containerHeight.hasLinearDimension()); |
| return img.getHeight().hasLinearDimension() |
| || (img.getHeight().hasExpandedDimension() && isContainerHeightKnown); |
| } else { |
| return isMeasurable(element.getImage().getWidth()); |
| } |
| case ARC: |
| case TEXT: |
| case SPANNABLE: |
| return true; |
| case INNER_NOT_SET: |
| return false; |
| default: // TODO(b/276703002): Remove default case |
| return false; |
| } |
| } |
| |
| private boolean isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth) { |
| switch (element.getInnerCase()) { |
| case COLUMN: |
| return isMeasurable(element.getColumn().getHeight()); |
| case ROW: |
| return isMeasurable(element.getRow().getHeight()); |
| case BOX: |
| return isMeasurable(element.getBox().getHeight()); |
| case SPACER: |
| return isMeasurable(element.getSpacer().getHeight()); |
| case IMAGE: |
| // Special-case. If the image height is proportional, then the width must be |
| // measurable. This means either a fixed size, or expanded where we know the parent |
| // dimension. |
| Image img = element.getImage(); |
| if (img.getHeight().hasProportionalDimension()) { |
| boolean isContainerWidthKnown = |
| (containerWidth.hasExpandedDimension() |
| || containerWidth.hasLinearDimension()); |
| return img.getWidth().hasLinearDimension() |
| || (img.getWidth().hasExpandedDimension() && isContainerWidthKnown); |
| } else { |
| return isMeasurable(element.getImage().getHeight()); |
| } |
| case ARC: |
| case TEXT: |
| case SPANNABLE: |
| return true; |
| case INNER_NOT_SET: |
| return false; |
| default: // TODO(b/276703002): Remove default case |
| return false; |
| } |
| } |
| |
| private boolean isMeasurable(ContainerDimension dimension) { |
| return dimensionToPx(dimension) != LayoutParams.MATCH_PARENT; |
| } |
| |
| private static boolean isMeasurable(ImageDimension dimension) { |
| switch (dimension.getInnerCase()) { |
| case LINEAR_DIMENSION: |
| case PROPORTIONAL_DIMENSION: |
| return true; |
| case EXPANDED_DIMENSION: |
| case INNER_NOT_SET: |
| return false; |
| } |
| return false; |
| } |
| |
| private static boolean isMeasurable(SpacerDimension dimension) { |
| switch (dimension.getInnerCase()) { |
| case LINEAR_DIMENSION: |
| return true; |
| case INNER_NOT_SET: |
| return false; |
| } |
| return false; |
| } |
| |
| private void inflateChildElements( |
| @NonNull ViewGroup parent, |
| @NonNull LayoutParams parentLayoutParams, |
| PendingLayoutParams childLayoutParams, |
| List<LayoutElement> childElements, |
| String parentPosId, |
| LayoutInfo.Builder layoutInfoBuilder, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| int index = FIRST_CHILD_INDEX; |
| for (LayoutElement childElement : childElements) { |
| String childPosId = ProtoLayoutDiffer.createNodePosId(parentPosId, index++); |
| inflateLayoutElement( |
| new ParentViewWrapper(parent, parentLayoutParams, childLayoutParams), |
| childElement, |
| childPosId, |
| /* includeChildren= */ true, |
| layoutInfoBuilder, |
| pipelineMaker); |
| } |
| } |
| |
| /** |
| * Inflates a ProtoLayout into {@code parent}. |
| * |
| * @param parent The view to attach the layout into. |
| * @return The {@link InflateResult} class containing the first child that was inflated, |
| * animations to be played, and new nodes for the dynamic data pipeline. Callers should use |
| * {@link InflateResult#updateDynamicDataPipeline} to apply those changes using a UI Thread. |
| * <p>This may be null if the proto is empty the top-level LayoutElement has no inner set, |
| * or the top-level LayoutElement contains an unsupported inner type. |
| */ |
| @Nullable |
| public InflateResult inflate(@NonNull ViewGroup parent) { |
| |
| // This is a full re-inflation, so we don't need any previous rendering information. |
| LayoutInfo.Builder layoutInfoBuilder = |
| new LayoutInfo.Builder(/* previousLayoutInfo= */ null); |
| |
| // Go! |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker = |
| mDataPipeline.map( |
| p -> |
| p.newPipelineMaker( |
| ProtoLayoutInflater::getEnterAnimations, |
| ProtoLayoutInflater::getExitAnimations)); |
| InflatedView firstInflatedChild = |
| inflateLayoutElement( |
| new ParentViewWrapper( |
| parent, |
| new LayoutParams( |
| LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)), |
| mLayoutProto.getRoot(), |
| ROOT_NODE_ID, |
| /* includeChildren= */ true, |
| layoutInfoBuilder, |
| pipelineMaker); |
| if (firstInflatedChild == null) { |
| return null; |
| } |
| if (mLayoutProto.hasFingerprint()) { |
| parent.setTag( |
| R.id.rendered_metadata_tag, |
| new RenderedMetadata(mLayoutProto.getFingerprint(), layoutInfoBuilder.build())); |
| } |
| return new InflateResult(parent, firstInflatedChild.mView, pipelineMaker); |
| } |
| |
| /** |
| * Compute the mutation that must be applied to the given {@link ViewGroup} in order to produce |
| * the given target layout. |
| * |
| * <p>If the return value is {@code null}, {@code parent} must be updated in full using {@link |
| * #inflate}. Otherwise, call {ViewGroupMutation#isNoOp} on the return value to check if there |
| * are any mutations to apply and call {@link #applyMutation} to apply them. |
| * |
| * <p>Can be called from a background thread. |
| * |
| * @param prevRenderedMetadata The metadata for the previous rendering of this view, either |
| * using {@code inflate} or {@code applyMutation}. This can be retrieved by calling {@link |
| * #getRenderedMetadata} on the previous layout view parent. |
| * @param targetLayout The target layout that the mutation should result in. |
| * @return The mutation that will produce the target layout. |
| */ |
| @Nullable |
| public ViewGroupMutation computeMutation( |
| @NonNull RenderedMetadata prevRenderedMetadata, |
| @NonNull Layout targetLayout, |
| @NonNull ViewProperties parentViewProp) { |
| if (prevRenderedMetadata.getTreeFingerprint() == null) { |
| Log.w(TAG, "No previous fingerprint available."); |
| return null; |
| } |
| @Nullable |
| LayoutDiff diff = |
| ProtoLayoutDiffer.getDiff(prevRenderedMetadata.getTreeFingerprint(), targetLayout); |
| if (diff == null) { |
| Log.w(TAG, "getDiff failed"); |
| return null; |
| } |
| |
| logDebug(diff); |
| |
| List<InflatedView> inflatedViews = new ArrayList<>(); |
| LayoutInfo.Builder layoutInfoBuilder = |
| new LayoutInfo.Builder(prevRenderedMetadata.getLayoutInfo()); |
| LayoutInfo prevLayoutInfo = prevRenderedMetadata.getLayoutInfo(); |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker = |
| mDataPipeline.map( |
| p -> |
| p.newPipelineMaker( |
| ProtoLayoutInflater::getEnterAnimations, |
| ProtoLayoutInflater::getExitAnimations)); |
| for (TreeNodeWithChange changedNode : diff.getChangedNodes()) { |
| String nodePosId = changedNode.getPosId(); |
| if (nodePosId.isEmpty()) { |
| // Failed to compute mutation. Need to update fully. |
| Log.w(TAG, "Empty nodePosId"); |
| return null; |
| } |
| ViewProperties parentInfo; |
| if (nodePosId.equals(ROOT_NODE_ID)) { |
| parentInfo = parentViewProp; |
| } else { |
| String parentNodePosId = getParentNodePosId(nodePosId); |
| if (parentNodePosId == null || !prevLayoutInfo.contains(parentNodePosId)) { |
| // Failed to compute mutation. Need to update fully. |
| Log.w(TAG, "Can't find view " + nodePosId); |
| return null; |
| } |
| |
| // The parent node might also have been updated. |
| @Nullable |
| ViewProperties possibleUpdatedParentInfo = |
| layoutInfoBuilder.getViewPropertiesFor(parentNodePosId); |
| parentInfo = |
| possibleUpdatedParentInfo != null |
| ? possibleUpdatedParentInfo |
| : checkNotNull( |
| prevLayoutInfo.getViewPropertiesFor(parentNodePosId)); |
| } |
| InflatedView inflatedView = null; |
| @Nullable LayoutElement updatedLayoutElement = changedNode.getLayoutElement(); |
| @Nullable ArcLayoutElement updatedArcLayoutElement = changedNode.getArcLayoutElement(); |
| if (updatedLayoutElement != null) { |
| inflatedView = |
| inflateLayoutElement( |
| new ParentViewWrapper(parentInfo), |
| updatedLayoutElement, |
| nodePosId, |
| !changedNode.isSelfOnlyChange(), |
| layoutInfoBuilder, |
| pipelineMaker); |
| } else if (updatedArcLayoutElement != null) { |
| inflatedView = |
| inflateArcLayoutElement( |
| new ParentViewWrapper(parentInfo), |
| updatedArcLayoutElement, |
| nodePosId, |
| layoutInfoBuilder, |
| pipelineMaker); |
| } |
| if (inflatedView == null) { |
| // Failed to compute mutation. Need to update fully. |
| Log.w(TAG, "No inflatedView"); |
| return null; |
| } |
| inflatedViews.add(inflatedView); |
| |
| if (!ProtoLayoutDiffer.UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE) { |
| |
| throw new UnsupportedOperationException(); |
| } |
| if (!changedNode.isSelfOnlyChange()) { |
| // A child addition/removal causes a full reinflation of the parent. So the only |
| // case that we might not replace a node in pipeline is when it's removed as part of |
| // a parent change. |
| pipelineMaker.ifPresent(p -> p.markForChildRemoval(nodePosId)); |
| } |
| pipelineMaker.ifPresent( |
| p -> p.markNodeAsChanged(nodePosId, !changedNode.isSelfOnlyChange())); |
| } |
| return new ViewGroupMutation( |
| inflatedViews, |
| new RenderedMetadata(targetLayout.getFingerprint(), layoutInfoBuilder.build()), |
| prevRenderedMetadata.getTreeFingerprint().getRoot(), |
| pipelineMaker); |
| } |
| |
| /** Apply the mutation that was previously computed with {@link #computeMutation}. */ |
| @UiThread |
| @NonNull |
| public ListenableFuture<Void> applyMutation( |
| @NonNull ViewGroup parent, @NonNull ViewGroupMutation groupMutation) { |
| RenderedMetadata prevRenderedMetadata = getRenderedMetadata(parent); |
| if (prevRenderedMetadata != null |
| && !ProtoLayoutDiffer.areNodesEquivalent( |
| prevRenderedMetadata.getTreeFingerprint().getRoot(), |
| groupMutation.mPreMutationRootNodeFingerprint)) { |
| |
| // be considered unequal. Log.e(TAG, "View has changed. Skipping mutation."); return |
| // false; |
| } |
| if (groupMutation.isNoOp()) { |
| // Nothing to do. |
| return immediateVoidFuture(); |
| } |
| |
| if (groupMutation.mPipelineMaker.isPresent()) { |
| SettableFuture<Void> result = SettableFuture.create(); |
| groupMutation |
| .mPipelineMaker |
| .get() |
| .playExitAnimations( |
| parent, |
| /* isReattaching= */ false, |
| () -> { |
| try { |
| applyMutationInternal(parent, groupMutation); |
| result.set(null); |
| } catch (ViewMutationException ex) { |
| result.setException(ex); |
| } |
| }); |
| return result; |
| } else { |
| try { |
| applyMutationInternal(parent, groupMutation); |
| return immediateVoidFuture(); |
| } catch (ViewMutationException ex) { |
| return immediateFailedFuture(ex); |
| } |
| } |
| } |
| |
| private void applyMutationInternal( |
| @NonNull ViewGroup parent, @NonNull ViewGroupMutation groupMutation) { |
| for (InflatedView inflatedView : groupMutation.mInflatedViews) { |
| String posId = inflatedView.getTag(); |
| if (posId == null) { |
| // Failed to apply the mutation. Need to update fully. |
| throw new ViewMutationException("View has no tag"); |
| } |
| View viewToUpdate = parent.findViewWithTag(posId); |
| if (viewToUpdate == null) { |
| // Failed to apply the mutation. Need to update fully. |
| throw new ViewMutationException("Can't find view " + posId); |
| } |
| ViewParent potentialImmediateParent = viewToUpdate.getParent(); |
| if (!(potentialImmediateParent instanceof ViewGroup)) { |
| // Failed to apply the mutation. Need to update fully. |
| throw new ViewMutationException("Parent not a ViewGroup"); |
| } |
| ViewGroup immediateParent = (ViewGroup) potentialImmediateParent; |
| int childIndex = immediateParent.indexOfChild(viewToUpdate); |
| if (childIndex == -1) { |
| // Failed to apply the mutation. Need to update fully. |
| throw new ViewMutationException("Can't find child at " + childIndex); |
| } |
| if (!inflatedView.addMissingChildrenFrom(viewToUpdate)) { |
| throw new ViewMutationException("Failed to add missing children " + posId); |
| } |
| immediateParent.removeViewAt(childIndex); |
| immediateParent.addView(inflatedView.mView, childIndex, inflatedView.mLayoutParams); |
| |
| if (DEBUG_DIFF_UPDATE_ENABLED) { |
| // Visualize diff update (by flashing the inflated element). |
| inflatedView |
| .mView |
| .animate() |
| .alpha(0.7f) |
| .setDuration(50) |
| .withEndAction(() -> inflatedView.mView.animate().alpha(1).setDuration(50)); |
| } |
| } |
| groupMutation.mPipelineMaker.ifPresent( |
| pipe -> pipe.commit(parent, /* isReattaching= */ false)); |
| parent.setTag(R.id.rendered_metadata_tag, groupMutation.mRenderedMetadataAfterMutation); |
| } |
| |
| /** Returns the {@link RenderedMetadata} attached to {@code inflateParent}. */ |
| @UiThread |
| @Nullable |
| public static RenderedMetadata getRenderedMetadata(@NonNull ViewGroup inflateParent) { |
| Object prevMetadataObject = inflateParent.getTag(R.id.rendered_metadata_tag); |
| if (prevMetadataObject instanceof RenderedMetadata) { |
| return (RenderedMetadata) prevMetadataObject; |
| } else { |
| if (prevMetadataObject != null) { |
| Log.w(TAG, "Incompatible prevMetadataObject"); |
| } |
| return null; |
| } |
| } |
| |
| /** Clears the {@link RenderedMetadata} attached to {@code inflateParent}. */ |
| @UiThread |
| public static void clearRenderedMetadata(@NonNull ViewGroup inflateParent) { |
| Log.d(TAG, "Clearing rendered metadata. Next inflation won't use diff update."); |
| inflateParent.setTag(R.id.rendered_metadata_tag, /* tag= */ null); |
| } |
| |
| // dereference of possibly-null reference ((FrameLayout.LayoutParams)child.getLayoutParams()) |
| @SuppressWarnings("nullness:dereference.of.nullable") |
| private static void applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity) { |
| for (int i = 0; i < parent.getChildCount(); i++) { |
| View child = parent.getChildAt(i); |
| |
| // All children should have a LayoutParams already set... |
| if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) { |
| // This...shouldn't happen. |
| throw new IllegalStateException( |
| "Layout params of child is not a descendant of FrameLayout.LayoutParams."); |
| } |
| |
| // Children should grow out from the middle of the layout. |
| ((FrameLayout.LayoutParams) child.getLayoutParams()).gravity = gravity; |
| } |
| } |
| |
| static String roleToClassName(SemanticsRole role) { |
| switch (role) { |
| case SEMANTICS_ROLE_IMAGE: |
| return "android.widget.ImageView"; |
| case SEMANTICS_ROLE_BUTTON: |
| return "android.widget.Button"; |
| case SEMANTICS_ROLE_CHECKBOX: |
| return "android.widget.CheckBox"; |
| case SEMANTICS_ROLE_SWITCH: |
| return "android.widget.Switch"; |
| case SEMANTICS_ROLE_RADIOBUTTON: |
| return "android.widget.RadioButton"; |
| default: |
| return ""; |
| } |
| } |
| |
| // getObsoleteContentDescription is used for backward compatibility |
| @SuppressWarnings("deprecation") |
| private void applySemantics( |
| View view, |
| Semantics semantics, |
| String posId, |
| Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { |
| view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); |
| ViewCompat.setAccessibilityDelegate( |
| view, |
| new AccessibilityDelegateCompat() { |
| @Override |
| public void onInitializeAccessibilityNodeInfo( |
| @NonNull View host, @NonNull AccessibilityNodeInfoCompat info) { |
| super.onInitializeAccessibilityNodeInfo(host, info); |
| |
| String className = roleToClassName(semantics.getRole()); |
| if (!className.isEmpty()) { |
| info.setClassName(className); |
| } |
| info.setFocusable(true); |
| info.setImportantForAccessibility(true); |
| } |
| }); |
| |
| if (semantics.hasContentDescription()) { |
| handleProp( |
| semantics.getContentDescription(), |
| view::setContentDescription, |
| posId, |
| pipelineMaker); |
| } else { |
| // This is for backward compatibility |
| view.setContentDescription(semantics.getObsoleteContentDescription()); |
| } |
| |
| if (semantics.hasStateDescription()) { |
| handleProp( |
| semantics.getStateDescription(), |
| (state) -> ViewCompat.setStateDescription(view, state), |
| posId, |
| pipelineMaker); |
| } |
| } |
| |
| /** Creates a TextView with the fallbackTextAppearance from the current theme. */ |
| private TextView newThemedTextView() { |
| return new TextView( |
| mProtoLayoutThemeContext, |
| /* attrs= */ null, |
| mProtoLayoutTheme.getFallbackTextAppearanceResId()); |
| } |
| |
| /** Creates a CurvedTextView with the fallbackTextAppearance from the current theme. */ |
| private CurvedTextView newThemedCurvedTextView() { |
| return new CurvedTextView( |
| mProtoLayoutThemeContext, |
| /* attrs= */ null, |
| mProtoLayoutTheme.getFallbackTextAppearanceResId()); |
| } |
| |
| private void logDebug(LayoutDiff diff) { |
| if (mLoggingUtils != null && mLoggingUtils.canLogD(TAG)) { |
| StringBuilder sb = |
| new StringBuilder("LayoutDiff result at LayoutInflater#computeMutation: \n"); |
| List<TreeNodeWithChange> diffNodes = diff.getChangedNodes(); |
| if (diffNodes.isEmpty()) { |
| mLoggingUtils.logD(TAG, "No diff."); |
| return; |
| } |
| for (TreeNodeWithChange changedNode : diffNodes) { |
| sb.append(formatNodeChangeForLogs(changedNode)); |
| if (changedNode.getLayoutElement() != null) { |
| sb.append(changedNode.getLayoutElement()); |
| } else if (changedNode.getArcLayoutElement() != null) { |
| sb.append(changedNode.getArcLayoutElement()); |
| } |
| sb.append("\n"); |
| } |
| mLoggingUtils.logD(TAG, sb.toString()); |
| } |
| } |
| |
| private static String formatNodeChangeForLogs(TreeNodeWithChange change) { |
| return "PosId: " |
| + change.getPosId() |
| + " | Fingerprint: " |
| + change.getFingerprint().getSelfTypeValue() |
| + " | isSelfOnlyChange: " |
| + change.isSelfOnlyChange(); |
| } |
| |
| /** Implementation of ClickableSpan for ProtoLayout's Clickables. */ |
| private class ProtoLayoutClickableSpan extends ClickableSpan { |
| private final Clickable mClickable; |
| |
| ProtoLayoutClickableSpan(@NonNull Clickable clickable) { |
| this.mClickable = clickable; |
| } |
| |
| @Override |
| public void onClick(@NonNull View widget) { |
| Action action = mClickable.getOnClick(); |
| |
| switch (action.getValueCase()) { |
| case LAUNCH_ACTION: |
| Intent i = |
| buildLaunchActionIntent( |
| action.getLaunchAction(), |
| mClickable.getId(), |
| mClickableIdExtra); |
| if (i != null) { |
| dispatchLaunchActionIntent(i); |
| } |
| break; |
| case LOAD_ACTION: |
| if (mLoadActionExecutor == null) { |
| Log.w(TAG, "Ignoring load action since an executor has not been provided."); |
| break; |
| } |
| mLoadActionExecutor.execute( |
| () -> |
| mLoadActionListener.onClick( |
| buildState( |
| action.getLoadAction(), mClickable.getId()))); |
| break; |
| case VALUE_NOT_SET: |
| break; |
| } |
| } |
| |
| @Override |
| public void updateDrawState(@NonNull TextPaint ds) { |
| // Don't change the underlying text appearance. |
| } |
| } |
| |
| // Android's normal ImageSpan (well, DynamicDrawableSpan) applies baseline alignment incorrectly |
| // in some cases. It incorrectly assumes that the difference between the bottom (as passed to |
| // draw) and baseline of the text is always equal to the font descent, when that doesn't always |
| // hold. Instead, the "y" parameter is the Y coordinate of the baseline, so base the baseline |
| // alignment on that rather than "bottom". |
| @VisibleForTesting |
| static class FixedImageSpan extends ImageSpan { |
| @Nullable private WeakReference<Drawable> mDrawableRef; |
| |
| FixedImageSpan(@NonNull Drawable drawable) { |
| super(drawable); |
| } |
| |
| FixedImageSpan(@NonNull Drawable drawable, int verticalAlignment) { |
| super(drawable, verticalAlignment); |
| } |
| |
| @Override |
| public void draw( |
| @NonNull Canvas canvas, |
| CharSequence text, |
| int start, |
| int end, |
| float x, |
| int top, |
| int y, |
| int bottom, |
| @NonNull Paint paint) { |
| Drawable b = getCachedDrawable(); |
| canvas.save(); |
| |
| int transY = bottom - b.getBounds().bottom; |
| if (mVerticalAlignment == ALIGN_BASELINE) { |
| transY = y - b.getBounds().bottom; |
| } else if (mVerticalAlignment == ALIGN_CENTER) { |
| transY = (bottom - top) / 2 - b.getBounds().height() / 2; |
| } |
| |
| canvas.translate(x, transY); |
| b.draw(canvas); |
| canvas.restore(); |
| } |
| |
| @VisibleForTesting |
| Drawable getCachedDrawable() { |
| WeakReference<Drawable> wr = mDrawableRef; |
| Drawable d = null; |
| |
| if (wr != null) { |
| d = wr.get(); |
| } |
| |
| if (d == null) { |
| d = getDrawable(); |
| mDrawableRef = new WeakReference<>(d); |
| } |
| |
| return d; |
| } |
| } |
| |
| /** Implementation of {@link Scroller} which inhibits all scrolling. */ |
| private static class InhibitingScroller extends Scroller { |
| InhibitingScroller(Context context) { |
| super(context); |
| } |
| |
| @Override |
| public void startScroll(int startX, int startY, int dx, int dy) {} |
| |
| @Override |
| public void startScroll(int startX, int startY, int dx, int dy, int duration) {} |
| } |
| } |