/*
* 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();
}
}
}