Skip to content

Commit

Permalink
[Badge] Badge cleanup/fixes:
Browse files Browse the repository at this point in the history
- Allow single digit badges to be non-circular if the label text is too big horizontally.
- Change maxCharacterCount to truncate both strings and numbers, and add new attribute maxNumber to truncate only numbers
- Updated maxCharacterCount so that if it doesn't exist, it does not truncate instead of defaulting to a value

Resolves #3321

GIT_ORIGIN_REV_ID=a8f5866eef5ffd4d949b8c6d7f1451b563536a6e
Co-authored-by: imhappi
PiperOrigin-RevId: 523453145
  • Loading branch information
pubiqq authored and drchen committed Apr 13, 2023
1 parent 69b5386 commit 4d50aa4
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 89 deletions.
14 changes: 7 additions & 7 deletions docs/components/BadgeDrawable.md
Expand Up @@ -91,13 +91,13 @@ center, use `setHorizontalOffset(int)` or `setVerticalOffset(int)`
### `BadgeDrawable` Attributes

| Feature | Relevant attributes |
|-----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| Color | `app:backgroundColor` <br> `app:badgeTextColor` |
| Width | `app:badgeWidth` <br> `app:badgeWithTextWidth` |
| Height | `app:badgeHeight` <br> `app:badgeWithTextHeight` |
| Shape | `app:badgeShapeAppearance` <br> `app:badgeShapeAppearanceOverlay` <br> `app:badgeWithTextShapeAppearance` <br> `app:badgeWithTextShapeAppearanceOverlay` |
| Label | `app:badgeText` (for text) <br> `app:number` (for numbers) |
| Label Length | `app:maxCharacterCount` |
|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| Color | `app:backgroundColor` <br> `app:badgeTextColor` |
| Width | `app:badgeWidth` <br> `app:badgeWithTextWidth` |
| Height | `app:badgeHeight` <br> `app:badgeWithTextHeight` |
| Shape | `app:badgeShapeAppearance` <br> `app:badgeShapeAppearanceOverlay` <br> `app:badgeWithTextShapeAppearance` <br> `app:badgeWithTextShapeAppearanceOverlay` |
| Label | `app:badgeText` (for text) <br> `app:number` (for numbers) |
| Label Length | `app:maxCharacterCount` (for all text) <br> `app:maxNumber` (for numbers only) |
| Label Text Color | `app:badgeTextColor` |
| Label Text Appearance | `app:badgeTextAppearance` |
| Badge Gravity | `app:badgeGravity` |
Expand Down
204 changes: 135 additions & 69 deletions lib/java/com/google/android/material/badge/BadgeDrawable.java
Expand Up @@ -125,10 +125,10 @@ public class BadgeDrawable extends Drawable implements TextDrawableDelegate {

/** Position the badge can be set to. */
@IntDef({
TOP_END,
TOP_START,
BOTTOM_END,
BOTTOM_START,
TOP_END,
TOP_START,
BOTTOM_END,
BOTTOM_START,
})
@Retention(RetentionPolicy.SOURCE)
public @interface BadgeGravity {}
Expand All @@ -145,18 +145,21 @@ public class BadgeDrawable extends Drawable implements TextDrawableDelegate {
/** The badge is positioned along the bottom and start edges of its anchor view */
public static final int BOTTOM_START = Gravity.BOTTOM | Gravity.START;

/** Maximum value of number that can be displayed in a circular badge. */
private static final int MAX_CIRCULAR_BADGE_NUMBER_COUNT = 9;

@StyleRes private static final int DEFAULT_STYLE = R.style.Widget_MaterialComponents_Badge;
@AttrRes private static final int DEFAULT_THEME_ATTR = R.attr.badgeStyle;

/**
* If the badge number exceeds the maximum allowed number, append this suffix to the max badge
* number and display is as the badge text instead.
* number and display it as the badge text instead.
*/
static final String DEFAULT_EXCEED_MAX_BADGE_NUMBER_SUFFIX = "+";

/**
* If the badge string exceeds the maximum allowed number of characters, append this suffix to the
* truncated badge text and display it as the badge text instead.
*/
static final String DEFAULT_EXCEED_MAX_BADGE_TEXT_SUFFIX = "…";

/**
* The badge offset begins at the edge of the anchor.
*/
Expand All @@ -181,6 +184,9 @@ public class BadgeDrawable extends Drawable implements TextDrawableDelegate {
/** A value to indicate that a badge radius has not been specified. */
static final int BADGE_RADIUS_NOT_SPECIFIED = -1;

/** A value to indicate that badge content should not be truncated. */
public static final int BADGE_CONTENT_NOT_TRUNCATED = -2;

@NonNull private final WeakReference<Context> contextRef;
@NonNull private final MaterialShapeDrawable shapeDrawable;
@NonNull private final TextDrawableHelper textDrawableHelper;
Expand Down Expand Up @@ -257,7 +263,7 @@ private void restoreState() {
onBadgeShapeAppearanceUpdated();
onBadgeTextAppearanceUpdated();

onMaxCharacterCountUpdated();
onMaxBadgeLengthUpdated();

onBadgeContentUpdated();
onAlphaUpdated();
Expand Down Expand Up @@ -527,14 +533,20 @@ public void setNumber(int number) {
number = Math.max(0, number);
if (this.state.getNumber() != number) {
state.setNumber(number);
onBadgeContentUpdated();
onNumberUpdated();
}
}

/** Resets any badge number so that a numberless badge will be displayed. */
/** Clears the badge's number. */
public void clearNumber() {
if (hasNumber()) {
state.clearNumber();
state.clearNumber();
onNumberUpdated();
}

private void onNumberUpdated() {
// The text has priority over the number so when the number changes, the badge is updated
// only if there is no text.
if (!hasText()) {
onBadgeContentUpdated();
}
}
Expand All @@ -556,17 +568,17 @@ public String getText() {
}

/**
* Sets the badge's text.
* Sets the badge's text. The specified text will be displayed, unless its length exceeds {@code
* maxCharacterCount} in which case a truncated version will be shown.
*
* @see #getText()
* @attr ref com.google.android.material.R.styleable#Badge_badgeText
*/
public void setText(@Nullable String text) {
if (TextUtils.equals(state.getText(), text)) {
return;
if (!TextUtils.equals(state.getText(), text)) {
state.setText(text);
onTextUpdated();
}
state.setText(text);
onBadgeContentUpdated();
}

/**
Expand All @@ -575,15 +587,13 @@ public void setText(@Nullable String text) {
public void clearText() {
if (state.hasText()) {
state.clearText();
onBadgeContentUpdated();
onTextUpdated();
}
}

private void onBadgeContentUpdated() {
textDrawableHelper.setTextSizeDirty(true);
onBadgeShapeAppearanceUpdated();
updateCenterAndBounds();
invalidateSelf();
private void onTextUpdated() {
// The text has priority over the number so any text change updates the badge content.
onBadgeContentUpdated();
}

/**
Expand All @@ -605,11 +615,34 @@ public int getMaxCharacterCount() {
public void setMaxCharacterCount(int maxCharacterCount) {
if (this.state.getMaxCharacterCount() != maxCharacterCount) {
this.state.setMaxCharacterCount(maxCharacterCount);
onMaxCharacterCountUpdated();
onMaxBadgeLengthUpdated();
}
}

private void onMaxCharacterCountUpdated() {
/**
* Returns this badge's max number. If maxCharacterCount is set, it will override this number.
*
* @see #setMaxNumber(int)
* @attr ref com.google.android.material.R.styleable#Badge_maxNumber
*/
public int getMaxNumber() {
return state.getMaxNumber();
}

/**
* Sets this badge's max number. If maxCharacterCount is set, it will override this number.
*
* @param maxNumber This badge's max number.
* @attr ref com.google.android.material.R.styleable#Badge_maxNumber
*/
public void setMaxNumber(int maxNumber) {
if (this.state.getMaxNumber() != maxNumber) {
this.state.setMaxNumber(maxNumber);
onMaxBadgeLengthUpdated();
}
}

private void onMaxBadgeLengthUpdated() {
updateMaxBadgeNumber();
textDrawableHelper.setTextSizeDirty(true);
updateCenterAndBounds();
Expand Down Expand Up @@ -691,7 +724,7 @@ public void draw(@NonNull Canvas canvas) {
}
shapeDrawable.draw(canvas);
if (hasBadgeContent()) {
drawText(canvas);
drawBadgeContent(canvas);
}
}

Expand Down Expand Up @@ -763,7 +796,7 @@ private String getNumberContentDescription() {
if (context == null) {
return null;
}
if (getNumber() <= maxBadgeNumber) {
if (maxBadgeNumber == BADGE_CONTENT_NOT_TRUNCATED || getNumber() <= maxBadgeNumber) {
return context
.getResources()
.getQuantityString(
Expand All @@ -776,6 +809,7 @@ private String getNumberContentDescription() {
return null;
}

@Nullable
private CharSequence getTextContentDescription() {
final CharSequence contentDescription = state.getContentDescriptionForText();
if (contentDescription != null) {
Expand Down Expand Up @@ -1083,10 +1117,10 @@ private void onBadgeShapeAppearanceUpdated() {
shapeDrawable.setShapeAppearanceModel(
ShapeAppearanceModel.builder(
context,
state.hasNumber()
hasBadgeContent()
? state.getBadgeWithTextShapeAppearanceResId()
: state.getBadgeShapeAppearanceResId(),
state.hasNumber()
hasBadgeContent()
? state.getBadgeWithTextShapeAppearanceOverlayResId()
: state.getBadgeShapeAppearanceOverlayResId())
.build());
Expand Down Expand Up @@ -1154,20 +1188,28 @@ private int getTotalHorizontalOffsetForState() {
}

private void calculateCenterAndBounds(@NonNull Rect anchorRect, @NonNull View anchorView) {
cornerRadius = !hasBadgeContent() ? state.badgeRadius : state.badgeWithTextRadius;
cornerRadius = hasBadgeContent() ? state.badgeWithTextRadius : state.badgeRadius;
if (cornerRadius != BADGE_RADIUS_NOT_SPECIFIED) {
halfBadgeHeight = cornerRadius;
halfBadgeWidth = cornerRadius;
halfBadgeHeight = cornerRadius;
} else {
halfBadgeHeight =
Math.round(!hasBadgeContent() ? state.badgeHeight / 2 : state.badgeWithTextHeight / 2);
halfBadgeWidth =
Math.round(!hasBadgeContent() ? state.badgeWidth / 2 : state.badgeWithTextWidth / 2);
Math.round(hasBadgeContent() ? state.badgeWithTextWidth / 2 : state.badgeWidth / 2);
halfBadgeHeight =
Math.round(hasBadgeContent() ? state.badgeWithTextHeight / 2 : state.badgeHeight / 2);
}
String badgeContent = getBadgeContent();

// If the badge has a number, we want to make sure that the badge is at least tall/wide
// enough to encompass the text with padding.
if (hasBadgeContent()) {
String badgeContent = getBadgeContent();

halfBadgeWidth =
Math.max(
halfBadgeWidth,
textDrawableHelper.getTextWidth(badgeContent) / 2f
+ state.getBadgeHorizontalPadding());

halfBadgeHeight =
Math.max(
halfBadgeHeight,
Expand All @@ -1178,17 +1220,6 @@ private void calculateCenterAndBounds(@NonNull Rect anchorRect, @NonNull View an
halfBadgeWidth = Math.max(halfBadgeWidth, halfBadgeHeight);
}

// If the badge has a number and it exceeds the max circular badge count, or the badge
// has text then the width of the badge should encapsulate the whole text.
if ((getNumber() > MAX_CIRCULAR_BADGE_NUMBER_COUNT)
|| (hasText() && !getText().isEmpty())) {
halfBadgeWidth =
Math.max(
halfBadgeWidth,
textDrawableHelper.getTextWidth(badgeContent) / 2f
+ state.getBadgeHorizontalPadding());
}

int totalVerticalOffset = getTotalVerticalOffsetForState();

switch (state.getBadgeGravity()) {
Expand Down Expand Up @@ -1328,26 +1359,28 @@ private float getRightCutoff(View anchorParent, float anchorViewOffset) {
return rightCutOff;
}

private void drawText(Canvas canvas) {
Rect textBounds = new Rect();
private void drawBadgeContent(Canvas canvas) {
String badgeContent = getBadgeContent();
textDrawableHelper
.getTextPaint()
.getTextBounds(badgeContent, 0, badgeContent.length(), textBounds);

// The text is centered horizontally using Paint.Align.Center. We calculate the correct
// y-coordinate ourselves using textbounds.exactCenterY, but this can look askew at low
// screen densities due to canvas.drawText rounding the coordinates to the nearest integer.
// To mitigate this, we round the y-coordinate following these rules:
// If the badge.bottom is <= 0, the text is drawn above its original origin (0,0) so
// we round down the y-coordinate since we want to keep it above its new origin.
// If the badge.bottom is positive, we round up for the opposite reason.
float exactCenterY = badgeCenterY - textBounds.exactCenterY();
canvas.drawText(
badgeContent,
badgeCenterX,
textBounds.bottom <= 0 ? (int) exactCenterY : Math.round(exactCenterY),
textDrawableHelper.getTextPaint());
if (badgeContent != null) {
Rect textBounds = new Rect();
textDrawableHelper
.getTextPaint()
.getTextBounds(badgeContent, 0, badgeContent.length(), textBounds);

// The text is centered horizontally using Paint.Align.Center. We calculate the correct
// y-coordinate ourselves using textbounds.exactCenterY, but this can look askew at low
// screen densities due to canvas.drawText rounding the coordinates to the nearest integer.
// To mitigate this, we round the y-coordinate following these rules:
// If the badge.bottom is <= 0, the text is drawn above its original origin (0,0) so
// we round down the y-coordinate since we want to keep it above its new origin.
// If the badge.bottom is positive, we round up for the opposite reason.
float exactCenterY = badgeCenterY - textBounds.exactCenterY();
canvas.drawText(
badgeContent,
badgeCenterX,
textBounds.bottom <= 0 ? (int) exactCenterY : Math.round(exactCenterY),
textDrawableHelper.getTextPaint());
}
}

private boolean hasBadgeContent() {
Expand All @@ -1367,13 +1400,32 @@ private String getBadgeContent() {

@Nullable
private String getTextBadgeText() {
return getText();
String text = getText();
final int maxCharacterCount = getMaxCharacterCount();
if (maxCharacterCount == BADGE_CONTENT_NOT_TRUNCATED) {
return text;
}

if (text != null && text.length() > maxCharacterCount) {
Context context = contextRef.get();
if (context == null) {
return "";
}

text = text.substring(0, maxCharacterCount - 1);
return String.format(
context.getString(R.string.m3_exceed_max_badge_text_suffix),
text,
DEFAULT_EXCEED_MAX_BADGE_TEXT_SUFFIX);
} else {
return text;
}
}

@NonNull
private String getNumberBadgeText() {
// If number exceeds max count, show badgeMaxCount+ instead of the number.
if (getNumber() <= maxBadgeNumber) {
if (maxBadgeNumber == BADGE_CONTENT_NOT_TRUNCATED || getNumber() <= maxBadgeNumber) {
return NumberFormat.getInstance(state.getNumberLocale()).format(getNumber());
} else {
Context context = contextRef.get();
Expand All @@ -1389,7 +1441,21 @@ private String getNumberBadgeText() {
}
}

private void onBadgeContentUpdated() {
textDrawableHelper.setTextSizeDirty(true);
onBadgeShapeAppearanceUpdated();
updateCenterAndBounds();
invalidateSelf();
}

private void updateMaxBadgeNumber() {
maxBadgeNumber = (int) Math.pow(10.0d, (double) getMaxCharacterCount() - 1) - 1;
if (getMaxCharacterCount() != BADGE_CONTENT_NOT_TRUNCATED) {
// If there exists a max character count, we set the maximum number a badge can have as the
// largest number that has maxCharCount - 1 digits, which accounts for the `+` as a character.
maxBadgeNumber = (int) Math.pow(10.0d, (double) getMaxCharacterCount() - 1) - 1;
} else {
maxBadgeNumber = getMaxNumber();
}
}
}

0 comments on commit 4d50aa4

Please sign in to comment.