1 /* 2 * Copyright 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.wear.protolayout.renderer.inflater; 18 19 import static android.util.TypedValue.COMPLEX_UNIT_SP; 20 import static android.view.View.INVISIBLE; 21 import static android.view.View.LAYOUT_DIRECTION_LTR; 22 import static android.view.View.LAYOUT_DIRECTION_RTL; 23 import static android.view.View.VISIBLE; 24 25 import static androidx.core.util.Preconditions.checkNotNull; 26 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX; 27 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID; 28 import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.getParentNodePosId; 29 import static androidx.wear.protolayout.renderer.inflater.PropHelpers.handleProp; 30 31 import static com.google.common.util.concurrent.Futures.immediateFailedFuture; 32 import static com.google.common.util.concurrent.Futures.immediateFuture; 33 34 import static java.lang.Math.max; 35 import static java.lang.Math.min; 36 import static java.lang.Math.round; 37 import static java.nio.charset.StandardCharsets.US_ASCII; 38 39 import android.annotation.SuppressLint; 40 import android.content.Context; 41 import android.content.Intent; 42 import android.content.pm.ActivityInfo; 43 import android.content.res.ColorStateList; 44 import android.content.res.Resources; 45 import android.content.res.Resources.Theme; 46 import android.graphics.Color; 47 import android.graphics.Outline; 48 import android.graphics.Paint.Cap; 49 import android.graphics.PorterDuff.Mode; 50 import android.graphics.Rect; 51 import android.graphics.Typeface; 52 import android.graphics.drawable.AnimatedVectorDrawable; 53 import android.graphics.drawable.BitmapDrawable; 54 import android.graphics.drawable.ColorDrawable; 55 import android.graphics.drawable.Drawable; 56 import android.text.SpannableStringBuilder; 57 import android.text.Spanned; 58 import android.text.TextPaint; 59 import android.text.TextUtils; 60 import android.text.TextUtils.TruncateAt; 61 import android.text.method.LinkMovementMethod; 62 import android.text.style.AbsoluteSizeSpan; 63 import android.text.style.ClickableSpan; 64 import android.text.style.ForegroundColorSpan; 65 import android.text.style.ImageSpan; 66 import android.text.style.StyleSpan; 67 import android.text.style.UnderlineSpan; 68 import android.util.Log; 69 import android.util.TypedValue; 70 import android.view.ContextThemeWrapper; 71 import android.view.Gravity; 72 import android.view.View; 73 import android.view.ViewGroup; 74 import android.view.ViewGroup.LayoutParams; 75 import android.view.ViewOutlineProvider; 76 import android.view.ViewParent; 77 import android.view.animation.AlphaAnimation; 78 import android.view.animation.AnimationSet; 79 import android.view.animation.TranslateAnimation; 80 import android.widget.FrameLayout; 81 import android.widget.ImageView; 82 import android.widget.ImageView.ScaleType; 83 import android.widget.LinearLayout; 84 import android.widget.Scroller; 85 import android.widget.Space; 86 import android.widget.TextView; 87 88 import androidx.annotation.UiThread; 89 import androidx.annotation.VisibleForTesting; 90 import androidx.core.content.ContextCompat; 91 import androidx.core.view.AccessibilityDelegateCompat; 92 import androidx.core.view.ViewCompat; 93 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; 94 import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable; 95 import androidx.wear.protolayout.expression.pipeline.AnimationsHelper; 96 import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec; 97 import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat; 98 import androidx.wear.protolayout.proto.ActionProto.Action; 99 import androidx.wear.protolayout.proto.ActionProto.AndroidActivity; 100 import androidx.wear.protolayout.proto.ActionProto.AndroidExtra; 101 import androidx.wear.protolayout.proto.ActionProto.LaunchAction; 102 import androidx.wear.protolayout.proto.ActionProto.LoadAction; 103 import androidx.wear.protolayout.proto.AlignmentProto.AngularAlignment; 104 import androidx.wear.protolayout.proto.AlignmentProto.ArcAnchorType; 105 import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignment; 106 import androidx.wear.protolayout.proto.AlignmentProto.TextAlignment; 107 import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignment; 108 import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignmentProp; 109 import androidx.wear.protolayout.proto.DimensionProto.AngularDimension; 110 import androidx.wear.protolayout.proto.DimensionProto.ArcLineLength; 111 import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension; 112 import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension.InnerCase; 113 import androidx.wear.protolayout.proto.DimensionProto.DegreesProp; 114 import androidx.wear.protolayout.proto.DimensionProto.DpProp; 115 import androidx.wear.protolayout.proto.DimensionProto.ExpandedAngularDimensionProp; 116 import androidx.wear.protolayout.proto.DimensionProto.ExpandedDimensionProp; 117 import androidx.wear.protolayout.proto.DimensionProto.ExtensionDimension; 118 import androidx.wear.protolayout.proto.DimensionProto.ImageDimension; 119 import androidx.wear.protolayout.proto.DimensionProto.PivotDimension; 120 import androidx.wear.protolayout.proto.DimensionProto.ProportionalDimensionProp; 121 import androidx.wear.protolayout.proto.DimensionProto.SpProp; 122 import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension; 123 import androidx.wear.protolayout.proto.DimensionProto.WrappedDimensionProp; 124 import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint; 125 import androidx.wear.protolayout.proto.LayoutElementProto.Arc; 126 import androidx.wear.protolayout.proto.LayoutElementProto.ArcDirection; 127 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement; 128 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLine; 129 import androidx.wear.protolayout.proto.LayoutElementProto.ArcSpacer; 130 import androidx.wear.protolayout.proto.LayoutElementProto.ArcText; 131 import androidx.wear.protolayout.proto.LayoutElementProto.Box; 132 import androidx.wear.protolayout.proto.LayoutElementProto.Column; 133 import androidx.wear.protolayout.proto.LayoutElementProto.ContentScaleMode; 134 import androidx.wear.protolayout.proto.LayoutElementProto.DashedArcLine; 135 import androidx.wear.protolayout.proto.LayoutElementProto.DashedLinePattern; 136 import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement; 137 import androidx.wear.protolayout.proto.LayoutElementProto.FontFeatureSetting; 138 import androidx.wear.protolayout.proto.LayoutElementProto.FontSetting; 139 import androidx.wear.protolayout.proto.LayoutElementProto.FontStyle; 140 import androidx.wear.protolayout.proto.LayoutElementProto.FontVariationSetting; 141 import androidx.wear.protolayout.proto.LayoutElementProto.Image; 142 import androidx.wear.protolayout.proto.LayoutElementProto.Layout; 143 import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement; 144 import androidx.wear.protolayout.proto.LayoutElementProto.MarqueeParameters; 145 import androidx.wear.protolayout.proto.LayoutElementProto.Row; 146 import androidx.wear.protolayout.proto.LayoutElementProto.Spacer; 147 import androidx.wear.protolayout.proto.LayoutElementProto.Span; 148 import androidx.wear.protolayout.proto.LayoutElementProto.SpanImage; 149 import androidx.wear.protolayout.proto.LayoutElementProto.SpanText; 150 import androidx.wear.protolayout.proto.LayoutElementProto.SpanVerticalAlignmentProp; 151 import androidx.wear.protolayout.proto.LayoutElementProto.Spannable; 152 import androidx.wear.protolayout.proto.LayoutElementProto.StrokeCapProp; 153 import androidx.wear.protolayout.proto.LayoutElementProto.Text; 154 import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflow; 155 import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflowProp; 156 import androidx.wear.protolayout.proto.ModifiersProto.ArcModifiers; 157 import androidx.wear.protolayout.proto.ModifiersProto.Background; 158 import androidx.wear.protolayout.proto.ModifiersProto.Border; 159 import androidx.wear.protolayout.proto.ModifiersProto.Clickable; 160 import androidx.wear.protolayout.proto.ModifiersProto.Corner; 161 import androidx.wear.protolayout.proto.ModifiersProto.CornerRadius; 162 import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition; 163 import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition; 164 import androidx.wear.protolayout.proto.ModifiersProto.FadeInTransition; 165 import androidx.wear.protolayout.proto.ModifiersProto.FadeOutTransition; 166 import androidx.wear.protolayout.proto.ModifiersProto.Modifiers; 167 import androidx.wear.protolayout.proto.ModifiersProto.Padding; 168 import androidx.wear.protolayout.proto.ModifiersProto.Semantics; 169 import androidx.wear.protolayout.proto.ModifiersProto.SemanticsRole; 170 import androidx.wear.protolayout.proto.ModifiersProto.Shadow; 171 import androidx.wear.protolayout.proto.ModifiersProto.SlideDirection; 172 import androidx.wear.protolayout.proto.ModifiersProto.SlideInTransition; 173 import androidx.wear.protolayout.proto.ModifiersProto.SlideOutTransition; 174 import androidx.wear.protolayout.proto.ModifiersProto.SlideParentSnapOption; 175 import androidx.wear.protolayout.proto.ModifiersProto.SpanModifiers; 176 import androidx.wear.protolayout.proto.ModifiersProto.Transformation; 177 import androidx.wear.protolayout.proto.StateProto.State; 178 import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger; 179 import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger; 180 import androidx.wear.protolayout.proto.TriggerProto.Trigger; 181 import androidx.wear.protolayout.proto.TypesProto.BoolProp; 182 import androidx.wear.protolayout.proto.TypesProto.FloatProp; 183 import androidx.wear.protolayout.proto.TypesProto.StringProp; 184 import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider; 185 import androidx.wear.protolayout.renderer.ProtoLayoutTheme; 186 import androidx.wear.protolayout.renderer.ProtoLayoutTheme.FontSet; 187 import androidx.wear.protolayout.renderer.R; 188 import androidx.wear.protolayout.renderer.common.LoggingUtils; 189 import androidx.wear.protolayout.renderer.common.NoOpProviderStatsLogger; 190 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer; 191 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.LayoutDiff; 192 import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.TreeNodeWithChange; 193 import androidx.wear.protolayout.renderer.common.ProviderStatsLogger.InflaterStatsLogger; 194 import androidx.wear.protolayout.renderer.common.RenderingArtifact; 195 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline; 196 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline.PipelineMaker; 197 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LayoutInfo; 198 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LinearLayoutProperties; 199 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingFrameLayoutParams; 200 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingLayoutParams; 201 import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.ViewProperties; 202 import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.ResourceAccessException; 203 import androidx.wear.widget.ArcLayout; 204 import androidx.wear.widget.CurvedTextView; 205 206 import com.google.common.collect.ImmutableSet; 207 import com.google.common.util.concurrent.ListenableFuture; 208 import com.google.common.util.concurrent.SettableFuture; 209 210 import org.jspecify.annotations.NonNull; 211 import org.jspecify.annotations.Nullable; 212 213 import java.nio.ByteBuffer; 214 import java.util.ArrayList; 215 import java.util.Arrays; 216 import java.util.List; 217 import java.util.Locale; 218 import java.util.Map; 219 import java.util.Optional; 220 import java.util.StringJoiner; 221 import java.util.concurrent.CancellationException; 222 import java.util.concurrent.ExecutionException; 223 import java.util.concurrent.Executor; 224 import java.util.concurrent.Future; 225 import java.util.function.Consumer; 226 import java.util.function.Function; 227 228 /** 229 * Renderer for ProtoLayout. 230 * 231 * <p>This variant uses Android views to represent the contents of the ProtoLayout. 232 */ 233 public final class ProtoLayoutInflater { 234 235 private static final String TAG = "ProtoLayoutInflater"; 236 private static final char ZERO_WIDTH_JOINER = '\u200D'; 237 238 // This will help debug potential layout issues that might be leading to full layout updates or 239 // poor performance in general. 240 private static final boolean DEBUG_DIFF_UPDATE_ENABLED = false; 241 242 /** Target alpha for fade in animation. */ 243 private static final float FADE_IN_TARGET_ALPHA = 1; 244 245 /** Initial alpha for fade out animation. */ 246 private static final float FADE_OUT_INITIAL_ALPHA = 1; 247 248 /** The default trigger for animations set to onLoad. */ 249 private static final Trigger DEFAULT_ANIMATION_TRIGGER = 250 Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build(); 251 252 /** The default minimal click target size, for meeting the accessibility requirement */ 253 @VisibleForTesting static final float DEFAULT_MIN_CLICKABLE_SIZE_DP = 48f; 254 255 /** 256 * Default maximum raw byte size for a bitmap drawable. 257 * 258 * @see <a 259 * 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"> 260 * The framework enforced max size</a> 261 */ 262 private static final int DEFAULT_MAX_BITMAP_RAW_SIZE = 20 * 1024 * 1024; 263 264 private static final int HORIZONTAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_HORIZONTAL; 265 private static final int VERTICAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_VERTICAL; 266 private static final int TEXT_ALIGN_DEFAULT = Gravity.CENTER_HORIZONTAL; 267 private static final ScaleType IMAGE_DEFAULT_SCALE_TYPE = ScaleType.FIT_CENTER; 268 269 @ArcLayout.LayoutParams.VerticalAlignment 270 private static final int ARC_VERTICAL_ALIGN_DEFAULT = 271 ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER; 272 273 @SizedArcContainer.LayoutParams.AngularAlignment 274 private static final int ANGULAR_ALIGNMENT_DEFAULT = 275 SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER; 276 277 private static final int SPAN_VERTICAL_ALIGN_DEFAULT = ImageSpan.ALIGN_BOTTOM; 278 279 // This is pretty badly named; TruncateAt specifies where to place the ellipsis (or whether to 280 // marquee). Disabling truncation with null actually disables the _ellipsis_, but text will 281 // still be truncated. 282 private static final @Nullable TruncateAt TEXT_OVERFLOW_DEFAULT = null; 283 284 private static final int TEXT_COLOR_DEFAULT = 0xFFFFFFFF; 285 private static final int TEXT_MAX_LINES_DEFAULT = 1; 286 @VisibleForTesting static final int TEXT_AUTOSIZES_LIMIT = 10; 287 private static final int TEXT_MIN_LINES = 1; 288 289 private static final ContainerDimension CONTAINER_DIMENSION_DEFAULT = 290 ContainerDimension.newBuilder() 291 .setWrappedDimension(WrappedDimensionProp.getDefaultInstance()) 292 .build(); 293 294 @ArcLayout.AnchorType private static final int ARC_ANCHOR_DEFAULT = ArcLayout.ANCHOR_CENTER; 295 296 // White 297 private static final int LINE_COLOR_DEFAULT = 0xFFFFFFFF; 298 299 static final PendingLayoutParams NO_OP_PENDING_LAYOUT_PARAMS = layoutParams -> layoutParams; 300 private static final byte UNSET_MASK = 0; 301 302 final Context mUiContext; 303 304 // Context wrapped with the provided ProtoLayoutTheme. This context should be used for creating 305 // any Text Views, as it will apply text appearance attributes. 306 private final Context mProtoLayoutThemeContext; 307 308 private final ProtoLayoutTheme mProtoLayoutTheme; 309 private final Layout mLayoutProto; 310 private final ResourceResolvers mLayoutResourceResolvers; 311 312 private final Optional<ProtoLayoutDynamicDataPipeline> mDataPipeline; 313 314 private final @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider; 315 316 private final boolean mAllowLayoutChangingBindsWithoutDefault; 317 final String mClickableIdExtra; 318 319 private final @Nullable LoggingUtils mLoggingUtils; 320 private final @NonNull InflaterStatsLogger mInflaterStatsLogger; 321 final @Nullable Executor mLoadActionExecutor; 322 final LoadActionListener mLoadActionListener; 323 final boolean mAnimationEnabled; 324 325 private boolean mApplyFontVariantBodyAsDefault = false; 326 327 @VisibleForTesting static final String WEIGHT_AXIS_TAG = "wght"; 328 @VisibleForTesting static final String WIDTH_AXIS_TAG = "wdth"; 329 @VisibleForTesting static final String ROUNDNESS_AXIS_TAG = "ROND"; 330 @VisibleForTesting static final String TABULAR_OPTION_TAG = "tnum"; 331 332 private static final ImmutableSet<String> SUPPORTED_FONT_SETTING_TAGS = 333 ImmutableSet.of( 334 WEIGHT_AXIS_TAG, WIDTH_AXIS_TAG, ROUNDNESS_AXIS_TAG, TABULAR_OPTION_TAG); 335 336 /** 337 * Listener for clicks on Clickable objects that have an Action to (re)load the contents of a 338 * layout. 339 */ 340 public interface LoadActionListener { 341 342 /** 343 * Called when a Clickable that has a LoadAction is clicked. 344 * 345 * @param nextState The state that the next layout should be in. 346 */ onClick(@onNull State nextState)347 void onClick(@NonNull State nextState); 348 } 349 350 /** 351 * A one-off class to be returned from {@link ProtoLayoutInflater#inflate} containing top level 352 * parent, list of content transition animations to be run and a PipelineMaker with pending 353 * changes to the dynamic data pipeline. 354 */ 355 public static final class InflateResult { 356 public final ViewGroup inflateParent; 357 public final View firstChild; 358 private final Optional<PipelineMaker> mPipelineMaker; 359 InflateResult( ViewGroup inflateParent, View firstChild, Optional<PipelineMaker> pipelineMaker)360 InflateResult( 361 ViewGroup inflateParent, View firstChild, Optional<PipelineMaker> pipelineMaker) { 362 this.inflateParent = inflateParent; 363 this.firstChild = firstChild; 364 this.mPipelineMaker = pipelineMaker; 365 } 366 367 /** 368 * Update the DynamicDataPipeline with new nodes that were stored during the layout update. 369 * 370 * @param isReattaching if True, this layout is being reattached and will skip content 371 * transition animations. 372 */ 373 @UiThread updateDynamicDataPipeline(boolean isReattaching)374 public void updateDynamicDataPipeline(boolean isReattaching) { 375 mPipelineMaker.ifPresent( 376 pipe -> pipe.clearDataPipelineAndCommit(inflateParent, isReattaching)); 377 } 378 } 379 380 /** A mutation that can be applied to a {@link ViewGroup}, using {@link #applyMutation}. */ 381 public static final class ViewGroupMutation { 382 final List<InflatedView> mInflatedViews; 383 final RenderedMetadata mRenderedMetadataAfterMutation; 384 final NodeFingerprint mPreMutationRootNodeFingerprint; 385 final Optional<PipelineMaker> mPipelineMaker; 386 ViewGroupMutation( List<InflatedView> inflatedViews, RenderedMetadata renderedMetadataAfterMutation, NodeFingerprint preMutationRootNodeFingerprint, Optional<PipelineMaker> pipelineMaker)387 ViewGroupMutation( 388 List<InflatedView> inflatedViews, 389 RenderedMetadata renderedMetadataAfterMutation, 390 NodeFingerprint preMutationRootNodeFingerprint, 391 Optional<PipelineMaker> pipelineMaker) { 392 this.mInflatedViews = inflatedViews; 393 this.mRenderedMetadataAfterMutation = renderedMetadataAfterMutation; 394 this.mPreMutationRootNodeFingerprint = preMutationRootNodeFingerprint; 395 this.mPipelineMaker = pipelineMaker; 396 } 397 398 /** Returns true if this mutation has no effect. */ isNoOp()399 public boolean isNoOp() { 400 return this.mInflatedViews.isEmpty(); 401 } 402 } 403 404 private static final class InflatedView { 405 final View mView; 406 final LayoutParams mLayoutParams; 407 final PendingLayoutParams mChildLayoutParams; 408 private int mNumMissingChildren; 409 410 /** 411 * @param view The {@link View} that has been inflated. 412 * @param layoutParams The {@link LayoutParams} that must be used when attaching the 413 * inflated view to a parent. 414 * @param childLayoutParams The {@link LayoutParams} that must be applied to children 415 * carried over from a previous layout. 416 * @param numMissingChildren Non-zero if {@code view} is a {@link ViewGroup} whose children 417 * have not been added. This means that before using this view in a layout, its children 418 * must be copied from the {@link ViewGroup} that represents the previous version of 419 * this layout element. 420 */ InflatedView( View view, LayoutParams layoutParams, PendingLayoutParams childLayoutParams, int numMissingChildren)421 InflatedView( 422 View view, 423 LayoutParams layoutParams, 424 PendingLayoutParams childLayoutParams, 425 int numMissingChildren) { 426 this.mView = view; 427 this.mLayoutParams = layoutParams; 428 this.mChildLayoutParams = childLayoutParams; 429 this.mNumMissingChildren = numMissingChildren; 430 } 431 InflatedView(View view, LayoutParams layoutParams)432 InflatedView(View view, LayoutParams layoutParams) { 433 this(view, layoutParams, NO_OP_PENDING_LAYOUT_PARAMS, /* numMissingChildren= */ 0); 434 } 435 addMissingChildrenFrom(View source)436 boolean addMissingChildrenFrom(View source) { 437 if (mNumMissingChildren == 0) { 438 // Nothing to do. 439 return true; 440 } 441 Object tagObj = source.getTag(); 442 String tag = (tagObj == null ? "unknown" : (String) tagObj); 443 if (!(mView instanceof ViewGroup)) { 444 Log.w(TAG, "Destination is not a group: " + tag); 445 return false; 446 } 447 ViewGroup destinationGroup = (ViewGroup) mView; 448 if (destinationGroup.getChildCount() > 0) { 449 Log.w(TAG, "Destination already has children: " + tag); 450 return false; 451 } 452 if (!(source instanceof ViewGroup)) { 453 Log.w(TAG, "Source is not a group: " + tag); 454 return false; 455 } 456 ViewGroup sourceGroup = (ViewGroup) source; 457 if (getEffectiveChildCount(sourceGroup) != mNumMissingChildren) { 458 Log.w( 459 TAG, 460 String.format( 461 "Expected %d children in %s found %d", 462 mNumMissingChildren, tag, sourceGroup.getChildCount())); 463 return false; 464 } 465 List<View> children = new ArrayList<>(sourceGroup.getChildCount()); 466 for (int i = 0; i < mNumMissingChildren; i++) { 467 children.add(sourceGroup.getChildAt(i)); 468 } 469 sourceGroup.removeAllViews(); 470 471 for (View child : children) { 472 destinationGroup.addView(child); 473 child.setLayoutParams( 474 mChildLayoutParams.apply(checkNotNull(child.getLayoutParams()))); 475 } 476 mNumMissingChildren = 0; 477 478 if (source.getTouchDelegate() != null) { 479 addTouchDelegate( 480 destinationGroup, (TouchDelegateComposite) source.getTouchDelegate()); 481 } 482 return true; 483 } 484 485 /** 486 * Returns the number of children that the given {@link ViewGroup} has, ignoring the 487 * children that are {@link IgnorableSpace} type. 488 */ getEffectiveChildCount(ViewGroup viewGroup)489 private static int getEffectiveChildCount(ViewGroup viewGroup) { 490 int nonIgnoredChildrenCnt = 0; 491 for (int i = 0; i < viewGroup.getChildCount(); i++) { 492 if (!(viewGroup.getChildAt(i) instanceof IgnorableSpace)) { 493 nonIgnoredChildrenCnt++; 494 } 495 } 496 return nonIgnoredChildrenCnt; 497 } 498 getTag()499 @Nullable String getTag() { 500 return (String) mView.getTag(); 501 } 502 } 503 504 /** 505 * A one-of class to pass either a real {@link ViewGroup} or only its needed properties through 506 * the renderer. 507 */ 508 private static final class ParentViewWrapper { 509 private final @Nullable ViewGroup mParent; 510 private final ViewProperties mParentProps; 511 ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams)512 ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams) { 513 this.mParent = parent; 514 this.mParentProps = 515 ViewProperties.fromViewGroup( 516 parent, parentLayoutParams, NO_OP_PENDING_LAYOUT_PARAMS); 517 } 518 ParentViewWrapper( ViewGroup parent, LayoutParams parentLayoutParams, PendingLayoutParams childLayoutParams)519 ParentViewWrapper( 520 ViewGroup parent, 521 LayoutParams parentLayoutParams, 522 PendingLayoutParams childLayoutParams) { 523 this.mParent = parent; 524 this.mParentProps = 525 ViewProperties.fromViewGroup(parent, parentLayoutParams, childLayoutParams); 526 } 527 ParentViewWrapper(ViewProperties parentProps)528 ParentViewWrapper(ViewProperties parentProps) { 529 this.mParent = null; 530 this.mParentProps = parentProps; 531 } 532 getParentProperties()533 ViewProperties getParentProperties() { 534 return mParentProps; 535 } 536 537 /** If this class holds a {@link ViewGroup}, add {@code child} to it. */ maybeAddView(View child, LayoutParams layoutParams)538 void maybeAddView(View child, LayoutParams layoutParams) { 539 if (mParent != null) { 540 mParent.addView(child, layoutParams); 541 } 542 } 543 } 544 545 /** Exception that will be thrown when applying a mutation to a {@link View} fails. */ 546 public static class ViewMutationException extends RuntimeException { ViewMutationException(@onNull String message)547 public ViewMutationException(@NonNull String message) { 548 super(message); 549 } 550 } 551 552 /** Config class for ProtoLayoutInflater */ 553 public static final class Config { 554 public static final String DEFAULT_CLICKABLE_ID_EXTRA = 555 "androidx.wear.protolayout.extra.CLICKABLE_ID"; 556 private final @NonNull Context mUiContext; 557 private final @NonNull Layout mLayout; 558 private final @NonNull ResourceResolvers mLayoutResourceResolvers; 559 private final @Nullable Executor mLoadActionExecutor; 560 private final @NonNull LoadActionListener mLoadActionListener; 561 private final @NonNull Resources mRendererResources; 562 private final @NonNull ProtoLayoutTheme mProtoLayoutTheme; 563 private final @Nullable ProtoLayoutDynamicDataPipeline mDataPipeline; 564 private final @NonNull String mClickableIdExtra; 565 566 private final @Nullable LoggingUtils mLoggingUtils; 567 private final @NonNull InflaterStatsLogger mInflaterStatsLogger; 568 private final @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider; 569 private final boolean mAnimationEnabled; 570 571 private final boolean mAllowLayoutChangingBindsWithoutDefault; 572 573 private final boolean mApplyFontVariantBodyAsDefault; 574 Config( @onNull 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, @NonNull InflaterStatsLogger inflaterStatsLogger, boolean animationEnabled, boolean allowLayoutChangingBindsWithoutDefault, boolean applyFontVariantBodyAsDefault)575 Config( 576 @NonNull Context uiContext, 577 @NonNull Layout layout, 578 @NonNull ResourceResolvers layoutResourceResolvers, 579 @Nullable Executor loadActionExecutor, 580 @NonNull LoadActionListener loadActionListener, 581 @NonNull Resources rendererResources, 582 @NonNull ProtoLayoutTheme protoLayoutTheme, 583 @Nullable ProtoLayoutDynamicDataPipeline dataPipeline, 584 @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider, 585 @NonNull String clickableIdExtra, 586 @Nullable LoggingUtils loggingUtils, 587 @NonNull InflaterStatsLogger inflaterStatsLogger, 588 boolean animationEnabled, 589 boolean allowLayoutChangingBindsWithoutDefault, 590 boolean applyFontVariantBodyAsDefault) { 591 this.mUiContext = uiContext; 592 this.mLayout = layout; 593 this.mLayoutResourceResolvers = layoutResourceResolvers; 594 this.mLoadActionExecutor = loadActionExecutor; 595 this.mLoadActionListener = loadActionListener; 596 this.mRendererResources = rendererResources; 597 this.mProtoLayoutTheme = protoLayoutTheme; 598 this.mDataPipeline = dataPipeline; 599 this.mAnimationEnabled = animationEnabled; 600 this.mAllowLayoutChangingBindsWithoutDefault = allowLayoutChangingBindsWithoutDefault; 601 this.mClickableIdExtra = clickableIdExtra; 602 this.mLoggingUtils = loggingUtils; 603 this.mInflaterStatsLogger = inflaterStatsLogger; 604 this.mExtensionViewProvider = extensionViewProvider; 605 this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault; 606 } 607 608 /** A {@link Context} suitable for interacting with UI. */ getUiContext()609 public @NonNull Context getUiContext() { 610 return mUiContext; 611 } 612 613 /** The layout to be rendered. */ getLayout()614 public @NonNull Layout getLayout() { 615 return mLayout; 616 } 617 618 /** Resolvers for the resources used for rendering this layout. */ getLayoutResourceResolvers()619 public @NonNull ResourceResolvers getLayoutResourceResolvers() { 620 return mLayoutResourceResolvers; 621 } 622 623 /** Executor to dispatch loadActionListener on. */ getLoadActionExecutor()624 public @Nullable Executor getLoadActionExecutor() { 625 return mLoadActionExecutor; 626 } 627 628 /** Listener for clicks that will cause contents to be reloaded. */ getLoadActionListener()629 public @NonNull LoadActionListener getLoadActionListener() { 630 return mLoadActionListener; 631 } 632 633 /** 634 * Renderer internal resources. This Resources object can be used to resolve Renderer's 635 * resources. 636 */ getRendererResources()637 public @NonNull Resources getRendererResources() { 638 return mRendererResources; 639 } 640 641 /** 642 * Theme to use for this ProtoLayoutInflater instance. This can be used to customise things 643 * like the default font family. 644 */ getProtoLayoutTheme()645 public @NonNull ProtoLayoutTheme getProtoLayoutTheme() { 646 return mProtoLayoutTheme; 647 } 648 649 /** 650 * Pipeline for dynamic data. If null, the dynamic properties would not be registered for 651 * update. 652 */ getDynamicDataPipeline()653 public @Nullable ProtoLayoutDynamicDataPipeline getDynamicDataPipeline() { 654 return mDataPipeline; 655 } 656 657 /** 658 * ID for the Intent extra containing the ID of a Clickable. Defaults to {@link 659 * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified. 660 */ getClickableIdExtra()661 public @NonNull String getClickableIdExtra() { 662 return mClickableIdExtra; 663 } 664 665 /** Debug logger used to log debug messages. */ getLoggingUtils()666 public @Nullable LoggingUtils getLoggingUtils() { 667 return mLoggingUtils; 668 } 669 670 /** Stats logger used for telemetry. */ getInflaterStatsLogger()671 public @NonNull InflaterStatsLogger getInflaterStatsLogger() { 672 return mInflaterStatsLogger; 673 } 674 675 /** View provider for the renderer extension. */ getExtensionViewProvider()676 public @Nullable ProtoLayoutExtensionViewProvider getExtensionViewProvider() { 677 return mExtensionViewProvider; 678 } 679 680 /** Whether animation is enabled, which decides whether to load contentUpdateAnimations. */ getAnimationEnabled()681 public boolean getAnimationEnabled() { 682 return mAnimationEnabled; 683 } 684 685 /** 686 * Whether a "layout changing" data bind can be applied without the "value_for_layout" field 687 * being filled in. This is to support legacy apps which use layout-changing data binds 688 * before the full support was built. 689 */ getAllowLayoutChangingBindsWithoutDefault()690 public boolean getAllowLayoutChangingBindsWithoutDefault() { 691 return mAllowLayoutChangingBindsWithoutDefault; 692 } 693 694 /** Whether to apply FONT_VARIANT_BODY as default variant. */ getApplyFontVariantBodyAsDefault()695 public boolean getApplyFontVariantBodyAsDefault() { 696 return mApplyFontVariantBodyAsDefault; 697 } 698 699 /** Builder for the Config class. */ 700 public static final class Builder { 701 private final @NonNull Context mUiContext; 702 private final @NonNull Layout mLayout; 703 private final @NonNull ResourceResolvers mLayoutResourceResolvers; 704 private @Nullable Executor mLoadActionExecutor; 705 private @Nullable LoadActionListener mLoadActionListener; 706 private @NonNull Resources mRendererResources; 707 private @Nullable ProtoLayoutTheme mProtoLayoutTheme; 708 private @Nullable ProtoLayoutDynamicDataPipeline mDataPipeline = null; 709 private boolean mAnimationEnabled = true; 710 private boolean mAllowLayoutChangingBindsWithoutDefault = false; 711 private @Nullable String mClickableIdExtra; 712 713 private @Nullable LoggingUtils mLoggingUtils; 714 private @Nullable InflaterStatsLogger mInflaterStatsLogger; 715 716 private @Nullable ProtoLayoutExtensionViewProvider mExtensionViewProvider = null; 717 718 private boolean mApplyFontVariantBodyAsDefault = false; 719 720 /** 721 * @param uiContext A {@link Context} suitable for interacting with UI with. 722 * @param layout The layout to be rendered. 723 * @param layoutResourceResolvers Resolvers for the resources used for rendering this 724 * layout. 725 */ Builder( @onNull Context uiContext, @NonNull Layout layout, @NonNull ResourceResolvers layoutResourceResolvers)726 public Builder( 727 @NonNull Context uiContext, 728 @NonNull Layout layout, 729 @NonNull ResourceResolvers layoutResourceResolvers) { 730 this.mUiContext = uiContext; 731 this.mRendererResources = uiContext.getResources(); 732 this.mLayout = layout; 733 this.mLayoutResourceResolvers = layoutResourceResolvers; 734 } 735 736 /** 737 * Sets the Executor to dispatch loadActionListener on. This is required when setting 738 * {@link Builder#setLoadActionListener}. 739 */ setLoadActionExecutor(@onNull Executor loadActionExecutor)740 public @NonNull Builder setLoadActionExecutor(@NonNull Executor loadActionExecutor) { 741 this.mLoadActionExecutor = loadActionExecutor; 742 return this; 743 } 744 745 /** 746 * Sets the listener for clicks that will cause contents to be reloaded. Defaults to 747 * no-op. This is required if the given layout contains a load action. When this is set, 748 * it's also required to set an executor with {@link Builder#setLoadActionExecutor}. 749 */ setLoadActionListener( @onNull LoadActionListener loadActionListener)750 public @NonNull Builder setLoadActionListener( 751 @NonNull LoadActionListener loadActionListener) { 752 this.mLoadActionListener = loadActionListener; 753 return this; 754 } 755 756 /** 757 * Sets the Renderer internal Resources object. This should be specified when loading 758 * the renderer from a separate APK. This can usually be retrieved with {@link 759 * android.content.pm.PackageManager#getResourcesForApplication(String)}. If not 760 * specified, this is retrieved from the Ui Context. 761 */ setRendererResources(@onNull Resources rendererResources)762 public @NonNull Builder setRendererResources(@NonNull Resources rendererResources) { 763 this.mRendererResources = rendererResources; 764 return this; 765 } 766 767 /** 768 * Sets the theme to use for this ProtoLayoutInflater instance. This can be used to 769 * customise things like the default font family. If not set, the default theme is used. 770 */ setProtoLayoutTheme( @onNull ProtoLayoutTheme protoLayoutTheme)771 public @NonNull Builder setProtoLayoutTheme( 772 @NonNull ProtoLayoutTheme protoLayoutTheme) { 773 this.mProtoLayoutTheme = protoLayoutTheme; 774 return this; 775 } 776 777 /** 778 * Sets the pipeline for dynamic data. If null, the dynamic properties would not be 779 * registered for update. 780 */ setDynamicDataPipeline( @onNull ProtoLayoutDynamicDataPipeline dataPipeline)781 public @NonNull Builder setDynamicDataPipeline( 782 @NonNull ProtoLayoutDynamicDataPipeline dataPipeline) { 783 this.mDataPipeline = dataPipeline; 784 return this; 785 } 786 787 /** Sets the view provider for the renderer extension. */ setExtensionViewProvider( @onNull ProtoLayoutExtensionViewProvider extensionViewProvider)788 public @NonNull Builder setExtensionViewProvider( 789 @NonNull ProtoLayoutExtensionViewProvider extensionViewProvider) { 790 this.mExtensionViewProvider = extensionViewProvider; 791 return this; 792 } 793 794 /** 795 * Sets whether animation is enabled, which decides whether to load 796 * contentUpdateAnimations. Defaults to true. 797 */ setAnimationEnabled(boolean animationEnabled)798 public @NonNull Builder setAnimationEnabled(boolean animationEnabled) { 799 this.mAnimationEnabled = animationEnabled; 800 return this; 801 } 802 803 /** 804 * Sets the ID for the Intent extra containing the ID of a Clickable. Defaults to {@link 805 * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified. 806 */ setClickableIdExtra(@onNull String clickableIdExtra)807 public @NonNull Builder setClickableIdExtra(@NonNull String clickableIdExtra) { 808 this.mClickableIdExtra = clickableIdExtra; 809 return this; 810 } 811 812 /** Sets the debug logger used for extensive logging. */ setLoggingUtils(@onNull LoggingUtils loggingUtils)813 public @NonNull Builder setLoggingUtils(@NonNull LoggingUtils loggingUtils) { 814 this.mLoggingUtils = loggingUtils; 815 return this; 816 } 817 818 /** Sets the stats logger used for telemetry. */ setInflaterStatsLogger( @onNull InflaterStatsLogger inflaterStatsLogger)819 public @NonNull Builder setInflaterStatsLogger( 820 @NonNull InflaterStatsLogger inflaterStatsLogger) { 821 this.mInflaterStatsLogger = inflaterStatsLogger; 822 return this; 823 } 824 825 /** 826 * Sets whether a "layout changing" data bind can be applied without the 827 * "value_for_layout" field being filled in, or being set to zero / empty. Defaults to 828 * false. 829 * 830 * <p>This is to support legacy apps which use layout-changing data bind before the full 831 * support was built. 832 */ setAllowLayoutChangingBindsWithoutDefault( boolean allowLayoutChangingBindsWithoutDefault)833 public @NonNull Builder setAllowLayoutChangingBindsWithoutDefault( 834 boolean allowLayoutChangingBindsWithoutDefault) { 835 this.mAllowLayoutChangingBindsWithoutDefault = 836 allowLayoutChangingBindsWithoutDefault; 837 return this; 838 } 839 840 /** Apply FONT_VARIANT_BODY as default variant. */ setApplyFontVariantBodyAsDefault( boolean applyFontVariantBodyAsDefault)841 public @NonNull Builder setApplyFontVariantBodyAsDefault( 842 boolean applyFontVariantBodyAsDefault) { 843 this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault; 844 return this; 845 } 846 847 /** Builds a Config instance. */ build()848 public @NonNull Config build() { 849 if (mLoadActionListener != null && mLoadActionExecutor == null) { 850 throw new IllegalArgumentException( 851 "A loadActionExecutor should always be set if setting a" 852 + " loadActionListener."); 853 } else if (mLoadActionListener == null && mLoadActionExecutor != null) { 854 throw new IllegalArgumentException( 855 "A loadActionExecutor has been provided but no loadActionListener was" 856 + " set."); 857 } 858 859 if (mLoadActionListener == null) { 860 mLoadActionListener = p -> {}; 861 } 862 863 if (mProtoLayoutTheme == null) { 864 this.mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext); 865 } 866 867 if (mClickableIdExtra == null) { 868 mClickableIdExtra = DEFAULT_CLICKABLE_ID_EXTRA; 869 } 870 871 if (mInflaterStatsLogger == null) { 872 mInflaterStatsLogger = 873 new NoOpProviderStatsLogger("No implementation was provided") 874 .createInflaterStatsLogger(); 875 } 876 877 return new Config( 878 mUiContext, 879 mLayout, 880 mLayoutResourceResolvers, 881 mLoadActionExecutor, 882 checkNotNull(mLoadActionListener), 883 mRendererResources, 884 checkNotNull(mProtoLayoutTheme), 885 mDataPipeline, 886 mExtensionViewProvider, 887 checkNotNull(mClickableIdExtra), 888 mLoggingUtils, 889 mInflaterStatsLogger, 890 mAnimationEnabled, 891 mAllowLayoutChangingBindsWithoutDefault, 892 mApplyFontVariantBodyAsDefault); 893 } 894 } 895 } 896 ProtoLayoutInflater(@onNull Config config)897 public ProtoLayoutInflater(@NonNull Config config) { 898 // Wrap the Ui Context with a Theme from rendererResources, so that any implicit resource 899 // reads using the R class from this package are successful. 900 Theme rendererTheme = config.getRendererResources().newTheme(); 901 rendererTheme.setTo(config.getUiContext().getTheme()); 902 this.mUiContext = new ContextThemeWrapper(config.getUiContext(), rendererTheme); 903 this.mProtoLayoutTheme = config.getProtoLayoutTheme(); 904 this.mProtoLayoutThemeContext = 905 new ContextThemeWrapper(mUiContext, mProtoLayoutTheme.getTheme()); 906 this.mLayoutProto = config.getLayout(); 907 this.mLayoutResourceResolvers = config.getLayoutResourceResolvers(); 908 this.mLoadActionExecutor = config.getLoadActionExecutor(); 909 this.mLoadActionListener = config.getLoadActionListener(); 910 this.mDataPipeline = Optional.ofNullable(config.getDynamicDataPipeline()); 911 this.mAnimationEnabled = config.getAnimationEnabled(); 912 this.mAllowLayoutChangingBindsWithoutDefault = 913 config.getAllowLayoutChangingBindsWithoutDefault(); 914 this.mClickableIdExtra = config.getClickableIdExtra(); 915 this.mLoggingUtils = config.getLoggingUtils(); 916 this.mInflaterStatsLogger = config.getInflaterStatsLogger(); 917 this.mExtensionViewProvider = config.getExtensionViewProvider(); 918 this.mApplyFontVariantBodyAsDefault = config.getApplyFontVariantBodyAsDefault(); 919 } 920 dpToPx(float dp)921 private int dpToPx(float dp) { 922 return round(dp * mUiContext.getResources().getDisplayMetrics().density); 923 } 924 safeDpToPx(float dp)925 private int safeDpToPx(float dp) { 926 return max(0, dpToPx(dp)); 927 } 928 safeDpToPx(DpProp dpProp)929 private int safeDpToPx(DpProp dpProp) { 930 return safeDpToPx(dpProp.getValue()); 931 } 932 safeAspectRatioOrNull( ProportionalDimensionProp proportionalDimensionProp)933 private static @Nullable Float safeAspectRatioOrNull( 934 ProportionalDimensionProp proportionalDimensionProp) { 935 final int dividend = proportionalDimensionProp.getAspectRatioWidth(); 936 final int divisor = proportionalDimensionProp.getAspectRatioHeight(); 937 938 if (dividend <= 0 || divisor <= 0) { 939 return null; 940 } 941 return (float) dividend / divisor; 942 } 943 getSourceBounds(View v)944 private static Rect getSourceBounds(View v) { 945 final int[] pos = new int[2]; 946 v.getLocationOnScreen(pos); 947 948 return new Rect( 949 /* left= */ pos[0], 950 /* top= */ pos[1], 951 /* right= */ pos[0] + v.getWidth(), 952 /* bottom= */ pos[1] + v.getHeight()); 953 } 954 955 /** 956 * Generates a generic LayoutParameters for use by all components. This just defaults to setting 957 * the width/height to WRAP_CONTENT. 958 * 959 * @return The default layout parameters. 960 */ generateDefaultLayoutParams()961 private static LayoutParams generateDefaultLayoutParams() { 962 return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 963 } 964 965 // dereference of possibly-null reference parent.getLayoutParams() 966 @SuppressWarnings("nullness:dereference.of.nullable") updateLayoutParamsInLinearLayout( LinearLayoutProperties linearLayoutProperties, LayoutParams layoutParams, ContainerDimension width, ContainerDimension height)967 private LayoutParams updateLayoutParamsInLinearLayout( 968 LinearLayoutProperties linearLayoutProperties, 969 LayoutParams layoutParams, 970 ContainerDimension width, 971 ContainerDimension height) { 972 // This is a little bit fun. ProtoLayout's semantics is that dimension = expand should eat 973 // all remaining space in that dimension, but not grow the parent. This is easy for standard 974 // containers, but a little trickier in rows and columns on Android. 975 // 976 // A Row (LinearLayout) supports this with width=0 and weight>0. After doing a layout pass, 977 // it will assign all remaining space to elements with width=0 and weight>0, biased by the 978 // weight. This causes problems if there are two (or more) "expand" elements in a row, which 979 // is itself set to WRAP_CONTENTS, and one of those elements has a measured width (e.g. 980 // Text). In that case, the LinearLayout will measure the text, then ensure that all 981 // elements with a weight set have their widths set according to the weight. For us, that 982 // means that _all_ elements with expand=true will size themselves to the same width as the 983 // Text, pushing out the bounds of the parent row. This happens on columns too, but of 984 // course regarding height. 985 // 986 // To get around this, if an element with expand=true is added to a row that is WRAP_CONTENT 987 // (e.g. a row with no explicit width, that is not expanded), we ignore the expand=true, and 988 // set the inner element's width to WRAP_CONTENT too. 989 990 LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(layoutParams); 991 LayoutParams parentLayoutParams = linearLayoutProperties.getRawLayoutParams(); 992 993 // Handle the width 994 if (linearLayoutProperties.getOrientation() == LinearLayout.HORIZONTAL 995 && width.getInnerCase() == InnerCase.EXPANDED_DIMENSION) { 996 // If the parent container would not normally have "remaining space", ignore the 997 // expand=true. 998 if (parentLayoutParams.width == LayoutParams.WRAP_CONTENT) { 999 linearLayoutParams.width = LayoutParams.WRAP_CONTENT; 1000 } else { 1001 linearLayoutParams.width = 0; 1002 float weight = width.getExpandedDimension().getLayoutWeight().getValue(); 1003 linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f; 1004 } 1005 } else { 1006 linearLayoutParams.width = dimensionToPx(width); 1007 } 1008 1009 // And the height 1010 if (linearLayoutProperties.getOrientation() == LinearLayout.VERTICAL 1011 && height.getInnerCase() == InnerCase.EXPANDED_DIMENSION) { 1012 // If the parent container would not normally have "remaining space", ignore the 1013 // expand=true. 1014 if (parentLayoutParams.height == LayoutParams.WRAP_CONTENT) { 1015 linearLayoutParams.height = LayoutParams.WRAP_CONTENT; 1016 } else { 1017 linearLayoutParams.height = 0; 1018 float weight = height.getExpandedDimension().getLayoutWeight().getValue(); 1019 linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f; 1020 } 1021 } else { 1022 linearLayoutParams.height = dimensionToPx(height); 1023 } 1024 1025 return linearLayoutParams; 1026 } 1027 1028 /** 1029 * Creates {@link ContainerDimension} from the given {@link SpacerDimension}. If none of the 1030 * linear or expanded dimension are present, it defaults to linear dimension 0. 1031 */ spacerDimensionToContainerDimension( SpacerDimension spacerDimension)1032 private static ContainerDimension spacerDimensionToContainerDimension( 1033 SpacerDimension spacerDimension) { 1034 ContainerDimension.Builder containerDimension = 1035 ContainerDimension.newBuilder().setLinearDimension(DpProp.newBuilder().setValue(0)); 1036 if (spacerDimension.hasLinearDimension()) { 1037 containerDimension.setLinearDimension(spacerDimension.getLinearDimension()); 1038 } else if (spacerDimension.hasExpandedDimension()) { 1039 containerDimension.setExpandedDimension(spacerDimension.getExpandedDimension()); 1040 } 1041 return containerDimension.build(); 1042 } 1043 updateLayoutParams( ViewProperties viewProperties, LayoutParams layoutParams, ContainerDimension width, ContainerDimension height)1044 private LayoutParams updateLayoutParams( 1045 ViewProperties viewProperties, 1046 LayoutParams layoutParams, 1047 ContainerDimension width, 1048 ContainerDimension height) { 1049 if (viewProperties instanceof LinearLayoutProperties) { 1050 // LinearLayouts have a bunch of messy caveats in ProtoLayout when their children can be 1051 // expanded; factor that case out to keep this clean. 1052 return updateLayoutParamsInLinearLayout( 1053 (LinearLayoutProperties) viewProperties, layoutParams, width, height); 1054 } else { 1055 layoutParams.width = dimensionToPx(width); 1056 layoutParams.height = dimensionToPx(height); 1057 } 1058 1059 return layoutParams; 1060 } 1061 resolveMinimumDimensions( View view, ContainerDimension width, ContainerDimension height)1062 private void resolveMinimumDimensions( 1063 View view, ContainerDimension width, ContainerDimension height) { 1064 if (width.getWrappedDimension().hasMinimumSize()) { 1065 view.setMinimumWidth(safeDpToPx(width.getWrappedDimension().getMinimumSize())); 1066 } 1067 1068 if (height.getWrappedDimension().hasMinimumSize()) { 1069 view.setMinimumHeight(safeDpToPx(height.getWrappedDimension().getMinimumSize())); 1070 } 1071 } 1072 1073 @VisibleForTesting() getFrameLayoutGravity( HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment)1074 static int getFrameLayoutGravity( 1075 HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) { 1076 return horizontalAlignmentToGravity(horizontalAlignment) 1077 | verticalAlignmentToGravity(verticalAlignment); 1078 } 1079 1080 @SuppressLint("RtlHardcoded") horizontalAlignmentToGravity(HorizontalAlignment alignment)1081 private static int horizontalAlignmentToGravity(HorizontalAlignment alignment) { 1082 switch (alignment) { 1083 case HORIZONTAL_ALIGN_START: 1084 return Gravity.START; 1085 case HORIZONTAL_ALIGN_CENTER: 1086 return Gravity.CENTER_HORIZONTAL; 1087 case HORIZONTAL_ALIGN_END: 1088 return Gravity.END; 1089 case HORIZONTAL_ALIGN_LEFT: 1090 return Gravity.LEFT; 1091 case HORIZONTAL_ALIGN_RIGHT: 1092 return Gravity.RIGHT; 1093 case UNRECOGNIZED: 1094 case HORIZONTAL_ALIGN_UNDEFINED: 1095 return HORIZONTAL_ALIGN_DEFAULT_GRAVITY; 1096 } 1097 1098 return HORIZONTAL_ALIGN_DEFAULT_GRAVITY; 1099 } 1100 verticalAlignmentToGravity(VerticalAlignment alignment)1101 private static int verticalAlignmentToGravity(VerticalAlignment alignment) { 1102 switch (alignment) { 1103 case VERTICAL_ALIGN_TOP: 1104 return Gravity.TOP; 1105 case VERTICAL_ALIGN_CENTER: 1106 return Gravity.CENTER_VERTICAL; 1107 case VERTICAL_ALIGN_BOTTOM: 1108 return Gravity.BOTTOM; 1109 case UNRECOGNIZED: 1110 case VERTICAL_ALIGN_UNDEFINED: 1111 return VERTICAL_ALIGN_DEFAULT_GRAVITY; 1112 } 1113 1114 return VERTICAL_ALIGN_DEFAULT_GRAVITY; 1115 } 1116 1117 @ArcLayout.LayoutParams.VerticalAlignment verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment)1118 private static int verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment) { 1119 switch (alignment.getValue()) { 1120 case VERTICAL_ALIGN_TOP: 1121 return ArcLayout.LayoutParams.VERTICAL_ALIGN_OUTER; 1122 case VERTICAL_ALIGN_CENTER: 1123 return ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER; 1124 case VERTICAL_ALIGN_BOTTOM: 1125 return ArcLayout.LayoutParams.VERTICAL_ALIGN_INNER; 1126 case UNRECOGNIZED: 1127 case VERTICAL_ALIGN_UNDEFINED: 1128 return ARC_VERTICAL_ALIGN_DEFAULT; 1129 } 1130 1131 return ARC_VERTICAL_ALIGN_DEFAULT; 1132 } 1133 contentScaleModeToScaleType(ContentScaleMode contentScaleMode)1134 private static ScaleType contentScaleModeToScaleType(ContentScaleMode contentScaleMode) { 1135 switch (contentScaleMode) { 1136 case CONTENT_SCALE_MODE_FIT: 1137 return ScaleType.FIT_CENTER; 1138 case CONTENT_SCALE_MODE_CROP: 1139 return ScaleType.CENTER_CROP; 1140 case CONTENT_SCALE_MODE_FILL_BOUNDS: 1141 return ScaleType.FIT_XY; 1142 case CONTENT_SCALE_MODE_UNDEFINED: 1143 case UNRECOGNIZED: 1144 return IMAGE_DEFAULT_SCALE_TYPE; 1145 } 1146 1147 return IMAGE_DEFAULT_SCALE_TYPE; 1148 } 1149 spanVerticalAlignmentToImgSpanAlignment( SpanVerticalAlignmentProp alignment)1150 private static int spanVerticalAlignmentToImgSpanAlignment( 1151 SpanVerticalAlignmentProp alignment) { 1152 switch (alignment.getValue()) { 1153 case SPAN_VERTICAL_ALIGN_TEXT_BASELINE: 1154 return ImageSpan.ALIGN_BASELINE; 1155 case SPAN_VERTICAL_ALIGN_BOTTOM: 1156 return ImageSpan.ALIGN_BOTTOM; 1157 case SPAN_VERTICAL_ALIGN_UNDEFINED: 1158 case UNRECOGNIZED: 1159 return SPAN_VERTICAL_ALIGN_DEFAULT; 1160 } 1161 1162 return SPAN_VERTICAL_ALIGN_DEFAULT; 1163 } 1164 1165 /** 1166 * Whether a font style is bold or not (has weight > 700). Note that this check is required, 1167 * even if you are using an explicitly bold font (e.g. Roboto-Bold), as Typeface still needs to 1168 * bold bit set to render properly. 1169 */ isBold(FontStyle fontStyle)1170 private static boolean isBold(FontStyle fontStyle) { 1171 // Even though we have weight axis too, this concept of bold and bold flag in Typeface is 1172 // different, so we only look at the FontWeight enum API here. Although this method could be 1173 // a simple equality check against FONT_WEIGHT_BOLD, we list all current cases here so that 1174 // this will become a compile time error as soon as a new FontWeight value is added to the 1175 // schema. If this fails to build, then this means that an int typeface style is no longer 1176 // enough to represent all FontWeight values and a customizable, per-weight text style must 1177 // be introduced to ProtoLayoutInflater to handle this. See b/176980535 1178 switch (fontStyle.getWeight().getValue()) { 1179 case FONT_WEIGHT_BOLD: 1180 return true; 1181 case FONT_WEIGHT_NORMAL: 1182 case FONT_WEIGHT_MEDIUM: 1183 case FONT_WEIGHT_UNDEFINED: 1184 case UNRECOGNIZED: 1185 return false; 1186 } 1187 1188 return false; 1189 } 1190 fontStyleToTypeface(FontStyle fontStyle)1191 private Typeface fontStyleToTypeface(FontStyle fontStyle) { 1192 String[] preferredFontFamilies = new String[fontStyle.getPreferredFontFamiliesCount() + 1]; 1193 if (fontStyle.getPreferredFontFamiliesCount() > 0) { 1194 fontStyle.getPreferredFontFamiliesList().toArray(preferredFontFamilies); 1195 } 1196 1197 // Add value from the FontVariant as fallback to work with providers using legacy 1198 // FONT_VARIANT API or with older system API. 1199 String fontVariantName; 1200 switch (fontStyle.getVariant().getValue()) { 1201 case FONT_VARIANT_TITLE: 1202 fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_TITLE; 1203 break; 1204 case UNRECOGNIZED: 1205 case FONT_VARIANT_BODY: 1206 default: 1207 // fall through as this is default 1208 fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_BODY; 1209 break; 1210 } 1211 preferredFontFamilies[preferredFontFamilies.length - 1] = fontVariantName; 1212 1213 FontSet fonts = mProtoLayoutTheme.getFontSet(preferredFontFamilies); 1214 1215 // Only use FontWeight enum API. Weight axis is already covered by FontVariationSetting. 1216 switch (fontStyle.getWeight().getValue()) { 1217 case FONT_WEIGHT_BOLD: 1218 return fonts.getBoldFont(); 1219 case FONT_WEIGHT_MEDIUM: 1220 return fonts.getMediumFont(); 1221 case FONT_WEIGHT_NORMAL: 1222 case FONT_WEIGHT_UNDEFINED: 1223 case UNRECOGNIZED: 1224 return fonts.getNormalFont(); 1225 } 1226 1227 return fonts.getNormalFont(); 1228 } 1229 fontStyleToTypefaceStyle(FontStyle fontStyle)1230 private static int fontStyleToTypefaceStyle(FontStyle fontStyle) { 1231 final boolean isBold = isBold(fontStyle); 1232 final boolean isItalic = fontStyle.getItalic().getValue(); 1233 1234 if (isBold && isItalic) { 1235 return Typeface.BOLD_ITALIC; 1236 } else if (isBold) { 1237 return Typeface.BOLD; 1238 } else if (isItalic) { 1239 return Typeface.ITALIC; 1240 } else { 1241 return Typeface.NORMAL; 1242 } 1243 } 1244 1245 @SuppressWarnings("nullness") createTypeface(FontStyle fontStyle)1246 private Typeface createTypeface(FontStyle fontStyle) { 1247 return Typeface.create(fontStyleToTypeface(fontStyle), fontStyleToTypefaceStyle(fontStyle)); 1248 } 1249 1250 /** 1251 * Returns whether or not the default style bits in Typeface can be used, or if we need to add 1252 * bold/italic flags there. 1253 */ hasDefaultTypefaceStyle(FontStyle fontStyle)1254 private static boolean hasDefaultTypefaceStyle(FontStyle fontStyle) { 1255 return !fontStyle.getItalic().getValue() && !isBold(fontStyle); 1256 } 1257 toPx(SpProp spField)1258 private float toPx(SpProp spField) { 1259 return TypedValue.applyDimension( 1260 COMPLEX_UNIT_SP, spField.getValue(), mUiContext.getResources().getDisplayMetrics()); 1261 } 1262 applyFontStyle( FontStyle style, TextView textView, String posId, Optional<PipelineMaker> pipelineMaker, boolean isAutoSizeAllowed)1263 private void applyFontStyle( 1264 FontStyle style, 1265 TextView textView, 1266 String posId, 1267 Optional<PipelineMaker> pipelineMaker, 1268 boolean isAutoSizeAllowed) { 1269 // Note: Underline must be applied as a Span to work correctly (as opposed to using 1270 // TextPaint#setTextUnderline). This is applied in the caller instead. 1271 1272 // Need to supply typefaceStyle when creating the typeface (will select specialist 1273 // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic 1274 // flags in Paint if they're not supported by the given typeface). This is fine to do even 1275 // for variable fonts with weight axis, as if their weight axis is larger than 700 and BOLD 1276 // flag is on, it should be "bolded" two times - one for Typeface selection, one for axis 1277 // value. 1278 textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style)); 1279 1280 if (fontStyleHasSize(style)) { 1281 List<SpProp> sizes = style.getSizeList(); 1282 int sizesCnt = sizes.size(); 1283 1284 if (sizesCnt == 1) { 1285 // No autosizing needed. 1286 textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(0).getValue()); 1287 } else if (isAutoSizeAllowed && sizesCnt <= TEXT_AUTOSIZES_LIMIT) { 1288 // We need to check values so that we are certain that there's at least 1 non zero 1289 // value. 1290 boolean atLeastOneCorrectSize = 1291 sizes.stream() 1292 .mapToInt(sp -> (int) sp.getValue()) 1293 .filter(sp -> sp > 0) 1294 .distinct() 1295 .count() 1296 > 0; 1297 1298 if (atLeastOneCorrectSize) { 1299 // Max size is needed so that TextView leaves enough space for it. Otherwise, 1300 // the text won't be able to grow. 1301 int maxSize = 1302 sizes.stream().mapToInt(sp -> (int) sp.getValue()).max().getAsInt(); 1303 textView.setTextSize(COMPLEX_UNIT_SP, maxSize); 1304 1305 // No need for sorting, TextView does that. 1306 textView.setAutoSizeTextTypeUniformWithPresetSizes( 1307 sizes.stream().mapToInt(spProp -> (int) spProp.getValue()).toArray(), 1308 COMPLEX_UNIT_SP); 1309 } else { 1310 Log.w( 1311 TAG, 1312 "Trying to autosize text but no valid font sizes has been specified."); 1313 } 1314 } else { 1315 // Fallback where multiple values can't be used and the last value would be used. 1316 // This can happen in two cases. 1317 if (!isAutoSizeAllowed) { 1318 // There is more than 1 size specified, but autosizing is not allowed. 1319 Log.w( 1320 TAG, 1321 "Trying to autosize text with multiple font sizes where it's not " 1322 + "allowed. Ignoring all other sizes and using the last one."); 1323 } else { 1324 Log.w( 1325 TAG, 1326 "More than " 1327 + TEXT_AUTOSIZES_LIMIT 1328 + " sizes has been added for the text autosizing. Ignoring all" 1329 + " other sizes and using the last one."); 1330 } 1331 1332 textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(sizesCnt - 1).getValue()); 1333 } 1334 } 1335 1336 if (style.hasLetterSpacing()) { 1337 textView.setLetterSpacing(style.getLetterSpacing().getValue()); 1338 } 1339 1340 if (style.hasColor()) { 1341 handleProp(style.getColor(), textView::setTextColor, posId, pipelineMaker); 1342 } else { 1343 textView.setTextColor(TEXT_COLOR_DEFAULT); 1344 } 1345 1346 if (style.getSettingsCount() > 0) { 1347 applyFontSetting(style, textView); 1348 } 1349 } 1350 applyFontSetting(@onNull FontStyle style, @NonNull TextView textView)1351 private void applyFontSetting(@NonNull FontStyle style, @NonNull TextView textView) { 1352 StringJoiner variationSettings = new StringJoiner(","); 1353 StringJoiner featureSettings = new StringJoiner(","); 1354 1355 for (FontSetting setting : style.getSettingsList()) { 1356 String tag = ""; 1357 1358 switch (setting.getInnerCase()) { 1359 case VARIATION: 1360 FontVariationSetting variation = setting.getVariation(); 1361 tag = toTagString(variation.getAxisTag()); 1362 1363 if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) { 1364 variationSettings.add("'" + tag + "' " + variation.getValue()); 1365 } else { 1366 // Skip not supported tags. 1367 Log.d(TAG, "FontVariation axes tag " + tag + " is not supported."); 1368 } 1369 1370 break; 1371 case FEATURE: 1372 FontFeatureSetting feature = setting.getFeature(); 1373 tag = toTagString(feature.getTag()); 1374 1375 if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) { 1376 featureSettings.add("'" + tag + "'"); 1377 } else { 1378 // Skip not supported tags. 1379 Log.d(TAG, "FontFeature tag " + tag + " is not supported."); 1380 } 1381 1382 break; 1383 case INNER_NOT_SET: 1384 break; 1385 } 1386 } 1387 1388 textView.setFontVariationSettings(variationSettings.toString()); 1389 textView.setFontFeatureSettings(featureSettings.toString()); 1390 } 1391 1392 /** Given the integer representation of 4 characters ASCII code, returns the String of it. */ toTagString(int tagCode)1393 private static @NonNull String toTagString(int tagCode) { 1394 return new String(ByteBuffer.allocate(4).putInt(tagCode).array(), US_ASCII); 1395 } 1396 applyFontStyle(FontStyle style, CurvedTextView textView)1397 private void applyFontStyle(FontStyle style, CurvedTextView textView) { 1398 // Need to supply typefaceStyle when creating the typeface (will select specialist 1399 // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic 1400 // flags in Paint if they're not supported by the given typeface). 1401 textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style)); 1402 1403 if (fontStyleHasSize(style)) { 1404 // We are using the last added size in the FontStyle because ArcText doesn't support 1405 // autosizing. This is the same behaviour as it was before size has made repeated. 1406 if (style.getSizeList().size() > 1) { 1407 Log.w( 1408 TAG, 1409 "Font size with multiple values has been used on Arc Text. Ignoring " 1410 + "all size except the first one."); 1411 } 1412 textView.setTextSize(toPx(style.getSize(style.getSizeCount() - 1))); 1413 } 1414 } 1415 dispatchLaunchActionIntent(Intent i)1416 void dispatchLaunchActionIntent(Intent i) { 1417 ActivityInfo ai = i.resolveActivityInfo(mUiContext.getPackageManager(), /* flags= */ 0); 1418 1419 if (ai != null && ai.exported && (ai.permission == null || ai.permission.isEmpty())) { 1420 mUiContext.startActivity(i); 1421 } 1422 } 1423 applyClickable( @onNull View view, @Nullable View wrapper, @NonNull Clickable clickable, boolean extendTouchTarget)1424 private void applyClickable( 1425 @NonNull View view, 1426 @Nullable View wrapper, 1427 @NonNull Clickable clickable, 1428 boolean extendTouchTarget) { 1429 view.setTag(R.id.clickable_id_tag, clickable.getId()); 1430 1431 boolean hasAction = false; 1432 switch (clickable.getOnClick().getValueCase()) { 1433 case LAUNCH_ACTION: 1434 Intent i = 1435 buildLaunchActionIntent( 1436 clickable.getOnClick().getLaunchAction(), 1437 clickable.getId(), 1438 mClickableIdExtra); 1439 if (i != null) { 1440 hasAction = true; 1441 view.setOnClickListener( 1442 v -> { 1443 i.setSourceBounds(getSourceBounds(view)); 1444 dispatchLaunchActionIntent(i); 1445 }); 1446 } 1447 break; 1448 case LOAD_ACTION: 1449 hasAction = true; 1450 if (mLoadActionExecutor == null) { 1451 Log.w(TAG, "Ignoring load action since an executor has not been provided."); 1452 break; 1453 } 1454 view.setOnClickListener( 1455 v -> 1456 checkNotNull(mLoadActionExecutor) 1457 .execute( 1458 () -> { 1459 Log.d( 1460 TAG, 1461 "Executing LoadAction listener" 1462 + " with clickable id: " 1463 + clickable.getId()); 1464 mLoadActionListener.onClick( 1465 buildState( 1466 clickable 1467 .getOnClick() 1468 .getLoadAction(), 1469 clickable.getId())); 1470 })); 1471 break; 1472 case VALUE_NOT_SET: 1473 break; 1474 } 1475 1476 if (hasAction) { 1477 if (!clickable.hasVisualFeedbackEnabled() || clickable.getVisualFeedbackEnabled()) { 1478 // Apply ripple effect 1479 applyRippleEffect(view); 1480 } 1481 1482 if (!extendTouchTarget) { 1483 // For temporarily disable the touch size check for element on arc 1484 return; 1485 } 1486 1487 view.post( 1488 () -> { 1489 // Use the logical parent (the parent in the proto) for the touch delegation 1490 // 1. When the direct parent is the wrapper view for layout sizing, it could 1491 // no provide extra space around the view. 1492 // 2. the ancestor view further up in the hierarchy are also NOT used, even 1493 // when the logical parent might not have enough space around the view. 1494 // Below are the considerations: 1495 // a. The clickables of the same logical parent should have same priority 1496 // of touch delegations. Only with this, we could avoid breaking the 1497 // rule of propagating touch event upwards in order. The higher the 1498 // ancester which forwards the touch event, the later the event is been 1499 // propagated to. 1500 // b. The minimal clickable size is not layout affecting, so unreasonable 1501 // big touch target size should not be guaranteed which leads to 1502 // unpredictable behavior. With it limited within the logical parent 1503 // bounds, the behaviour is predictable. 1504 ViewGroup logicalParent; 1505 if (wrapper != null) { 1506 logicalParent = (ViewGroup) wrapper.getParent(); 1507 } else { 1508 logicalParent = (ViewGroup) view.getParent(); 1509 } 1510 if (logicalParent != null) { 1511 float widthDp = DEFAULT_MIN_CLICKABLE_SIZE_DP; 1512 float heightDp = DEFAULT_MIN_CLICKABLE_SIZE_DP; 1513 if (clickable.hasMinimumClickableWidth()) { 1514 widthDp = clickable.getMinimumClickableWidth().getValue(); 1515 } 1516 if (clickable.hasMinimumClickableHeight()) { 1517 heightDp = clickable.getMinimumClickableHeight().getValue(); 1518 } 1519 extendClickableAreaIfNeeded( 1520 view, logicalParent, safeDpToPx(widthDp), safeDpToPx(heightDp)); 1521 } 1522 }); 1523 } 1524 } 1525 applyRippleEffect(@onNull View view)1526 private void applyRippleEffect(@NonNull View view) { 1527 if (mProtoLayoutTheme.getRippleResId() != 0) { 1528 try { 1529 view.setForeground( 1530 mProtoLayoutTheme 1531 .getTheme() 1532 .getDrawable(mProtoLayoutTheme.getRippleResId())); 1533 return; 1534 } catch (Resources.NotFoundException e) { 1535 Log.e( 1536 TAG, 1537 "Could not resolve the provided ripple resource id from the theme, " 1538 + "fallback to use the system default ripple."); 1539 } 1540 } 1541 1542 // Use the system default ripple effect by resolving selectableItemBackground against the 1543 // mUiContext theme, which provides the drawable. 1544 TypedValue outValue = new TypedValue(); 1545 boolean isValid = 1546 mUiContext 1547 .getTheme() 1548 .resolveAttribute( 1549 android.R.attr.selectableItemBackground, 1550 outValue, 1551 /* resolveRefs= */ true); 1552 if (isValid) { 1553 view.setForeground(mUiContext.getDrawable(outValue.resourceId)); 1554 } else { 1555 Log.e( 1556 TAG, 1557 "Could not resolve android.R.attr.selectableItemBackground from Ui Context."); 1558 } 1559 } 1560 1561 /* 1562 * Attempt to extend the clickable area if the view bound falls below the required minimum 1563 * clickable width/height. The clickable area is extended by delegating the touch event of its 1564 * surrounding space from its logical parent. Note that, the view's direct parent might not 1565 * be the logical parent in the proto. For example, ImageView is wrapped in a RatioViewWrapper 1566 */ extendClickableAreaIfNeeded( @onNull View view, @NonNull ViewGroup logicalParent, int minClickableWidthPx, int minClickableHeightPx)1567 private static void extendClickableAreaIfNeeded( 1568 @NonNull View view, 1569 @NonNull ViewGroup logicalParent, 1570 int minClickableWidthPx, 1571 int minClickableHeightPx) { 1572 Rect hitRect = new Rect(); 1573 view.getHitRect(hitRect); 1574 if (minClickableWidthPx > hitRect.width() || minClickableHeightPx > hitRect.height()) { 1575 1576 ViewGroup directParent = (ViewGroup) view.getParent(); 1577 while (logicalParent != directParent) { 1578 if (directParent == null) { 1579 return; 1580 } 1581 Rect rect = new Rect(); 1582 directParent.getHitRect(rect); 1583 hitRect.offset(rect.left, rect.top); 1584 directParent = (ViewGroup) directParent.getParent(); 1585 } 1586 1587 Rect actualRect = new Rect(hitRect); 1588 // Negative inset makes the rect wider. 1589 hitRect.inset( 1590 min(0, hitRect.width() - minClickableWidthPx) / 2, 1591 min(0, hitRect.height() - minClickableHeightPx) / 2); 1592 1593 addTouchDelegate(logicalParent, new TouchDelegateComposite(view, actualRect, hitRect)); 1594 } 1595 } 1596 addTouchDelegate( @onNull View parent, @NonNull TouchDelegateComposite touchDelegateComposite)1597 private static void addTouchDelegate( 1598 @NonNull View parent, @NonNull TouchDelegateComposite touchDelegateComposite) { 1599 TouchDelegateComposite touchDelegate; 1600 if (parent.getTouchDelegate() != null) { 1601 touchDelegate = (TouchDelegateComposite) parent.getTouchDelegate(); 1602 touchDelegate.mergeFrom(touchDelegateComposite); 1603 } else { 1604 touchDelegate = touchDelegateComposite; 1605 } 1606 parent.setTouchDelegate(touchDelegate); 1607 } 1608 applyPadding(View view, Padding padding)1609 private void applyPadding(View view, Padding padding) { 1610 if (padding.getRtlAware().getValue()) { 1611 view.setPaddingRelative( 1612 safeDpToPx(padding.getStart()), 1613 safeDpToPx(padding.getTop()), 1614 safeDpToPx(padding.getEnd()), 1615 safeDpToPx(padding.getBottom())); 1616 } else { 1617 view.setPadding( 1618 safeDpToPx(padding.getStart()), 1619 safeDpToPx(padding.getTop()), 1620 safeDpToPx(padding.getEnd()), 1621 safeDpToPx(padding.getBottom())); 1622 } 1623 } 1624 applyBackground( View view, @Nullable View wrapper, Background background, @Nullable BackgroundDrawable drawable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)1625 private BackgroundDrawable applyBackground( 1626 View view, 1627 @Nullable View wrapper, 1628 Background background, 1629 @Nullable BackgroundDrawable drawable, 1630 String posId, 1631 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 1632 if (drawable == null) { 1633 drawable = new BackgroundDrawable(); 1634 } 1635 1636 if (background.hasBrush() && background.getBrush().hasLinearGradient()) { 1637 try { 1638 LinearGradientHelper linearGradientHelper = 1639 new LinearGradientHelper( 1640 background.getBrush().getLinearGradient(), 1641 wrapper != null ? wrapper : view, 1642 pipelineMaker, 1643 posId, 1644 view::invalidate); 1645 drawable.setLinearGradientHelper(linearGradientHelper); 1646 } catch (IllegalArgumentException e) { 1647 Log.e(TAG, "Failed to apply linear gradient to background", e); 1648 } 1649 } else if (background.hasColor()) { 1650 handleProp(background.getColor(), drawable::setColor, posId, pipelineMaker); 1651 } 1652 1653 applyCornerToBackground(view, background, drawable); 1654 1655 return drawable; 1656 } 1657 applyCornerToBackground( View view, @NonNull Background background, @NonNull BackgroundDrawable drawable)1658 private void applyCornerToBackground( 1659 View view, @NonNull Background background, @NonNull BackgroundDrawable drawable) { 1660 if (!background.hasCorner()) { 1661 return; 1662 } 1663 1664 // apply corner 1665 final Corner corner = background.getCorner(); 1666 final int radiusPx = corner.hasRadius() ? safeDpToPx(corner.getRadius()) : 0; 1667 1668 // Sort out specific corner radii. 1669 float[] radii = new float[8]; 1670 Arrays.fill(radii, radiusPx); 1671 if (corner.hasTopLeftRadius()) { 1672 setCornerRadiusToArray(corner.getTopLeftRadius(), radii, /* index= */ 0); 1673 } 1674 if (corner.hasTopRightRadius()) { 1675 setCornerRadiusToArray(corner.getTopRightRadius(), radii, /* index= */ 2); 1676 } 1677 if (corner.hasBottomRightRadius()) { 1678 setCornerRadiusToArray(corner.getBottomRightRadius(), radii, /* index= */ 4); 1679 } 1680 if (corner.hasBottomLeftRadius()) { 1681 setCornerRadiusToArray(corner.getBottomLeftRadius(), radii, /* index= */ 6); 1682 } 1683 1684 boolean isCornerWithEqualRadii = areAllEqual(radii, /* count= */ 8); 1685 if (isCornerWithEqualRadii) { 1686 if (radii[0] == 0) { 1687 return; 1688 } 1689 // We will clip the drawable view with all equal corner radii by using outline, but we 1690 // still 1691 // need to set corners for cases when we use border. 1692 drawable.setCornerRadius(radii[0]); 1693 1694 // Set outline provider to correctly clip to the given corner, when it's some form of 1695 // rounded 1696 // rectangular (i.e. all equal corner radii). This can't be done automatically by the 1697 // drawable 1698 // due to the aliasing issues. See b/357061501 for more details. 1699 view.setOutlineProvider( 1700 new ViewOutlineProvider() { 1701 @Override 1702 public void getOutline(View view, Outline outline) { 1703 outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), radii[0]); 1704 outline.setAlpha(0.0f); 1705 } 1706 }); 1707 view.setClipToOutline(true); 1708 } else { 1709 drawable.setCornerRadii(radii); 1710 } 1711 } 1712 setCornerRadiusToArray( @onNull CornerRadius cornerRadius, float[] radii, int index)1713 private void setCornerRadiusToArray( 1714 @NonNull CornerRadius cornerRadius, float[] radii, int index) { 1715 if (cornerRadius.hasX()) { 1716 radii[index] = safeDpToPx(cornerRadius.getX()); 1717 } 1718 if (cornerRadius.hasY()) { 1719 radii[index + 1] = safeDpToPx(cornerRadius.getY()); 1720 } 1721 } 1722 areAllEqual(float[] array, int count)1723 private static boolean areAllEqual(float[] array, int count) { 1724 for (int i = 1; i < count; i++) { 1725 if (array[0] != array[i]) { 1726 return false; 1727 } 1728 } 1729 return true; 1730 } 1731 applyBorder( Border border, @Nullable BackgroundDrawable drawable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)1732 private BackgroundDrawable applyBorder( 1733 Border border, 1734 @Nullable BackgroundDrawable drawable, 1735 String posId, 1736 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 1737 if (drawable == null) { 1738 drawable = new BackgroundDrawable(); 1739 } 1740 1741 BackgroundDrawable finalDrawable = drawable; 1742 int width = safeDpToPx(border.getWidth()); 1743 handleProp( 1744 border.getColor(), 1745 borderColor -> finalDrawable.setStroke(width, borderColor), 1746 posId, 1747 pipelineMaker); 1748 1749 return drawable; 1750 } 1751 applyTransformation( @onNull View view, @NonNull Transformation transformation, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)1752 private void applyTransformation( 1753 @NonNull View view, 1754 @NonNull Transformation transformation, 1755 @NonNull String posId, 1756 @NonNull Optional<PipelineMaker> pipelineMaker) { 1757 // In a composite transformation, the order of applying the individual transformations 1758 // does not affect the result, as Android view does the transformation in fixed order by 1759 // first scale, then rotate then translate. 1760 1761 if (transformation.hasTranslationX()) { 1762 handleProp( 1763 transformation.getTranslationX(), 1764 translationX -> view.setTranslationX(dpToPx(translationX)), 1765 posId, 1766 pipelineMaker); 1767 } 1768 1769 if (transformation.hasTranslationY()) { 1770 handleProp( 1771 transformation.getTranslationY(), 1772 translationY -> view.setTranslationY(dpToPx(translationY)), 1773 posId, 1774 pipelineMaker); 1775 } 1776 1777 if (transformation.hasScaleX()) { 1778 handleProp(transformation.getScaleX(), view::setScaleX, posId, pipelineMaker); 1779 } 1780 1781 if (transformation.hasScaleY()) { 1782 handleProp(transformation.getScaleY(), view::setScaleY, posId, pipelineMaker); 1783 } 1784 1785 if (transformation.hasRotation()) { 1786 handleProp(transformation.getRotation(), view::setRotation, posId, pipelineMaker); 1787 } 1788 1789 if (transformation.hasPivotX()) { 1790 applyTransformationPivot( 1791 view, 1792 transformation.getPivotX(), 1793 offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.X), 1794 locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.X), 1795 posId, 1796 pipelineMaker); 1797 } 1798 1799 if (transformation.hasPivotY()) { 1800 applyTransformationPivot( 1801 view, 1802 transformation.getPivotY(), 1803 offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.Y), 1804 locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.Y), 1805 posId, 1806 pipelineMaker); 1807 } 1808 } 1809 1810 private enum PivotType { 1811 X, 1812 Y 1813 } 1814 setPivotInOffsetDp(View view, float pivot, PivotType type)1815 private void setPivotInOffsetDp(View view, float pivot, PivotType type) { 1816 if (type == PivotType.X) { 1817 view.setPivotX(dpToPx(pivot) + view.getWidth() * 0.5f); 1818 } else { 1819 view.setPivotY(dpToPx(pivot) + view.getHeight() * 0.5f); 1820 } 1821 } 1822 setPivotInLocationRatio(View view, float pivot, PivotType type)1823 private void setPivotInLocationRatio(View view, float pivot, PivotType type) { 1824 if (type == PivotType.X) { 1825 view.setPivotX(pivot * view.getWidth()); 1826 } else { 1827 view.setPivotY(pivot * view.getHeight()); 1828 } 1829 } 1830 applyTransformationPivot( View view, PivotDimension pivotDimension, Consumer<Float> consumerOffsetDp, Consumer<Float> consumerLocationRatio, @NonNull String posId, Optional<PipelineMaker> pipelineMaker)1831 private void applyTransformationPivot( 1832 View view, 1833 PivotDimension pivotDimension, 1834 Consumer<Float> consumerOffsetDp, 1835 Consumer<Float> consumerLocationRatio, 1836 @NonNull String posId, 1837 Optional<PipelineMaker> pipelineMaker) { 1838 switch (pivotDimension.getInnerCase()) { 1839 case OFFSET_DP: 1840 DpProp offset = pivotDimension.getOffsetDp(); 1841 handleProp( 1842 offset, 1843 value -> view.post(() -> consumerOffsetDp.accept(value)), 1844 consumerOffsetDp, 1845 posId, 1846 pipelineMaker); 1847 break; 1848 case LOCATION_RATIO: 1849 FloatProp ratio = pivotDimension.getLocationRatio().getRatio(); 1850 handleProp( 1851 ratio, 1852 value -> view.post(() -> consumerLocationRatio.accept(value)), 1853 consumerLocationRatio, 1854 posId, 1855 pipelineMaker); 1856 break; 1857 case INNER_NOT_SET: 1858 Log.w( 1859 TAG, 1860 "PivotDimension has an unknown dimension type: " 1861 + pivotDimension.getInnerCase().name()); 1862 } 1863 } 1864 applyModifiers( @onNull View view, @Nullable View wrapper, @NonNull Modifiers modifiers, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)1865 private View applyModifiers( 1866 @NonNull View view, 1867 @Nullable View wrapper, // The wrapper view for layout sizing, if any 1868 @NonNull Modifiers modifiers, 1869 @NonNull String posId, 1870 @NonNull Optional<PipelineMaker> pipelineMaker) { 1871 if (modifiers.hasVisible()) { 1872 applyVisible( 1873 view, 1874 modifiers.getVisible(), 1875 posId, 1876 pipelineMaker, 1877 visible -> visible ? VISIBLE : INVISIBLE); 1878 } else if (modifiers.hasHidden()) { 1879 // This is a deprecated field 1880 applyVisible( 1881 view, 1882 modifiers.getHidden(), 1883 posId, 1884 pipelineMaker, 1885 hidden -> hidden ? INVISIBLE : VISIBLE); 1886 } 1887 1888 if (modifiers.hasClickable()) { 1889 applyClickable(view, wrapper, modifiers.getClickable(), /* extendTouchTarget= */ true); 1890 } 1891 1892 if (modifiers.hasSemantics()) { 1893 applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker); 1894 } 1895 1896 if (modifiers.hasPadding()) { 1897 applyPadding(view, modifiers.getPadding()); 1898 } 1899 1900 BackgroundDrawable backgroundDrawable = null; 1901 1902 if (modifiers.hasBackground()) { 1903 backgroundDrawable = 1904 applyBackground( 1905 view, 1906 wrapper, 1907 modifiers.getBackground(), 1908 backgroundDrawable, 1909 posId, 1910 pipelineMaker); 1911 } 1912 1913 if (modifiers.hasBorder()) { 1914 backgroundDrawable = 1915 applyBorder(modifiers.getBorder(), backgroundDrawable, posId, pipelineMaker); 1916 } 1917 1918 if (backgroundDrawable != null) { 1919 view.setBackground(backgroundDrawable); 1920 } 1921 1922 if (mAnimationEnabled && modifiers.hasContentUpdateAnimation()) { 1923 pipelineMaker.ifPresent( 1924 p -> 1925 p.storeAnimatedVisibilityFor( 1926 posId, modifiers.getContentUpdateAnimation())); 1927 } 1928 1929 if (modifiers.hasTransformation()) { 1930 applyTransformation( 1931 wrapper == null ? view : wrapper, 1932 modifiers.getTransformation(), 1933 posId, 1934 pipelineMaker); 1935 } 1936 1937 if (modifiers.hasOpacity()) { 1938 handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker); 1939 } 1940 1941 return view; 1942 } 1943 applyVisible( View view, BoolProp visible, String posId, Optional<PipelineMaker> pipelineMaker, Function<Boolean, Integer> toViewVisibility)1944 private void applyVisible( 1945 View view, 1946 BoolProp visible, 1947 String posId, 1948 Optional<PipelineMaker> pipelineMaker, 1949 Function<Boolean, Integer> toViewVisibility) { 1950 handleProp( 1951 visible, 1952 visibility -> view.setVisibility(toViewVisibility.apply(visibility)), 1953 posId, 1954 pipelineMaker); 1955 } 1956 1957 @SuppressWarnings("RestrictTo") getEnterAnimations( @onNull EnterTransition enterTransition, @NonNull View view)1958 static AnimationSet getEnterAnimations( 1959 @NonNull EnterTransition enterTransition, @NonNull View view) { 1960 AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false); 1961 if (enterTransition.hasFadeIn()) { 1962 FadeInTransition fadeIn = enterTransition.getFadeIn(); 1963 AlphaAnimation alphaAnimation = 1964 new AlphaAnimation(fadeIn.getInitialAlpha(), FADE_IN_TARGET_ALPHA); 1965 1966 // If it doesn't exist, this will be default object. 1967 AnimationSpec spec = fadeIn.getAnimationSpec(); 1968 1969 AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec); 1970 animations.addAnimation(alphaAnimation); 1971 } 1972 1973 if (enterTransition.hasSlideIn()) { 1974 SlideInTransition slideIn = enterTransition.getSlideIn(); 1975 1976 // If it doesn't exist, this will be default object. 1977 AnimationSpec spec = slideIn.getAnimationSpec(); 1978 1979 float fromXDelta = 0; 1980 float toXDelta = 0; 1981 float fromYDelta = 0; 1982 float toYDelta = 0; 1983 1984 switch (slideIn.getDirectionValue()) { 1985 case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE: 1986 // Do the same as for horizontal as that is default. 1987 case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE: 1988 case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE: 1989 fromXDelta = getInitialOffsetOrDefaultX(slideIn, view); 1990 break; 1991 case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE: 1992 case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE: 1993 fromYDelta = getInitialOffsetOrDefaultY(slideIn, view); 1994 break; 1995 } 1996 1997 TranslateAnimation translateAnimation = 1998 new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta); 1999 AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec); 2000 animations.addAnimation(translateAnimation); 2001 } 2002 return animations; 2003 } 2004 2005 @SuppressWarnings("RestrictTo") getExitAnimations( @onNull ExitTransition exitTransition, @NonNull View view)2006 static AnimationSet getExitAnimations( 2007 @NonNull ExitTransition exitTransition, @NonNull View view) { 2008 AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false); 2009 if (exitTransition.hasFadeOut()) { 2010 FadeOutTransition fadeOut = exitTransition.getFadeOut(); 2011 AlphaAnimation alphaAnimation = 2012 new AlphaAnimation(FADE_OUT_INITIAL_ALPHA, fadeOut.getTargetAlpha()); 2013 2014 // If it doesn't exist, this will be default object. 2015 AnimationSpec spec = fadeOut.getAnimationSpec(); 2016 2017 // Indefinite Exit animations aren't allowed. 2018 if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) { 2019 AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec); 2020 animations.addAnimation(alphaAnimation); 2021 } 2022 } 2023 2024 if (exitTransition.hasSlideOut()) { 2025 SlideOutTransition slideOut = exitTransition.getSlideOut(); 2026 2027 // If it doesn't exist, this will be default object. 2028 AnimationSpec spec = slideOut.getAnimationSpec(); 2029 // Indefinite Exit animations aren't allowed. 2030 if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) { 2031 float fromXDelta = 0; 2032 float toXDelta = 0; 2033 float fromYDelta = 0; 2034 float toYDelta = 0; 2035 2036 switch (slideOut.getDirectionValue()) { 2037 case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE: 2038 // Do the same as for horizontal as that is default. 2039 case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE: 2040 case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE: 2041 toXDelta = getTargetOffsetOrDefaultX(slideOut, view); 2042 break; 2043 case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE: 2044 case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE: 2045 toYDelta = getTargetOffsetOrDefaultY(slideOut, view); 2046 break; 2047 } 2048 2049 TranslateAnimation translateAnimation = 2050 new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta); 2051 AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec); 2052 animations.addAnimation(translateAnimation); 2053 } 2054 } 2055 return animations; 2056 } 2057 2058 /** 2059 * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which 2060 * * is sliding to the left or right parent edge, depending on the direction. 2061 */ getInitialOffsetOrDefaultX( @onNull SlideInTransition slideIn, @NonNull View view)2062 private static float getInitialOffsetOrDefaultX( 2063 @NonNull SlideInTransition slideIn, @NonNull View view) { 2064 int sign = 2065 slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE 2066 ? -1 2067 : 1; 2068 if (slideIn.hasInitialSlideBound()) { 2069 2070 switch (slideIn.getInitialSlideBound().getInnerCase()) { 2071 case LINEAR_BOUND: 2072 return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign; 2073 case PARENT_BOUND: 2074 if (slideIn.getInitialSlideBound().getParentBound().getSnapTo() 2075 == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { 2076 return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight()) 2077 * sign; 2078 } 2079 // fall through 2080 case INNER_NOT_SET: 2081 break; 2082 } 2083 } 2084 return (sign == -1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign; 2085 } 2086 2087 /** 2088 * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which 2089 * * is sliding to the left or right parent edge, depending on the direction. 2090 */ getInitialOffsetOrDefaultY( @onNull SlideInTransition slideIn, @NonNull View view)2091 private static float getInitialOffsetOrDefaultY( 2092 @NonNull SlideInTransition slideIn, @NonNull View view) { 2093 int sign = 2094 slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE 2095 ? -1 2096 : 1; 2097 if (slideIn.hasInitialSlideBound()) { 2098 2099 switch (slideIn.getInitialSlideBound().getInnerCase()) { 2100 case LINEAR_BOUND: 2101 return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign; 2102 case PARENT_BOUND: 2103 if (slideIn.getInitialSlideBound().getParentBound().getSnapTo() 2104 == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { 2105 return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom()) 2106 * sign; 2107 } 2108 // fall through 2109 case INNER_NOT_SET: 2110 break; 2111 } 2112 } 2113 return (sign == -1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign; 2114 } 2115 2116 /** 2117 * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value 2118 * which is sliding to the left or right parent edge, depending on the direction. 2119 */ getTargetOffsetOrDefaultX( @onNull SlideOutTransition slideOut, @NonNull View view)2120 private static float getTargetOffsetOrDefaultX( 2121 @NonNull SlideOutTransition slideOut, @NonNull View view) { 2122 int sign = 2123 slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE 2124 ? 1 2125 : -1; 2126 if (slideOut.hasTargetSlideBound()) { 2127 2128 switch (slideOut.getTargetSlideBound().getInnerCase()) { 2129 case LINEAR_BOUND: 2130 return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign; 2131 case PARENT_BOUND: 2132 if (slideOut.getTargetSlideBound().getParentBound().getSnapTo() 2133 == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { 2134 return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight()) 2135 * sign; 2136 } 2137 // fall through 2138 case INNER_NOT_SET: 2139 break; 2140 } 2141 } 2142 return (sign == 1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign; 2143 } 2144 2145 /** 2146 * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value 2147 * which is sliding to the top or bottom parent edge, depending on the direction. 2148 */ getTargetOffsetOrDefaultY( @onNull SlideOutTransition slideOut, @NonNull View view)2149 private static float getTargetOffsetOrDefaultY( 2150 @NonNull SlideOutTransition slideOut, @NonNull View view) { 2151 int sign = 2152 slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE 2153 ? 1 2154 : -1; 2155 if (slideOut.hasTargetSlideBound()) { 2156 2157 switch (slideOut.getTargetSlideBound().getInnerCase()) { 2158 case LINEAR_BOUND: 2159 return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign; 2160 case PARENT_BOUND: 2161 if (slideOut.getTargetSlideBound().getParentBound().getSnapTo() 2162 == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) { 2163 return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom()) 2164 * sign; 2165 } 2166 // fall through 2167 case INNER_NOT_SET: 2168 break; 2169 } 2170 } 2171 return (sign == 1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign; 2172 } 2173 2174 // This is a little nasty; ArcLayout.Widget is just an interface, so we have no guarantee that 2175 // the instance also extends View (as it should). Instead, just take a View in and rename this, 2176 // and check that it's an ArcLayout.Widget internally. applyModifiersToArcLayoutView( View view, ArcModifiers modifiers, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2177 private View applyModifiersToArcLayoutView( 2178 View view, 2179 ArcModifiers modifiers, 2180 String posId, 2181 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 2182 if (!(view instanceof ArcLayout.Widget)) { 2183 Log.e( 2184 TAG, 2185 "applyModifiersToArcLayoutView should only be called with an ArcLayout.Widget"); 2186 return view; 2187 } 2188 2189 if (modifiers.hasClickable()) { 2190 2191 // set the extendTouchTarget as false to disable the clickable area extension for 2192 // meeting the required minimum clickable area 2193 applyClickable( 2194 view, 2195 /* wrapper= */ null, 2196 modifiers.getClickable(), 2197 /* extendTouchTarget= */ false); 2198 } 2199 2200 if (modifiers.hasSemantics()) { 2201 applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker); 2202 } 2203 2204 if (modifiers.hasOpacity()) { 2205 handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker); 2206 } 2207 2208 return view; 2209 } 2210 textAlignToAndroidGravity(TextAlignment alignment)2211 private static int textAlignToAndroidGravity(TextAlignment alignment) { 2212 // Vertical center alignment is usually a default and text will be centered vertically. 2213 // However, we need it explicitly for cases when max lines are adjusted and shrank, so there 2214 // could be a bit of extra space and text would be anchored to the top. 2215 return textAlignToAndroidGravityHorizontal(alignment) | Gravity.CENTER_VERTICAL; 2216 } 2217 textAlignToAndroidGravityHorizontal(TextAlignment alignment)2218 private static int textAlignToAndroidGravityHorizontal(TextAlignment alignment) { 2219 switch (alignment) { 2220 case TEXT_ALIGN_START: 2221 return Gravity.START; 2222 case TEXT_ALIGN_CENTER: 2223 return Gravity.CENTER_HORIZONTAL; 2224 case TEXT_ALIGN_END: 2225 return Gravity.END; 2226 case TEXT_ALIGN_UNDEFINED: 2227 case UNRECOGNIZED: 2228 return TEXT_ALIGN_DEFAULT; 2229 } 2230 2231 return TEXT_ALIGN_DEFAULT; 2232 } 2233 textTruncationToEllipsize(TextOverflow overflowValue)2234 private static @Nullable TruncateAt textTruncationToEllipsize(TextOverflow overflowValue) { 2235 switch (overflowValue) { 2236 case TEXT_OVERFLOW_TRUNCATE: 2237 // A null TruncateAt disables adding an ellipsis. 2238 return null; 2239 case TEXT_OVERFLOW_ELLIPSIZE_END: 2240 case TEXT_OVERFLOW_ELLIPSIZE: 2241 return TruncateAt.END; 2242 case TEXT_OVERFLOW_MARQUEE: 2243 return TruncateAt.MARQUEE; 2244 case TEXT_OVERFLOW_UNDEFINED: 2245 case UNRECOGNIZED: 2246 return TEXT_OVERFLOW_DEFAULT; 2247 } 2248 2249 return TEXT_OVERFLOW_DEFAULT; 2250 } 2251 2252 @ArcLayout.AnchorType anchorTypeToAnchorPos(ArcAnchorType type)2253 private static int anchorTypeToAnchorPos(ArcAnchorType type) { 2254 switch (type) { 2255 case ARC_ANCHOR_START: 2256 return ArcLayout.ANCHOR_START; 2257 case ARC_ANCHOR_CENTER: 2258 return ArcLayout.ANCHOR_CENTER; 2259 case ARC_ANCHOR_END: 2260 return ArcLayout.ANCHOR_END; 2261 case ARC_ANCHOR_UNDEFINED: 2262 case UNRECOGNIZED: 2263 return ARC_ANCHOR_DEFAULT; 2264 } 2265 2266 return ARC_ANCHOR_DEFAULT; 2267 } 2268 2269 @SizedArcContainer.LayoutParams.AngularAlignment angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment)2270 private static int angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment) { 2271 switch (angularAlignment) { 2272 case ANGULAR_ALIGNMENT_START: 2273 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_START; 2274 case ANGULAR_ALIGNMENT_CENTER: 2275 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER; 2276 case ANGULAR_ALIGNMENT_END: 2277 return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_END; 2278 case ANGULAR_ALIGNMENT_UNDEFINED: 2279 case UNRECOGNIZED: 2280 return ANGULAR_ALIGNMENT_DEFAULT; 2281 } 2282 2283 return ANGULAR_ALIGNMENT_DEFAULT; 2284 } 2285 dimensionToPx(ContainerDimension containerDimension)2286 private int dimensionToPx(ContainerDimension containerDimension) { 2287 switch (containerDimension.getInnerCase()) { 2288 case LINEAR_DIMENSION: 2289 return safeDpToPx(containerDimension.getLinearDimension()); 2290 case EXPANDED_DIMENSION: 2291 return LayoutParams.MATCH_PARENT; 2292 case WRAPPED_DIMENSION: 2293 return LayoutParams.WRAP_CONTENT; 2294 case INNER_NOT_SET: 2295 return dimensionToPx(CONTAINER_DIMENSION_DEFAULT); 2296 } 2297 2298 return dimensionToPx(CONTAINER_DIMENSION_DEFAULT); 2299 } 2300 extractTextColorArgb(FontStyle fontStyle)2301 private static int extractTextColorArgb(FontStyle fontStyle) { 2302 if (fontStyle.hasColor()) { 2303 return fontStyle.getColor().getArgb(); 2304 } else { 2305 return TEXT_COLOR_DEFAULT; 2306 } 2307 } 2308 2309 /** 2310 * Returns an Android {@link Intent} that can perform the action defined in the given layout 2311 * {@link LaunchAction}. 2312 */ buildLaunchActionIntent( @onNull LaunchAction launchAction, @NonNull String clickableId, @NonNull String clickableIdExtra)2313 public static @Nullable Intent buildLaunchActionIntent( 2314 @NonNull LaunchAction launchAction, 2315 @NonNull String clickableId, 2316 @NonNull String clickableIdExtra) { 2317 if (launchAction.hasAndroidActivity()) { 2318 AndroidActivity activity = launchAction.getAndroidActivity(); 2319 Intent i = 2320 new Intent().setClassName(activity.getPackageName(), activity.getClassName()); 2321 i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 2322 2323 if (!clickableId.isEmpty() && !clickableIdExtra.isEmpty()) { 2324 i.putExtra(clickableIdExtra, clickableId); 2325 } 2326 2327 for (Map.Entry<String, AndroidExtra> entry : activity.getKeyToExtraMap().entrySet()) { 2328 if (entry.getValue().hasStringVal()) { 2329 i.putExtra(entry.getKey(), entry.getValue().getStringVal().getValue()); 2330 } else if (entry.getValue().hasIntVal()) { 2331 i.putExtra(entry.getKey(), entry.getValue().getIntVal().getValue()); 2332 } else if (entry.getValue().hasLongVal()) { 2333 i.putExtra(entry.getKey(), entry.getValue().getLongVal().getValue()); 2334 } else if (entry.getValue().hasDoubleVal()) { 2335 i.putExtra(entry.getKey(), entry.getValue().getDoubleVal().getValue()); 2336 } else if (entry.getValue().hasBooleanVal()) { 2337 i.putExtra(entry.getKey(), entry.getValue().getBooleanVal().getValue()); 2338 } 2339 } 2340 2341 return i; 2342 } 2343 2344 return null; 2345 } 2346 buildState(LoadAction loadAction, String clickableId)2347 static State buildState(LoadAction loadAction, String clickableId) { 2348 // Get the state specified by the provider and add the last clicked clickable's ID to it. 2349 return loadAction.getRequestState().toBuilder().setLastClickableId(clickableId).build(); 2350 } 2351 inflateColumn( ParentViewWrapper parentViewWrapper, Column column, String columnPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2352 private @Nullable InflatedView inflateColumn( 2353 ParentViewWrapper parentViewWrapper, 2354 Column column, 2355 String columnPosId, 2356 boolean includeChildren, 2357 LayoutInfo.Builder layoutInfoBuilder, 2358 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 2359 ContainerDimension width = 2360 column.hasWidth() ? column.getWidth() : CONTAINER_DIMENSION_DEFAULT; 2361 ContainerDimension height = 2362 column.hasHeight() ? column.getHeight() : CONTAINER_DIMENSION_DEFAULT; 2363 2364 if (!canMeasureContainer(width, height, column.getContentsList())) { 2365 Log.w(TAG, "Column set to wrap but contents are unmeasurable. Ignoring."); 2366 return null; 2367 } 2368 2369 LinearLayout linearLayout = new LinearLayout(mUiContext); 2370 linearLayout.setOrientation(LinearLayout.VERTICAL); 2371 2372 LayoutParams layoutParams = generateDefaultLayoutParams(); 2373 2374 linearLayout.setGravity( 2375 horizontalAlignmentToGravity(column.getHorizontalAlignment().getValue())); 2376 2377 layoutParams = 2378 updateLayoutParams( 2379 parentViewWrapper.getParentProperties(), layoutParams, width, height); 2380 resolveMinimumDimensions(linearLayout, width, height); 2381 2382 View wrappedView = 2383 applyModifiers( 2384 linearLayout, 2385 /* wrapper= */ null, 2386 column.getModifiers(), 2387 columnPosId, 2388 pipelineMaker); 2389 2390 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 2391 2392 if (includeChildren) { 2393 inflateChildElements( 2394 linearLayout, 2395 layoutParams, 2396 NO_OP_PENDING_LAYOUT_PARAMS, 2397 column.getContentsList(), 2398 columnPosId, 2399 layoutInfoBuilder, 2400 pipelineMaker); 2401 layoutInfoBuilder.removeSubtree(columnPosId); 2402 } 2403 2404 int numMissingChildren = includeChildren ? 0 : column.getContentsCount(); 2405 return new InflatedView( 2406 wrappedView, 2407 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), 2408 NO_OP_PENDING_LAYOUT_PARAMS, 2409 numMissingChildren); 2410 } 2411 inflateRow( ParentViewWrapper parentViewWrapper, Row row, String rowPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)2412 private @Nullable InflatedView inflateRow( 2413 ParentViewWrapper parentViewWrapper, 2414 Row row, 2415 String rowPosId, 2416 boolean includeChildren, 2417 LayoutInfo.Builder layoutInfoBuilder, 2418 Optional<PipelineMaker> pipelineMaker) { 2419 ContainerDimension width = row.hasWidth() ? row.getWidth() : CONTAINER_DIMENSION_DEFAULT; 2420 ContainerDimension height = row.hasHeight() ? row.getHeight() : CONTAINER_DIMENSION_DEFAULT; 2421 2422 if (!canMeasureContainer(width, height, row.getContentsList())) { 2423 Log.w(TAG, "Row set to wrap but contents are unmeasurable. Ignoring."); 2424 return null; 2425 } 2426 2427 LinearLayout linearLayout = new LinearLayout(mUiContext); 2428 linearLayout.setOrientation(LinearLayout.HORIZONTAL); 2429 2430 LayoutParams layoutParams = generateDefaultLayoutParams(); 2431 2432 linearLayout.setGravity(verticalAlignmentToGravity(row.getVerticalAlignment().getValue())); 2433 2434 layoutParams = 2435 updateLayoutParams( 2436 parentViewWrapper.getParentProperties(), layoutParams, width, height); 2437 resolveMinimumDimensions(linearLayout, width, height); 2438 2439 View wrappedView = 2440 applyModifiers( 2441 linearLayout, 2442 /* wrapper= */ null, 2443 row.getModifiers(), 2444 rowPosId, 2445 pipelineMaker); 2446 2447 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 2448 2449 if (includeChildren) { 2450 inflateChildElements( 2451 linearLayout, 2452 layoutParams, 2453 NO_OP_PENDING_LAYOUT_PARAMS, 2454 row.getContentsList(), 2455 rowPosId, 2456 layoutInfoBuilder, 2457 pipelineMaker); 2458 layoutInfoBuilder.removeSubtree(rowPosId); 2459 } 2460 2461 int numMissingChildren = includeChildren ? 0 : row.getContentsCount(); 2462 return new InflatedView( 2463 wrappedView, 2464 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), 2465 NO_OP_PENDING_LAYOUT_PARAMS, 2466 numMissingChildren); 2467 } 2468 2469 // dereference of possibly-null reference lp 2470 @SuppressWarnings("nullness:dereference.of.nullable") inflateBox( ParentViewWrapper parentViewWrapper, Box box, String boxPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)2471 private @Nullable InflatedView inflateBox( 2472 ParentViewWrapper parentViewWrapper, 2473 Box box, 2474 String boxPosId, 2475 boolean includeChildren, 2476 LayoutInfo.Builder layoutInfoBuilder, 2477 Optional<PipelineMaker> pipelineMaker) { 2478 ContainerDimension width = box.hasWidth() ? box.getWidth() : CONTAINER_DIMENSION_DEFAULT; 2479 ContainerDimension height = box.hasHeight() ? box.getHeight() : CONTAINER_DIMENSION_DEFAULT; 2480 2481 if (!canMeasureContainer(width, height, box.getContentsList())) { 2482 Log.w(TAG, "Box set to wrap but contents are unmeasurable. Ignoring."); 2483 return null; 2484 } 2485 2486 FrameLayout frame = new FrameLayout(mUiContext); 2487 2488 LayoutParams layoutParams = generateDefaultLayoutParams(); 2489 2490 layoutParams = 2491 updateLayoutParams( 2492 parentViewWrapper.getParentProperties(), layoutParams, width, height); 2493 resolveMinimumDimensions(frame, width, height); 2494 2495 int gravity = 2496 getFrameLayoutGravity( 2497 box.getHorizontalAlignment().getValue(), 2498 box.getVerticalAlignment().getValue()); 2499 PendingFrameLayoutParams childLayoutParams = new PendingFrameLayoutParams(gravity); 2500 2501 View wrappedView = 2502 applyModifiers( 2503 frame, /* wrapper= */ null, box.getModifiers(), boxPosId, pipelineMaker); 2504 2505 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 2506 2507 if (includeChildren) { 2508 inflateChildElements( 2509 frame, 2510 layoutParams, 2511 childLayoutParams, 2512 box.getContentsList(), 2513 boxPosId, 2514 layoutInfoBuilder, 2515 pipelineMaker); 2516 layoutInfoBuilder.removeSubtree(boxPosId); 2517 } 2518 2519 // We can't set layout gravity to a FrameLayout ahead of time (and foregroundGravity only 2520 // sets the gravity of the foreground Drawable). Go and apply gravity to the child. 2521 try { 2522 applyGravityToFrameLayoutChildren(frame, gravity); 2523 } catch (IllegalStateException ex) { 2524 Log.e(TAG, "Error applying Gravity to FrameLayout children.", ex); 2525 } 2526 2527 // HACK: FrameLayout has a bug in it. If we add one WRAP_CONTENT child, and one MATCH_PARENT 2528 // child, the expected behaviour is that the FrameLayout sizes itself to fit the 2529 // WRAP_CONTENT child (e.g. a TextView), then the MATCH_PARENT child is forced to the same 2530 // size as the outer FrameLayout (and hence, the size of the TextView, after accounting for 2531 // padding etc). Because of a bug though, this doesn't happen; instead, the MATCH_PARENT 2532 // child will just keep its intrinsic size. This is because FrameLayout only forces 2533 // MATCH_PARENT children to a given size if there are _more than one_ of them (see the 2534 // bottom of FrameLayout#onMeasure). 2535 // 2536 // To work around this (without copying the whole of FrameLayout just to change a "1" to 2537 // "0"), we add a specific IgnorableSpace element in if there is one MATCH_PARENT child. 2538 // This has a tiny cost to the measure pass, and negligible cost to layout/draw (since it 2539 // doesn't take part in those passes). 2540 // This element needs to be specific class that won't be counted towards the effective 2541 // children of the parent when doing layout mutation. See b/345186544 2542 int numMatchParentChildren = 0; 2543 for (int i = 0; i < frame.getChildCount(); i++) { 2544 LayoutParams lp = frame.getChildAt(i).getLayoutParams(); 2545 if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) { 2546 numMatchParentChildren++; 2547 } 2548 } 2549 2550 if (numMatchParentChildren == 1) { 2551 IgnorableSpace hackSpace = new IgnorableSpace(mUiContext); 2552 LayoutParams hackSpaceLp = 2553 new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); 2554 frame.addView(hackSpace, hackSpaceLp); 2555 } 2556 2557 int numMissingChildren = includeChildren ? 0 : box.getContentsCount(); 2558 return new InflatedView( 2559 wrappedView, 2560 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), 2561 childLayoutParams, 2562 numMissingChildren); 2563 } 2564 inflateSpacer( ParentViewWrapper parentViewWrapper, Spacer spacer, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)2565 private @Nullable InflatedView inflateSpacer( 2566 ParentViewWrapper parentViewWrapper, 2567 Spacer spacer, 2568 String posId, 2569 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 2570 LayoutParams layoutParams = generateDefaultLayoutParams(); 2571 layoutParams = 2572 updateLayoutParams( 2573 parentViewWrapper.getParentProperties(), 2574 layoutParams, 2575 // This doesn't copy layout constraint for dynamic fields. That is fine, 2576 // because that will be later applied to the wrapper, and this layoutParams 2577 // would have dimension reset and put into the pipeline. 2578 spacerDimensionToContainerDimension(spacer.getWidth()), 2579 spacerDimensionToContainerDimension(spacer.getHeight())); 2580 2581 // Initialize the size wrapper here, if needed. This simplifies the logic below when 2582 // creating the actual Spacer and adding it to its parent... 2583 FrameLayout sizeWrapper = null; 2584 Float widthForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getWidth()); 2585 Float heightForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getHeight()); 2586 2587 // Handling dynamic width/height for the spacer. 2588 if (widthForLayoutDp != null || heightForLayoutDp != null) { 2589 sizeWrapper = new FrameLayout(mUiContext); 2590 LayoutParams spaceWrapperLayoutParams = generateDefaultLayoutParams(); 2591 spaceWrapperLayoutParams = 2592 updateLayoutParams( 2593 parentViewWrapper.getParentProperties(), 2594 spaceWrapperLayoutParams, 2595 spacerDimensionToContainerDimension(spacer.getWidth()), 2596 spacerDimensionToContainerDimension(spacer.getHeight())); 2597 2598 if (widthForLayoutDp != null) { 2599 if (widthForLayoutDp <= 0f) { 2600 Log.w( 2601 TAG, 2602 "Spacer width's value_for_layout is not a positive value. Element won't" 2603 + " be visible."); 2604 } 2605 spaceWrapperLayoutParams.width = safeDpToPx(widthForLayoutDp); 2606 } 2607 2608 if (heightForLayoutDp != null) { 2609 if (heightForLayoutDp <= 0f) { 2610 Log.w( 2611 TAG, 2612 "Spacer height's value_for_layout is not a positive value. Element" 2613 + " won't be visible."); 2614 } 2615 spaceWrapperLayoutParams.height = safeDpToPx(heightForLayoutDp); 2616 } 2617 2618 int gravity = 2619 (spacer.getWidth().hasLinearDimension() 2620 ? horizontalAlignmentToGravity( 2621 spacer.getWidth() 2622 .getLinearDimension() 2623 .getHorizontalAlignmentForLayout()) 2624 : UNSET_MASK) 2625 | (spacer.getHeight().hasLinearDimension() 2626 ? verticalAlignmentToGravity( 2627 spacer.getHeight() 2628 .getLinearDimension() 2629 .getVerticalAlignmentForLayout()) 2630 : UNSET_MASK); 2631 2632 // This layoutParams will override what we initially had and will be used for Spacer 2633 // itself. This means that the wrapper's layout params should follow the rules for 2634 // expand, and this one should just match the parents size when dimension is set to 2635 // expand. When dimension is dynamic, the value will be assigned during the 2636 // evaluation, so currently we will just copy over the value from constraints. 2637 FrameLayout.LayoutParams frameLayoutLayoutParams = 2638 new FrameLayout.LayoutParams(layoutParams); 2639 frameLayoutLayoutParams.gravity = gravity; 2640 if (spacer.getWidth().hasExpandedDimension()) { 2641 frameLayoutLayoutParams.width = LayoutParams.MATCH_PARENT; 2642 } 2643 if (spacer.getHeight().hasExpandedDimension()) { 2644 frameLayoutLayoutParams.height = LayoutParams.MATCH_PARENT; 2645 } 2646 layoutParams = frameLayoutLayoutParams; 2647 2648 parentViewWrapper.maybeAddView(sizeWrapper, spaceWrapperLayoutParams); 2649 2650 parentViewWrapper = new ParentViewWrapper(sizeWrapper, spaceWrapperLayoutParams); 2651 } 2652 2653 // Modifiers cannot be applied to android's Space, so use a plain View if this Spacer has 2654 // modifiers. 2655 View view; 2656 2657 // Init the layout params to 0 in case of linear dimension (so we don't get strange 2658 // behaviour before the first data pipeline update). 2659 if (spacer.getWidth().hasLinearDimension()) { 2660 layoutParams.width = 0; 2661 } 2662 if (spacer.getHeight().hasLinearDimension()) { 2663 layoutParams.height = 0; 2664 } 2665 2666 if (spacer.hasModifiers()) { 2667 view = 2668 applyModifiers( 2669 new View(mUiContext), 2670 sizeWrapper, 2671 spacer.getModifiers(), 2672 posId, 2673 pipelineMaker); 2674 2675 // LayoutParams have been updated above to accommodate from expand option. 2676 // The View needs to be added before any of the *Prop messages are wired up. 2677 // View#getLayoutParams will return null if the View has not been added to a container 2678 // yet 2679 // (since the LayoutParams are technically managed by the parent). 2680 parentViewWrapper.maybeAddView(view, layoutParams); 2681 2682 if (spacer.getWidth().hasLinearDimension()) { 2683 handleProp( 2684 spacer.getWidth().getLinearDimension(), 2685 widthDp -> updateLayoutWidthParam(view, widthDp), 2686 posId, 2687 pipelineMaker); 2688 } 2689 2690 if (spacer.getHeight().hasLinearDimension()) { 2691 handleProp( 2692 spacer.getHeight().getLinearDimension(), 2693 heightDp -> updateLayoutHeightParam(view, heightDp), 2694 posId, 2695 pipelineMaker); 2696 } 2697 } else { 2698 view = new Space(mUiContext); 2699 parentViewWrapper.maybeAddView(view, layoutParams); 2700 if (spacer.getWidth().hasLinearDimension()) { 2701 handleProp( 2702 spacer.getWidth().getLinearDimension(), 2703 widthDp -> updateLayoutWidthParam(view, widthDp), 2704 posId, 2705 pipelineMaker); 2706 } 2707 if (spacer.getHeight().hasLinearDimension()) { 2708 handleProp( 2709 spacer.getHeight().getLinearDimension(), 2710 heightDp -> updateLayoutHeightParam(view, heightDp), 2711 posId, 2712 pipelineMaker); 2713 } 2714 } 2715 2716 if (sizeWrapper != null) { 2717 return new InflatedView( 2718 sizeWrapper, 2719 parentViewWrapper 2720 .getParentProperties() 2721 .applyPendingChildLayoutParams(layoutParams)); 2722 } else { 2723 return new InflatedView( 2724 view, 2725 parentViewWrapper 2726 .getParentProperties() 2727 .applyPendingChildLayoutParams(layoutParams)); 2728 } 2729 } 2730 updateLayoutWidthParam(@onNull View view, float widthDp)2731 private void updateLayoutWidthParam(@NonNull View view, float widthDp) { 2732 scheduleLayoutParamsUpdate( 2733 view, 2734 () -> { 2735 checkNotNull(view.getLayoutParams()).width = safeDpToPx(widthDp); 2736 view.requestLayout(); 2737 }); 2738 } 2739 updateLayoutHeightParam(@onNull View view, float heightDp)2740 private void updateLayoutHeightParam(@NonNull View view, float heightDp) { 2741 scheduleLayoutParamsUpdate( 2742 view, 2743 () -> { 2744 checkNotNull(view.getLayoutParams()).height = safeDpToPx(heightDp); 2745 view.requestLayout(); 2746 }); 2747 } 2748 scheduleLayoutParamsUpdate(@onNull View view, Runnable layoutParamsUpdater)2749 private void scheduleLayoutParamsUpdate(@NonNull View view, Runnable layoutParamsUpdater) { 2750 if (view.getLayoutParams() != null) { 2751 layoutParamsUpdater.run(); 2752 return; 2753 } 2754 2755 // View#getLayoutParams() returns null if this view is not attached to a parent ViewGroup. 2756 // And once the view is attached to a parent ViewGroup, it guarantees a non-null return 2757 // value. Thus, we use the listener to do the update of the layout param the moment that the 2758 // view is attached to window. 2759 view.addOnAttachStateChangeListener( 2760 new View.OnAttachStateChangeListener() { 2761 2762 @Override 2763 public void onViewAttachedToWindow(@NonNull View v) { 2764 layoutParamsUpdater.run(); 2765 v.removeOnAttachStateChangeListener(this); 2766 } 2767 2768 @Override 2769 public void onViewDetachedFromWindow(@NonNull View v) {} 2770 }); 2771 } 2772 inflateArcSpacer( ParentViewWrapper parentViewWrapper, ArcSpacer spacer, String posId, Optional<PipelineMaker> pipelineMaker)2773 private @Nullable InflatedView inflateArcSpacer( 2774 ParentViewWrapper parentViewWrapper, 2775 ArcSpacer spacer, 2776 String posId, 2777 Optional<PipelineMaker> pipelineMaker) { 2778 float length = 0; 2779 int thicknessPx = safeDpToPx(spacer.getThickness()); 2780 WearCurvedSpacer space = new WearCurvedSpacer(mUiContext); 2781 ArcLayout.LayoutParams layoutParams = 2782 new ArcLayout.LayoutParams(generateDefaultLayoutParams()); 2783 2784 if (spacer.hasAngularLength()) { 2785 final AngularDimension angularLength = spacer.getAngularLength(); 2786 switch (angularLength.getInnerCase()) { 2787 case DEGREES: 2788 length = max(0, angularLength.getDegrees().getValue()); 2789 space.setSweepAngleDegrees(length); 2790 break; 2791 2792 case EXPANDED_ANGULAR_DIMENSION: 2793 { 2794 float weight = 2795 angularLength 2796 .getExpandedAngularDimension() 2797 .getLayoutWeight() 2798 .getValue(); 2799 if (weight == 0 && thicknessPx == 0) { 2800 return null; 2801 } 2802 layoutParams.setWeight(weight); 2803 2804 space.setThickness(thicknessPx); 2805 2806 View wrappedView = 2807 applyModifiersToArcLayoutView( 2808 space, spacer.getModifiers(), posId, pipelineMaker); 2809 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 2810 2811 return new InflatedView( 2812 wrappedView, 2813 parentViewWrapper 2814 .getParentProperties() 2815 .applyPendingChildLayoutParams(layoutParams)); 2816 } 2817 2818 case DP: 2819 length = max(0, safeDpToPx(angularLength.getDp().getValue())); 2820 space.setLengthPx(length); 2821 break; 2822 2823 case INNER_NOT_SET: 2824 break; 2825 } 2826 } else { 2827 length = max(0, spacer.getLength().getValue()); 2828 space.setSweepAngleDegrees(length); 2829 } 2830 2831 if (length == 0 && thicknessPx == 0) { 2832 return null; 2833 } 2834 space.setThickness(thicknessPx); 2835 2836 View wrappedView = 2837 applyModifiersToArcLayoutView(space, spacer.getModifiers(), posId, pipelineMaker); 2838 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 2839 2840 return new InflatedView( 2841 wrappedView, 2842 parentViewWrapper 2843 .getParentProperties() 2844 .applyPendingChildLayoutParams(layoutParams)); 2845 } 2846 applyTextOverflow( TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters)2847 private void applyTextOverflow( 2848 TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters) { 2849 TextOverflow overflowValue = overflow.getValue(); 2850 if (!mAnimationEnabled && overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE) { 2851 overflowValue = TextOverflow.TEXT_OVERFLOW_UNDEFINED; 2852 } 2853 2854 textView.setEllipsize(textTruncationToEllipsize(overflowValue)); 2855 if (overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE && textView.getMaxLines() == 1) { 2856 int marqueeIterations = 2857 marqueeParameters.hasIterations() 2858 ? marqueeParameters.getIterations() 2859 : -1; // Defaults to repeat indefinitely (-1). 2860 textView.setMarqueeRepeatLimit(marqueeIterations); 2861 textView.setSelected(true); 2862 textView.setSingleLine(); 2863 textView.setHorizontalFadingEdgeEnabled(true); 2864 } 2865 } 2866 inflateText( ParentViewWrapper parentViewWrapper, Text text, String posId, Optional<PipelineMaker> pipelineMaker)2867 private InflatedView inflateText( 2868 ParentViewWrapper parentViewWrapper, 2869 Text text, 2870 String posId, 2871 Optional<PipelineMaker> pipelineMaker) { 2872 TextView textView = newThemedTextView(); 2873 2874 LayoutParams layoutParams = generateDefaultLayoutParams(); 2875 2876 handleProp( 2877 text.getText(), 2878 mUiContext.getResources().getConfiguration().getLocales().get(0), 2879 t -> { 2880 // Underlines are applied using a Spannable here, rather than setting paint bits 2881 // (or 2882 // using Paint#setTextUnderline). When multiple fonts are mixed on the same line 2883 // (especially when mixing anything with NotoSans-CJK), multiple underlines can 2884 // appear. Using UnderlineSpan instead though causes the correct behaviour to 2885 // happen 2886 // (only a 2887 // single underline). 2888 SpannableStringBuilder ssb = new SpannableStringBuilder(); 2889 ssb.append(t); 2890 2891 if (text.getFontStyle().getUnderline().getValue()) { 2892 ssb.setSpan(new UnderlineSpan(), 0, ssb.length(), Spanned.SPAN_MARK_MARK); 2893 } 2894 2895 textView.setText(ssb); 2896 }, 2897 posId, 2898 pipelineMaker); 2899 2900 textView.setGravity(textAlignToAndroidGravity(text.getMultilineAlignment().getValue())); 2901 2902 String valueForLayout = resolveValueForLayoutIfNeeded(text.getText()); 2903 2904 // Use valueForLayout as a proxy for "has a dynamic size". If there's a dynamic binding for 2905 // the text element, then it can only have a single line of text. 2906 if (text.hasMaxLines() && valueForLayout == null) { 2907 textView.setMaxLines(max(TEXT_MIN_LINES, text.getMaxLines().getValue())); 2908 } else { 2909 textView.setMaxLines(TEXT_MAX_LINES_DEFAULT); 2910 } 2911 2912 TextOverflowProp overflow = text.getOverflow(); 2913 applyTextOverflow(textView, overflow, text.getMarqueeParameters()); 2914 2915 if (overflow.getValue() == TextOverflow.TEXT_OVERFLOW_ELLIPSIZE 2916 && !text.getText().hasDynamicValue() 2917 // There's no need for any optimizations if max lines is already 1. 2918 && textView.getMaxLines() != 1) { 2919 OneOffLayoutChangeListener.add(textView, () -> adjustMaxLinesForEllipsize(textView)); 2920 } 2921 2922 // Text auto size is not supported for dynamic text. 2923 boolean isAutoSizeAllowed = !(text.hasText() && text.getText().hasDynamicValue()); 2924 // Setting colours **must** go after setting the Text Appearance, otherwise it will get 2925 // immediately overridden. 2926 if (text.hasFontStyle()) { 2927 applyFontStyle(text.getFontStyle(), textView, posId, pipelineMaker, isAutoSizeAllowed); 2928 } else { 2929 applyFontStyle( 2930 FontStyle.getDefaultInstance(), 2931 textView, 2932 posId, 2933 pipelineMaker, 2934 isAutoSizeAllowed); 2935 } 2936 2937 // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also 2938 // have excluded font padding. Here, it's being ignored, and default value (excluded font 2939 // padding) is used. 2940 applyExcludeFontPadding(textView); 2941 2942 if (text.hasLineHeight()) { 2943 float lineHeightPx = toPx(text.getLineHeight()); 2944 final float fontHeightPx = textView.getPaint().getFontSpacing(); 2945 if (lineHeightPx != fontHeightPx) { 2946 textView.setLineSpacing(lineHeightPx - fontHeightPx, 1f); 2947 } 2948 } 2949 2950 // We don't want the text to be screen-reader focusable, unless wrapped in a Spannable 2951 // modifier. This prevents automatically reading out partial text (e.g. text in a row) etc. 2952 // 2953 // This **must** be done before applying modifiers; applying a Semantics modifier will set 2954 // importantForAccessibility, so we don't want to override it after applying modifiers. 2955 textView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); 2956 2957 if (valueForLayout != null) { 2958 if (valueForLayout.isEmpty()) { 2959 Log.w(TAG, "Text's value_for_layout is empty. Element won't be visible."); 2960 } 2961 2962 // Now create a "container" element, with that size, to hold the text. 2963 FrameLayout sizeChangingTextWrapper = new FrameLayout(mUiContext); 2964 LayoutParams sizeChangingTextWrapperLayoutParams = generateDefaultLayoutParams(); 2965 // Use the actual TextView to measure the text width. 2966 sizeChangingTextWrapperLayoutParams.width = 2967 (int) textView.getPaint().measureText(valueForLayout); 2968 sizeChangingTextWrapperLayoutParams.height = LayoutParams.WRAP_CONTENT; 2969 View wrappedView = 2970 applyModifiers( 2971 textView, 2972 sizeChangingTextWrapper, 2973 text.getModifiers(), 2974 posId, 2975 pipelineMaker); 2976 2977 // Set horizontal gravity on the wrapper to reflect alignment. 2978 int gravity = textAlignToAndroidGravity(text.getText().getTextAlignmentForLayout()); 2979 FrameLayout.LayoutParams frameLayoutLayoutParams = 2980 new FrameLayout.LayoutParams(layoutParams); 2981 frameLayoutLayoutParams.gravity = gravity; 2982 layoutParams = frameLayoutLayoutParams; 2983 2984 sizeChangingTextWrapper.addView(wrappedView, layoutParams); 2985 parentViewWrapper.maybeAddView( 2986 sizeChangingTextWrapper, sizeChangingTextWrapperLayoutParams); 2987 return new InflatedView( 2988 sizeChangingTextWrapper, 2989 parentViewWrapper 2990 .getParentProperties() 2991 .applyPendingChildLayoutParams(sizeChangingTextWrapperLayoutParams)); 2992 } else { 2993 View wrappedView = 2994 applyModifiers( 2995 textView, 2996 /* wrapper= */ null, 2997 text.getModifiers(), 2998 posId, 2999 pipelineMaker); 3000 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 3001 return new InflatedView( 3002 wrappedView, 3003 parentViewWrapper 3004 .getParentProperties() 3005 .applyPendingChildLayoutParams(layoutParams)); 3006 } 3007 } 3008 3009 /** 3010 * Sorts out what maxLines should be if the text could possibly be truncated before maxLines is 3011 * reached. 3012 * 3013 * <p>Should be only called for the {@link TextOverflow#TEXT_OVERFLOW_ELLIPSIZE} option which 3014 * ellipsizes the text even before the last line, if there's no space for all lines. This is 3015 * different than what TEXT_OVERFLOW_ELLIPSIZE_END does, as that option just ellipsizes the last 3016 * line of text. 3017 */ adjustMaxLinesForEllipsize(@onNull TextView textView)3018 private static void adjustMaxLinesForEllipsize(@NonNull TextView textView) { 3019 ViewParent maybeParent = textView.getParent(); 3020 if (!(maybeParent instanceof View)) { 3021 Log.d( 3022 TAG, 3023 "Couldn't adjust max lines for ellipsizing as there's no View/ViewGroup" 3024 + " parent."); 3025 return; 3026 } 3027 3028 View parent = (View) maybeParent; 3029 int availableHeight = parent.getHeight(); 3030 int oneLineHeight = textView.getLineHeight(); 3031 // This is what was set in proto, we shouldn't exceed it. 3032 int maxMaxLines = textView.getMaxLines(); 3033 // Avoid having maxLines as 0 in case the space is really tight. 3034 int availableLines = max(availableHeight / oneLineHeight, 1); 3035 3036 // Update only if maxLines are changed. 3037 if (availableLines >= maxMaxLines) { 3038 return; 3039 } 3040 3041 textView.setMaxLines(availableLines); 3042 // We need to trigger TextView to re-measure its content in order to place the ellipsis 3043 // correctly at the and of {@code availableLines}th line. Using only {@code requestLayout} 3044 // or {@code invalidate} isn't enough as TextView wouldn't remeasure itself. 3045 textView.setText(textView.getText()); 3046 } 3047 3048 /** 3049 * Sets font padding to be excluded and applies correct padding to the TextView to avoid 3050 * clipping taller languages. 3051 */ applyExcludeFontPadding(TextView textView)3052 private void applyExcludeFontPadding(TextView textView) { 3053 textView.setIncludeFontPadding(false); 3054 3055 // We need to update padding in the TextView to avoid clipping of taller languages. 3056 3057 float ascent = textView.getPaint().getFontMetrics().ascent; 3058 float descent = textView.getPaint().getFontMetrics().descent; 3059 String text = textView.getText().toString(); 3060 Rect bounds = new Rect(); 3061 3062 textView.getPaint().getTextBounds(text, 0, max(0, text.length() - 1), bounds); 3063 3064 int topPadding = textView.getPaddingTop(); 3065 int bottomPadding = textView.getPaddingBottom(); 3066 3067 if (ascent > bounds.top) { 3068 topPadding = (int) (ascent - bounds.top); 3069 } 3070 3071 if (descent < bounds.bottom) { 3072 bottomPadding = (int) (bounds.bottom - descent); 3073 } 3074 3075 textView.setPadding( 3076 textView.getPaddingLeft(), topPadding, textView.getPaddingRight(), bottomPadding); 3077 } 3078 inflateArcText( ParentViewWrapper parentViewWrapper, ArcText text, String posId, Optional<PipelineMaker> pipelineMaker)3079 private InflatedView inflateArcText( 3080 ParentViewWrapper parentViewWrapper, 3081 ArcText text, 3082 String posId, 3083 Optional<PipelineMaker> pipelineMaker) { 3084 CurvedTextView textView = newThemedCurvedTextView(); 3085 3086 LayoutParams layoutParams = generateDefaultLayoutParams(); 3087 layoutParams.width = LayoutParams.MATCH_PARENT; 3088 layoutParams.height = LayoutParams.MATCH_PARENT; 3089 3090 textView.setText(text.getText().getValue()); 3091 3092 if (text.hasFontStyle()) { 3093 applyFontStyle(text.getFontStyle(), textView); 3094 } else if (mApplyFontVariantBodyAsDefault) { 3095 applyFontStyle(FontStyle.getDefaultInstance(), textView); 3096 } 3097 3098 // Setting colours **must** go after setting the Text Appearance, otherwise it will get 3099 // immediately overridden. 3100 textView.setTextColor(extractTextColorArgb(text.getFontStyle())); 3101 3102 if (text.hasArcDirection()) { 3103 switch (text.getArcDirection().getValue()) { 3104 case ARC_DIRECTION_NORMAL: 3105 textView.setClockwise(!isRtlLayoutDirectionFromLocale()); 3106 break; 3107 case ARC_DIRECTION_COUNTER_CLOCKWISE: 3108 textView.setClockwise(false); 3109 break; 3110 case ARC_DIRECTION_CLOCKWISE: 3111 case UNRECOGNIZED: 3112 textView.setClockwise(true); 3113 break; 3114 } 3115 } 3116 3117 View wrappedView = 3118 applyModifiersToArcLayoutView(textView, text.getModifiers(), posId, pipelineMaker); 3119 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 3120 3121 return new InflatedView( 3122 wrappedView, 3123 parentViewWrapper 3124 .getParentProperties() 3125 .applyPendingChildLayoutParams(layoutParams)); 3126 } 3127 isZeroLengthImageDimension(ImageDimension dimension)3128 private static boolean isZeroLengthImageDimension(ImageDimension dimension) { 3129 return dimension.getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION 3130 && dimension.getLinearDimension().getValue() == 0; 3131 } 3132 imageDimensionToContainerDimension(ImageDimension dimension)3133 private static ContainerDimension imageDimensionToContainerDimension(ImageDimension dimension) { 3134 switch (dimension.getInnerCase()) { 3135 case LINEAR_DIMENSION: 3136 return ContainerDimension.newBuilder() 3137 .setLinearDimension(dimension.getLinearDimension()) 3138 .build(); 3139 case EXPANDED_DIMENSION: 3140 return ContainerDimension.newBuilder() 3141 .setExpandedDimension(ExpandedDimensionProp.getDefaultInstance()) 3142 .build(); 3143 case PROPORTIONAL_DIMENSION: 3144 // A ratio size should be translated to a WRAP_CONTENT; the RatioViewWrapper will 3145 // deal with the sizing of that. 3146 return ContainerDimension.newBuilder() 3147 .setWrappedDimension(WrappedDimensionProp.getDefaultInstance()) 3148 .build(); 3149 case INNER_NOT_SET: 3150 break; 3151 } 3152 // Caller should have already checked for this. 3153 throw new IllegalArgumentException( 3154 "ImageDimension has an unknown dimension type: " + dimension.getInnerCase().name()); 3155 } 3156 3157 @SuppressWarnings("ExecutorTaskName") inflateImage( ParentViewWrapper parentViewWrapper, Image image, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)3158 private @Nullable InflatedView inflateImage( 3159 ParentViewWrapper parentViewWrapper, 3160 Image image, 3161 String posId, 3162 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 3163 String protoResId = image.getResourceId().getValue(); 3164 3165 // If either width or height isn't set, abort. 3166 if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET 3167 || image.getHeight().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET) { 3168 Log.w(TAG, "One of width and height not set on image " + protoResId); 3169 return null; 3170 } 3171 3172 // The image must occupy _some_ space. 3173 if (isZeroLengthImageDimension(image.getWidth()) 3174 || isZeroLengthImageDimension(image.getHeight())) { 3175 Log.w(TAG, "One of width and height was zero on image " + protoResId); 3176 return null; 3177 } 3178 3179 // Both dimensions can't be ratios. 3180 if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION 3181 && image.getHeight().getInnerCase() 3182 == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { 3183 Log.w(TAG, "Both width and height were proportional for image " + protoResId); 3184 return null; 3185 } 3186 3187 // Pull the ratio for the RatioViewWrapper. Was either argument a proportional dimension? 3188 Float ratio = RatioViewWrapper.UNDEFINED_ASPECT_RATIO; 3189 3190 if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { 3191 ratio = safeAspectRatioOrNull(image.getWidth().getProportionalDimension()); 3192 } 3193 3194 if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) { 3195 ratio = safeAspectRatioOrNull(image.getHeight().getProportionalDimension()); 3196 } 3197 3198 if (ratio == null) { 3199 Log.w(TAG, "Invalid aspect ratio for image " + protoResId); 3200 return null; 3201 } 3202 3203 ImageViewWithoutIntrinsicSizes imageView = new ImageViewWithoutIntrinsicSizes(mUiContext); 3204 3205 if (image.hasContentScaleMode()) { 3206 imageView.setScaleType( 3207 contentScaleModeToScaleType(image.getContentScaleMode().getValue())); 3208 } 3209 3210 if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) { 3211 imageView.setMinimumWidth(safeDpToPx(image.getWidth().getLinearDimension())); 3212 } 3213 3214 if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) { 3215 imageView.setMinimumHeight(safeDpToPx(image.getHeight().getLinearDimension())); 3216 } 3217 3218 // We need to sort out the sizing of the widget now, so we can pass the correct params to 3219 // RatioViewWrapper. First, translate the ImageSize to a ContainerSize. A ratio size should 3220 // be translated to a WRAP_CONTENT; the RatioViewWrapper will deal with the sizing of that. 3221 LayoutParams ratioWrapperLayoutParams = generateDefaultLayoutParams(); 3222 ratioWrapperLayoutParams = 3223 updateLayoutParams( 3224 parentViewWrapper.getParentProperties(), 3225 ratioWrapperLayoutParams, 3226 imageDimensionToContainerDimension(image.getWidth()), 3227 imageDimensionToContainerDimension(image.getHeight())); 3228 3229 // Apply the modifiers to the ImageView, **not** the RatioViewWrapper. 3230 // 3231 // RatioViewWrapper doesn't do any custom drawing, it only exists to force dimensions during 3232 // the measure/layout passes, so it doesn't matter which element any border/background 3233 // modifiers get applied to. Applying modifiers to the ImageView is important for Semantics 3234 // though; screen readers try and pick up on the type of element being read, so in the case 3235 // of an image would read "image, <description>" (where the location of "image" can move 3236 // depending on user settings). If we apply the modifiers to RatioViewWrapper though, screen 3237 // readers will not realise that this is an image, and will read the incorrect description. 3238 RatioViewWrapper ratioViewWrapper = new RatioViewWrapper(mUiContext); 3239 ratioViewWrapper.setAspectRatio(ratio); 3240 View wrappedImageView = 3241 applyModifiers( 3242 imageView, ratioViewWrapper, image.getModifiers(), posId, pipelineMaker); 3243 ratioViewWrapper.addView(wrappedImageView); 3244 3245 parentViewWrapper.maybeAddView(ratioViewWrapper, ratioWrapperLayoutParams); 3246 3247 ListenableFuture<Drawable> drawableFuture = 3248 mLayoutResourceResolvers.getDrawable(protoResId); 3249 Drawable immediatelySetDrawable = null; 3250 if (drawableFuture.isDone() && !drawableFuture.isCancelled()) { 3251 // If the future is done, immediately draw. 3252 immediatelySetDrawable = setImageDrawable(imageView, drawableFuture, protoResId); 3253 } 3254 3255 if (immediatelySetDrawable != null && pipelineMaker.isPresent()) { 3256 if (immediatelySetDrawable instanceof AnimatedVectorDrawable) { 3257 AnimatedVectorDrawable avd = (AnimatedVectorDrawable) immediatelySetDrawable; 3258 try { 3259 Trigger trigger = mLayoutResourceResolvers.getAnimationTrigger(protoResId); 3260 3261 if (trigger != null 3262 && trigger.getInnerCase() 3263 == Trigger.InnerCase.ON_CONDITION_MET_TRIGGER) { 3264 OnConditionMetTrigger conditionTrigger = trigger.getOnConditionMetTrigger(); 3265 pipelineMaker 3266 .get() 3267 .addResolvedAnimatedImageWithBoolTrigger( 3268 avd, trigger, posId, conditionTrigger.getCondition()); 3269 } else { 3270 // Use default trigger if it's not set. 3271 if (trigger == null 3272 || trigger.getInnerCase() == Trigger.InnerCase.INNER_NOT_SET) { 3273 trigger = DEFAULT_ANIMATION_TRIGGER; 3274 } 3275 pipelineMaker.get().addResolvedAnimatedImage(avd, trigger, posId); 3276 } 3277 } catch (RuntimeException ex) { 3278 Log.e(TAG, "Error setting up animation trigger", ex); 3279 } 3280 } else if (immediatelySetDrawable instanceof SeekableAnimatedVectorDrawable) { 3281 SeekableAnimatedVectorDrawable seekableAvd = 3282 (SeekableAnimatedVectorDrawable) immediatelySetDrawable; 3283 try { 3284 DynamicFloat progress = mLayoutResourceResolvers.getBoundProgress(protoResId); 3285 if (progress != null) { 3286 pipelineMaker 3287 .get() 3288 .addResolvedSeekableAnimatedImage(seekableAvd, progress, posId); 3289 } 3290 } catch (IllegalArgumentException ex) { 3291 Log.e(TAG, "Error setting up seekable animated image", ex); 3292 } 3293 } 3294 } else { 3295 // Is there a placeholder to use in the meantime? 3296 try { 3297 if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) { 3298 if (setImageDrawable( 3299 imageView, 3300 mLayoutResourceResolvers.getPlaceholderDrawableOrThrow( 3301 protoResId), 3302 protoResId) 3303 == null) { 3304 Log.w(TAG, "Failed to set the placeholder for " + protoResId); 3305 } 3306 } 3307 } catch (ResourceAccessException | IllegalArgumentException ex) { 3308 Log.e(TAG, "Exception loading placeholder for resource " + protoResId, ex); 3309 } 3310 3311 // Otherwise, handle the result on the UI thread. 3312 drawableFuture.addListener( 3313 () -> setImageDrawable(imageView, drawableFuture, protoResId), 3314 ContextCompat.getMainExecutor(mUiContext)); 3315 } 3316 3317 boolean canImageBeTinted = false; 3318 3319 try { 3320 canImageBeTinted = mLayoutResourceResolvers.canImageBeTinted(protoResId); 3321 } catch (IllegalArgumentException ex) { 3322 Log.e(TAG, "Exception tinting image " + protoResId, ex); 3323 } 3324 3325 if (image.getColorFilter().hasTint() && canImageBeTinted) { 3326 // Only allow tinting for Android images.f 3327 handleProp( 3328 image.getColorFilter().getTint(), 3329 tintColor -> { 3330 ColorStateList tint = ColorStateList.valueOf(tintColor); 3331 imageView.setImageTintList(tint); 3332 3333 // SRC_IN throws away the colours in the drawable that we're tinting. 3334 // Effectively, the drawable being tinted is only a mask to apply the colour 3335 // to. 3336 imageView.setImageTintMode(Mode.SRC_IN); 3337 }, 3338 posId, 3339 pipelineMaker); 3340 } 3341 3342 return new InflatedView( 3343 ratioViewWrapper, 3344 parentViewWrapper 3345 .getParentProperties() 3346 .applyPendingChildLayoutParams(ratioWrapperLayoutParams)); 3347 } 3348 3349 /** 3350 * Set drawable to the image view. 3351 * 3352 * @return Returns the drawable if it is successfully retrieved from the drawable future and set 3353 * to the image view; otherwise returns null to indicate the failure of setting drawable. 3354 */ setImageDrawable( ImageView imageView, Future<Drawable> drawableFuture, String protoResId)3355 private @Nullable Drawable setImageDrawable( 3356 ImageView imageView, Future<Drawable> drawableFuture, String protoResId) { 3357 try { 3358 return setImageDrawable(imageView, drawableFuture.get(), protoResId); 3359 } catch (ExecutionException | InterruptedException | CancellationException e) { 3360 Log.w(TAG, "Could not get drawable for image " + protoResId, e); 3361 } 3362 return null; 3363 } 3364 3365 /** 3366 * Set drawable to the image view. 3367 * 3368 * @return Returns the drawable if it is successfully set to the image view; otherwise returns 3369 * null to indicate the failure of setting drawable. 3370 */ setImageDrawable( ImageView imageView, Drawable drawable, String protoResId)3371 private @Nullable Drawable setImageDrawable( 3372 ImageView imageView, Drawable drawable, String protoResId) { 3373 if (drawable != null) { 3374 mInflaterStatsLogger.logDrawableUsage(drawable); 3375 } 3376 if (drawable instanceof BitmapDrawable 3377 && ((BitmapDrawable) drawable).getBitmap().getByteCount() 3378 > DEFAULT_MAX_BITMAP_RAW_SIZE) { 3379 Log.w(TAG, "Ignoring image " + protoResId + " as it's too large."); 3380 return null; 3381 } 3382 imageView.setImageDrawable(drawable); 3383 return drawable; 3384 } 3385 inflateArcLine( ParentViewWrapper parentViewWrapper, ArcLine line, String posId, Optional<PipelineMaker> pipelineMaker)3386 private @Nullable InflatedView inflateArcLine( 3387 ParentViewWrapper parentViewWrapper, 3388 ArcLine line, 3389 String posId, 3390 Optional<PipelineMaker> pipelineMaker) { 3391 float lengthDegrees = 0; 3392 if (line.hasAngularLength()) { 3393 if (line.getAngularLength().getInnerCase() == ArcLineLength.InnerCase.DEGREES) { 3394 lengthDegrees = max(0, line.getAngularLength().getDegrees().getValue()); 3395 } 3396 } else { 3397 lengthDegrees = max(0, line.getLength().getValue()); 3398 } 3399 3400 int thicknessPx = safeDpToPx(line.getThickness()); 3401 3402 if (lengthDegrees == 0 && thicknessPx == 0) { 3403 return null; 3404 } 3405 3406 WearCurvedLineView lineView = new WearCurvedLineView(mUiContext); 3407 3408 try { 3409 lineView.setUpdatesEnabled(false); 3410 3411 if (line.hasBrush() && line.getBrush().hasSweepGradient()) { 3412 try { 3413 SweepGradientHelper sweepGradientHelper = 3414 SweepGradientHelper.create( 3415 line.getBrush().getSweepGradient(), 3416 posId, 3417 pipelineMaker, 3418 lineView::triggerRefresh); 3419 lineView.setSweepGradient(sweepGradientHelper); 3420 } catch (IllegalArgumentException e) { 3421 Log.e(TAG, "Invalid SweepGradient definition: " + e.getMessage()); 3422 } 3423 } else if (line.hasColor()) { 3424 handleProp(line.getColor(), lineView::setColor, posId, pipelineMaker); 3425 } else { 3426 lineView.setColor(LINE_COLOR_DEFAULT); 3427 } 3428 3429 if (line.hasStrokeCap()) { 3430 StrokeCapProp strokeCapProp = line.getStrokeCap(); 3431 switch (strokeCapProp.getValue()) { 3432 case STROKE_CAP_BUTT: 3433 lineView.setStrokeCap(Cap.BUTT); 3434 break; 3435 case STROKE_CAP_ROUND: 3436 lineView.setStrokeCap(Cap.ROUND); 3437 break; 3438 case STROKE_CAP_SQUARE: 3439 lineView.setStrokeCap(Cap.SQUARE); 3440 break; 3441 case UNRECOGNIZED: 3442 case STROKE_CAP_UNDEFINED: 3443 Log.w(TAG, "Undefined StrokeCap value."); 3444 break; 3445 } 3446 3447 if (strokeCapProp.hasShadow()) { 3448 Shadow shadow = strokeCapProp.getShadow(); 3449 int color = 3450 shadow.getColor().hasArgb() ? shadow.getColor().getArgb() : Color.BLACK; 3451 lineView.setStrokeCapShadow( 3452 safeDpToPx(shadow.getBlurRadius().getValue()), color); 3453 } 3454 } 3455 3456 lineView.setThickness(thicknessPx); 3457 3458 DegreesProp length = DegreesProp.getDefaultInstance(); 3459 3460 float arcLayoutWeight = 0; 3461 if (line.hasAngularLength()) { 3462 final ArcLineLength angularLength = line.getAngularLength(); 3463 switch (angularLength.getInnerCase()) { 3464 case DEGREES: 3465 length = line.getAngularLength().getDegrees(); 3466 handleProp( 3467 length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker); 3468 break; 3469 3470 case EXPANDED_ANGULAR_DIMENSION: 3471 { 3472 ExpandedAngularDimensionProp expandedAngularDimension = 3473 angularLength.getExpandedAngularDimension(); 3474 arcLayoutWeight = 3475 expandedAngularDimension.hasLayoutWeight() 3476 ? expandedAngularDimension.getLayoutWeight().getValue() 3477 : 1.0f; 3478 length = DegreesProp.getDefaultInstance(); 3479 break; 3480 } 3481 case INNER_NOT_SET: 3482 break; 3483 } 3484 } else { 3485 length = line.getLength(); 3486 handleProp(length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker); 3487 } 3488 3489 ArcDirection arcLineDirection = 3490 line.hasArcDirection() 3491 ? line.getArcDirection().getValue() 3492 : ArcDirection.ARC_DIRECTION_CLOCKWISE; 3493 3494 lineView.setLineDirection(arcLineDirection); 3495 3496 @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length); 3497 if (sizeForLayout != null) { 3498 lineView.setMaxSweepAngleDegrees(sizeForLayout); 3499 } 3500 View wrappedView = 3501 applyModifiersToArcLayoutView( 3502 lineView, line.getModifiers(), posId, pipelineMaker); 3503 return addLineViewToParentArc( 3504 parentViewWrapper, 3505 wrappedView, 3506 sizeForLayout, 3507 arcLineDirection, 3508 angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()), 3509 arcLayoutWeight); 3510 } finally { 3511 lineView.setUpdatesEnabled(true); 3512 } 3513 } 3514 inflateDashedArcLine( @onNull ParentViewWrapper parentViewWrapper, @NonNull DashedArcLine dashedLine, @NonNull String posId, @NonNull Optional<PipelineMaker> pipelineMaker)3515 private @Nullable InflatedView inflateDashedArcLine( 3516 @NonNull ParentViewWrapper parentViewWrapper, 3517 @NonNull DashedArcLine dashedLine, 3518 @NonNull String posId, 3519 @NonNull Optional<PipelineMaker> pipelineMaker) { 3520 float lengthDegrees = max(0, dashedLine.getLength().getValue()); 3521 int thicknessPx = safeDpToPx(dashedLine.getThickness()); 3522 if (lengthDegrees == 0 && thicknessPx == 0) { 3523 return null; 3524 } 3525 3526 WearDashedArcLineView dashedLineView = new WearDashedArcLineView(mUiContext); 3527 dashedLineView.setThickness(thicknessPx); 3528 3529 if (dashedLine.hasColor()) { 3530 handleProp(dashedLine.getColor(), dashedLineView::setColor, posId, pipelineMaker); 3531 } else { 3532 dashedLineView.setColor(LINE_COLOR_DEFAULT); 3533 } 3534 3535 if (dashedLine.hasLinePattern()) { 3536 DashedLinePattern linePattern = dashedLine.getLinePattern(); 3537 if (linePattern.getGapLocationsCount() > 0) { 3538 List<Float> gapLocations = new ArrayList<>(); 3539 for (DegreesProp degree : linePattern.getGapLocationsList()) { 3540 gapLocations.add(degree.getValue()); 3541 } 3542 dashedLineView.setGapLocations(gapLocations); 3543 } 3544 dashedLineView.setGapSize(safeDpToPx(linePattern.getGapSize())); 3545 } 3546 3547 ArcDirection arcLineDirection = 3548 dashedLine.hasArcDirection() 3549 ? dashedLine.getArcDirection().getValue() 3550 : ArcDirection.UNRECOGNIZED; 3551 dashedLineView.setLineDirection(arcLineDirection); 3552 3553 DegreesProp length = dashedLine.getLength(); 3554 handleProp(length, dashedLineView::setLineSweepAngleDegrees, posId, pipelineMaker); 3555 3556 @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length); 3557 if (sizeForLayout != null) { 3558 dashedLineView.setMaxSweepAngleDegrees(sizeForLayout); 3559 } 3560 View wrappedView = 3561 applyModifiersToArcLayoutView( 3562 dashedLineView, dashedLine.getModifiers(), posId, pipelineMaker); 3563 return addLineViewToParentArc( 3564 parentViewWrapper, 3565 wrappedView, 3566 sizeForLayout, 3567 arcLineDirection, 3568 angularAlignmentProtoToAngularAlignment(length.getAngularAlignmentForLayout()), 3569 /* arcLayoutWeight= */ 0 3570 // Zero weight in ArcLayout means the view should not be stretched. 3571 ); 3572 } 3573 addLineViewToParentArc( @onNull ParentViewWrapper parentArc, @NonNull View lineView, @Nullable Float sizeForLayout, @NonNull ArcDirection arcLineDirection, @SizedArcContainer.LayoutParams.AngularAlignment int angularAlignment, float arcLayoutWeight)3574 private InflatedView addLineViewToParentArc( 3575 @NonNull ParentViewWrapper parentArc, 3576 @NonNull View lineView, 3577 @Nullable Float sizeForLayout, 3578 @NonNull ArcDirection arcLineDirection, 3579 @SizedArcContainer.LayoutParams.AngularAlignment int angularAlignment, 3580 float arcLayoutWeight) { 3581 SizedArcContainer sizeWrapper = null; 3582 SizedArcContainer.LayoutParams sizedLayoutParams = 3583 new SizedArcContainer.LayoutParams( 3584 LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); 3585 if (sizeForLayout != null) { 3586 sizeWrapper = new SizedArcContainer(mUiContext); 3587 sizeWrapper.setArcDirection(arcLineDirection); 3588 if (sizeForLayout <= 0f) { 3589 Log.w( 3590 TAG, 3591 "Arc Line length's value_for_layout is not a positive value. Element won't" 3592 + " be visible."); 3593 } 3594 sizeWrapper.setSweepAngleDegrees(sizeForLayout); 3595 sizedLayoutParams.setAngularAlignment(angularAlignment); 3596 } 3597 3598 // A WearDashedArcLineView or WearCurvedLineView must always be the same width/height as its 3599 // parent, so it can draw the line properly inside of those bounds. 3600 ArcLayout.LayoutParams layoutParams = 3601 new ArcLayout.LayoutParams(generateDefaultLayoutParams()); 3602 layoutParams.width = LayoutParams.MATCH_PARENT; 3603 layoutParams.height = LayoutParams.MATCH_PARENT; 3604 layoutParams.setWeight(arcLayoutWeight); 3605 3606 if (sizeWrapper != null) { 3607 sizeWrapper.addView(lineView, sizedLayoutParams); 3608 parentArc.maybeAddView(sizeWrapper, layoutParams); 3609 return new InflatedView( 3610 sizeWrapper, 3611 parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams)); 3612 } else { 3613 parentArc.maybeAddView(lineView, layoutParams); 3614 return new InflatedView( 3615 lineView, 3616 parentArc.getParentProperties().applyPendingChildLayoutParams(layoutParams)); 3617 } 3618 } 3619 3620 // dereference of possibly-null reference childLayoutParams 3621 @SuppressWarnings("nullness:dereference.of.nullable") inflateArc( ParentViewWrapper parentViewWrapper, Arc arc, String arcPosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)3622 private @Nullable InflatedView inflateArc( 3623 ParentViewWrapper parentViewWrapper, 3624 Arc arc, 3625 String arcPosId, 3626 boolean includeChildren, 3627 LayoutInfo.Builder layoutInfoBuilder, 3628 Optional<PipelineMaker> pipelineMaker) { 3629 ArcLayout arcLayout = new ArcLayout(mUiContext); 3630 int anchorAngleSign = 1; 3631 3632 if (arc.hasArcDirection()) { 3633 switch (arc.getArcDirection().getValue()) { 3634 case ARC_DIRECTION_CLOCKWISE: 3635 arcLayout.setLayoutDirection(LAYOUT_DIRECTION_LTR); 3636 break; 3637 case ARC_DIRECTION_COUNTER_CLOCKWISE: 3638 arcLayout.setLayoutDirection(LAYOUT_DIRECTION_RTL); 3639 anchorAngleSign = -1; 3640 break; 3641 case ARC_DIRECTION_NORMAL: 3642 boolean isRtl = isRtlLayoutDirectionFromLocale(); 3643 arcLayout.setLayoutDirection( 3644 isRtl ? LAYOUT_DIRECTION_RTL : LAYOUT_DIRECTION_LTR); 3645 if (isRtl) { 3646 anchorAngleSign = -1; 3647 } 3648 break; 3649 case UNRECOGNIZED: 3650 break; 3651 } 3652 } 3653 3654 LayoutParams layoutParams = generateDefaultLayoutParams(); 3655 layoutParams.width = LayoutParams.MATCH_PARENT; 3656 layoutParams.height = LayoutParams.MATCH_PARENT; 3657 3658 int finalAnchorAngleSign = anchorAngleSign; 3659 handleProp( 3660 arc.getAnchorAngle(), 3661 angle -> { 3662 arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * angle); 3663 // Invalidating arcLayout isn't enough. AnchorAngleDegrees change should trigger 3664 // child requestLayout. 3665 arcLayout.requestLayout(); 3666 }, 3667 arcPosId, 3668 pipelineMaker); 3669 arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * arc.getAnchorAngle().getValue()); 3670 arcLayout.setAnchorType(anchorTypeToAnchorPos(arc.getAnchorType().getValue())); 3671 3672 if (arc.hasMaxAngle()) { 3673 arcLayout.setMaxAngleDegrees(arc.getMaxAngle().getValue()); 3674 } 3675 3676 // Add all children. 3677 if (includeChildren) { 3678 int index = FIRST_CHILD_INDEX; 3679 for (ArcLayoutElement child : arc.getContentsList()) { 3680 String childPosId = ProtoLayoutDiffer.createNodePosId(arcPosId, index++); 3681 InflatedView childView = 3682 inflateArcLayoutElement( 3683 new ParentViewWrapper(arcLayout, layoutParams), 3684 child, 3685 childPosId, 3686 layoutInfoBuilder, 3687 pipelineMaker); 3688 if (childView != null) { 3689 ArcLayout.LayoutParams childLayoutParams = 3690 (ArcLayout.LayoutParams) childView.mView.getLayoutParams(); 3691 boolean rotate = false; 3692 if (child.hasAdapter()) { 3693 rotate = child.getAdapter().getRotateContents().getValue(); 3694 } 3695 3696 // Apply rotation and gravity. 3697 childLayoutParams.setRotated(rotate); 3698 childLayoutParams.setVerticalAlignment( 3699 verticalAlignmentToArcVAlign(arc.getVerticalAlign())); 3700 } 3701 } 3702 layoutInfoBuilder.removeSubtree(arcPosId); 3703 } 3704 3705 View wrappedView = 3706 applyModifiers( 3707 arcLayout, 3708 /* wrapper= */ null, 3709 arc.getModifiers(), 3710 arcPosId, 3711 pipelineMaker); 3712 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 3713 3714 int numMissingChildren = includeChildren ? 0 : arc.getContentsCount(); 3715 return new InflatedView( 3716 wrappedView, 3717 parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams), 3718 NO_OP_PENDING_LAYOUT_PARAMS, 3719 numMissingChildren); 3720 } 3721 isRtlLayoutDirectionFromLocale()3722 static boolean isRtlLayoutDirectionFromLocale() { 3723 return TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL; 3724 } 3725 applyStylesToSpan( SpannableStringBuilder builder, int start, int end, FontStyle fontStyle)3726 private void applyStylesToSpan( 3727 SpannableStringBuilder builder, int start, int end, FontStyle fontStyle) { 3728 if (fontStyleHasSize(fontStyle)) { 3729 // We are using the last added size in the FontStyle because ArcText doesn't support 3730 // autosizing. This is the same behaviour as it was before size has made repeated. 3731 if (fontStyle.getSizeList().size() > 1) { 3732 Log.w( 3733 TAG, 3734 "Font size with multiple values has been used on Span Text. Ignoring " 3735 + "all size except the first one."); 3736 } 3737 AbsoluteSizeSpan span = 3738 new AbsoluteSizeSpan( 3739 round(toPx(fontStyle.getSize(fontStyle.getSizeCount() - 1)))); 3740 builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); 3741 } 3742 3743 if (fontStyle.hasWeight() || fontStyle.hasVariant()) { 3744 CustomTypefaceSpan span = new CustomTypefaceSpan(fontStyleToTypeface(fontStyle)); 3745 builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); 3746 } 3747 3748 if (!hasDefaultTypefaceStyle(fontStyle)) { 3749 StyleSpan span = new StyleSpan(fontStyleToTypefaceStyle(fontStyle)); 3750 builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); 3751 } 3752 3753 if (fontStyle.getUnderline().getValue()) { 3754 UnderlineSpan span = new UnderlineSpan(); 3755 builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); 3756 } 3757 3758 if (fontStyle.hasLetterSpacing()) { 3759 LetterSpacingSpan span = new LetterSpacingSpan(fontStyle.getLetterSpacing().getValue()); 3760 builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK); 3761 } 3762 3763 ForegroundColorSpan colorSpan = new ForegroundColorSpan(extractTextColorArgb(fontStyle)); 3764 3765 builder.setSpan(colorSpan, start, end, Spanned.SPAN_MARK_MARK); 3766 } 3767 fontStyleHasSize(FontStyle fontStyle)3768 private static boolean fontStyleHasSize(FontStyle fontStyle) { 3769 return !fontStyle.getSizeList().isEmpty(); 3770 } 3771 applyModifiersToSpan( SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers)3772 private void applyModifiersToSpan( 3773 SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers) { 3774 if (modifiers.hasClickable()) { 3775 ClickableSpan clickableSpan = new ProtoLayoutClickableSpan(modifiers.getClickable()); 3776 3777 builder.setSpan(clickableSpan, start, end, Spanned.SPAN_MARK_MARK); 3778 } 3779 } 3780 inflateTextInSpannable( SpannableStringBuilder builder, SpanText text)3781 private SpannableStringBuilder inflateTextInSpannable( 3782 SpannableStringBuilder builder, SpanText text) { 3783 int currentPos = builder.length(); 3784 int lastPos = currentPos + text.getText().getValue().length(); 3785 3786 builder.append(text.getText().getValue()); 3787 3788 applyStylesToSpan(builder, currentPos, lastPos, text.getFontStyle()); 3789 applyModifiersToSpan(builder, currentPos, lastPos, text.getModifiers()); 3790 3791 return builder; 3792 } 3793 3794 @SuppressWarnings("ExecutorTaskName") inflateImageInSpannable( SpannableStringBuilder builder, SpanImage protoImage, TextView textView)3795 private SpannableStringBuilder inflateImageInSpannable( 3796 SpannableStringBuilder builder, SpanImage protoImage, TextView textView) { 3797 String protoResId = protoImage.getResourceId().getValue(); 3798 3799 if (protoImage.getWidth().getValue() == 0 || protoImage.getHeight().getValue() == 0) { 3800 Log.w(TAG, "One of width and height was zero on image " + protoResId); 3801 return builder; 3802 } 3803 3804 ListenableFuture<Drawable> drawableFuture = 3805 mLayoutResourceResolvers.getDrawable(protoResId); 3806 if (drawableFuture.isDone()) { 3807 // If the future is done, immediately add drawable to builder. 3808 try { 3809 Drawable drawable = drawableFuture.get(); 3810 appendSpanDrawable(builder, drawable, protoImage); 3811 } catch (ExecutionException | InterruptedException e) { 3812 Log.w( 3813 TAG, 3814 "Could not get drawable for image " 3815 + protoImage.getResourceId().getValue()); 3816 } 3817 } else { 3818 // If the future is not done, add an empty drawable to builder as a placeholder. 3819 Drawable placeholderDrawable = null; 3820 3821 try { 3822 if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) { 3823 placeholderDrawable = 3824 mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(protoResId); 3825 } 3826 } catch (ResourceAccessException | IllegalArgumentException ex) { 3827 Log.e(TAG, "Could not get placeholder for image " + protoResId, ex); 3828 } 3829 3830 if (placeholderDrawable == null) { 3831 placeholderDrawable = new ColorDrawable(Color.TRANSPARENT); 3832 } 3833 3834 int startInclusive = builder.length(); 3835 FixedImageSpan placeholderDrawableSpan = 3836 appendSpanDrawable(builder, placeholderDrawable, protoImage); 3837 int endExclusive = builder.length(); 3838 3839 // When the future is done, replace the empty drawable with the received one. 3840 drawableFuture.addListener( 3841 () -> { 3842 // Remove the placeholder. This should be safe, even with other modifiers 3843 // applied. This just removes the single drawable span, and should leave 3844 // other spans in place. 3845 builder.removeSpan(placeholderDrawableSpan); 3846 // Add the new drawable to the same range. 3847 setSpanDrawable( 3848 builder, drawableFuture, startInclusive, endExclusive, protoImage); 3849 // Update the TextView. 3850 textView.setText(builder); 3851 }, 3852 ContextCompat.getMainExecutor(mUiContext)); 3853 } 3854 3855 return builder; 3856 } 3857 appendSpanDrawable( SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage)3858 private FixedImageSpan appendSpanDrawable( 3859 SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage) { 3860 drawable.setBounds( 3861 0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight())); 3862 FixedImageSpan imgSpan = 3863 new FixedImageSpan( 3864 drawable, 3865 spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment())); 3866 3867 int startPos = builder.length(); 3868 3869 // Adding NBSP around the space to prevent it from being trimmed. 3870 builder.append( 3871 ZERO_WIDTH_JOINER + " " + ZERO_WIDTH_JOINER, imgSpan, Spanned.SPAN_MARK_MARK); 3872 int endPos = builder.length(); 3873 3874 applyModifiersToSpan(builder, startPos, endPos, protoImage.getModifiers()); 3875 3876 return imgSpan; 3877 } 3878 setSpanDrawable( SpannableStringBuilder builder, ListenableFuture<Drawable> drawableFuture, int startInclusive, int endExclusive, SpanImage protoImage)3879 private void setSpanDrawable( 3880 SpannableStringBuilder builder, 3881 ListenableFuture<Drawable> drawableFuture, 3882 int startInclusive, 3883 int endExclusive, 3884 SpanImage protoImage) { 3885 final String protoResourceId = protoImage.getResourceId().getValue(); 3886 3887 try { 3888 // Add the image span to the same range occupied by the placeholder. 3889 Drawable drawable = drawableFuture.get(); 3890 drawable.setBounds( 3891 0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight())); 3892 FixedImageSpan imgSpan = 3893 new FixedImageSpan( 3894 drawable, 3895 spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment())); 3896 builder.setSpan( 3897 imgSpan, 3898 startInclusive, 3899 endExclusive, 3900 android.text.Spannable.SPAN_INCLUSIVE_EXCLUSIVE); 3901 } catch (ExecutionException | InterruptedException | CancellationException e) { 3902 Log.w(TAG, "Could not get drawable for image " + protoResourceId); 3903 } 3904 } 3905 inflateSpannable( ParentViewWrapper parentViewWrapper, Spannable spannable, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)3906 private InflatedView inflateSpannable( 3907 ParentViewWrapper parentViewWrapper, 3908 Spannable spannable, 3909 String posId, 3910 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 3911 TextView tv = newThemedTextView(); 3912 3913 // Setting colours **must** go after setting the Text Appearance, otherwise it will get 3914 // immediately overridden. 3915 if (mApplyFontVariantBodyAsDefault) { 3916 applyFontStyle( 3917 FontStyle.getDefaultInstance(), 3918 tv, 3919 posId, 3920 pipelineMaker, 3921 /* isAutoSizeAllowed= */ false); 3922 } 3923 3924 LayoutParams layoutParams = generateDefaultLayoutParams(); 3925 3926 SpannableStringBuilder builder = new SpannableStringBuilder(); 3927 3928 boolean isAnySpanClickable = false; 3929 3930 for (Span element : spannable.getSpansList()) { 3931 switch (element.getInnerCase()) { 3932 case IMAGE: 3933 SpanImage protoImage = element.getImage(); 3934 builder = inflateImageInSpannable(builder, protoImage, tv); 3935 3936 if (protoImage.getModifiers().hasClickable()) { 3937 isAnySpanClickable = true; 3938 } 3939 3940 break; 3941 case TEXT: 3942 SpanText protoText = element.getText(); 3943 builder = inflateTextInSpannable(builder, protoText); 3944 3945 if (protoText.getModifiers().hasClickable()) { 3946 isAnySpanClickable = true; 3947 } 3948 3949 break; 3950 case INNER_NOT_SET: 3951 Log.w(TAG, "Unknown Span child type."); 3952 break; 3953 } 3954 } 3955 3956 tv.setGravity(horizontalAlignmentToGravity(spannable.getMultilineAlignment().getValue())); 3957 3958 if (spannable.hasMaxLines()) { 3959 tv.setMaxLines(max(TEXT_MIN_LINES, spannable.getMaxLines().getValue())); 3960 } else { 3961 tv.setMaxLines(TEXT_MAX_LINES_DEFAULT); 3962 } 3963 applyTextOverflow(tv, spannable.getOverflow(), spannable.getMarqueeParameters()); 3964 3965 if (spannable.hasLineHeight()) { 3966 // We use a Span here instead of just calling TextViewCompat#setLineHeight. 3967 // setLineHeight is implemented by taking the difference between the current font height 3968 // (via the font metrics, not just the size in SP), subtracting that from the desired 3969 // line height, and setting that as the inter-line spacing. This doesn't work for our 3970 // Spannables; we don't use a default height, yet TextView still has a default font (and 3971 // size) that it tries to base the requested line height on, despite that never actually 3972 // being used. The end result is that the line height never actually drops out as 3973 // expected. 3974 // 3975 // Instead, wrap the whole thing in a LineHeightSpan with the desired line height. This 3976 // gets calculated properly as the TextView is calculating its per-line font metrics, 3977 // and will actually work correctly. 3978 StandardLineHeightSpan span = 3979 new StandardLineHeightSpan((int) toPx(spannable.getLineHeight())); 3980 builder.setSpan( 3981 span, 3982 /* start= */ 0, 3983 /* end= */ builder.length(), 3984 Spanned.SPAN_INCLUSIVE_EXCLUSIVE); 3985 } else if (spannable.hasLineSpacing()) { 3986 tv.setLineSpacing(toPx(spannable.getLineSpacing()), 1f); 3987 } 3988 3989 tv.setText(builder); 3990 3991 // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also 3992 // have excluded font padding. Here, it's being ignored, and default value (excluded font 3993 // padding) is used. 3994 applyExcludeFontPadding(tv); 3995 3996 if (isAnySpanClickable) { 3997 // For any ClickableSpans to work, the MovementMethod must be set to LinkMovementMethod. 3998 tv.setMovementMethod(LinkMovementMethod.getInstance()); 3999 4000 // Disable the highlight color; if we don't do this, the clicked span will get 4001 // highlighted, which will be cleared half a second later if using LoadAction as the 4002 // next layout will be delivered, which recreates the elements and clears the highlight. 4003 tv.setHighlightColor(Color.TRANSPARENT); 4004 4005 // Use InhibitingScroller to prevent the text from scrolling when tapped. Setting a 4006 // MovementMethod on a TextView (e.g. for clickables in a Spannable) then cause the 4007 // TextView to be scrollable, and to jump to the end when tapped. 4008 tv.setScroller(new InhibitingScroller(mUiContext)); 4009 } 4010 4011 View wrappedView = 4012 applyModifiers( 4013 tv, /* wrapper= */ null, spannable.getModifiers(), posId, pipelineMaker); 4014 parentViewWrapper.maybeAddView(wrappedView, layoutParams); 4015 4016 return new InflatedView( 4017 wrappedView, 4018 parentViewWrapper 4019 .getParentProperties() 4020 .applyPendingChildLayoutParams(layoutParams)); 4021 } 4022 inflateArcLayoutElement( ParentViewWrapper parentViewWrapper, ArcLayoutElement element, String nodePosId, LayoutInfo.Builder layoutInfoBuilder, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)4023 private @Nullable InflatedView inflateArcLayoutElement( 4024 ParentViewWrapper parentViewWrapper, 4025 ArcLayoutElement element, 4026 String nodePosId, 4027 LayoutInfo.Builder layoutInfoBuilder, 4028 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 4029 InflatedView inflatedView = null; 4030 4031 switch (element.getInnerCase()) { 4032 case ADAPTER: 4033 if (hasTransformation(element.getAdapter().getContent())) { 4034 Log.e( 4035 TAG, 4036 "Error inflating " 4037 + element.getAdapter().getContent().getInnerCase().name() 4038 + " in the arc. Transformation modifier is not supported for " 4039 + "the layout element" 4040 + " inside an ArcAdapter."); 4041 inflatedView = null; 4042 } else { 4043 // Fall back to the normal inflater. 4044 inflatedView = 4045 inflateLayoutElement( 4046 parentViewWrapper, 4047 element.getAdapter().getContent(), 4048 nodePosId, 4049 /* includeChildren= */ true, 4050 layoutInfoBuilder, 4051 pipelineMaker); 4052 } 4053 break; 4054 4055 case SPACER: 4056 inflatedView = 4057 inflateArcSpacer( 4058 parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker); 4059 break; 4060 4061 case LINE: 4062 inflatedView = 4063 inflateArcLine( 4064 parentViewWrapper, element.getLine(), nodePosId, pipelineMaker); 4065 break; 4066 4067 case TEXT: 4068 inflatedView = 4069 inflateArcText( 4070 parentViewWrapper, element.getText(), nodePosId, pipelineMaker); 4071 break; 4072 4073 case DASHED_LINE: 4074 inflatedView = 4075 inflateDashedArcLine( 4076 parentViewWrapper, 4077 element.getDashedLine(), 4078 nodePosId, 4079 pipelineMaker); 4080 break; 4081 4082 case INNER_NOT_SET: 4083 break; 4084 } 4085 4086 if (inflatedView == null) { 4087 // Covers null (returned when the childCase in the proto isn't known). Sadly, ProtoLite 4088 // doesn't give us a way to access childCase's underlying tag, so we can't give any 4089 // smarter error message here. 4090 Log.w(TAG, "Unknown child type"); 4091 } else if (nodePosId.isEmpty()) { 4092 Log.w(TAG, "No node ID for " + element.getInnerCase().name()); 4093 } else { 4094 // Set the view's tag to a known, position-based ID so that it can be looked up to apply 4095 // mutations. 4096 inflatedView.mView.setTag(nodePosId); 4097 if (inflatedView.mView instanceof ViewGroup) { 4098 layoutInfoBuilder.add( 4099 nodePosId, 4100 ViewProperties.fromViewGroup( 4101 (ViewGroup) inflatedView.mView, 4102 inflatedView.mLayoutParams, 4103 inflatedView.mChildLayoutParams)); 4104 } 4105 pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId)); 4106 } 4107 return inflatedView; 4108 } 4109 4110 /** Checks whether a layout element has a transformation modifier. */ hasTransformation(@onNull LayoutElement content)4111 private static boolean hasTransformation(@NonNull LayoutElement content) { 4112 switch (content.getInnerCase()) { 4113 case IMAGE: 4114 return content.getImage().hasModifiers() 4115 && content.getImage().getModifiers().hasTransformation(); 4116 case TEXT: 4117 return content.getText().hasModifiers() 4118 && content.getText().getModifiers().hasTransformation(); 4119 case SPACER: 4120 return content.getSpacer().hasModifiers() 4121 && content.getSpacer().getModifiers().hasTransformation(); 4122 case BOX: 4123 return content.getBox().hasModifiers() 4124 && content.getBox().getModifiers().hasTransformation(); 4125 case ROW: 4126 return content.getRow().hasModifiers() 4127 && content.getRow().getModifiers().hasTransformation(); 4128 case COLUMN: 4129 return content.getColumn().hasModifiers() 4130 && content.getColumn().getModifiers().hasTransformation(); 4131 case SPANNABLE: 4132 return content.getSpannable().hasModifiers() 4133 && content.getSpannable().getModifiers().hasTransformation(); 4134 case ARC: 4135 return content.getArc().hasModifiers() 4136 && content.getArc().getModifiers().hasTransformation(); 4137 case EXTENSION: 4138 // fall through 4139 case INNER_NOT_SET: 4140 return false; 4141 } 4142 return false; 4143 } 4144 inflateLayoutElement( ParentViewWrapper parentViewWrapper, LayoutElement element, String nodePosId, boolean includeChildren, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)4145 private @Nullable InflatedView inflateLayoutElement( 4146 ParentViewWrapper parentViewWrapper, 4147 LayoutElement element, 4148 String nodePosId, 4149 boolean includeChildren, 4150 LayoutInfo.Builder layoutInfoBuilder, 4151 Optional<PipelineMaker> pipelineMaker) { 4152 InflatedView inflatedView = null; 4153 // What is it? 4154 switch (element.getInnerCase()) { 4155 case COLUMN: 4156 inflatedView = 4157 inflateColumn( 4158 parentViewWrapper, 4159 element.getColumn(), 4160 nodePosId, 4161 includeChildren, 4162 layoutInfoBuilder, 4163 pipelineMaker); 4164 break; 4165 case ROW: 4166 inflatedView = 4167 inflateRow( 4168 parentViewWrapper, 4169 element.getRow(), 4170 nodePosId, 4171 includeChildren, 4172 layoutInfoBuilder, 4173 pipelineMaker); 4174 break; 4175 case BOX: 4176 inflatedView = 4177 inflateBox( 4178 parentViewWrapper, 4179 element.getBox(), 4180 nodePosId, 4181 includeChildren, 4182 layoutInfoBuilder, 4183 pipelineMaker); 4184 break; 4185 case SPACER: 4186 inflatedView = 4187 inflateSpacer( 4188 parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker); 4189 break; 4190 case TEXT: 4191 inflatedView = 4192 inflateText(parentViewWrapper, element.getText(), nodePosId, pipelineMaker); 4193 break; 4194 case IMAGE: 4195 inflatedView = 4196 inflateImage( 4197 parentViewWrapper, element.getImage(), nodePosId, pipelineMaker); 4198 break; 4199 case ARC: 4200 inflatedView = 4201 inflateArc( 4202 parentViewWrapper, 4203 element.getArc(), 4204 nodePosId, 4205 includeChildren, 4206 layoutInfoBuilder, 4207 pipelineMaker); 4208 break; 4209 case SPANNABLE: 4210 inflatedView = 4211 inflateSpannable( 4212 parentViewWrapper, 4213 element.getSpannable(), 4214 nodePosId, 4215 pipelineMaker); 4216 break; 4217 case EXTENSION: 4218 try { 4219 inflatedView = inflateExtension(parentViewWrapper, element.getExtension()); 4220 } catch (IllegalStateException ex) { 4221 Log.w(TAG, "Error inflating Extension.", ex); 4222 } 4223 break; 4224 case INNER_NOT_SET: 4225 Log.w(TAG, "Unknown child type: " + element.getInnerCase().name()); 4226 break; 4227 } 4228 4229 if (inflatedView == null) { 4230 Log.w(TAG, "Error inflating " + element.getInnerCase().name()); 4231 } else if (nodePosId.isEmpty()) { 4232 Log.w(TAG, "No node ID for " + element.getInnerCase().name()); 4233 } else { 4234 // Set the view's tag to a known, position-based ID so that it can be looked up to apply 4235 // mutations. 4236 inflatedView.mView.setTag(nodePosId); 4237 if (inflatedView.mView instanceof ViewGroup) { 4238 layoutInfoBuilder.add( 4239 nodePosId, 4240 ViewProperties.fromViewGroup( 4241 (ViewGroup) inflatedView.mView, 4242 inflatedView.mLayoutParams, 4243 inflatedView.mChildLayoutParams)); 4244 } 4245 pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId)); 4246 } 4247 return inflatedView; 4248 } 4249 inflateExtension( ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element)4250 private @Nullable InflatedView inflateExtension( 4251 ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) { 4252 int widthPx = safeDpToPx(element.getWidth().getLinearDimension()); 4253 int heightPx = safeDpToPx(element.getHeight().getLinearDimension()); 4254 4255 if (widthPx == 0 && heightPx == 0) { 4256 return null; 4257 } 4258 4259 if (mExtensionViewProvider == null) { 4260 Log.e(TAG, "Layout has extension payload, but no extension provider is available."); 4261 return inflateFailedExtension(parentViewWrapper, element); 4262 } 4263 4264 View view = 4265 mExtensionViewProvider.provideView( 4266 element.getPayload().toByteArray(), element.getExtensionId()); 4267 4268 if (view == null) { 4269 Log.w(TAG, "Extension view provider returned null."); 4270 // A failed extension should still occupy space. 4271 return inflateFailedExtension(parentViewWrapper, element); 4272 } 4273 4274 if (view.getTag() != null) { 4275 throw new IllegalStateException("Extension must not set View's default tag"); 4276 } 4277 4278 LayoutParams lp = new LayoutParams(widthPx, heightPx); 4279 parentViewWrapper.maybeAddView(view, lp); 4280 4281 return new InflatedView( 4282 view, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp)); 4283 } 4284 inflateFailedExtension( ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element)4285 private InflatedView inflateFailedExtension( 4286 ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) { 4287 int widthPx = safeDpToPx(element.getWidth().getLinearDimension()); 4288 int heightPx = safeDpToPx(element.getHeight().getLinearDimension()); 4289 4290 Space space = new Space(mUiContext); 4291 4292 LayoutParams lp = new LayoutParams(widthPx, heightPx); 4293 parentViewWrapper.maybeAddView(space, lp); 4294 4295 return new InflatedView( 4296 space, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp)); 4297 } 4298 4299 /** 4300 * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic 4301 * values. Returns null if no size wrapper is needed. 4302 */ resolveValueForLayoutIfNeeded(StringProp stringProp)4303 private @Nullable String resolveValueForLayoutIfNeeded(StringProp stringProp) { 4304 if (!stringProp.hasDynamicValue() || !mDataPipeline.isPresent()) { 4305 return null; 4306 } 4307 4308 // If value_for_layout is set to non-zero, always use it. 4309 if (!stringProp.getValueForLayout().isEmpty()) { 4310 return stringProp.getValueForLayout(); 4311 } 4312 4313 return mAllowLayoutChangingBindsWithoutDefault ? null : ""; 4314 } 4315 4316 /** 4317 * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic 4318 * values. Returns null if no size wrapper is needed. 4319 */ resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension)4320 private @Nullable Float resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension) { 4321 DpProp dimension = spacerDimension.getLinearDimension(); 4322 if (!dimension.hasDynamicValue() || !mDataPipeline.isPresent()) { 4323 return null; 4324 } 4325 4326 if (dimension.getValueForLayout() > 0f) { 4327 return dimension.getValueForLayout(); 4328 } 4329 4330 return mAllowLayoutChangingBindsWithoutDefault ? null : 0f; 4331 } 4332 4333 /** 4334 * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic 4335 * values. Returns null if no size wrapper is needed. 4336 */ resolveSizeForLayoutIfNeeded(DegreesProp degreesProp)4337 private @Nullable Float resolveSizeForLayoutIfNeeded(DegreesProp degreesProp) { 4338 if (!degreesProp.hasDynamicValue() || !mDataPipeline.isPresent()) { 4339 return null; 4340 } 4341 4342 // If value_for_layout is set to non-zero, always use it 4343 if (degreesProp.getValueForLayout() > 0f) { 4344 return degreesProp.getValueForLayout(); 4345 } 4346 4347 return mAllowLayoutChangingBindsWithoutDefault ? null : 0f; 4348 } 4349 canMeasureContainer( ContainerDimension containerWidth, ContainerDimension containerHeight, List<LayoutElement> elements)4350 private boolean canMeasureContainer( 4351 ContainerDimension containerWidth, 4352 ContainerDimension containerHeight, 4353 List<LayoutElement> elements) { 4354 // We can't measure a container if it's set to wrap-contents but all of its contents are set 4355 // to expand-to-parent. Such containers must not be displayed. 4356 if (containerWidth.hasWrappedDimension() 4357 && !containsMeasurableWidth(containerHeight, elements)) { 4358 return false; 4359 } 4360 return !containerHeight.hasWrappedDimension() 4361 || containsMeasurableHeight(containerWidth, elements); 4362 } 4363 containsMeasurableWidth( ContainerDimension containerHeight, List<LayoutElement> elements)4364 private boolean containsMeasurableWidth( 4365 ContainerDimension containerHeight, List<LayoutElement> elements) { 4366 for (LayoutElement element : elements) { 4367 if (isWidthMeasurable(element, containerHeight)) { 4368 // Enough to find a single element that is measurable. 4369 return true; 4370 } 4371 } 4372 return false; 4373 } 4374 containsMeasurableHeight( ContainerDimension containerWidth, List<LayoutElement> elements)4375 private boolean containsMeasurableHeight( 4376 ContainerDimension containerWidth, List<LayoutElement> elements) { 4377 for (LayoutElement element : elements) { 4378 if (isHeightMeasurable(element, containerWidth)) { 4379 // Enough to find a single element that is measurable. 4380 return true; 4381 } 4382 } 4383 return false; 4384 } 4385 isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight)4386 private boolean isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight) { 4387 switch (element.getInnerCase()) { 4388 case COLUMN: 4389 return isMeasurable(element.getColumn().getWidth()); 4390 case ROW: 4391 return isMeasurable(element.getRow().getWidth()); 4392 case BOX: 4393 return isMeasurable(element.getBox().getWidth()); 4394 case SPACER: 4395 return isMeasurable(element.getSpacer().getWidth()); 4396 case IMAGE: 4397 // Special-case. If the image width is proportional, then the height must be 4398 // measurable. This means either a fixed size, or expanded where we know the parent 4399 // dimension. 4400 Image img = element.getImage(); 4401 if (img.getWidth().hasProportionalDimension()) { 4402 boolean isContainerHeightKnown = 4403 (containerHeight.hasExpandedDimension() 4404 || containerHeight.hasLinearDimension()); 4405 return img.getHeight().hasLinearDimension() 4406 || (img.getHeight().hasExpandedDimension() && isContainerHeightKnown); 4407 } else { 4408 return isMeasurable(element.getImage().getWidth()); 4409 } 4410 case ARC: 4411 case TEXT: 4412 case SPANNABLE: 4413 return true; 4414 case EXTENSION: 4415 return isMeasurable(element.getExtension().getWidth()); 4416 case INNER_NOT_SET: 4417 return false; 4418 } 4419 return false; 4420 } 4421 isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth)4422 private boolean isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth) { 4423 switch (element.getInnerCase()) { 4424 case COLUMN: 4425 return isMeasurable(element.getColumn().getHeight()); 4426 case ROW: 4427 return isMeasurable(element.getRow().getHeight()); 4428 case BOX: 4429 return isMeasurable(element.getBox().getHeight()); 4430 case SPACER: 4431 return isMeasurable(element.getSpacer().getHeight()); 4432 case IMAGE: 4433 // Special-case. If the image height is proportional, then the width must be 4434 // measurable. This means either a fixed size, or expanded where we know the parent 4435 // dimension. 4436 Image img = element.getImage(); 4437 if (img.getHeight().hasProportionalDimension()) { 4438 boolean isContainerWidthKnown = 4439 (containerWidth.hasExpandedDimension() 4440 || containerWidth.hasLinearDimension()); 4441 return img.getWidth().hasLinearDimension() 4442 || (img.getWidth().hasExpandedDimension() && isContainerWidthKnown); 4443 } else { 4444 return isMeasurable(element.getImage().getHeight()); 4445 } 4446 case ARC: 4447 case TEXT: 4448 case SPANNABLE: 4449 return true; 4450 case EXTENSION: 4451 return isMeasurable(element.getExtension().getHeight()); 4452 case INNER_NOT_SET: 4453 return false; 4454 } 4455 return false; 4456 } 4457 isMeasurable(ContainerDimension dimension)4458 private boolean isMeasurable(ContainerDimension dimension) { 4459 return dimensionToPx(dimension) != LayoutParams.MATCH_PARENT; 4460 } 4461 isMeasurable(ImageDimension dimension)4462 private static boolean isMeasurable(ImageDimension dimension) { 4463 switch (dimension.getInnerCase()) { 4464 case LINEAR_DIMENSION: 4465 case PROPORTIONAL_DIMENSION: 4466 return true; 4467 case EXPANDED_DIMENSION: 4468 case INNER_NOT_SET: 4469 return false; 4470 } 4471 return false; 4472 } 4473 isMeasurable(SpacerDimension dimension)4474 private static boolean isMeasurable(SpacerDimension dimension) { 4475 switch (dimension.getInnerCase()) { 4476 case LINEAR_DIMENSION: 4477 return true; 4478 case EXPANDED_DIMENSION: 4479 return false; 4480 case INNER_NOT_SET: 4481 return false; 4482 } 4483 return false; 4484 } 4485 isMeasurable(ExtensionDimension dimension)4486 private static boolean isMeasurable(ExtensionDimension dimension) { 4487 switch (dimension.getInnerCase()) { 4488 case LINEAR_DIMENSION: 4489 return true; 4490 case INNER_NOT_SET: 4491 return false; 4492 } 4493 return false; 4494 } 4495 inflateChildElements( @onNull ViewGroup parent, @NonNull LayoutParams parentLayoutParams, PendingLayoutParams childLayoutParams, List<LayoutElement> childElements, String parentPosId, LayoutInfo.Builder layoutInfoBuilder, Optional<PipelineMaker> pipelineMaker)4496 private void inflateChildElements( 4497 @NonNull ViewGroup parent, 4498 @NonNull LayoutParams parentLayoutParams, 4499 PendingLayoutParams childLayoutParams, 4500 List<LayoutElement> childElements, 4501 String parentPosId, 4502 LayoutInfo.Builder layoutInfoBuilder, 4503 Optional<PipelineMaker> pipelineMaker) { 4504 int index = FIRST_CHILD_INDEX; 4505 for (LayoutElement childElement : childElements) { 4506 String childPosId = ProtoLayoutDiffer.createNodePosId(parentPosId, index++); 4507 inflateLayoutElement( 4508 new ParentViewWrapper(parent, parentLayoutParams, childLayoutParams), 4509 childElement, 4510 childPosId, 4511 /* includeChildren= */ true, 4512 layoutInfoBuilder, 4513 pipelineMaker); 4514 } 4515 } 4516 4517 /** 4518 * Inflates a ProtoLayout into {@code inflateParent}. 4519 * 4520 * @param inflateParent The view to attach the layout into. 4521 * @return The {@link InflateResult} class containing the first child that was inflated, 4522 * animations to be played, and new nodes for the dynamic data pipeline. Callers should use 4523 * {@link InflateResult#updateDynamicDataPipeline} to apply those changes using a UI Thread. 4524 * <p>This may be null if the proto is empty the top-level LayoutElement has no inner set, 4525 * or the top-level LayoutElement contains an unsupported inner type. 4526 */ inflate(@onNull ViewGroup inflateParent)4527 public @Nullable InflateResult inflate(@NonNull ViewGroup inflateParent) { 4528 4529 // This is a full re-inflation, so we don't need any previous rendering information. 4530 LayoutInfo.Builder layoutInfoBuilder = 4531 new LayoutInfo.Builder(/* previousLayoutInfo= */ null); 4532 4533 // Go! 4534 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker = 4535 mDataPipeline.map( 4536 p -> 4537 p.newPipelineMaker( 4538 ProtoLayoutInflater::getEnterAnimations, 4539 ProtoLayoutInflater::getExitAnimations)); 4540 InflatedView firstInflatedChild = 4541 inflateLayoutElement( 4542 new ParentViewWrapper( 4543 inflateParent, 4544 new LayoutParams( 4545 LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)), 4546 mLayoutProto.getRoot(), 4547 ROOT_NODE_ID, 4548 /* includeChildren= */ true, 4549 layoutInfoBuilder, 4550 pipelineMaker); 4551 if (firstInflatedChild == null) { 4552 return null; 4553 } 4554 if (mLayoutProto.hasFingerprint()) { 4555 inflateParent.setTag( 4556 R.id.rendered_metadata_tag, 4557 new RenderedMetadata(mLayoutProto.getFingerprint(), layoutInfoBuilder.build())); 4558 } 4559 return new InflateResult(inflateParent, firstInflatedChild.mView, pipelineMaker); 4560 } 4561 4562 /** 4563 * Compute the mutation that must be applied to the given {@link ViewGroup} in order to produce 4564 * the given target layout. 4565 * 4566 * <p>If the return value is {@code null}, {@code parent} must be updated in full using {@link 4567 * #inflate}. Otherwise, call {ViewGroupMutation#isNoOp} on the return value to check if there 4568 * are any mutations to apply and call {@link #applyMutation} to apply them. 4569 * 4570 * <p>Can be called from a background thread. 4571 * 4572 * @param prevRenderedMetadata The metadata for the previous rendering of this view, either 4573 * using {@code inflate} or {@code applyMutation}. This can be retrieved by calling {@link 4574 * #getRenderedMetadata} on the previous layout view parent. 4575 * @param targetLayout The target layout that the mutation should result in. 4576 * @return The mutation that will produce the target layout. 4577 */ computeMutation( @onNull RenderedMetadata prevRenderedMetadata, @NonNull Layout targetLayout, @NonNull ViewProperties parentViewProp)4578 public @Nullable ViewGroupMutation computeMutation( 4579 @NonNull RenderedMetadata prevRenderedMetadata, 4580 @NonNull Layout targetLayout, 4581 @NonNull ViewProperties parentViewProp) { 4582 if (prevRenderedMetadata.getTreeFingerprint() == null) { 4583 Log.w(TAG, "No previous fingerprint available."); 4584 return null; 4585 } 4586 LayoutDiff diff = 4587 ProtoLayoutDiffer.getDiff(prevRenderedMetadata.getTreeFingerprint(), targetLayout); 4588 if (diff == null) { 4589 Log.w(TAG, "getDiff failed"); 4590 return null; 4591 } 4592 4593 logDebug(diff); 4594 4595 List<InflatedView> inflatedViews = new ArrayList<>(); 4596 LayoutInfo.Builder layoutInfoBuilder = 4597 new LayoutInfo.Builder(prevRenderedMetadata.getLayoutInfo()); 4598 LayoutInfo prevLayoutInfo = prevRenderedMetadata.getLayoutInfo(); 4599 Optional<PipelineMaker> pipelineMaker = 4600 mDataPipeline.map( 4601 p -> 4602 p.newPipelineMaker( 4603 ProtoLayoutInflater::getEnterAnimations, 4604 ProtoLayoutInflater::getExitAnimations)); 4605 for (TreeNodeWithChange changedNode : diff.getChangedNodes()) { 4606 String nodePosId = changedNode.getPosId(); 4607 if (nodePosId.isEmpty()) { 4608 // Failed to compute mutation. Need to update fully. 4609 Log.w(TAG, "Empty nodePosId"); 4610 return null; 4611 } 4612 ViewProperties parentInfo; 4613 if (nodePosId.equals(ROOT_NODE_ID)) { 4614 parentInfo = parentViewProp; 4615 } else { 4616 String parentNodePosId = getParentNodePosId(nodePosId); 4617 if (parentNodePosId == null || !prevLayoutInfo.contains(parentNodePosId)) { 4618 // Failed to compute mutation. Need to update fully. 4619 Log.w(TAG, "Can't find view " + nodePosId); 4620 return null; 4621 } 4622 4623 // The parent node might also have been updated. 4624 ViewProperties possibleUpdatedParentInfo = 4625 layoutInfoBuilder.getViewPropertiesFor(parentNodePosId); 4626 parentInfo = 4627 possibleUpdatedParentInfo != null 4628 ? possibleUpdatedParentInfo 4629 : checkNotNull( 4630 prevLayoutInfo.getViewPropertiesFor(parentNodePosId)); 4631 } 4632 InflatedView inflatedView = null; 4633 LayoutElement updatedLayoutElement = changedNode.getLayoutElement(); 4634 ArcLayoutElement updatedArcLayoutElement = changedNode.getArcLayoutElement(); 4635 if (updatedLayoutElement != null) { 4636 inflatedView = 4637 inflateLayoutElement( 4638 new ParentViewWrapper(parentInfo), 4639 updatedLayoutElement, 4640 nodePosId, 4641 !changedNode.isSelfOnlyChange(), 4642 layoutInfoBuilder, 4643 pipelineMaker); 4644 } else if (updatedArcLayoutElement != null) { 4645 inflatedView = 4646 inflateArcLayoutElement( 4647 new ParentViewWrapper(parentInfo), 4648 updatedArcLayoutElement, 4649 nodePosId, 4650 layoutInfoBuilder, 4651 pipelineMaker); 4652 } 4653 if (inflatedView == null) { 4654 // Failed to compute mutation. Need to update fully. 4655 Log.w(TAG, "No inflatedView"); 4656 return null; 4657 } 4658 inflatedViews.add(inflatedView); 4659 4660 if (!ProtoLayoutDiffer.UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE) { 4661 4662 throw new UnsupportedOperationException(); 4663 } 4664 if (!changedNode.isSelfOnlyChange()) { 4665 // A child addition/removal causes a full reinflation of the parent. So the only 4666 // case that we might not replace a node in pipeline is when it's removed as part of 4667 // a parent change. 4668 pipelineMaker.ifPresent(p -> p.markForChildRemoval(nodePosId)); 4669 } 4670 pipelineMaker.ifPresent( 4671 p -> p.markNodeAsChanged(nodePosId, !changedNode.isSelfOnlyChange())); 4672 } 4673 return new ViewGroupMutation( 4674 inflatedViews, 4675 new RenderedMetadata(targetLayout.getFingerprint(), layoutInfoBuilder.build()), 4676 prevRenderedMetadata.getTreeFingerprint().getRoot(), 4677 pipelineMaker); 4678 } 4679 4680 /** Apply the mutation that was previously computed with {@link #computeMutation}. */ 4681 @UiThread applyMutation( @onNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation)4682 public @NonNull ListenableFuture<RenderingArtifact> applyMutation( 4683 @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) { 4684 RenderedMetadata prevRenderedMetadata = getRenderedMetadata(prevInflatedParent); 4685 if (prevRenderedMetadata != null 4686 && !ProtoLayoutDiffer.areNodesEquivalent( 4687 prevRenderedMetadata.getTreeFingerprint().getRoot(), 4688 groupMutation.mPreMutationRootNodeFingerprint)) { 4689 4690 // be considered unequal. Log.e(TAG, "View has changed. Skipping mutation."); return 4691 // false; 4692 } 4693 if (groupMutation.isNoOp()) { 4694 // Nothing to do. 4695 return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger)); 4696 } 4697 4698 if (groupMutation.mPipelineMaker.isPresent()) { 4699 SettableFuture<RenderingArtifact> result = SettableFuture.create(); 4700 groupMutation 4701 .mPipelineMaker 4702 .get() 4703 .playExitAnimations( 4704 prevInflatedParent, 4705 /* isReattaching= */ false, 4706 () -> { 4707 try { 4708 applyMutationInternal(prevInflatedParent, groupMutation); 4709 result.set(RenderingArtifact.create(mInflaterStatsLogger)); 4710 } catch (ViewMutationException ex) { 4711 result.setException(ex); 4712 } 4713 }); 4714 return result; 4715 } else { 4716 try { 4717 applyMutationInternal(prevInflatedParent, groupMutation); 4718 return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger)); 4719 } catch (ViewMutationException ex) { 4720 return immediateFailedFuture(ex); 4721 } 4722 } 4723 } 4724 applyMutationInternal( @onNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation)4725 private void applyMutationInternal( 4726 @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) { 4727 mInflaterStatsLogger.logMutationChangedNodes(groupMutation.mInflatedViews.size()); 4728 for (InflatedView inflatedView : groupMutation.mInflatedViews) { 4729 String posId = inflatedView.getTag(); 4730 if (posId == null) { 4731 // Failed to apply the mutation. Need to update fully. 4732 throw new ViewMutationException("View has no tag"); 4733 } 4734 View viewToUpdate = prevInflatedParent.findViewWithTag(posId); 4735 if (viewToUpdate == null) { 4736 // Failed to apply the mutation. Need to update fully. 4737 throw new ViewMutationException("Can't find view " + posId); 4738 } 4739 ViewParent potentialImmediateParent = viewToUpdate.getParent(); 4740 if (!(potentialImmediateParent instanceof ViewGroup)) { 4741 // Failed to apply the mutation. Need to update fully. 4742 throw new ViewMutationException("Parent not a ViewGroup"); 4743 } 4744 ViewGroup immediateParent = (ViewGroup) potentialImmediateParent; 4745 int childIndex = immediateParent.indexOfChild(viewToUpdate); 4746 if (childIndex == -1) { 4747 // Failed to apply the mutation. Need to update fully. 4748 throw new ViewMutationException("Can't find child at " + childIndex); 4749 } 4750 if (!inflatedView.addMissingChildrenFrom(viewToUpdate)) { 4751 throw new ViewMutationException("Failed to add missing children " + posId); 4752 } 4753 // Remove the touch delegate to the view to be updated 4754 if (immediateParent.getTouchDelegate() != null) { 4755 TouchDelegateComposite delegateComposite = 4756 (TouchDelegateComposite) immediateParent.getTouchDelegate(); 4757 delegateComposite.removeDelegate(viewToUpdate); 4758 4759 // Make sure to remove the touch delegate when the actual clickable view is wrapped, 4760 // for example ImageView inside the RatioViewWrapper 4761 if (viewToUpdate instanceof ViewGroup 4762 && ((ViewGroup) viewToUpdate).getChildCount() > 0) { 4763 delegateComposite.removeDelegate(((ViewGroup) viewToUpdate).getChildAt(0)); 4764 } 4765 4766 // If no more touch delegate left in the composite, remove it completely from the 4767 // parent 4768 if (delegateComposite.isEmpty()) { 4769 immediateParent.setTouchDelegate(null); 4770 } 4771 } 4772 immediateParent.removeViewAt(childIndex); 4773 immediateParent.addView(inflatedView.mView, childIndex, inflatedView.mLayoutParams); 4774 4775 if (DEBUG_DIFF_UPDATE_ENABLED) { 4776 // Visualize diff update (by flashing the inflated element). 4777 inflatedView 4778 .mView 4779 .animate() 4780 .alpha(0.7f) 4781 .setDuration(50) 4782 .withEndAction(() -> inflatedView.mView.animate().alpha(1).setDuration(50)); 4783 } 4784 } 4785 groupMutation.mPipelineMaker.ifPresent( 4786 pipe -> pipe.commit(prevInflatedParent, /* isReattaching= */ false)); 4787 prevInflatedParent.setTag( 4788 R.id.rendered_metadata_tag, groupMutation.mRenderedMetadataAfterMutation); 4789 } 4790 4791 /** Returns the {@link RenderedMetadata} attached to {@code inflateParent}. */ 4792 @UiThread getRenderedMetadata(@onNull ViewGroup inflateParent)4793 public static @Nullable RenderedMetadata getRenderedMetadata(@NonNull ViewGroup inflateParent) { 4794 Object prevMetadataObject = inflateParent.getTag(R.id.rendered_metadata_tag); 4795 if (prevMetadataObject instanceof RenderedMetadata) { 4796 return (RenderedMetadata) prevMetadataObject; 4797 } else { 4798 if (prevMetadataObject != null) { 4799 Log.w(TAG, "Incompatible prevMetadataObject"); 4800 } 4801 return null; 4802 } 4803 } 4804 4805 /** Clears the {@link RenderedMetadata} attached to {@code inflateParent}. */ 4806 @UiThread clearRenderedMetadata(@onNull ViewGroup inflateParent)4807 public static void clearRenderedMetadata(@NonNull ViewGroup inflateParent) { 4808 Log.d(TAG, "Clearing rendered metadata. Next inflation won't use diff update."); 4809 inflateParent.setTag(R.id.rendered_metadata_tag, /* tag= */ null); 4810 } 4811 4812 // dereference of possibly-null reference ((FrameLayout.LayoutParams)child.getLayoutParams()) 4813 @SuppressWarnings("nullness:dereference.of.nullable") applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity)4814 private static void applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity) { 4815 for (int i = 0; i < parent.getChildCount(); i++) { 4816 View child = parent.getChildAt(i); 4817 4818 // All children should have a LayoutParams already set... 4819 if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) { 4820 // This...shouldn't happen. 4821 throw new IllegalStateException( 4822 "Layout params of child is not a descendant of FrameLayout.LayoutParams."); 4823 } 4824 4825 // Children should grow out from the middle of the layout. 4826 ((FrameLayout.LayoutParams) child.getLayoutParams()).gravity = gravity; 4827 } 4828 } 4829 roleToClassName(SemanticsRole role)4830 static String roleToClassName(SemanticsRole role) { 4831 switch (role) { 4832 case SEMANTICS_ROLE_IMAGE: 4833 return "android.widget.ImageView"; 4834 case SEMANTICS_ROLE_BUTTON: 4835 return "android.widget.Button"; 4836 case SEMANTICS_ROLE_CHECKBOX: 4837 return "android.widget.CheckBox"; 4838 case SEMANTICS_ROLE_SWITCH: 4839 return "android.widget.Switch"; 4840 case SEMANTICS_ROLE_RADIOBUTTON: 4841 return "android.widget.RadioButton"; 4842 case SEMANTICS_ROLE_NONE: 4843 case UNRECOGNIZED: 4844 return ""; 4845 } 4846 return ""; 4847 } 4848 applySemantics( View view, Semantics semantics, String posId, Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker)4849 private void applySemantics( 4850 View view, 4851 Semantics semantics, 4852 String posId, 4853 Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) { 4854 view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); 4855 ViewCompat.setAccessibilityDelegate( 4856 view, 4857 new AccessibilityDelegateCompat() { 4858 @Override 4859 public void onInitializeAccessibilityNodeInfo( 4860 @NonNull View host, @NonNull AccessibilityNodeInfoCompat info) { 4861 super.onInitializeAccessibilityNodeInfo(host, info); 4862 4863 String className = roleToClassName(semantics.getRole()); 4864 if (!className.isEmpty()) { 4865 info.setClassName(className); 4866 } 4867 info.setFocusable(true); 4868 info.setImportantForAccessibility(true); 4869 } 4870 }); 4871 4872 if (semantics.hasContentDescription()) { 4873 handleProp( 4874 semantics.getContentDescription(), 4875 mUiContext.getResources().getConfiguration().getLocales().get(0), 4876 view::setContentDescription, 4877 posId, 4878 pipelineMaker); 4879 } else { 4880 // This is for backward compatibility 4881 view.setContentDescription(semantics.getObsoleteContentDescription()); 4882 } 4883 4884 if (semantics.hasStateDescription()) { 4885 handleProp( 4886 semantics.getStateDescription(), 4887 mUiContext.getResources().getConfiguration().getLocales().get(0), 4888 (state) -> ViewCompat.setStateDescription(view, state), 4889 posId, 4890 pipelineMaker); 4891 } 4892 } 4893 4894 /** Creates a TextView with the fallbackTextAppearance from the current theme. */ newThemedTextView()4895 private TextView newThemedTextView() { 4896 return new TextView( 4897 mProtoLayoutThemeContext, 4898 /* attrs= */ null, 4899 mProtoLayoutTheme.getFallbackTextAppearanceResId()); 4900 } 4901 4902 /** Creates a CurvedTextView with the fallbackTextAppearance from the current theme. */ newThemedCurvedTextView()4903 private CurvedTextView newThemedCurvedTextView() { 4904 return new CurvedTextView( 4905 mProtoLayoutThemeContext, 4906 /* attrs= */ null, 4907 mProtoLayoutTheme.getFallbackTextAppearanceResId()); 4908 } 4909 logDebug(LayoutDiff diff)4910 private void logDebug(LayoutDiff diff) { 4911 if (mLoggingUtils != null && mLoggingUtils.canLogD(TAG)) { 4912 StringBuilder sb = 4913 new StringBuilder("LayoutDiff result at LayoutInflater#computeMutation: \n"); 4914 List<TreeNodeWithChange> diffNodes = diff.getChangedNodes(); 4915 if (diffNodes.isEmpty()) { 4916 mLoggingUtils.logD(TAG, "No diff."); 4917 return; 4918 } 4919 for (TreeNodeWithChange changedNode : diffNodes) { 4920 sb.append(formatNodeChangeForLogs(changedNode)); 4921 if (changedNode.getLayoutElement() != null) { 4922 sb.append(changedNode.getLayoutElement()); 4923 } else if (changedNode.getArcLayoutElement() != null) { 4924 sb.append(changedNode.getArcLayoutElement()); 4925 } 4926 sb.append("\n"); 4927 } 4928 mLoggingUtils.logD(TAG, sb.toString()); 4929 } 4930 } 4931 formatNodeChangeForLogs(TreeNodeWithChange change)4932 private static String formatNodeChangeForLogs(TreeNodeWithChange change) { 4933 return "PosId: " 4934 + change.getPosId() 4935 + " | Fingerprint: " 4936 + change.getFingerprint().getSelfTypeValue() 4937 + " | isSelfOnlyChange: " 4938 + change.isSelfOnlyChange(); 4939 } 4940 4941 /** Implementation of ClickableSpan for ProtoLayout's Clickables. */ 4942 private class ProtoLayoutClickableSpan extends ClickableSpan { 4943 private final Clickable mClickable; 4944 ProtoLayoutClickableSpan(@onNull Clickable clickable)4945 ProtoLayoutClickableSpan(@NonNull Clickable clickable) { 4946 this.mClickable = clickable; 4947 } 4948 4949 @Override onClick(@onNull View widget)4950 public void onClick(@NonNull View widget) { 4951 Action action = mClickable.getOnClick(); 4952 4953 switch (action.getValueCase()) { 4954 case LAUNCH_ACTION: 4955 Intent i = 4956 buildLaunchActionIntent( 4957 action.getLaunchAction(), 4958 mClickable.getId(), 4959 mClickableIdExtra); 4960 if (i != null) { 4961 dispatchLaunchActionIntent(i); 4962 } 4963 break; 4964 case LOAD_ACTION: 4965 if (mLoadActionExecutor == null) { 4966 Log.w(TAG, "Ignoring load action since an executor has not been provided."); 4967 break; 4968 } 4969 mLoadActionExecutor.execute( 4970 () -> 4971 mLoadActionListener.onClick( 4972 buildState( 4973 action.getLoadAction(), mClickable.getId()))); 4974 break; 4975 case VALUE_NOT_SET: 4976 break; 4977 } 4978 } 4979 4980 @Override updateDrawState(@onNull TextPaint ds)4981 public void updateDrawState(@NonNull TextPaint ds) { 4982 // Don't change the underlying text appearance. 4983 } 4984 } 4985 4986 /** Implementation of {@link Scroller} which inhibits all scrolling. */ 4987 private static class InhibitingScroller extends Scroller { InhibitingScroller(Context context)4988 InhibitingScroller(Context context) { 4989 super(context); 4990 } 4991 4992 @Override startScroll(int startX, int startY, int dx, int dy)4993 public void startScroll(int startX, int startY, int dx, int dy) {} 4994 4995 @Override startScroll(int startX, int startY, int dx, int dy, int duration)4996 public void startScroll(int startX, int startY, int dx, int dy, int duration) {} 4997 } 4998 } 4999