| /* |
| * Copyright 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.wear.tiles.material; |
| |
| import static androidx.annotation.Dimension.DP; |
| import static androidx.wear.tiles.DimensionBuilders.dp; |
| import static androidx.wear.tiles.LayoutElementBuilders.CONTENT_SCALE_MODE_FILL_BOUNDS; |
| import static androidx.wear.tiles.material.ButtonDefaults.DEFAULT_BUTTON_SIZE; |
| import static androidx.wear.tiles.material.ButtonDefaults.EXTRA_LARGE_BUTTON_SIZE; |
| import static androidx.wear.tiles.material.ButtonDefaults.LARGE_BUTTON_SIZE; |
| import static androidx.wear.tiles.material.ButtonDefaults.PRIMARY_BUTTON_COLORS; |
| import static androidx.wear.tiles.material.Helper.checkNotNull; |
| import static androidx.wear.tiles.material.Helper.radiusOf; |
| |
| import androidx.annotation.Dimension; |
| import androidx.annotation.IntDef; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.RestrictTo.Scope; |
| import androidx.wear.tiles.ActionBuilders.Action; |
| import androidx.wear.tiles.ColorBuilders.ColorProp; |
| import androidx.wear.tiles.DeviceParametersBuilders.DeviceParameters; |
| import androidx.wear.tiles.DimensionBuilders.ContainerDimension; |
| import androidx.wear.tiles.DimensionBuilders.DpProp; |
| import androidx.wear.tiles.LayoutElementBuilders.Box; |
| import androidx.wear.tiles.LayoutElementBuilders.ColorFilter; |
| import androidx.wear.tiles.LayoutElementBuilders.FontStyle; |
| import androidx.wear.tiles.LayoutElementBuilders.FontStyles; |
| import androidx.wear.tiles.LayoutElementBuilders.Image; |
| import androidx.wear.tiles.LayoutElementBuilders.LayoutElement; |
| import androidx.wear.tiles.LayoutElementBuilders.Text; |
| import androidx.wear.tiles.ModifiersBuilders; |
| import androidx.wear.tiles.ModifiersBuilders.Background; |
| import androidx.wear.tiles.ModifiersBuilders.Clickable; |
| import androidx.wear.tiles.ModifiersBuilders.Corner; |
| import androidx.wear.tiles.ModifiersBuilders.Modifiers; |
| import androidx.wear.tiles.proto.LayoutElementProto; |
| |
| import java.lang.annotation.Retention; |
| import java.lang.annotation.RetentionPolicy; |
| |
| /** |
| * Tiles component {@link Button} that represents clickable button with the given content. |
| * |
| * <p>The Button is circular in shape. The recommended sizes are defined in {@link ButtonDefaults}. |
| * |
| * <p>The recommended set of {@link ButtonColors} styles can be obtained from {@link |
| * ButtonDefaults}., e.g. {@link ButtonDefaults#PRIMARY_BUTTON_COLORS} to get a color scheme for a |
| * primary {@link Button} which by default will have a solid background of {@link Colors#PRIMARY} |
| * and content color of {@link Colors#ON_PRIMARY}. |
| */ |
| public class Button implements LayoutElement { |
| @NonNull private final Box mElement; |
| |
| Button(@NonNull Box element) { |
| mElement = element; |
| } |
| |
| /** Builder class for {@link Button}. */ |
| public static final class Builder implements LayoutElement.Builder { |
| private static final int NOT_SET = -1; |
| private static final int ICON = 0; |
| private static final int TEXT = 1; |
| private static final int IMAGE = 2; |
| private static final int CUSTOM_CONTENT = 3; |
| |
| /** @hide */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY) |
| @Retention(RetentionPolicy.SOURCE) |
| @IntDef({NOT_SET, ICON, TEXT, IMAGE, CUSTOM_CONTENT}) |
| @interface ButtonType {} |
| |
| @Nullable private LayoutElement mCustomContent; |
| @Nullable private LayoutElement.Builder mContent; |
| @NonNull private final Action mAction; |
| @NonNull private final String mClickableId; |
| @NonNull private String mContentDescription = ""; |
| @NonNull private DpProp mSize = DEFAULT_BUTTON_SIZE; |
| @Nullable private String mText = null; |
| @Nullable private FontStyle mFontStyle = null; |
| @Nullable private String mIcon = null; |
| @Nullable private DpProp mIconSize = null; |
| @Nullable private String mImage = null; |
| @NonNull private ButtonColors mButtonColors = PRIMARY_BUTTON_COLORS; |
| private @ButtonType int mType = NOT_SET; |
| private boolean mDefaultSize = false; |
| @Nullable private DeviceParameters mDeviceParameters = null; |
| |
| /** |
| * Creates a builder for the {@link Button} from the given content. Custom content should be |
| * later set with one of the following ({@link #setIconContent}, {@link #setTextContent}, |
| * {@link #setImageContent}. |
| * |
| * @param action Associated Actions for click events. When the Button is clicked it will |
| * fire the associated action. |
| * @param clickableId The ID associated with the given action. |
| */ |
| // Action is not a functional interface (and should not be used as one), suppress the |
| // warning. |
| @SuppressWarnings("LambdaLast") |
| public Builder(@NonNull Action action, @NonNull String clickableId) { |
| mAction = action; |
| mClickableId = clickableId; |
| } |
| |
| /** |
| * Sets the content description for the {@link Button}. It is highly recommended to provide |
| * this for button containing icon or image. |
| */ |
| @NonNull |
| public Builder setContentDescription(@NonNull String contentDescription) { |
| this.mContentDescription = contentDescription; |
| return this; |
| } |
| |
| /** |
| * Sets the size for the {@link Button}. Strongly recommended values are {@link |
| * ButtonDefaults#DEFAULT_BUTTON_SIZE}, {@link ButtonDefaults#LARGE_BUTTON_SIZE} and {@link |
| * ButtonDefaults#EXTRA_LARGE_BUTTON_SIZE}. If not set, {@link |
| * ButtonDefaults#DEFAULT_BUTTON_SIZE} will be used. |
| */ |
| @NonNull |
| public Builder setSize(@NonNull DpProp size) { |
| mSize = size; |
| return this; |
| } |
| |
| /** |
| * Sets the size for the {@link Button}. Strongly recommended values are {@link |
| * ButtonDefaults#DEFAULT_BUTTON_SIZE}, {@link ButtonDefaults#LARGE_BUTTON_SIZE} and {@link |
| * ButtonDefaults#EXTRA_LARGE_BUTTON_SIZE}. If not set, {@link |
| * ButtonDefaults#DEFAULT_BUTTON_SIZE} will be used. |
| */ |
| @NonNull |
| public Builder setSize(@Dimension(unit = DP) float size) { |
| mSize = dp(size); |
| return this; |
| } |
| |
| // TODO(b/203078514): Add getting color from the current Theme (from XML). |
| /** |
| * Sets the colors for the {@link Button}. If set, {@link ButtonColors#getBackgroundColor()} |
| * will be used for the background of the button. If not set, {@link |
| * ButtonDefaults#PRIMARY_BUTTON_COLORS} will be used. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setButtonColors(@NonNull ButtonColors buttonColors) { |
| mButtonColors = buttonColors; |
| return this; |
| } |
| |
| /** |
| * Sets the custom content for this Button. Any previously added content will be overridden. |
| */ |
| @NonNull |
| public Builder setContent(@NonNull LayoutElement content) { |
| resetContent(); |
| this.mCustomContent = content; |
| this.mType = CUSTOM_CONTENT; |
| return this; |
| } |
| |
| /** |
| * Sets the content of this Button to be the given icon. Any previously added content will |
| * be overridden. Provided icon will be tinted to the given content color from {@link |
| * ButtonColors} and with the given size. This icon should be image with chosen alpha |
| * channel and not an actual image. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setIconContent(@NonNull String resourceId, @NonNull DpProp size) { |
| resetContent(); |
| this.mIcon = resourceId; |
| this.mType = ICON; |
| this.mDefaultSize = false; |
| this.mIconSize = size; |
| return this; |
| } |
| |
| /** |
| * Sets the content of this Button to be the given icon with the default size that is half |
| * of the set size of the button. Any previously added content will be overridden. Provided |
| * icon will be tinted to the given content color from {@link ButtonColors}. This icon |
| * should be image with chosen alpha channel and not an actual image. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setIconContent(@NonNull String resourceId) { |
| resetContent(); |
| this.mIcon = resourceId; |
| this.mType = ICON; |
| this.mDefaultSize = true; |
| return this; |
| } |
| |
| /** |
| * Sets the content of this Button to be the given text with the default font for the set |
| * size (for the {@link ButtonDefaults#DEFAULT_BUTTON_SIZE}, {@link |
| * ButtonDefaults#LARGE_BUTTON_SIZE} and {@link ButtonDefaults#EXTRA_LARGE_BUTTON_SIZE} is |
| * {@link FontStyles#title2}, {@link FontStyles#title1} and {@link FontStyles#display3} |
| * respectively). Any previously added content will be overridden. Text should contain no |
| * more than 3 characters, otherwise it will overflow from the edges. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setTextContent( |
| @NonNull String text, @NonNull DeviceParameters deviceParameters) { |
| resetContent(); |
| this.mText = text; |
| this.mDeviceParameters = deviceParameters; |
| this.mType = TEXT; |
| this.mDefaultSize = true; |
| return this; |
| } |
| |
| /** |
| * Sets the content of this Button to be the given text with the given font. You should add |
| * chosen color to your font. Any previously added content will be overridden. Text should |
| * contain no more than 3 characters, otherwise it will overflow from the edges. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setTextContent(@NonNull String text, @NonNull FontStyle font) { |
| resetContent(); |
| this.mText = text; |
| this.mFontStyle = font; |
| this.mType = TEXT; |
| this.mDefaultSize = false; |
| return this; |
| } |
| |
| /** |
| * Sets the content of this Button to be the given icon with the default size that is half |
| * of the size of the button. Any previously added content will be overridden. Provided icon |
| * will be tinted to the given content color from {@link ButtonColors}. This icon should be |
| * image with chosen alpha channel and not an actual image. |
| */ |
| @NonNull |
| @SuppressWarnings("MissingGetterMatchingBuilder") |
| public Builder setImageContent(@NonNull String resourceId) { |
| resetContent(); |
| this.mImage = resourceId; |
| this.mType = IMAGE; |
| this.mDefaultSize = false; |
| return this; |
| } |
| |
| private void resetContent() { |
| this.mText = null; |
| this.mFontStyle = null; |
| this.mIcon = null; |
| this.mImage = null; |
| this.mCustomContent = null; |
| this.mIconSize = null; |
| } |
| |
| /** Constructs and returns {@link Button} with the provided field and look. */ |
| @NonNull |
| @Override |
| public Button build() { |
| Modifiers.Builder modifiers = |
| new Modifiers.Builder() |
| .setClickable( |
| new Clickable.Builder() |
| .setId(mClickableId) |
| .setOnClick(mAction) |
| .build()) |
| .setBackground( |
| new Background.Builder() |
| .setColor(mButtonColors.getBackgroundColor()) |
| .setCorner( |
| new Corner.Builder() |
| .setRadius(radiusOf(mSize)) |
| .build()) |
| .build()); |
| if (!mContentDescription.isEmpty()) { |
| modifiers.setSemantics( |
| new ModifiersBuilders.Semantics.Builder() |
| .setContentDescription(mContentDescription) |
| .build()); |
| } |
| |
| Box.Builder element = |
| new Box.Builder() |
| .setHeight(mSize) |
| .setWidth(mSize) |
| .setModifiers(modifiers.build()); |
| |
| if (mType != NOT_SET) { |
| element.addContent(getCorrectContent()); |
| } |
| return new Button(element.build()); |
| } |
| |
| @NonNull |
| private LayoutElement getCorrectContent() { |
| assertContentFields(); |
| |
| switch (mType) { |
| case ICON: |
| { |
| DpProp iconSize = |
| mDefaultSize ? ButtonDefaults.recommendedIconSize(mSize) |
| : checkNotNull(mIconSize); |
| mContent = |
| new Image.Builder() |
| .setResourceId(checkNotNull(mIcon)) |
| .setHeight(checkNotNull(iconSize)) |
| .setWidth(iconSize) |
| .setContentScaleMode(CONTENT_SCALE_MODE_FILL_BOUNDS) |
| .setColorFilter( |
| new ColorFilter.Builder() |
| .setTint(mButtonColors.getContentColor()) |
| .build()); |
| |
| return mContent.build(); |
| } |
| case TEXT: |
| { |
| FontStyle fontStyle; |
| if (mFontStyle != null) { |
| fontStyle = |
| FontStyle.fromProto( |
| mFontStyle.toProto().toBuilder() |
| .setColor(mButtonColors.getContentColor().toProto()) |
| .build()); |
| } else { |
| fontStyle = |
| getDefaultFontForSize(mSize, checkNotNull(mDeviceParameters)) |
| .setColor(mButtonColors.getContentColor()) |
| .build(); |
| } |
| |
| mContent = new Text.Builder() |
| .setText(checkNotNull(mText)) |
| .setMaxLines(1) |
| .setFontStyle(fontStyle); |
| |
| return mContent.build(); |
| } |
| case IMAGE: |
| { |
| mContent = |
| new Image.Builder() |
| .setResourceId(checkNotNull(mImage)) |
| .setHeight(mSize) |
| .setWidth(mSize) |
| .setContentScaleMode(CONTENT_SCALE_MODE_FILL_BOUNDS); |
| return mContent.build(); |
| } |
| case CUSTOM_CONTENT: |
| return checkNotNull(mCustomContent); |
| case NOT_SET: |
| // Shouldn't happen. |
| default: |
| // Shouldn't happen. |
| throw new IllegalArgumentException("Wrong Button type"); |
| } |
| } |
| |
| private void assertContentFields() { |
| int numOfNonNull = 0; |
| if (mText != null) { |
| numOfNonNull++; |
| } |
| if (mIcon != null) { |
| numOfNonNull++; |
| } |
| if (mImage != null) { |
| numOfNonNull++; |
| } |
| if (mCustomContent != null) { |
| numOfNonNull++; |
| } |
| if (numOfNonNull == 0) { |
| throw new IllegalArgumentException("Content is not set."); |
| } |
| if (numOfNonNull > 1) { |
| throw new IllegalArgumentException( |
| "Too many contents are set. Only one content should be set in the button."); |
| } |
| } |
| |
| @NonNull |
| private FontStyle.Builder getDefaultFontForSize( |
| @NonNull DpProp size, @NonNull DeviceParameters deviceParameters) { |
| if (size.getValue() == LARGE_BUTTON_SIZE.getValue()) { |
| return FontStyles.title1(deviceParameters); |
| } else { |
| if (size.getValue() == EXTRA_LARGE_BUTTON_SIZE.getValue()) { |
| return FontStyles.display3(deviceParameters); |
| } else { |
| return FontStyles.title2(deviceParameters); |
| } |
| } |
| } |
| } |
| |
| /** Returns the content of this Button. Intended for testing purposes only. */ |
| @NonNull |
| public LayoutElement getContent() { |
| return checkNotNull(mElement.getContents().get(0)); |
| } |
| |
| /** |
| * Returns click event action associated with this Button. Intended for testing purposes only. |
| */ |
| @NonNull |
| public Action getAction() { |
| return checkNotNull( |
| checkNotNull(checkNotNull(mElement.getModifiers()).getClickable()).getOnClick()); |
| } |
| |
| /** Returns content description for this Button. Intended for testing purposes only. */ |
| @NonNull |
| public String getContentDescription() { |
| return checkNotNull( |
| checkNotNull(checkNotNull(mElement.getModifiers()).getSemantics()) |
| .getContentDescription()); |
| } |
| |
| /** Returns size for this Button. Intended for testing purposes only. */ |
| @NonNull |
| public ContainerDimension getSize() { |
| return checkNotNull(mElement.getWidth()); |
| } |
| |
| private ColorProp getBackgroundColor() { |
| return checkNotNull( |
| checkNotNull(checkNotNull(mElement.getModifiers()).getBackground()).getColor()); |
| } |
| |
| /** Returns button color of this Button. Intended for testing purposes only. */ |
| @NonNull |
| public ButtonColors getButtonColors() { |
| ColorProp backgroundColor = getBackgroundColor(); |
| LayoutElement mainElement = checkNotNull(mElement.getContents().get(0)); |
| int type = getType(mainElement); |
| ColorProp contentColor; |
| switch (type) { |
| case Builder.ICON: |
| contentColor = |
| checkNotNull( |
| checkNotNull(((Image) mainElement).getColorFilter()).getTint() |
| ); |
| break; |
| case Builder.TEXT: |
| contentColor = |
| checkNotNull(checkNotNull(((Text) mainElement).getFontStyle()).getColor()); |
| break; |
| case Builder.IMAGE: |
| case Builder.CUSTOM_CONTENT: |
| case Builder.NOT_SET: |
| default: |
| // Default color so we can construct ButtonColors object in case where content color |
| // is not set (i.e. button with an actual image doesn't use content color, or |
| // content is set to custom one by the user. |
| contentColor = new ColorProp.Builder().build(); |
| break; |
| } |
| |
| return new ButtonColors(backgroundColor, contentColor); |
| } |
| |
| /** |
| * Returns the type of the Button. Types are defined in {@link Builder}. This is used in {@link |
| * #getButtonColors} to ease if the current Button has content color. |
| * |
| * <p>Type is determined by the content of the outer Box layout and if that content has the |
| * color because if the content is set by some of the provided setters in the Builder it will |
| * have color. |
| */ |
| private @Builder.ButtonType int getType(LayoutElement element) { |
| if (element instanceof Text) { |
| FontStyle fontStyle = ((Text) element).getFontStyle(); |
| if (fontStyle != null && fontStyle.getColor() != null) { |
| return Builder.TEXT; |
| } |
| } |
| if (element instanceof Image) { |
| // This means that the Button is either Button with an image or Button with an icon. |
| // Only Button with an icon will have the tint color set. |
| ColorFilter colorFilter = ((Image) element).getColorFilter(); |
| if (colorFilter != null && colorFilter.getTint() != null) { |
| return Builder.ICON; |
| } else { |
| return Builder.IMAGE; |
| } |
| } |
| return Builder.CUSTOM_CONTENT; |
| } |
| |
| /** @hide */ |
| @NonNull |
| @Override |
| @RestrictTo(Scope.LIBRARY_GROUP) |
| public LayoutElementProto.LayoutElement toLayoutElementProto() { |
| return checkNotNull(mElement.toLayoutElementProto()); |
| } |
| } |