com.scvngr.levelup.core.ui.view.LevelUpCodeView.java Source code

Java tutorial

Introduction

Here is the source code for com.scvngr.levelup.core.ui.view.LevelUpCodeView.java

Source

/*
 * Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.scvngr.levelup.core.ui.view;

import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PorterDuff.Mode;
import android.graphics.PorterDuffXfermode;
import android.graphics.Xfermode;
import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.view.GestureDetectorCompat;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Transformation;

import com.scvngr.levelup.core.R;
import com.scvngr.levelup.core.annotation.VisibleForTesting;
import com.scvngr.levelup.core.annotation.VisibleForTesting.Visibility;
import com.scvngr.levelup.core.ui.view.LevelUpQrCodeGenerator.LevelUpQrCodeImage;
import com.scvngr.levelup.core.ui.view.PendingImage.OnImageLoaded;
import com.scvngr.levelup.core.util.NullUtils;

/**
 * <p>
 * A view that displays a LevelUp QR code. LevelUp codes are loaded asynchronously using a
 * {@link LevelUpCodeLoader}.
 * </p>
 * <p>
 * To use, simply call {@link #setLevelUpCode(String, LevelUpCodeLoader)}. Code load status can be
 * monitored by registering an {@link OnCodeLoadListener} using
 * {@link #setOnCodeLoadListener(OnCodeLoadListener)} before calling
 * {@link #setLevelUpCode(String, LevelUpCodeLoader)}.
 * </p>
 * <p>
 * LevelUp codes are rotated 180 from standard QR codes and are optionally colorized using the
 * LevelUp logo colors. The colors used are
 * {@link com.scvngr.levelup.core.R.color#levelup_logo_blue},
 * {@link com.scvngr.levelup.core.R.color#levelup_logo_green}, and
 * {@link com.scvngr.levelup.core.R.color#levelup_logo_orange}. To aid scanning, the colors can be
 * set to automatically fade from bright to dim after a short duration.
 * </p>
 * <p>
 * Colorizing can be disabled by setting the XML property
 * {@link com.scvngr.levelup.core.R.attr#colorize} to false. The automatic fading of the colors can
 * be disabled by setting {@link com.scvngr.levelup.core.R.attr#fade_colors}. The colors can still
 * be manually faded by calling {@link #animateFadeColorsDelayed()} or
 * {@link #animateFadeColorsImmediately()}.
 * </p>
 */
public final class LevelUpCodeView extends View {

    // Target coordinates are given as an arrary [left, top, right, bottom].

    /**
     * Target coordinate array index for the left position for a {@link LevelUpQrCodeImage}.
     */
    private static final int TARGET_LEFT_INDEX = 0;

    /**
     * Target coordinate array index for the top position for a {@link LevelUpQrCodeImage}.
     */
    private static final int TARGET_TOP_INDEX = 1;

    /**
     * Target coordinate array index for the right position for a {@link LevelUpQrCodeImage}.
     */
    private static final int TARGET_RIGHT_INDEX = 2;

    /**
     * Target coordinate array index for the bottom position for a {@link LevelUpQrCodeImage}.
     */
    private static final int TARGET_BOTTOM_INDEX = 3;

    /**
     * The duration of the color fade animation, in milliseconds.
     */
    public static final int ANIM_FADE_DURATION_MILLIS = 2000;

    /**
     * The delay before starting the color fade animation, as used by
     * {@link #animateFadeColorsDelayed()}.
     */
    public static final int ANIM_FADE_START_DELAY_MILLIS = 0;

    /**
     * The end value of the alpha channel for the color fade animation.
     */
    @VisibleForTesting(visibility = Visibility.PRIVATE)
    /* package */static final int ANIM_FADE_COLOR_ALPHA_END = 150;

    /**
     * The start value of the alpha channel for the color fade animation.
     */
    @VisibleForTesting(visibility = Visibility.PRIVATE)
    /* package */static final int ANIM_FADE_COLOR_ALPHA_START = 255;

    /**
     * The default value for {@link #mIsColorizeSet}.
     */
    private static final boolean COLORIZE_DEFAULT = true;

    /**
     * The default of whether or not the colors should fade.
     */
    private static final boolean FADE_COLORS_DEFAULT = true;

    /**
     * The currently-displayed QR code.
     */
    @Nullable
    @VisibleForTesting(visibility = Visibility.PRIVATE)
    /* package */LevelUpQrCodeImage mCurrentCode;

    /**
     * Scaling matrix used for biggering the code to fill the view.
     */
    private final Matrix mCodeScalingMatrix = new Matrix();

    /**
     * The current alpha value of the colored target areas.
     */
    @VisibleForTesting(visibility = Visibility.PRIVATE)
    /* package */int mColorAlpha = ANIM_FADE_COLOR_ALPHA_START;

    /**
     * The currently-displayed data.
     */
    @Nullable
    private String mCurrentData;

    private GestureDetectorCompat mGestureDetector;

    /**
     * A little easter-egg that lets you restart the fade animation by double-tapping on the code.
     * Only enabled when the code is colorized and automatic fading is turned on. This shouldn't
     * interfere with touch events, as it doesn't claim to handle any events.
     */
    private final GestureDetector.SimpleOnGestureListener mGestureDetectorCallbacks = new GestureDetector.SimpleOnGestureListener() {

        @Override
        public boolean onDoubleTap(final MotionEvent e) {
            animateFadeColorsImmediately();
            return false;
        }
    };

    /**
     * If true, the QR code will be colorized.
     */
    private boolean mIsColorizeSet = COLORIZE_DEFAULT;

    /**
     * Whether or not to fade the colors.
     */
    private boolean mIsFadeColorsSet = FADE_COLORS_DEFAULT;

    /**
     * The listener that this view will call when a code is loaded. See
     * {@link #callOnCodeLoadListener(boolean)}.
     */
    @Nullable
    private OnCodeLoadListener mOnCodeLoadListener;

    /**
     * Listener which updates the QR code image once it's loaded.
     */
    private final OnImageLoaded<LevelUpQrCodeImage> mOnImageLoaded = new OnImageLoaded<LevelUpQrCodeGenerator.LevelUpQrCodeImage>() {

        @Override
        public void onImageLoaded(@NonNull final String loadKey, @NonNull final LevelUpQrCodeImage image) {
            mCurrentCode = image;
            callOnCodeLoadListener(false);
            invalidate();
        }
    };

    /**
     * The QR code image, possibly not loaded yet.
     */
    @Nullable
    private PendingImage<LevelUpQrCodeImage> mPendingImage;

    /**
     * The previous value for isCodeLoading, used to keep track of changes for
     * {@link #callOnCodeLoadListener(boolean)}.
     */
    private boolean mPreviousIsCodeLoading = false;

    /**
     * The paint for drawing the QR code bitmap. It's important that it's initialized with 0,
     * otherwise it becomes blurry.
     */
    private final Paint mQrCodePaint = new Paint(0);

    /**
     * Bottom left colored target paint.
     */
    private final Paint mTargetBottomLeftPaint = new Paint();

    /**
     * Bottom right colored target paint.
     */
    private final Paint mTargetBottomRightPaint = new Paint();

    /**
     * Top right colored target paint.
     */
    private final Paint mTargetTopRightPaint = new Paint();

    /**
     * @param context activity context.
     */
    public LevelUpCodeView(@NonNull final Context context) {
        super(context);
        init(context);
    }

    /**
     * @param context activity context.
     * @param attrs attributes.
     */
    public LevelUpCodeView(@NonNull final Context context, @Nullable final AttributeSet attrs) {
        super(context, attrs);
        init(context);
        loadAttributes(context, attrs, R.attr.levelup_code_view_style);
    }

    /**
     * @param context activity context.
     * @param attrs attributes.
     * @param defStyle default style.
     */
    public LevelUpCodeView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) {
        super(context, attrs, defStyle);
        init(context);
        loadAttributes(context, attrs, defStyle);
    }

    /**
     * Animate the fading of the colors after a delay. The delay is
     * {@link #ANIM_FADE_START_DELAY_MILLIS}.
     *
     * @see #animateFadeColorsImmediately()
     */
    public void animateFadeColorsDelayed() {
        if (mIsColorizeSet) {
            final FadeColorsAnimation animation = new FadeColorsAnimation(ANIM_FADE_DURATION_MILLIS);
            animation.setStartOffset(ANIM_FADE_START_DELAY_MILLIS);
            animation.setFillBefore(true);
            startAnimation(animation);
        }
    }

    /**
     * Starts the color fade animation. The duration of the animation is
     * {@link #ANIM_FADE_DURATION_MILLIS}.
     */
    public void animateFadeColorsImmediately() {
        if (mIsColorizeSet) {
            final FadeColorsAnimation animation = new FadeColorsAnimation(ANIM_FADE_DURATION_MILLIS);
            startAnimation(animation);
        }
    }

    /**
     * @return if the LevelUp code has colorized target markers.
     */
    public boolean isColorizeSet() {
        return mIsColorizeSet;
    }

    /**
     * @return true if the color fading flag is set.
     */
    public boolean isFadeColorsSet() {
        return mIsFadeColorsSet;
    }

    @Override
    public boolean onTouchEvent(final MotionEvent event) {
        final boolean handled = super.onTouchEvent(event);

        if (mIsColorizeSet && mIsFadeColorsSet) {
            mGestureDetector.onTouchEvent(event);
        }

        return handled;
    }

    /**
     * <p>
     * Enables/disables the colorization of the LevelUp code.
     * </p>
     * <p>
     * This must be called from the UI thread.
     * </p>
     *
     * @param isColorized if true, the LevelUp code's target markers will be colorized.
     */
    public void setColorize(final boolean isColorized) {
        if (mIsColorizeSet != isColorized) {
            mIsColorizeSet = isColorized;
            invalidate();
        }
    }

    /**
     * @param fadeColors if true, the colors will automatically fade after a period of time the
     *        first time the code is set with {@link #setLevelUpCode(String, LevelUpCodeLoader)}.
     */
    public void setFadeColors(final boolean fadeColors) {
        mIsFadeColorsSet = fadeColors;
    }

    /**
     * <p>
     * Sets the code that will be displayed.
     * </p>
     * <p>
     * This must be called on the UI thread.
     * </p>
     *
     * @param codeData the raw content to be displayed in the QR code.
     * @param codeLoader the loader by which to load the QR code.
     */
    public void setLevelUpCode(@NonNull final String codeData, @NonNull final LevelUpCodeLoader codeLoader) {

        if (Looper.getMainLooper() != Looper.myLooper()) {
            throw new AssertionError("Must be called from the main thread.");
        }

        if (null == mCurrentData && mIsFadeColorsSet) {
            animateFadeColorsDelayed();
        }

        if (codeData.equals(mCurrentData)) {
            final OnCodeLoadListener codeLoadListener = mOnCodeLoadListener;

            if (null != codeLoadListener) {
                if (null != mPendingImage && mPendingImage.isLoaded()) {
                    codeLoadListener.onCodeLoad(false);
                }
            }
            return;
        }

        if (mPendingImage != null) {
            mPendingImage.cancelLoad();
        }

        mCurrentData = codeData;

        /*
         * The current code needs to be cleared so that it isn't displayed while the new code is
         * loading.
         */
        mCurrentCode = null;

        // Fake the loading flag so that cached results get a single isLoading(false) call.
        mPreviousIsCodeLoading = true;
        final PendingImage<LevelUpQrCodeImage> pendingImage = codeLoader.getLevelUpCode(codeData, mOnImageLoaded);
        mPendingImage = pendingImage;
        mPreviousIsCodeLoading = false;

        if (!pendingImage.isLoaded()) {
            callOnCodeLoadListener(true);

            // If the image is cached, invalidate() will be called from there.
            invalidate();
        }
    }

    /**
     * @param onCodeLoadListener the code load listener.
     */
    public void setOnCodeLoadListener(@Nullable final OnCodeLoadListener onCodeLoadListener) {
        this.mOnCodeLoadListener = onCodeLoadListener;
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();

        // Cancel and clear any pending images or loaders.
        if (null != mPendingImage) {
            mPendingImage.cancelLoad();
            mPendingImage = null;
        }
    }

    @Override
    protected void onDraw(final Canvas canvas) {
        super.onDraw(canvas);

        final LevelUpQrCodeImage currentCode = mCurrentCode;

        if (null != currentCode) {
            drawQrCode(NullUtils.nonNullContract(canvas), currentCode);
        }
    }

    @Override
    protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);

        // Sizes both width and height to the smallest dimension in order to create a square view.
        width = Math.min(height, width);
        height = Math.min(height, width);

        // Enforces the minimum-suggested sizes.
        final int minSize = Math.max(getSuggestedMinimumHeight(), getSuggestedMinimumWidth());
        width = Math.max(minSize, width);
        height = Math.max(minSize, height);

        setMeasuredDimension(width, height);
    }

    /**
     * Calls the registered {@link OnCodeLoadListener}, if there is one. Subsequent calls with the
     * same {@code isCodeLoading} value will not deliver updates. This only delivers changes in the
     * supplied parameter to the listener.
     *
     * @param isCodeLoading true if the code is being loaded; false if the load has completed.
     */
    private void callOnCodeLoadListener(final boolean isCodeLoading) {
        if (null != mOnCodeLoadListener && (mPreviousIsCodeLoading != isCodeLoading)) {
            mOnCodeLoadListener.onCodeLoad(isCodeLoading);
            mPreviousIsCodeLoading = isCodeLoading;
        }
    }

    /**
     * Draw the colored target areas to the canvas using sizing information provided by
     * {@code codeBitmapWithTarget}.
     *
     * @param canvas the canvas to draw on.
     * @param codeBitmapWithTarget the code to draw.
     */
    private void drawColoredTargetAreas(@NonNull final Canvas canvas,
            @NonNull final LevelUpQrCodeImage codeBitmapWithTarget) {

        int[] target;
        target = codeBitmapWithTarget.getTargetTopRight();
        mTargetTopRightPaint.setAlpha(mColorAlpha);
        canvas.drawRect(target[TARGET_LEFT_INDEX], target[TARGET_TOP_INDEX], target[TARGET_RIGHT_INDEX],
                target[TARGET_BOTTOM_INDEX], mTargetTopRightPaint);

        target = codeBitmapWithTarget.getTargetBottomRight();
        mTargetBottomRightPaint.setAlpha(mColorAlpha);
        canvas.drawRect(target[TARGET_LEFT_INDEX], target[TARGET_TOP_INDEX], target[TARGET_RIGHT_INDEX],
                target[TARGET_BOTTOM_INDEX], mTargetBottomRightPaint);

        target = codeBitmapWithTarget.getTargetBottomLeft();
        mTargetBottomLeftPaint.setAlpha(mColorAlpha);
        canvas.drawRect(target[TARGET_LEFT_INDEX], target[TARGET_TOP_INDEX], target[TARGET_RIGHT_INDEX],
                target[TARGET_BOTTOM_INDEX], mTargetBottomLeftPaint);
    }

    /**
     * Draws the QR code to the canvas, scaling it up to fit the measured size of the view. If
     * enabled, this also draws the colored target areas per
     * {@link #drawColoredTargetAreas(Canvas, LevelUpQrCodeImage)}.
     *
     * @param canvas the drawing canvas.
     * @param levelUpQrCodeImage the image of the QR code with target marker information.
     */
    private void drawQrCode(@NonNull final Canvas canvas, @NonNull final LevelUpQrCodeImage levelUpQrCodeImage) {
        final Bitmap codeBitmap = levelUpQrCodeImage.getBitmap();
        /*
         * The code is cached in the smallest size and must be scaled before being displayed. It is
         * necessary to draw it directly onto a canvas and scale it in the same operation for
         * efficiency (so we do not have any perceivable lag when switching tip values).
         */
        mCodeScalingMatrix.setScale((float) getMeasuredWidth() / codeBitmap.getWidth(),
                (float) getMeasuredHeight() / codeBitmap.getHeight());

        // Save the canvas without the scaling matrix.
        canvas.save();

        canvas.concat(mCodeScalingMatrix);
        canvas.drawBitmap(codeBitmap, 0, 0, mQrCodePaint);

        if (mIsColorizeSet) {
            drawColoredTargetAreas(canvas, levelUpQrCodeImage);
        }

        canvas.restore();
    }

    /**
     * Initialize the view.
     *
     * @param context view context.
     */
    private void init(@NonNull final Context context) {
        setWillNotDraw(false);
        setClickable(true);

        final Resources res = context.getResources();
        final Xfermode xferMode = new PorterDuffXfermode(Mode.SCREEN);

        if (!isInEditMode()) {
            mTargetBottomLeftPaint.setColor(res.getColor(R.color.levelup_logo_green));
            mTargetBottomRightPaint.setColor(res.getColor(R.color.levelup_logo_blue));
            mTargetTopRightPaint.setColor(res.getColor(R.color.levelup_logo_orange));
        } else {
            mTargetBottomLeftPaint.setColor(Color.GREEN);
            mTargetBottomRightPaint.setColor(Color.BLUE);
            mTargetTopRightPaint.setColor(Color.YELLOW);
        }

        mTargetBottomLeftPaint.setXfermode(xferMode);
        mTargetBottomRightPaint.setXfermode(xferMode);
        mTargetTopRightPaint.setXfermode(xferMode);

        mGestureDetector = new GestureDetectorCompat(context, mGestureDetectorCallbacks);
    }

    /**
     * Loads the XML attributes, passed in from the constructor.
     *
     * @param context view context.
     * @param attrs optional attribute set.
     * @param defaultStyle optional default style.
     * @see com.scvngr.levelup.core.R.styleable#LevelUpCodeView
     */
    private void loadAttributes(@NonNull final Context context, @Nullable final AttributeSet attrs,
            final int defaultStyle) {

        final TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.LevelUpCodeView,
                defaultStyle, 0);
        try {
            mIsColorizeSet = attributes.getBoolean(R.styleable.LevelUpCodeView_colorize, COLORIZE_DEFAULT);
            mIsFadeColorsSet = attributes.getBoolean(R.styleable.LevelUpCodeView_fade_colors, FADE_COLORS_DEFAULT);
            if (!mIsFadeColorsSet) {
                mColorAlpha = ANIM_FADE_COLOR_ALPHA_END;
            }
        } finally {
            attributes.recycle();
        }
    }

    /**
     * A listener which will be notified when a LevelUp code is being loaded.
     */
    public interface OnCodeLoadListener {
        /**
         * This will be called when a code begins or ends loading. It is only called when a change
         * in loading/non-loading state occurs. It will not be called more than once per load
         * operation except when an identical code is given to {@link #setLevelUpCode}. In that
         * case, this callback will be called again if that code has already completed loading.
         *
         * @param isCodeLoading {@code true} if the displayed code is currently loading.
         *        {@code false} when the code has loaded.
         */
        void onCodeLoad(final boolean isCodeLoading);
    }

    /**
     * A simple animation to fade the colors of the target markers. This works around the
     * limitations of Android's old animation framework by setting {@code mColorAlpha} and
     * {@code postInvalidate()} on the host view.
     */
    @VisibleForTesting(visibility = Visibility.PRIVATE)
    /* package */class FadeColorsAnimation extends Animation {

        /**
         * @param durationMillis the duration of the animation, in milliseconds.
         */
        public FadeColorsAnimation(final int durationMillis) {
            setInterpolator(new DecelerateInterpolator());
            setDuration(durationMillis);
        }

        @Override
        public boolean willChangeBounds() {
            return false;
        }

        @Override
        public boolean willChangeTransformationMatrix() {
            return false;
        }

        @Override
        protected void applyTransformation(final float interpolatedTime, final Transformation t) {
            /*
             * This is not actually how applyTransformation is intended to be used, but the
             * Transformation object is too limited, even for this relatively simple use case.
             */
            mColorAlpha = (int) ((ANIM_FADE_COLOR_ALPHA_END - ANIM_FADE_COLOR_ALPHA_START) * interpolatedTime
                    + ANIM_FADE_COLOR_ALPHA_START);
            postInvalidate();
        }
    }
}