1 /*
2  * Copyright 2021 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.material;
18 
19 import static androidx.annotation.Dimension.DP;
20 import static androidx.wear.protolayout.DimensionBuilders.dp;
21 import static androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER;
22 import static androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_START;
23 import static androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_UNDEFINED;
24 import static androidx.wear.protolayout.material.ChipDefaults.DEFAULT_HEIGHT;
25 import static androidx.wear.protolayout.material.ChipDefaults.DEFAULT_MARGIN_PERCENT;
26 import static androidx.wear.protolayout.material.ChipDefaults.HORIZONTAL_PADDING;
27 import static androidx.wear.protolayout.material.ChipDefaults.ICON_SIZE;
28 import static androidx.wear.protolayout.material.ChipDefaults.ICON_SPACER_WIDTH;
29 import static androidx.wear.protolayout.material.ChipDefaults.PRIMARY_COLORS;
30 import static androidx.wear.protolayout.materialcore.Chip.METADATA_TAG_CUSTOM_CONTENT;
31 import static androidx.wear.protolayout.materialcore.Chip.METADATA_TAG_ICON;
32 import static androidx.wear.protolayout.materialcore.Helper.checkNotNull;
33 import static androidx.wear.protolayout.materialcore.Helper.staticString;
34 
35 import android.content.Context;
36 
37 import androidx.annotation.Dimension;
38 import androidx.annotation.RestrictTo;
39 import androidx.annotation.RestrictTo.Scope;
40 import androidx.wear.protolayout.ColorBuilders.ColorProp;
41 import androidx.wear.protolayout.DeviceParametersBuilders.DeviceParameters;
42 import androidx.wear.protolayout.DimensionBuilders.ContainerDimension;
43 import androidx.wear.protolayout.DimensionBuilders.DpProp;
44 import androidx.wear.protolayout.LayoutElementBuilders;
45 import androidx.wear.protolayout.LayoutElementBuilders.ColorFilter;
46 import androidx.wear.protolayout.LayoutElementBuilders.HorizontalAlignment;
47 import androidx.wear.protolayout.LayoutElementBuilders.Image;
48 import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement;
49 import androidx.wear.protolayout.ModifiersBuilders.Clickable;
50 import androidx.wear.protolayout.TypeBuilders.StringProp;
51 import androidx.wear.protolayout.expression.Fingerprint;
52 import androidx.wear.protolayout.material.Typography.TypographyName;
53 import androidx.wear.protolayout.proto.LayoutElementProto;
54 
55 import org.jspecify.annotations.NonNull;
56 import org.jspecify.annotations.Nullable;
57 
58 /**
59  * ProtoLayout component {@link Chip} that represents clickable object with the text, optional label
60  * and optional icon or with custom content.
61  *
62  * <p>The Chip is Stadium shape that has a max height designed to take no more than two lines of
63  * text of {@link Typography#TYPOGRAPHY_BUTTON} style and with minimum tap target to meet
64  * accessibility requirements. The {@link Chip} can have an icon horizontally parallel to the two
65  * lines of text. Width of chip can very, and the recommended size is screen dependent with the
66  * recommended margin being applied.
67  *
68  * <p>The recommended set of {@link ChipColors} styles can be obtained from {@link ChipDefaults}.,
69  * e.g. {@link ChipDefaults#PRIMARY_COLORS} to get a color scheme for a primary {@link Chip}.
70  *
71  * <p>When accessing the contents of a container for testing, note that this element can't be simply
72  * casted back to the original type, i.e.:
73  *
74  * <pre>{@code
75  * Chip chip = new Chip...
76  * Box box = new Box.Builder().addContent(chip).build();
77  *
78  * Chip myChip = (Chip) box.getContents().get(0);
79  * }</pre>
80  *
81  * will fail.
82  *
83  * <p>To be able to get {@link Chip} object from any layout element, {@link #fromLayoutElement}
84  * method should be used, i.e.:
85  *
86  * <pre>{@code
87  * Chip myChip = Chip.fromLayoutElement(box.getContents().get(0));
88  * }</pre>
89  *
90  * @see androidx.wear.protolayout.material.layouts.PrimaryLayout.Builder#setContent if this Chip is
91  *     used inside of {@link androidx.wear.protolayout.material.layouts.PrimaryLayout}.
92  */
93 public class Chip implements LayoutElement {
94     private final androidx.wear.protolayout.materialcore.@NonNull Chip mElement;
95 
Chip(androidx.wear.protolayout.materialcore.@onNull Chip element)96     Chip(androidx.wear.protolayout.materialcore.@NonNull Chip element) {
97         mElement = element;
98     }
99 
100     /** Builder class for {@link Chip}. */
101     public static final class Builder implements LayoutElement.Builder {
102         private final @NonNull Context mContext;
103         private @Nullable LayoutElement mCustomContent;
104         private @Nullable String mImageResourceId = null;
105         private @Nullable String mPrimaryLabel = null;
106         private @Nullable String mSecondaryLabel = null;
107         private @Nullable StringProp mContentDescription = null;
108         private @NonNull ChipColors mChipColors = PRIMARY_COLORS;
109         private @NonNull DpProp mIconSize = ICON_SIZE;
110         @HorizontalAlignment private int mHorizontalAlign = HORIZONTAL_ALIGN_UNDEFINED;
111         @TypographyName private int mPrimaryLabelTypography;
112         private boolean mIsScalable = true;
113         private int mMaxLines = 0; // 0 indicates that is not set.
114         private final androidx.wear.protolayout.materialcore.Chip.@NonNull Builder mCoreBuilder;
115 
116         /**
117          * Creates a builder for the {@link Chip} with associated action. It is required to add
118          * content later with setters.
119          *
120          * @param context The application's context.
121          * @param clickable Associated {@link Clickable} for click events. When the Chip is clicked
122          *     it will fire the associated action.
123          * @param deviceParameters The device parameters used to derive defaults for this Chip.
124          */
Builder( @onNull Context context, @NonNull Clickable clickable, @NonNull DeviceParameters deviceParameters)125         public Builder(
126                 @NonNull Context context,
127                 @NonNull Clickable clickable,
128                 @NonNull DeviceParameters deviceParameters) {
129             mContext = context;
130             float width =
131                     (100 - 2 * DEFAULT_MARGIN_PERCENT) * deviceParameters.getScreenWidthDp() / 100;
132             mPrimaryLabelTypography = Typography.TYPOGRAPHY_BUTTON;
133             mCoreBuilder = new androidx.wear.protolayout.materialcore.Chip.Builder(clickable);
134             mCoreBuilder.setWidth(dp(width));
135             mCoreBuilder.setHorizontalPadding(HORIZONTAL_PADDING);
136             mCoreBuilder.setHeight(DEFAULT_HEIGHT);
137             mCoreBuilder.setBackgroundColor(mChipColors.getBackgroundColor());
138             mCoreBuilder.setMinimalTappableSquareLength(ChipDefaults.MIN_TAPPABLE_SQUARE_LENGTH);
139             mCoreBuilder.setIconSpacerWidth(ICON_SPACER_WIDTH);
140         }
141 
142         /**
143          * Sets the width of {@link Chip}. If not set, default value will be set to fill the screen.
144          */
setWidth(@onNull ContainerDimension width)145         public @NonNull Builder setWidth(@NonNull ContainerDimension width) {
146             mCoreBuilder.setWidth(width);
147             return this;
148         }
149 
150         /**
151          * Sets the width of {@link Chip}. If not set, default value will be set to fill the
152          * screen.
153          */
setWidth(@imensionunit = DP) float width)154         public @NonNull Builder setWidth(@Dimension(unit = DP) float width) {
155             return setWidth(dp(width));
156         }
157 
158         /**
159          * Sets the custom content for the {@link Chip}. Any previously added content will be
160          * overridden.
161          */
setCustomContent(@onNull LayoutElement content)162         public @NonNull Builder setCustomContent(@NonNull LayoutElement content) {
163             this.mCustomContent = content;
164             this.mPrimaryLabel = null;
165             this.mSecondaryLabel = null;
166             this.mImageResourceId = null;
167             return this;
168         }
169 
170         /**
171          * Sets the static content description for the {@link Chip}. It is highly recommended to
172          * provide this for chip containing icon.
173          */
setContentDescription(@onNull CharSequence contentDescription)174         public @NonNull Builder setContentDescription(@NonNull CharSequence contentDescription) {
175             return setContentDescription(staticString(contentDescription.toString()));
176         }
177 
178         /**
179          * Sets the content description for the {@link Chip}. It is highly recommended to provide
180          * this for chip containing icon.
181          *
182          * <p>While this field is statically accessible from 1.0, it's only bindable since version
183          * 1.2 and renderers supporting version 1.2 will use the dynamic value (if set).
184          */
setContentDescription(@onNull StringProp contentDescription)185         public @NonNull Builder setContentDescription(@NonNull StringProp contentDescription) {
186             this.mContentDescription = contentDescription;
187             return this;
188         }
189 
190         /**
191          * Sets the primary label for the {@link Chip}. Any previously added custom content will be
192          * overridden. Primary label can be on 1 or 2 lines, depending on the length and existence
193          * of secondary label.
194          */
setPrimaryLabelContent(@onNull String primaryLabel)195         public @NonNull Builder setPrimaryLabelContent(@NonNull String primaryLabel) {
196             this.mPrimaryLabel = primaryLabel;
197             this.mCustomContent = null;
198             return this;
199         }
200 
201         /**
202          * Used for creating {@code CompactChip} and {@code TitleChip}.
203          *
204          * <p>Sets the font for the primary label and should only be used internally.
205          */
setPrimaryLabelTypography(@ypographyName int typography)206         @NonNull Builder setPrimaryLabelTypography(@TypographyName int typography) {
207             this.mPrimaryLabelTypography = typography;
208             return this;
209         }
210 
211         /**
212          * Used for creating {@code CompactChip} and {@code TitleChip}.
213          *
214          * <p>Sets the icon size and should only be used internally.
215          */
setIconSize(@onNull DpProp size)216         @NonNull Builder setIconSize(@NonNull DpProp size) {
217             this.mIconSize = size;
218             return this;
219         }
220 
221         /**
222          * Used for creating {@code CompactChip} and {@code TitleChip}.
223          *
224          * <p>Sets whether the font for the primary label is scalable.
225          */
setIsPrimaryLabelScalable(boolean isScalable)226         @NonNull Builder setIsPrimaryLabelScalable(boolean isScalable) {
227             this.mIsScalable = isScalable;
228             return this;
229         }
230 
231         /**
232          * Sets the secondary label for the {@link Chip}. Any previously added custom content will
233          * be overridden. If secondary label is set, primary label must be set too with {@link
234          * #setPrimaryLabelContent}.
235          */
setSecondaryLabelContent(@onNull String secondaryLabel)236         public @NonNull Builder setSecondaryLabelContent(@NonNull String secondaryLabel) {
237             this.mSecondaryLabel = secondaryLabel;
238             this.mCustomContent = null;
239             return this;
240         }
241 
242         /**
243          * Sets the icon for the {@link Chip}. Any previously added custom content will be
244          * overridden. Provided icon will be tinted to the given content color from {@link
245          * ChipColors}. This icon should be image with chosen alpha channel and not an actual image.
246          * If icon is set, primary label must be set too with {@link #setPrimaryLabelContent}.
247          */
setIconContent(@onNull String imageResourceId)248         public @NonNull Builder setIconContent(@NonNull String imageResourceId) {
249             this.mImageResourceId = imageResourceId;
250             this.mCustomContent = null;
251             return this;
252         }
253 
254         /**
255          * Sets the colors for the {@link Chip}. If set, {@link ChipColors#getBackgroundColor()}
256          * will be used for the background of the button, {@link ChipColors#getContentColor()} for
257          * main text, {@link ChipColors#getSecondaryContentColor()} for label text and {@link
258          * ChipColors#getIconColor()} will be used as color for the icon itself. If not set, {@link
259          * ChipDefaults#PRIMARY_COLORS} will be used.
260          */
setChipColors(@onNull ChipColors chipColors)261         public @NonNull Builder setChipColors(@NonNull ChipColors chipColors) {
262             mChipColors = chipColors;
263             mCoreBuilder.setBackgroundColor(chipColors.getBackgroundColor());
264             return this;
265         }
266 
267         /**
268          * Sets the horizontal alignment in the chip. It is strongly recommended that the content of
269          * the chip is start-aligned if there is more than primary text in it. By default, {@link
270          * HorizontalAlignment#HORIZONTAL_ALIGN_CENTER} will be used when only a primary label is
271          * present. Otherwise {@link HorizontalAlignment#HORIZONTAL_ALIGN_START} will be used.
272          */
setHorizontalAlignment( @orizontalAlignment int horizontalAlignment)273         public @NonNull Builder setHorizontalAlignment(
274                 @HorizontalAlignment int horizontalAlignment) {
275             mHorizontalAlign = horizontalAlignment;
276             return this;
277         }
278 
279         /** Used for creating {@code CompactChip} and {@code TitleChip}. */
setHorizontalPadding(@onNull DpProp horizontalPadding)280         @NonNull Builder setHorizontalPadding(@NonNull DpProp horizontalPadding) {
281             mCoreBuilder.setHorizontalPadding(horizontalPadding);
282             return this;
283         }
284 
285         /** Used for creating {@code CompactChip} and {@code TitleChip}. */
setHeight(@onNull DpProp height)286         @NonNull Builder setHeight(@NonNull DpProp height) {
287             mCoreBuilder.setHeight(height);
288             return this;
289         }
290 
291         /** Used for creating {@code CompactChip} and {@code TitleChip}. */
setMaxLines(int maxLines)292         @NonNull Builder setMaxLines(int maxLines) {
293             this.mMaxLines = maxLines;
294             return this;
295         }
296 
297         /** Constructs and returns {@link Chip} with the provided content and look. */
298         @Override
build()299         public @NonNull Chip build() {
300             mCoreBuilder.setContentDescription(getCorrectContentDescription());
301             mCoreBuilder.setHorizontalAlignment(getCorrectHorizontalAlignment());
302 
303             if (mCustomContent != null) {
304                 mCoreBuilder.setCustomContent(mCustomContent);
305             } else {
306                 setCorrectContent();
307             }
308 
309             return new Chip(mCoreBuilder.build());
310         }
311 
getCorrectContentDescription()312         private @NonNull StringProp getCorrectContentDescription() {
313             if (mContentDescription == null) {
314                 String staticValue = "";
315                 if (mPrimaryLabel != null) {
316                     staticValue += mPrimaryLabel;
317                 }
318                 if (mSecondaryLabel != null) {
319                     staticValue += "\n" + mSecondaryLabel;
320                 }
321                 mContentDescription = new StringProp.Builder(staticValue).build();
322             }
323             return checkNotNull(mContentDescription);
324         }
325 
326         @HorizontalAlignment
getCorrectHorizontalAlignment()327         private int getCorrectHorizontalAlignment() {
328             if (mHorizontalAlign != HORIZONTAL_ALIGN_UNDEFINED) {
329                 return mHorizontalAlign;
330             }
331             if (mPrimaryLabel != null && mSecondaryLabel == null && mImageResourceId == null) {
332                 return HORIZONTAL_ALIGN_CENTER;
333             } else {
334                 return HORIZONTAL_ALIGN_START;
335             }
336         }
337 
isIconOnly()338         private boolean isIconOnly() {
339             return mPrimaryLabel == null && mSecondaryLabel == null && mCustomContent == null;
340         }
341 
342         @SuppressWarnings("deprecation") // TEXT_OVERFLOW_ELLIPSIZE_END as existing API
setCorrectContent()343         private void setCorrectContent() {
344             if (mImageResourceId != null) {
345                 Image icon =
346                         new Image.Builder()
347                                 .setResourceId(mImageResourceId)
348                                 .setWidth(mIconSize)
349                                 .setHeight(mIconSize)
350                                 .setColorFilter(
351                                         new ColorFilter.Builder()
352                                                 .setTint(mChipColors.getIconColor())
353                                                 .build())
354                                 .build();
355                 mCoreBuilder.setIconContent(icon);
356 
357                 if (isIconOnly()) {
358                     return;
359                 }
360             }
361 
362             Text mainTextElement =
363                     new Text.Builder(mContext, checkNotNull(mPrimaryLabel))
364                             .setTypography(mPrimaryLabelTypography)
365                             .setColor(mChipColors.getContentColor())
366                             .setMaxLines(getCorrectMaxLines())
367                             .setOverflow(LayoutElementBuilders.TEXT_OVERFLOW_ELLIPSIZE_END)
368                             .setMultilineAlignment(LayoutElementBuilders.TEXT_ALIGN_START)
369                             .setScalable(mIsScalable)
370                             .build();
371 
372             mCoreBuilder.setPrimaryLabelContent(mainTextElement);
373 
374             if (mSecondaryLabel != null) {
375                 Text labelTextElement =
376                         new Text.Builder(mContext, mSecondaryLabel)
377                                 .setTypography(Typography.TYPOGRAPHY_CAPTION2)
378                                 .setColor(mChipColors.getSecondaryContentColor())
379                                 .setMaxLines(1)
380                                 .setOverflow(LayoutElementBuilders.TEXT_OVERFLOW_ELLIPSIZE_END)
381                                 .setMultilineAlignment(LayoutElementBuilders.TEXT_ALIGN_START)
382                                 .build();
383                 mCoreBuilder.setSecondaryLabelContent(labelTextElement);
384             }
385         }
386 
getCorrectMaxLines()387         private int getCorrectMaxLines() {
388             if (mMaxLines > 0) {
389                 return mMaxLines;
390             }
391             return mSecondaryLabel != null ? 1 : 2;
392         }
393     }
394 
395     /** Returns the visible height of this Chip. */
getHeight()396     public @NonNull ContainerDimension getHeight() {
397         return mElement.getHeight();
398     }
399 
400     /** Returns width of this Chip. */
getWidth()401     public @NonNull ContainerDimension getWidth() {
402         return mElement.getWidth();
403     }
404 
405     /** Returns click event action associated with this Chip. */
getClickable()406     public @NonNull Clickable getClickable() {
407         return mElement.getClickable();
408     }
409 
410     /** Returns chip colors of this Chip. */
getChipColors()411     public @NonNull ChipColors getChipColors() {
412         ColorProp backgroundColor = mElement.getBackgroundColor();
413         ColorProp contentColor = null;
414         ColorProp secondaryContentColor = null;
415         ColorProp iconTintColor = null;
416 
417         if (!getMetadataTag().equals(METADATA_TAG_CUSTOM_CONTENT)) {
418             if (getMetadataTag().equals(METADATA_TAG_ICON)) {
419                 Image icon = checkNotNull(getIconContentObject());
420                 iconTintColor = checkNotNull(checkNotNull(icon.getColorFilter()).getTint());
421             }
422 
423 
424             Text maybePrimaryLabel = getPrimaryLabelContentObject();
425             if (maybePrimaryLabel != null) {
426                 contentColor = checkNotNull(maybePrimaryLabel).getColor();
427                 Text label = getSecondaryLabelContentObject();
428                 if (label != null) {
429                     secondaryContentColor = label.getColor();
430                 }
431             }
432         }
433 
434         // Populate other colors if they are not found.
435         if (contentColor == null) {
436             contentColor = new ColorProp.Builder(0).build();
437         }
438         if (secondaryContentColor == null) {
439             secondaryContentColor = contentColor;
440         }
441         if (iconTintColor == null) {
442             iconTintColor = contentColor;
443         }
444 
445         return new ChipColors(backgroundColor, iconTintColor, contentColor, secondaryContentColor);
446     }
447 
448     /** Returns content description of this Chip. */
getContentDescription()449     public @Nullable StringProp getContentDescription() {
450         return mElement.getContentDescription();
451     }
452 
453     /** Returns custom content from this Chip if it has been added. Otherwise, it returns null. */
getCustomContent()454     public @Nullable LayoutElement getCustomContent() {
455         return mElement.getCustomContent();
456     }
457 
458     /** Returns primary label from this Chip if it has been added. Otherwise, it returns null. */
getPrimaryLabelContent()459     public @Nullable String getPrimaryLabelContent() {
460         Text primaryLabel = getPrimaryLabelContentObject();
461         return primaryLabel != null ? primaryLabel.getText().getValue() : null;
462     }
463 
464     /** Returns secondary label from this Chip if it has been added. Otherwise, it returns null. */
getSecondaryLabelContent()465     public @Nullable String getSecondaryLabelContent() {
466         Text secondaryLabel = getSecondaryLabelContentObject();
467         return secondaryLabel != null ? secondaryLabel.getText().getValue() : null;
468     }
469 
470     /** Returns icon id from this Chip if it has been added. Otherwise, it returns null. */
getIconContent()471     public @Nullable String getIconContent() {
472         Image icon = getIconContentObject();
473         return icon != null ? checkNotNull(icon.getResourceId()).getValue() : null;
474     }
475 
getPrimaryLabelContentObject()476     private @Nullable Text getPrimaryLabelContentObject() {
477         LayoutElement content = mElement.getPrimaryLabelContent();
478         if (content != null) {
479             return Text.fromLayoutElement(content);
480         }
481         return null;
482     }
483 
getSecondaryLabelContentObject()484     private @Nullable Text getSecondaryLabelContentObject() {
485         LayoutElement content = mElement.getSecondaryLabelContent();
486         if (content != null) {
487             return Text.fromLayoutElement(content);
488         }
489         return null;
490     }
491 
getIconContentObject()492     private @Nullable Image getIconContentObject() {
493         LayoutElement content = mElement.getIconContent();
494         return content instanceof Image ? (Image) content : null;
495     }
496 
497     /** Returns the horizontal alignment of the content in this Chip. */
498     @HorizontalAlignment
getHorizontalAlignment()499     public int getHorizontalAlignment() {
500         return mElement.getHorizontalAlignment();
501     }
502 
503     /** Returns metadata tag set to this Chip. */
getMetadataTag()504     @NonNull String getMetadataTag() {
505         return mElement.getMetadataTag();
506     }
507 
508     /**
509      * Returns Chip object from the given LayoutElement (e.g. one retrieved from a container's
510      * content with {@code container.getContents().get(index)}) if that element can be converted to
511      * Chip. Otherwise, it will return null.
512      */
fromLayoutElement(@onNull LayoutElement element)513     public static @Nullable Chip fromLayoutElement(@NonNull LayoutElement element) {
514         if (element instanceof Chip) {
515             return (Chip) element;
516         }
517         androidx.wear.protolayout.materialcore.Chip coreChip =
518                 androidx.wear.protolayout.materialcore.Chip.fromLayoutElement(element);
519         return coreChip == null ? null : new Chip(coreChip);
520     }
521 
522     @Override
523     @RestrictTo(Scope.LIBRARY_GROUP)
toLayoutElementProto()524     public LayoutElementProto.@NonNull LayoutElement toLayoutElementProto() {
525         return mElement.toLayoutElementProto();
526     }
527 
528     @Override
529     @RestrictTo(Scope.LIBRARY_GROUP)
getFingerprint()530     public @Nullable Fingerprint getFingerprint() {
531         return mElement.getFingerprint();
532     }
533 }
534