Last active
August 16, 2020 11:22
-
-
Save Skyyo/bfca7e7b6e8725b61df283bd0f78620d to your computer and use it in GitHub Desktop.
Custom badge drawable that can be attached to views #view #badge
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<dimen name="default_badge_text_size">10sp</dimen> | |
<dimen name="default_badge_radius">8dp</dimen> | |
<dimen name="default_badge_long_text_horizontal_padding">10dp</dimen> | |
<dimen name="default_badge_size">16dp</dimen> | |
<attr name="badgeDrawableStyle"/> | |
<declare-styleable name="CustomBadgeDrawable"> | |
<attr format="color" name="badge_backgroundColor"/> | |
<attr format="color" name="badge_textColor"/> | |
<attr format="color" name="badge_textSize"/> | |
<attr format="integer" name="badge_maxNumber"/> | |
<attr format="integer" name="badge_number"/> | |
<attr format="dimension" name="badge_radii"/> | |
<attr format="boolean" name="sizeAdjustRadii"/> | |
<attr format="dimension" name="badge_size"/> | |
<attr format="dimension" name="badge_width"/> | |
<attr format="dimension" name="badge_height"/> | |
<attr format="dimension" name="badge_horizontalPadding"/> | |
<attr format="dimension" name="badge_horizontalOffset"/> | |
<attr format="dimension" name="badge_verticalOffset"/> | |
<attr format="integer" name="badge_alpha"/> | |
<attr name="badge_shape"> | |
<enum name="rectangle" value="0"/> | |
<enum name="dot" value="1"/> | |
</attr> | |
<attr name="badge_gravity"> | |
<!-- Gravity.TOP | Gravity.END --> | |
<enum name="TOP_END" value="8388661"/> | |
<!-- Gravity.TOP | Gravity.START --> | |
<enum name="TOP_START" value="8388659"/> | |
<!-- Gravity.BOTTOM | Gravity.END --> | |
<enum name="BOTTOM_END" value="8388693"/> | |
<!-- Gravity.BOTTOM | Gravity.START --> | |
<enum name="BOTTOM_START" value="8388691"/> | |
</attr> | |
</declare-styleable> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.annotation.SuppressLint | |
import android.content.Context | |
import android.content.res.Resources | |
import android.graphics.* | |
import android.graphics.drawable.Drawable | |
import android.graphics.drawable.GradientDrawable | |
import android.text.TextPaint | |
import android.text.TextUtils | |
import android.util.AttributeSet | |
import android.util.Xml | |
import android.view.Gravity | |
import android.view.View | |
import androidx.annotation.* | |
import androidx.annotation.IntRange | |
import org.xmlpull.v1.XmlPullParser | |
import org.xmlpull.v1.XmlPullParserException | |
import java.io.IOException | |
import java.lang.ref.WeakReference | |
class CustomBadgeDrawable private constructor(context: Context) : Drawable() { | |
@IntDef( | |
TOP_END, | |
TOP_START, | |
BOTTOM_END, | |
BOTTOM_START | |
) | |
@kotlin.annotation.Retention(AnnotationRetention.SOURCE) | |
annotation class BadgeGravity | |
@kotlin.annotation.Retention(AnnotationRetention.SOURCE) | |
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER) | |
@IntDef(STYLE_DOT, STYLE_RECTANGLE) | |
annotation class ShapeStyle | |
private val badgeBounds: RectF | |
@ColorInt | |
private var backgroundColor = DEFAULT_BADGE_BACKGROUND_COLOR | |
private val shapeDrawable: GradientDrawable | |
private val badgeTextPaint: TextPaint | |
private var anchorViewRef: WeakReference<View>? = null | |
private val anchorBounds = Rect() | |
private var alpha1 = 255 | |
private var badgeRadii1: Float | |
private var badgeSize: Int | |
private var badgeWidth1 = 0 | |
private var badgeHeight1 = 0 | |
private var sizeAdjustRadius1 = false | |
private var number1 = 0 | |
private var maxNumber1 = DEFAULT_MAX_BADGE_NUMBER | |
@ShapeStyle | |
private var shapeStyle1 = STYLE_DOT | |
private var horizontalPadding1: Int | |
private var horizontalOffset1 = 0 | |
private var verticalOffset1 = 0 | |
private var gravity1 = TOP_END | |
private val numberText: String | |
get() { | |
var numberText = number1.toString() | |
if (number1 > maxNumber1) { | |
numberText = | |
maxNumber1.toString() + DEFAULT_EXCEED_MAX_BADGE_NUMBER_SUFFIX | |
} | |
return numberText | |
} | |
init { | |
val resources = context.resources | |
badgeRadii1 = resources.getDimensionPixelOffset(R.dimen.default_badge_radius).toFloat() | |
horizontalPadding1 = | |
resources.getDimensionPixelOffset(R.dimen.default_badge_long_text_horizontal_padding) | |
badgeSize = resources.getDimensionPixelSize(R.dimen.default_badge_size) | |
badgeBounds = RectF() | |
shapeDrawable = GradientDrawable() | |
badgeTextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG) | |
badgeTextPaint.textAlign = Paint.Align.CENTER | |
badgeTextPaint.color = DEFAULT_BADGE_TEXT_COLOR | |
badgeTextPaint.typeface = Typeface.DEFAULT_BOLD | |
setTextSize(resources.getDimensionPixelSize(R.dimen.default_badge_text_size).toFloat()) | |
} | |
override fun draw(canvas: Canvas) { | |
if (!isVisible) { | |
return | |
} | |
shapeDrawable.alpha = alpha1 | |
shapeDrawable.setColor(backgroundColor) | |
shapeDrawable.setBounds( | |
badgeBounds.left.toInt(), | |
badgeBounds.top.toInt(), | |
badgeBounds.right.toInt(), | |
badgeBounds.bottom.toInt() | |
) | |
when (shapeStyle1) { | |
STYLE_DOT -> shapeDrawable.shape = GradientDrawable.OVAL | |
STYLE_RECTANGLE -> shapeDrawable.shape = GradientDrawable.RECTANGLE | |
else -> { | |
} | |
} | |
var radii = badgeRadii1 | |
if (sizeAdjustRadius1) { | |
radii = Math.min(badgeBounds.width(), badgeBounds.height()) / 2f | |
} | |
shapeDrawable.cornerRadius = radii | |
shapeDrawable.draw(canvas) | |
if (hasNumber()) { | |
drawText(canvas) | |
} | |
} | |
/** | |
* Specify an alpha value for the drawable. 0 means fully transparent, and | |
* 255 means fully opaque. | |
* | |
* @param alpha set the alpha component [0..255] of the paint's color. | |
*/ | |
override fun setAlpha( | |
@IntRange( | |
from = 0, | |
to = 255 | |
) alpha: Int | |
) { | |
if (alpha1 != alpha) { | |
alpha1 = alpha | |
invalidateSelf() | |
} | |
} | |
override fun setColorFilter(colorFilter: ColorFilter?) { | |
// Intentionally empty. | |
} | |
override fun getOpacity(): Int { | |
return PixelFormat.TRANSLUCENT | |
} | |
public override fun onStateChange(state: IntArray): Boolean { | |
return super.onStateChange(state) | |
} | |
private fun calculateTextWidth(charSequence: CharSequence?): Float { | |
return if (charSequence == null) { | |
0f | |
} else badgeTextPaint.measureText(charSequence, 0, charSequence.length) | |
} | |
private fun loadDefaultStateFromAttributes( | |
context: Context, | |
attrs: AttributeSet?, | |
@AttrRes defStyleAttr: Int, | |
@StyleRes defStyleRes: Int | |
) { | |
val ta = context.obtainStyledAttributes( | |
attrs, R.styleable.CustomBadgeDrawable, | |
defStyleAttr, defStyleRes | |
) | |
setBackgroundColor( | |
ta.getColor( | |
R.styleable.CustomBadgeDrawable_badge_backgroundColor, | |
DEFAULT_BADGE_BACKGROUND_COLOR | |
) | |
) | |
setBadgeRadii(ta.getDimension(R.styleable.CustomBadgeDrawable_badge_radii, badgeRadii1)) | |
setBadgeSize( | |
ta.getDimensionPixelSize( | |
R.styleable.CustomBadgeDrawable_badge_size, | |
badgeSize | |
) | |
) | |
setBadgeWidth(ta.getDimensionPixelOffset(R.styleable.CustomBadgeDrawable_badge_width, -1)) | |
setBadgeHeight(ta.getDimensionPixelOffset(R.styleable.CustomBadgeDrawable_badge_height, -1)) | |
setSizeAdjustRadius(ta.getBoolean(R.styleable.CustomBadgeDrawable_sizeAdjustRadii, false)) | |
setBadgeGravity( | |
ta.getInt( | |
R.styleable.CustomBadgeDrawable_badge_gravity, | |
TOP_END | |
) | |
) | |
setTextSize( | |
ta.getDimension( | |
R.styleable.CustomBadgeDrawable_badge_textSize, | |
badgeTextPaint.textSize | |
) | |
) | |
setTextColor( | |
ta.getColor( | |
R.styleable.CustomBadgeDrawable_badge_textColor, | |
DEFAULT_BADGE_TEXT_COLOR | |
) | |
) | |
setHorizontalOffset( | |
ta.getDimensionPixelOffset( | |
R.styleable.CustomBadgeDrawable_badge_horizontalOffset, | |
0 | |
) | |
) | |
setVerticalOffset( | |
ta.getDimensionPixelOffset( | |
R.styleable.CustomBadgeDrawable_badge_verticalOffset, | |
0 | |
) | |
) | |
setHorizontalPadding( | |
ta.getDimensionPixelOffset( | |
R.styleable.CustomBadgeDrawable_badge_horizontalPadding, | |
horizontalPadding1 | |
) | |
) | |
setNumber(ta.getInt(R.styleable.CustomBadgeDrawable_badge_number, 0)) | |
setMaxNumber( | |
ta.getInt( | |
R.styleable.CustomBadgeDrawable_badge_maxNumber, | |
DEFAULT_MAX_BADGE_NUMBER | |
) | |
) | |
setShapeStyle(ta.getInt(R.styleable.CustomBadgeDrawable_badge_shape, shapeStyle1)) | |
alpha = ta.getInt(R.styleable.CustomBadgeDrawable_badge_alpha, alpha1) | |
ta.recycle() | |
} | |
private fun updateBadgeBounds() { | |
val anchorView = | |
(if (anchorViewRef == null) null else anchorViewRef!!.get()) | |
?: return | |
anchorView.getDrawingRect(anchorBounds) | |
var width = if (badgeWidth1 > 0) badgeWidth1 else badgeSize | |
val height = if (badgeHeight1 > 0) badgeHeight1 else badgeSize | |
if (badgeWidth1 <= 0 && hasNumber() && number1 > MAX_CIRCULAR_BADGE_NUMBER_COUNT) { | |
width = (calculateTextWidth(numberText) + horizontalPadding1).toInt() | |
} | |
val left: Int | |
val top: Int | |
left = when (gravity1) { | |
TOP_START, BOTTOM_START -> anchorBounds.left + horizontalOffset1 | |
TOP_END, BOTTOM_END -> anchorBounds.right - width - 48 + horizontalOffset1 | |
else -> anchorBounds.right - width - 48 + horizontalOffset1 | |
} | |
top = when (gravity1) { | |
BOTTOM_START, BOTTOM_END -> anchorBounds.bottom - height + verticalOffset1 | |
TOP_START, TOP_END -> anchorBounds.top + 16 + verticalOffset1 | |
else -> anchorBounds.top + 16 + verticalOffset1 | |
} | |
badgeBounds[left.toFloat(), top.toFloat(), left + width.toFloat()] = top + height.toFloat() | |
} | |
private fun hasNumber(): Boolean { | |
return number1 > 0 | |
} | |
private fun drawText(canvas: Canvas) { | |
val bounds = badgeBounds | |
val textPaint: Paint = badgeTextPaint | |
textPaint.alpha = alpha1 | |
val numberText = numberText | |
textPaint.measureText(numberText, 0, numberText.length) | |
val fontMetrics = textPaint.fontMetrics | |
val centerY = | |
bounds.centerY() - fontMetrics.descent + (fontMetrics.descent - fontMetrics.ascent) / 2f | |
canvas.drawText(numberText, bounds.centerX(), centerY, textPaint) | |
} | |
fun setBadgeWidth(badgeWidth: Int) { | |
if (badgeWidth1 != badgeWidth) { | |
badgeWidth1 = badgeWidth | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setBadgeHeight(badgeHeight: Int) { | |
if (badgeHeight1 != badgeHeight) { | |
badgeHeight1 = badgeHeight | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setBadgeGravity(@BadgeGravity gravity: Int) { | |
if (gravity1 != gravity) { | |
gravity1 = gravity | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setHorizontalPadding(horizontalPadding: Int) { | |
if (horizontalPadding1 != horizontalPadding) { | |
horizontalPadding1 = horizontalPadding | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setHorizontalOffset(horizontalOffset: Int) { | |
if (horizontalOffset1 != horizontalOffset) { | |
horizontalOffset1 = horizontalOffset | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setVerticalOffset(verticalOffset: Int) { | |
if (verticalOffset1 != verticalOffset) { | |
verticalOffset1 = verticalOffset | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setSizeAdjustRadius(sizeAdjustRadius: Boolean) { | |
if (sizeAdjustRadius1 != sizeAdjustRadius) { | |
sizeAdjustRadius1 = sizeAdjustRadius | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge background shape style | |
* | |
* @param shapeStyle background shape style | |
*/ | |
fun setShapeStyle(@ShapeStyle shapeStyle: Int) { | |
if (shapeStyle1 != shapeStyle) { | |
shapeStyle1 = shapeStyle | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set or clear the typeface object. | |
* | |
* @param typeface May be null. The typeface to be installed in the paint | |
*/ | |
fun setTypeface(typeface: Typeface) { | |
val paint: Paint = badgeTextPaint | |
if (paint.typeface !== typeface) { | |
paint.typeface = typeface | |
invalidateSelf() | |
} | |
} | |
fun setBadgeSize(newBadgeSize: Int) { | |
if (badgeSize != newBadgeSize) { | |
badgeSize = newBadgeSize | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge radius, badge size radius | |
* | |
* @param badgeRadii badge radius | |
*/ | |
fun setBadgeRadii(badgeRadii: Float) { | |
if (badgeRadii1 != badgeRadii) { | |
badgeRadii1 = badgeRadii | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
fun setMaxNumber(maxNumber: Int) { | |
var res = maxNumber | |
res = Math.max(0, res) | |
if (maxNumber1 != res) { | |
maxNumber1 = res | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge number | |
* | |
* @param number number | |
*/ | |
fun setNumber(newNumber: Int) { | |
var number = newNumber | |
number = Math.max(0, number) | |
if (number != number1) { | |
number1 = number | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge text size | |
* | |
* @param textSize text size | |
*/ | |
fun setTextSize(textSize: Float) { | |
if (badgeTextPaint.textSize != textSize) { | |
badgeTextPaint.textSize = textSize | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge text color, regardless of the values of r,g,b | |
* | |
* @param color text color | |
*/ | |
fun setTextColor(@ColorInt color: Int) { | |
val textPaint: Paint = badgeTextPaint | |
if (textPaint.color != color) { | |
textPaint.color = color | |
invalidateSelf() | |
} | |
} | |
/** | |
* Set badge background color | |
* | |
* @param color background color | |
*/ | |
fun setBackgroundColor(@ColorInt color: Int) { | |
if (backgroundColor != color) { | |
backgroundColor = color | |
invalidateSelf() | |
} | |
} | |
fun setVisible(visible: Boolean) { | |
setVisible(visible, /* restart= */false) | |
} | |
fun updateBadgeCoordinates(anchorView: View) { | |
if (anchorViewRef == null || anchorViewRef!!.get() !== anchorView) { | |
anchorViewRef = WeakReference(anchorView) | |
} | |
updateBadgeBounds() | |
invalidateSelf() | |
} | |
companion object { | |
private const val DEFAULT_STYLE = R.style.DefaultBadge | |
private const val DEFAULT_THEME_ATTR = R.attr.badgeDrawableStyle | |
private const val MAX_CIRCULAR_BADGE_NUMBER_COUNT = 9 | |
private const val DEFAULT_MAX_BADGE_NUMBER = 99 | |
private const val DEFAULT_EXCEED_MAX_BADGE_NUMBER_SUFFIX = "+" | |
private const val DEFAULT_BADGE_BACKGROUND_COLOR = -0xab8f | |
private const val DEFAULT_BADGE_TEXT_COLOR = Color.WHITE | |
const val TOP_END = Gravity.TOP or Gravity.END | |
const val TOP_START = Gravity.TOP or Gravity.START | |
const val BOTTOM_END = Gravity.BOTTOM or Gravity.END | |
const val BOTTOM_START = Gravity.BOTTOM or Gravity.START | |
const val STYLE_RECTANGLE = 0 | |
const val STYLE_DOT = 1 | |
fun create(context: Context): CustomBadgeDrawable { | |
return createFromAttributes( | |
context, /* attrs= */ | |
null, | |
DEFAULT_THEME_ATTR, | |
DEFAULT_STYLE | |
) | |
} | |
fun create(context: Context, @StyleRes id: Int): CustomBadgeDrawable { | |
return createFromAttributes( | |
context, /* attrs= */ | |
null, | |
DEFAULT_THEME_ATTR, | |
id | |
) | |
} | |
@SuppressLint("RestrictedApi") | |
fun createFromResource( | |
context: Context, | |
@XmlRes id: Int | |
): CustomBadgeDrawable { | |
val attrs = | |
parseDrawableXml(context, id, "badge") | |
@StyleRes var style = attrs.styleAttribute | |
if (style == 0) { | |
style = DEFAULT_STYLE | |
} | |
return createFromAttributes( | |
context, | |
attrs, | |
DEFAULT_THEME_ATTR, | |
style | |
) | |
} | |
private fun createFromAttributes( | |
context: Context, | |
attrs: AttributeSet?, | |
@AttrRes defStyleAttr: Int, | |
@StyleRes defStyleRes: Int | |
): CustomBadgeDrawable { | |
val badge = CustomBadgeDrawable(context) | |
badge.loadDefaultStateFromAttributes(context, attrs, defStyleAttr, defStyleRes) | |
return badge | |
} | |
fun attachBadgeDrawable( | |
customBadgeDrawable: CustomBadgeDrawable, | |
anchor: View | |
) { | |
if (customBadgeDrawable.callback == null) { | |
anchor.overlay.add(customBadgeDrawable) | |
anchor.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> | |
customBadgeDrawable.updateBadgeCoordinates(anchor) | |
} | |
} | |
} | |
fun detachBadgeDrawable( | |
customBadgeDrawable: CustomBadgeDrawable, | |
anchor: View | |
) { | |
val overlay = anchor.overlay | |
overlay.remove(customBadgeDrawable) | |
} | |
fun parseDrawableXml( | |
context: Context, | |
@XmlRes id: Int, | |
startTag: CharSequence | |
): AttributeSet { | |
return try { | |
val parser: XmlPullParser = context.resources.getXml(id) | |
var type: Int | |
do { | |
type = parser.next() | |
} while (type != XmlPullParser.START_TAG && type != XmlPullParser.END_DOCUMENT) | |
if (type != XmlPullParser.START_TAG) { | |
throw XmlPullParserException("No start tag found") | |
} | |
if (!TextUtils.equals(parser.name, startTag)) { | |
throw XmlPullParserException("Must have a <$startTag> start tag") | |
} | |
Xml.asAttributeSet(parser) | |
} catch (e: XmlPullParserException) { | |
val exception = Resources.NotFoundException( | |
"Can't load badge resource ID #0x" + Integer.toHexString(id) | |
) | |
exception.initCause(e) | |
throw exception | |
} catch (e: IOException) { | |
val exception = Resources.NotFoundException( | |
"Can't load badge resource ID #0x" + Integer.toHexString(id) | |
) | |
exception.initCause(e) | |
throw exception | |
} | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<style name="DefaultBadge"> | |
<item name="badge_gravity">TOP_END</item> | |
</style> | |
<style name="BadgeTextLong" parent="DefaultBadge"> | |
<item name="badge_number">999</item> | |
<item name="badge_maxNumber">1000</item> | |
<item name="badge_size">14dp</item> | |
<item name="badge_height">16dp</item> | |
<item name="badge_radii">4dp</item> | |
<item name="badge_shape">rectangle</item> | |
<item name="badge_horizontalOffset">8dp</item> | |
<item name="badge_verticalOffset">-2dp</item> | |
</style> | |
<style name="BadgeTextDot" parent="DefaultBadge"> | |
<item name="badge_number">9</item> | |
<item name="badge_size">16dp</item> | |
<item name="badge_shape">dot</item> | |
<item name="badge_horizontalOffset">2dp</item> | |
<item name="badge_verticalOffset">-2dp</item> | |
</style> | |
<style name="BadgeDot" parent="DefaultBadge"> | |
<item name="badge_size">6dp</item> | |
<item name="badge_shape">dot</item> | |
<item name="badge_horizontalOffset">2dp</item> | |
<item name="badge_verticalOffset">-2dp</item> | |
</style> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
val notificationBadge = CustomBadgeDrawable.create(this, R.style.BadgeTextDot).apply { setNumber(count)} | |
attachBadgeDrawable(notificationBadge,yourView) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment