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..d556c57 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,45 @@ * 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 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 +80,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 +89,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 +127,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 +136,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 +148,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 +161,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 +187,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 +198,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); + } + + @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) { + final float centerX = drawBounds.exactCenterX(); + final float centerY = drawBounds.exactCenterY(); + 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 +397,117 @@ 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 (e.g. LayerDrawable) + // and support library (e.g. NavigationView). + @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,