package com.joanzapata.iconify; import android.annotation.TargetApi; import android.content.Context; 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.support.annotation.CheckResult; import android.support.annotation.ColorInt; import android.support.annotation.ColorRes; import android.support.annotation.DimenRes; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextPaint; import android.util.LayoutDirection; import android.util.StateSet; import android.util.TypedValue; import android.view.View; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.JELLY_BEAN_MR1; import static android.os.Build.VERSION_CODES.LOLLIPOP; import static android.os.Build.VERSION_CODES.M; import static android.util.TypedValue.COMPLEX_UNIT_DIP; import static android.view.View.LAYOUT_DIRECTION_RTL; /** * Embed an icon into a Drawable that can be used as TextView icons, or ActionBar icons. * <pre> * new IconDrawable(context, IconValue.icon_star) * .colorRes(R.color.white) * .actionBarSize(); * </pre> * If you don't set the size of the drawable, it will use the size * 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 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 = 600; // Font Awesome uses 8-step rotation for pulse, and // it seems to have the only pulsing spinner. If // spinners with different pulses are introduced at // some point, then a pulse property can be // implemented for the icons. private static final int ROTATION_PULSES = 8; private static final int ROTATION_PULSE_DURATION = ROTATION_DURATION / ROTATION_PULSES; private static final int ANDROID_ACTIONBAR_ICON_SIZE_DP = 24; private static final Rect TEMP_DRAW_BOUNDS = new Rect(); @NonNull private IconState iconState; private final TextPaint paint; @ColorInt private int color; @Nullable private ColorFilter tintFilter; @ColorInt private int tintColor; @IntRange(from = -1) private long spinStartTime = -1; private boolean mMutated; @NonNull private final String text; @NonNull private final Rect drawBounds = new Rect(); private float centerX, centerY; @Nullable private Runnable invalidateRunnable; @CheckResult @NonNull private static Icon findValidIconForKey(@NonNull String iconKey) { Icon icon = Iconify.findIconForKey(iconKey); if (icon == null) { throw new IllegalArgumentException("No icon found with key \"" + iconKey + "\"."); } return icon; } @NonNull private static Icon validateIcon(@NonNull Icon icon) { if (Iconify.findTypefaceOf(icon) == null) { throw new IllegalStateException("Unable to find the module associated " + "with icon " + icon.key() + ", have you registered the module " + "you are trying to use with Iconify.with(...) in your Application?"); } return icon; } /** * Create an IconDrawable. * @param context Your activity or application context. * @param iconKey The icon key you want this drawable to display. * @throws IllegalArgumentException if the key doesn't match any icon. */ public IconDrawable(@NonNull Context context, @NonNull String iconKey) { this(context, new IconState(findValidIconForKey(iconKey))); } /** * Create an IconDrawable. * @param context Your activity or application context. * @param icon The icon you want this drawable to display. */ public IconDrawable(@NonNull Context context, @NonNull Icon icon) { this(context, new IconState(validateIcon(icon))); } private IconDrawable(@NonNull IconState state) { this(null, state); } private IconDrawable(Context context, @NonNull 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); color = state.colorStateList.getColorForState(StateSet.WILD_CARD, DEFAULT_COLOR); paint.setColor(color); updateTintFilter(); setModulatedAlpha(); paint.setDither(iconState.dither); text = String.valueOf(iconState.icon.character()); if (SDK_INT < LOLLIPOP && iconState.bounds != null) { setBounds(iconState.bounds); } } /** * Set the size of this icon to the standard Android ActionBar. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable actionBarSize(@NonNull Context context) { return sizeDp(context, ANDROID_ACTIONBAR_ICON_SIZE_DP); } /** * Set the size of the drawable. * @param dimenRes The dimension resource. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable sizeRes(@NonNull Context context, @DimenRes int dimenRes) { return sizePx(context.getResources().getDimensionPixelSize(dimenRes)); } /** * Set the size of the drawable. * @param size The size in density-independent pixels (dp). * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable sizeDp(@NonNull Context context, @IntRange(from = 0) int size) { return sizePx((int) TypedValue.applyDimension( COMPLEX_UNIT_DIP, size, context.getResources().getDisplayMetrics())); } /** * Set the size of the drawable. * @param size The size in pixels (px). * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable sizePx(@IntRange(from = -1) int size) { iconState.height = size; if (size == -1) { iconState.width = -1; } else { paint.setTextSize(size); paint.getTextBounds(text, 0, 1, TEMP_DRAW_BOUNDS); iconState.width = TEMP_DRAW_BOUNDS.width(); } return this; } /** * Set the color of the drawable. * @param color The color, usually from android.graphics.Color or 0xFF012345. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable color(@ColorInt int color) { return color(ColorStateList.valueOf(color)); } /** * Set the color of the drawable. * @param colorStateList The color state list. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable color(@NonNull ColorStateList colorStateList) { if (colorStateList != iconState.colorStateList) { iconState.colorStateList = colorStateList; color = iconState.colorStateList.getColorForState(StateSet.WILD_CARD, DEFAULT_COLOR); paint.setColor(color); invalidateSelf(); } return this; } /** * Set the color of the drawable. * @param colorRes The color resource, from your R file. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable colorRes(@NonNull Context context, @ColorRes int colorRes) { // Since we have an @ColorRes annotation on the colorRes parameter, // we can be sure that we will get a non-null ColorStateList. //noinspection ConstantConditions return color(context.getResources().getColorStateList(colorRes)); } /** * Set the alpha of this drawable. * @param alpha The alpha, between 0 (transparent) and 255 (opaque). * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable alpha(@ColorInt int alpha) { setAlpha(alpha); return this; } /** * Start a spinning animation on this drawable. Call {@link #stop()} * to stop it. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable spin() { start(); return this; } /** * Start a pulse animation on this drawable. Call {@link #stop()} * to stop it. * @return The current IconDrawable for chaining. */ @NonNull public IconDrawable pulse() { iconState.pulse = true; start(); return this; } /** * Returns the icon to be displayed * @return The icon */ @CheckResult @NonNull public final Icon getIcon() { return iconState.icon; } @Override @CheckResult @IntRange(from = -1) public int getIntrinsicHeight() { return iconState.height; } @Override @CheckResult @IntRange(from = -1) public int getIntrinsicWidth() { return iconState.width; } @Override protected void onBoundsChange(@NonNull Rect bounds) { final int width = bounds.width(); final int height = bounds.height(); paint.setTextSize(height); 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 @CheckResult @NonNull public Rect getDirtyBounds() { return drawBounds; } @Override @TargetApi(Build.VERSION_CODES.LOLLIPOP) public void getOutline(@NonNull Outline outline) { outline.setRect(drawBounds); } @Override public void draw(@NonNull Canvas canvas) { canvas.save(); final boolean needMirroring = needMirroring(); if (needMirroring) { canvas.translate(getBounds().width(), 0); canvas.scale(-1.0f, 1.0f); } if (iconState.spinning) { long currentTime = SystemClock.uptimeMillis(); if (spinStartTime < 0) { spinStartTime = currentTime; if (isVisible()) { if (iconState.pulse) { scheduleSelf(invalidateRunnable, currentTime + ROTATION_PULSE_DURATION); } else { invalidateSelf(); } } } else { boolean isVisible = isVisible(); long timeElapsed = currentTime - spinStartTime; float rotation; if (iconState.pulse) { rotation = timeElapsed / (float) ROTATION_PULSE_DURATION; if (isVisible) { scheduleSelf(invalidateRunnable, currentTime + (timeElapsed * (int) (rotation + 1))); } rotation = ((int) Math.floor(rotation)) * 360f / ROTATION_PULSES; } else { rotation = timeElapsed / (float) ROTATION_DURATION * 360f; if (isVisible) { invalidateSelf(); } } canvas.rotate(rotation, centerX, centerY); } if (isVisible()) { invalidateSelf(); } } canvas.drawText(text, centerX, drawBounds.bottom, paint); canvas.restore(); } @Override @CheckResult public boolean isStateful() { return iconState.colorStateList.isStateful() || (iconState.tint != null && iconState.tint.isStateful()); } @Override protected boolean onStateChange(@NonNull 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) { // If tintFilter is not null, then it's guaranteed that tint and tintMode // are not null as well, so suppress any warnings otherwise. //noinspection ConstantConditions int newTintColor = iconState.tint.getColorForState(state, Color.TRANSPARENT); if (newTintColor != tintColor) { tintColor = newTintColor; //noinspection ConstantConditions tintFilter = new PorterDuffColorFilter(tintColor, iconState.tintMode); if (iconState.colorFilter == null) { paint.setColorFilter(tintFilter); changed = true; } } } return changed; } @Override public void setAlpha(@ColorInt int alpha) { if (alpha != iconState.alpha) { iconState.alpha = alpha; setModulatedAlpha(); invalidateSelf(); } } private void setModulatedAlpha() { paint.setAlpha(((color >> 24) * iconState.alpha) / 255); } @Override @CheckResult @ColorInt public int getAlpha() { return iconState.alpha; } @Override @CheckResult 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(@Nullable ColorFilter cf) { if (cf != iconState.colorFilter) { iconState.colorFilter = cf; paint.setColorFilter(cf); invalidateSelf(); } } @Override @CheckResult @Nullable public ColorFilter getColorFilter() { return iconState.colorFilter; } @NonNull public IconDrawable tint(@Nullable ColorStateList tint) { if (tint != iconState.tint) { iconState.tint = tint; updateTintFilter(); invalidateSelf(); } return this; } @Override public void setTintList(@Nullable ColorStateList tint) { tint(tint); } @Override public void setTintMode(@NonNull 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(); } } /** * Sets paint style. * @param style to be applied */ public void setStyle(@NonNull Paint.Style style) { if (style != iconState.style) { iconState.style = style; paint.setStyle(style); invalidateSelf(); } } @Override public void setAutoMirrored(boolean mirrored) { if (SDK_INT >= JELLY_BEAN_MR1 && iconState.icon.supportsRtl() && iconState.autoMirrored != mirrored) { iconState.autoMirrored = mirrored; invalidateSelf(); } } @Override @CheckResult public final boolean isAutoMirrored() { return iconState.autoMirrored; } // Since the auto-mirrored state is only set to true if the SDK // version supports it, we don't need an explicit check for that // before calling getLayoutDirection(). @TargetApi(JELLY_BEAN_MR1) @CheckResult private boolean needMirroring() { if (isAutoMirrored()) { if (SDK_INT >= M) { return getLayoutDirection() == LayoutDirection.RTL; } // Since getLayoutDirection() is hidden prior to Marshmallow, we // will try to get the layout direction from the View, which we will // assume is set as the callback. As the setLayoutDirection() method // is also hidden, we can safely rely on the behaviour of the // platform Views to provide a correct replacement for the hidden // method. Callback callback = getCallback(); if (callback instanceof View) { return ((View) callback).getLayoutDirection() == LAYOUT_DIRECTION_RTL; } } return false; } @Override public void start() { if (!iconState.spinning) { iconState.spinning = true; if (iconState.pulse && invalidateRunnable == null) { invalidateRunnable = new InvalidateRunnable(); } invalidateSelf(); } } @Override public void stop() { if (iconState.spinning) { iconState.spinning = false; if (invalidateRunnable != null) { unscheduleSelf(invalidateRunnable); invalidateRunnable = null; } } } @Override @CheckResult public boolean isRunning() { return iconState.spinning; } @Override public boolean setVisible(boolean visible, boolean restart) { final boolean changed = super.setVisible(visible, restart); if (iconState.spinning) { if (changed) { if (visible) { invalidateSelf(); } else if (invalidateRunnable != null) { unscheduleSelf(invalidateRunnable); } } else { if (restart && visible) { spinStartTime = -1; } } } return changed; } private class InvalidateRunnable implements Runnable { @Override public void run() { invalidateSelf(); } } @Override @CheckResult 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 @CheckResult @Nullable public ConstantState getConstantState() { // The bounds level need to be copied here to work around a bug in // LayerDrawable where it doesn't copy the bounds and level in it's // children when mutated or cloned. This bug has been fixed in // Lollipop. The layout direction was not copied as well in Jelly // Bean MR 1, but we're ignoring it for the moment as it's not // exposed in the SDK prior to Marshmallow. If it ever becomes an // issue though, then we'll need to handle that as well. iconState.bounds = getBounds(); return iconState; } @Override @NonNull public Drawable mutate() { if (!mMutated && super.mutate() == this) { iconState = new IconState(iconState); mMutated = true; } return this; } private static class IconState extends ConstantState { @NonNull final Icon icon; @IntRange(from = -1) int height = -1; @IntRange(from = -1) int width = -1; @NonNull ColorStateList colorStateList = ColorStateList.valueOf(DEFAULT_COLOR); @ColorInt int alpha = 255; boolean dither; @Nullable ColorFilter colorFilter; @Nullable ColorStateList tint = DEFAULT_TINT; @Nullable PorterDuff.Mode tintMode = DEFAULT_TINT_MODE; @NonNull Paint.Style style = Paint.Style.FILL; boolean spinning; boolean pulse; boolean autoMirrored; int changingConfigurations; @Nullable Rect bounds; IconState(@NonNull Icon icon) { this.icon = icon; autoMirrored = SDK_INT >= JELLY_BEAN_MR1 && icon.supportsRtl(); } IconState(@NonNull 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; spinning = state.spinning; pulse = state.pulse; autoMirrored = state.autoMirrored; changingConfigurations = state.changingConfigurations; } @Override @CheckResult @NonNull public Drawable newDrawable() { return new IconDrawable(this); } @Override @CheckResult public int getChangingConfigurations() { return changingConfigurations; } } }