From 87eb19430c72c52bcb65914c4090ced64738becc Mon Sep 17 00:00:00 2001 From: Ibraheem Zaman Date: Tue, 27 Oct 2015 11:41:41 +0500 Subject: [PATCH] Fix and enhance IconDrawable This commit adds lots of enhancements and fixes to IconDrawable: - Adds spin animation functionality. - Adds constant state in order to work around bugs in the framework and support library where this is expected. - Handle the case where the width of the icon is not the same as it's height by returning the correct intrinsic width, and changing the drawing logic to always fit the icon within it's bounds. - Adds support for color state lists. - Adds support for tint, and sets the default to implement the half translucency on disabled state, and removed the custom logic to do it by changing the alpha. - Modulates the existing alpha from the color instead of replacing it. - Removes the changing of the bounds upon size change, as that should only be done by the view. - Adds a public method to get the icon. - Adds missing implementations of base methods. Note that because we need to be able to generate the drawable without a Context from the constant state, the methods that resolved resources (i.e. colors, dimensions) now have to take a Context as a parameter. This unfortunately makes the change backwards-incompatible. The spin animation logic is also slightly changed in CustomTypefaceSpan to initialize the start time upon first draw instead of upon instantiation, since that is the actual start time of the animation. This matches it with the implementation in IconDrawable. --- .../com/joanzapata/iconify/IconDrawable.java | 446 +++++++++++++++--- .../iconify/internal/CustomTypefaceSpan.java | 18 +- 2 files changed, 382 insertions(+), 82 deletions(-) diff --git a/android-iconify/src/main/java/com/joanzapata/iconify/IconDrawable.java b/android-iconify/src/main/java/com/joanzapata/iconify/IconDrawable.java index aafd3ab..d0d4abe 100644 --- a/android-iconify/src/main/java/com/joanzapata/iconify/IconDrawable.java +++ b/android-iconify/src/main/java/com/joanzapata/iconify/IconDrawable.java @@ -1,9 +1,23 @@ package com.joanzapata.iconify; +import android.annotation.TargetApi; import android.content.Context; -import android.graphics.*; +import android.content.res.ColorStateList; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Outline; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; import android.text.TextPaint; +import android.util.StateSet; import android.util.TypedValue; import static android.util.TypedValue.COMPLEX_UNIT_DIP; @@ -19,19 +33,46 @@ * that is given to him. Note that in an ActionBar, if you don't * set the size explicitly it uses 0, so please use actionBarSize(). */ -public class IconDrawable extends Drawable { +public final class IconDrawable extends Drawable implements Animatable { + private static final int DEFAULT_COLOR = Color.BLACK; + // Set the default tint to make it half translucent on disabled state. + private static final PorterDuff.Mode DEFAULT_TINT_MODE = PorterDuff.Mode.MULTIPLY; + private static final ColorStateList DEFAULT_TINT = new ColorStateList( + new int[][] { { -android.R.attr.state_enabled }, StateSet.WILD_CARD }, + new int[] { 0x80FFFFFF, 0xFFFFFFFF } + ); + private static final int ROTATION_DURATION = 2000; + private static final int ANDROID_ACTIONBAR_ICON_SIZE_DP = 24; + private static final Rect TEMP_DRAW_BOUNDS = new Rect(); - public static final int ANDROID_ACTIONBAR_ICON_SIZE_DP = 24; + private IconState iconState; + private final TextPaint paint; + private int color; + private ColorFilter tintFilter; + private int tintColor; + private long rotationStartTime = -1; + private boolean mMutated; + private final String text; + private final Rect drawBounds = new Rect(); + private float centerX, centerY; - private Context context; - - private Icon icon; - - private TextPaint paint; - - private int size = -1; + private static Icon findValidIconForKey(String iconKey) { + Icon icon = Iconify.findIconForKey(iconKey); + if (icon == null) { + throw new IllegalArgumentException("No icon found with key \"" + iconKey + "\"."); + } + return icon; + } - private int alpha = 255; + private static Icon validateIcon(Icon icon) { + if (icon == null) { + throw new NullPointerException("Icon can't be null."); + } + if (Iconify.findTypefaceOf(icon) == null) { + throw new IllegalArgumentException("No typeface registered for icon."); + } + return icon; + } /** * Create an IconDrawable. @@ -40,11 +81,7 @@ public class IconDrawable extends Drawable { * @throws IllegalArgumentException if the key doesn't match any icon. */ public IconDrawable(Context context, String iconKey) { - Icon icon = Iconify.findIconForKey(iconKey); - if (icon == null) { - throw new IllegalArgumentException("No icon with that key \"" + iconKey + "\"."); - } - init(context, icon); + this(context, new IconState(findValidIconForKey(iconKey))); } /** @@ -53,27 +90,37 @@ public IconDrawable(Context context, String iconKey) { * @param icon The icon you want this drawable to display. */ public IconDrawable(Context context, Icon icon) { - init(context, icon); + this(context, new IconState(validateIcon(icon))); } - private void init(Context context, Icon icon) { - this.context = context; - this.icon = icon; - paint = new TextPaint(); - paint.setTypeface(Iconify.findTypefaceOf(icon).getTypeface(context)); - paint.setStyle(Paint.Style.FILL); + private IconDrawable(IconState state) { + this(null, state); + } + + private IconDrawable(Context context, IconState state) { + iconState = state; + paint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + // We have already confirmed that a typeface exists for this icon during + // validation, so we can ignore the null pointer warning. + //noinspection ConstantConditions + paint.setTypeface(Iconify.findTypefaceOf(state.icon).getTypeface(context)); + paint.setStyle(state.style); paint.setTextAlign(Paint.Align.CENTER); paint.setUnderlineText(false); - paint.setColor(Color.BLACK); - paint.setAntiAlias(true); + color = state.colorStateList.getColorForState(StateSet.WILD_CARD, DEFAULT_COLOR); + paint.setColor(color); + updateTintFilter(); + setModulatedAlpha(); + paint.setDither(iconState.dither); + text = String.valueOf(iconState.icon.character()); } /** * Set the size of this icon to the standard Android ActionBar. * @return The current IconDrawable for chaining. */ - public IconDrawable actionBarSize() { - return sizeDp(ANDROID_ACTIONBAR_ICON_SIZE_DP); + public IconDrawable actionBarSize(Context context) { + return sizeDp(context, ANDROID_ACTIONBAR_ICON_SIZE_DP); } /** @@ -81,7 +128,7 @@ public IconDrawable actionBarSize() { * @param dimenRes The dimension resource. * @return The current IconDrawable for chaining. */ - public IconDrawable sizeRes(int dimenRes) { + public IconDrawable sizeRes(Context context, int dimenRes) { return sizePx(context.getResources().getDimensionPixelSize(dimenRes)); } @@ -90,8 +137,10 @@ public IconDrawable sizeRes(int dimenRes) { * @param size The size in density-independent pixels (dp). * @return The current IconDrawable for chaining. */ - public IconDrawable sizeDp(int size) { - return sizePx(convertDpToPx(context, size)); + public IconDrawable sizeDp(Context context, int size) { + return sizePx((int) TypedValue.applyDimension( + COMPLEX_UNIT_DIP, size, + context.getResources().getDisplayMetrics())); } /** @@ -100,9 +149,10 @@ public IconDrawable sizeDp(int size) { * @return The current IconDrawable for chaining. */ public IconDrawable sizePx(int size) { - this.size = size; - setBounds(0, 0, size, size); - invalidateSelf(); + iconState.height = size; + paint.setTextSize(size); + paint.getTextBounds(text, 0, 1, TEMP_DRAW_BOUNDS); + iconState.width = TEMP_DRAW_BOUNDS.width(); return this; } @@ -112,8 +162,24 @@ public IconDrawable sizePx(int size) { * @return The current IconDrawable for chaining. */ public IconDrawable color(int color) { - paint.setColor(color); - invalidateSelf(); + return color(ColorStateList.valueOf(color)); + } + + /** + * Set the color of the drawable. + * @param colorStateList The color state list. + * @return The current IconDrawable for chaining. + */ + public IconDrawable color(ColorStateList colorStateList) { + if (colorStateList == null) { + colorStateList = ColorStateList.valueOf(DEFAULT_COLOR); + } + if (colorStateList != iconState.colorStateList) { + iconState.colorStateList = colorStateList; + color = colorStateList.getColorForState(StateSet.WILD_CARD, DEFAULT_COLOR); + paint.setColor(color); + invalidateSelf(); + } return this; } @@ -122,10 +188,8 @@ public IconDrawable color(int color) { * @param colorRes The color resource, from your R file. * @return The current IconDrawable for chaining. */ - public IconDrawable colorRes(int colorRes) { - paint.setColor(context.getResources().getColor(colorRes)); - invalidateSelf(); - return this; + public IconDrawable colorRes(Context context, int colorRes) { + return color(context.getResources().getColorStateList(colorRes)); } /** @@ -135,65 +199,198 @@ public IconDrawable colorRes(int colorRes) { */ public IconDrawable alpha(int alpha) { setAlpha(alpha); - invalidateSelf(); return this; } + /** + * Start a spinning animation on this drawable. Call {@link #stop()} + * to stop it. + * @return The current IconDrawable for chaining. + */ + public IconDrawable rotate() { + start(); + return this; + } + + /** + * Returns the icon to be displayed + * @return The icon + */ + public final Icon getIcon() { + return iconState.icon; + } + @Override public int getIntrinsicHeight() { - return size; + return iconState.height; } @Override public int getIntrinsicWidth() { - return size; + return iconState.width; } @Override - public void draw(Canvas canvas) { - Rect bounds = getBounds(); - int height = bounds.height(); + protected void onBoundsChange(Rect bounds) { + final int width = bounds.width(); + final int height = bounds.height(); paint.setTextSize(height); - Rect textBounds = new Rect(); - String textValue = String.valueOf(icon.character()); - paint.getTextBounds(textValue, 0, 1, textBounds); - int textHeight = textBounds.height(); - float textBottom = bounds.top + (height - textHeight) / 2f + textHeight - textBounds.bottom; - canvas.drawText(textValue, bounds.exactCenterX(), textBottom, paint); + paint.getTextBounds(text, 0, 1, drawBounds); + paint.setTextSize(Math.min(height, (int) Math.ceil( + width * (height / (float) drawBounds.width())))); + paint.getTextBounds(text, 0, 1, drawBounds); + drawBounds.offsetTo(bounds.left + (width - drawBounds.width()) / 2, + bounds.top + (height - drawBounds.height()) / 2 - drawBounds.bottom); + centerX = bounds.exactCenterX(); + centerY = bounds.exactCenterY(); + } + + @Override + public Rect getDirtyBounds() { + return drawBounds; + } + + @Override + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public void getOutline(Outline outline) { + outline.setRect(drawBounds); + } + + @Override + public void draw(Canvas canvas) { + canvas.save(); + if (iconState.rotating) { + long currentTime = SystemClock.uptimeMillis(); + if (rotationStartTime < 0) { + rotationStartTime = currentTime; + } else { + float rotation = (currentTime - rotationStartTime) / + (float) ROTATION_DURATION * 360f; + canvas.rotate(rotation, centerX, centerY); + } + if (isVisible()) { + invalidateSelf(); + } + } + canvas.drawText(text, centerX, drawBounds.bottom, paint); + canvas.restore(); } @Override public boolean isStateful() { - return true; + return iconState.colorStateList.isStateful() || + (iconState.tint != null && iconState.tint.isStateful()); } @Override - public boolean setState(int[] stateSet) { - int oldValue = paint.getAlpha(); - int newValue = isEnabled(stateSet) ? alpha : alpha / 2; - paint.setAlpha(newValue); - return oldValue != newValue; + protected boolean onStateChange(int[] state) { + boolean changed = false; + + int newColor = iconState.colorStateList.getColorForState(state, DEFAULT_COLOR); + if (newColor != color) { + color = newColor; + paint.setColor(color); + setModulatedAlpha(); + changed = true; + } + + if (tintFilter != null) { + int newTintColor = iconState.tint.getColorForState(state, Color.TRANSPARENT); + if (newTintColor != tintColor) { + tintColor = newTintColor; + tintFilter = new PorterDuffColorFilter(tintColor, iconState.tintMode); + if (iconState.colorFilter == null) { + paint.setColorFilter(tintFilter); + changed = true; + } + } + } + + return changed; } @Override public void setAlpha(int alpha) { - this.alpha = alpha; - paint.setAlpha(alpha); + if (alpha != iconState.alpha) { + iconState.alpha = alpha; + setModulatedAlpha(); + invalidateSelf(); + } + } + + private void setModulatedAlpha() { + paint.setAlpha(((color >> 24) * iconState.alpha) / 255); + } + + @Override + public int getAlpha() { + return iconState.alpha; + } + + @Override + public int getOpacity() { + int baseAlpha = color >> 24; + if (baseAlpha == 255 && iconState.alpha == 255) return PixelFormat.OPAQUE; + if (baseAlpha == 0 || iconState.alpha == 0) return PixelFormat.TRANSPARENT; + return PixelFormat.OPAQUE; + } + + @Override + public void setDither(boolean dither) { + if (dither != iconState.dither) { + iconState.dither = dither; + paint.setDither(dither); + invalidateSelf(); + } } @Override public void setColorFilter(ColorFilter cf) { - paint.setColorFilter(cf); + if (cf != iconState.colorFilter) { + iconState.colorFilter = cf; + paint.setColorFilter(cf); + invalidateSelf(); + } } @Override - public void clearColorFilter() { - paint.setColorFilter(null); + public ColorFilter getColorFilter() { + return iconState.colorFilter; } @Override - public int getOpacity() { - return this.alpha; + public void setTintList(ColorStateList tint) { + if (tint != iconState.tint) { + iconState.tint = tint; + updateTintFilter(); + invalidateSelf(); + } + } + + @Override + public void setTintMode(PorterDuff.Mode tintMode) { + if (tintMode != iconState.tintMode) { + iconState.tintMode = tintMode; + updateTintFilter(); + invalidateSelf(); + } + } + + private void updateTintFilter() { + if (iconState.tint == null || iconState.tintMode == null) { + if (tintFilter == null) { + return; + } + tintColor = 0; + tintFilter = null; + } else { + tintColor = iconState.tint.getColorForState(getState(), Color.TRANSPARENT); + tintFilter = new PorterDuffColorFilter(tintColor, iconState.tintMode); + } + if (iconState.colorFilter == null) { + paint.setColorFilter(tintFilter); + invalidateSelf(); + } } /** @@ -201,21 +398,118 @@ public int getOpacity() { * @param style to be applied */ public void setStyle(Paint.Style style) { - paint.setStyle(style); + if (style != iconState.style) { + iconState.style = style; + paint.setStyle(style); + invalidateSelf(); + } } - // Util - private boolean isEnabled(int[] stateSet) { - for (int state : stateSet) - if (state == android.R.attr.state_enabled) - return true; - return false; + @Override + public void start() { + if (!iconState.rotating) { + iconState.rotating = true; + invalidateSelf(); + } } - // Util - private int convertDpToPx(Context context, float dp) { - return (int) TypedValue.applyDimension( - COMPLEX_UNIT_DIP, dp, - context.getResources().getDisplayMetrics()); + @Override + public void stop() { + if (iconState.rotating) { + iconState.rotating = false; + } + } + + @Override + public boolean isRunning() { + return iconState.rotating; + } + + @Override + public boolean setVisible(boolean visible, boolean restart) { + final boolean changed = super.setVisible(visible, restart); + if (iconState.rotating) { + if (changed) { + if (visible) { + invalidateSelf(); + } + } else { + if (restart && visible) { + rotationStartTime = -1; + } + } + } + return changed; + } + + @Override + public int getChangingConfigurations() { + return iconState.changingConfigurations; + } + + @Override + public void setChangingConfigurations(int configs) { + iconState.changingConfigurations = configs; + } + + // Implementing shared state despite being a third-party implementation + // in order to work around bugs in the framework and support library: + // http://b.android.com/191754 + // https://github.com/JoanZapata/android-iconify/issues/93 + @Override + public ConstantState getConstantState() { + return iconState; + } + + @Override + public Drawable mutate() { + if (!mMutated && super.mutate() == this) { + iconState = new IconState(iconState); + mMutated = true; + } + return this; + } + + private static class IconState extends ConstantState { + final Icon icon; + int height = -1, width = -1; + ColorStateList colorStateList = ColorStateList.valueOf(DEFAULT_COLOR); + int alpha = 255; + boolean dither; + ColorFilter colorFilter; + ColorStateList tint = DEFAULT_TINT; + PorterDuff.Mode tintMode = DEFAULT_TINT_MODE; + Paint.Style style = Paint.Style.FILL; + boolean rotating; + int changingConfigurations; + + IconState(Icon icon) { + this.icon = icon; + } + + IconState(IconState state) { + icon = state.icon; + height = state.height; + width = state.width; + colorStateList = state.colorStateList; + alpha = state.alpha; + dither = state.dither; + colorFilter = state.colorFilter; + tint = state.tint; + tintMode = state.tintMode; + style = state.style; + rotating = state.rotating; + changingConfigurations = state.changingConfigurations; + } + + @Override + public Drawable newDrawable() { + return new IconDrawable(this); + } + + @Override + public int getChangingConfigurations() { + return changingConfigurations; + } } } \ No newline at end of file diff --git a/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java b/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java index 60209ca..7d04f8e 100644 --- a/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java +++ b/android-iconify/src/main/java/com/joanzapata/iconify/internal/CustomTypefaceSpan.java @@ -4,6 +4,7 @@ import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; +import android.os.SystemClock; import android.text.style.ReplacementSpan; import com.joanzapata.iconify.Icon; @@ -19,7 +20,7 @@ public class CustomTypefaceSpan extends ReplacementSpan { private final float iconSizeRatio; private final int iconColor; private final boolean rotate; - private final long rotationStartTime; + private long rotationStartTime = -1; public CustomTypefaceSpan(Icon icon, Typeface type, float iconSizePx, float iconSizeRatio, int iconColor, boolean rotate) { this.rotate = rotate; @@ -28,7 +29,6 @@ public CustomTypefaceSpan(Icon icon, Typeface type, float iconSizePx, float icon this.iconSizePx = iconSizePx; this.iconSizeRatio = iconSizeRatio; this.iconColor = iconColor; - this.rotationStartTime = System.currentTimeMillis(); } @Override @@ -51,10 +51,16 @@ public void draw(Canvas canvas, CharSequence text, int start, int end, float x, paint.getTextBounds(icon, 0, 1, TEXT_BOUNDS); canvas.save(); if (rotate) { - float rotation = (System.currentTimeMillis() - rotationStartTime) / (float) ROTATION_DURATION * 360f; - float centerX = x + TEXT_BOUNDS.width() / 2f; - float centerY = y - TEXT_BOUNDS.height() / 2f + TEXT_BOUNDS.height() * BASELINE_RATIO; - canvas.rotate(rotation, centerX, centerY); + long currentTime = SystemClock.uptimeMillis(); + if (rotationStartTime < 0) { + rotationStartTime = currentTime; + } else { + float rotation = (currentTime - rotationStartTime) / + (float) ROTATION_DURATION * 360f; + float centerX = x + TEXT_BOUNDS.width() / 2f; + float centerY = y - TEXT_BOUNDS.height() / 2f + TEXT_BOUNDS.height() * BASELINE_RATIO; + canvas.rotate(rotation, centerX, centerY); + } } canvas.drawText(icon,