Java tutorial
/* * Copyright (c) 2016 Andr Mion * * 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.andremion.music; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Region; import android.graphics.drawable.Animatable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.ColorInt; import android.support.annotation.IntDef; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.v4.os.ParcelableCompat; import android.support.v4.os.ParcelableCompatCreatorCallbacks; import android.support.v4.view.AbsSavedState; import android.support.v7.graphics.Palette; import android.transition.ChangeImageTransform; import android.transition.ChangeTransform; import android.transition.Transition; import android.transition.TransitionManager; import android.transition.TransitionSet; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.animation.Animation; import android.view.animation.LinearInterpolator; import android.widget.ImageView; import com.andremion.music.cover.R; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; public class MusicCoverView extends ImageView implements Animatable { public static final int SHAPE_RECTANGLE = 0; public static final int SHAPE_CIRCLE = 1; public static final int SHAPE_SQUARE = 2; static final int ALPHA_TRANSPARENT = 0; static final int ALPHA_OPAQUE = 255; private static final float TRACK_SIZE = 10; private static final float TRACK_WIDTH = 1; private static final int TRACK_COLOR = Color.parseColor("#56FFFFFF"); private static final int DISC_CENTER_COLOR = Color.parseColor("white"); private static final int DISC_CENTER_DECOR_COLOR = 0xCC000000; private static final float FULL_ANGLE = 360; private static final float HALF_ANGLE = FULL_ANGLE / 2; private static final int DURATION = 2500; private static final float DURATION_PER_DEGREES = DURATION / FULL_ANGLE; private static final int DURATION_SQUARE = 10000; private static final float DURATION_SQUARE_PER_DEGREES = DURATION_SQUARE / FULL_ANGLE; private final ValueAnimator mStartRotateAnimator; private final ValueAnimator mEndRotateAnimator; private final Transition mCircleToRectTransition; private final Transition mRectToCircleTransition; private final Transition mSquareToSquareTransition; private final float mTrackSize; private final Paint mTrackPaint; private int mTrackAlpha; private int mDiscCenterColor = DISC_CENTER_COLOR; private final Paint mDiscPaintCenterDecor; private final Paint mDiscPaintCenter; private final Path mClipPath = new Path(); private final Path mRectSquarePath = new Path(); private final Path mTrackPath = new Path(); private final Path mClipPathAsCircle = new Path(); private final Path mDiscPathCenterDecor = new Path(); private final Path mDiscPathCenter = new Path(); private boolean mDrawSquareAsCircle; private boolean mIsMorphing; private float mRadius = 0; private Callbacks mCallbacks; private int mShape; public MusicCoverView(Context context) { this(context, null, 0); } public MusicCoverView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public MusicCoverView(Context context, AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MusicCoverView); @Shape int shape = a.getInt(R.styleable.MusicCoverView_shape, SHAPE_SQUARE); @ColorInt int trackColor = a.getColor(R.styleable.MusicCoverView_trackColor, TRACK_COLOR); a.recycle(); // TODO: Canvas.clipPath works wrong when running with hardware acceleration on Android N if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M) { setLayerType(View.LAYER_TYPE_HARDWARE, null); } final float density = getResources().getDisplayMetrics().density; mTrackSize = TRACK_SIZE * density; mTrackPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTrackPaint.setStyle(Paint.Style.STROKE); mTrackPaint.setStrokeWidth(TRACK_WIDTH * density); mDiscPaintCenterDecor = new Paint(Paint.ANTI_ALIAS_FLAG); mDiscPaintCenterDecor.setStyle(Paint.Style.FILL); mDiscPaintCenter = new Paint(Paint.ANTI_ALIAS_FLAG); mDiscPaintCenter.setStyle(Paint.Style.FILL); setShape(shape); setTrackColor(trackColor); if (getDrawable() != null && ((BitmapDrawable) getDrawable()).getBitmap() != null) { setCenterColor(DISC_CENTER_COLOR, Palette.generate(((BitmapDrawable) getDrawable()).getBitmap(), 32) .getLightVibrantColor(DISC_CENTER_DECOR_COLOR)); } else { setCenterColor(DISC_CENTER_COLOR, DISC_CENTER_DECOR_COLOR); } setScaleType(); mStartRotateAnimator = ObjectAnimator.ofFloat(this, View.ROTATION, 0, FULL_ANGLE); mStartRotateAnimator.setInterpolator(new LinearInterpolator()); if (SHAPE_SQUARE == mShape) { mStartRotateAnimator.setDuration(DURATION_SQUARE); } else { mStartRotateAnimator.setDuration(DURATION); mStartRotateAnimator.setRepeatCount(Animation.INFINITE); } mStartRotateAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { float current = getRotation(); float target = current > HALF_ANGLE ? FULL_ANGLE : 0; // Choose the shortest distance to 0 rotation float diff = target > 0 ? FULL_ANGLE - current : current; mEndRotateAnimator.setFloatValues(current, target); if (SHAPE_SQUARE == mShape) { mEndRotateAnimator.setDuration((int) (DURATION_SQUARE_PER_DEGREES * diff)); } else { mEndRotateAnimator.setDuration((int) (DURATION_PER_DEGREES * diff)); } mEndRotateAnimator.start(); } }); mEndRotateAnimator = ObjectAnimator.ofFloat(MusicCoverView.this, View.ROTATION, 0); mEndRotateAnimator.setInterpolator(new LinearInterpolator()); mEndRotateAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { setRotation(0); // isRunning method return true if it's called form here. // So we need call from post method to get the right returning. post(new Runnable() { @Override public void run() { if (mCallbacks != null) { mCallbacks.onRotateEnd(MusicCoverView.this); } } }); } }); mRectToCircleTransition = new MorphTransition(SHAPE_RECTANGLE); mRectToCircleTransition.addTarget(this); mRectToCircleTransition.addListener(new TransitionAdapter() { @Override public void onTransitionStart(Transition transition) { mIsMorphing = true; } @Override public void onTransitionEnd(Transition transition) { mIsMorphing = false; mShape = SHAPE_CIRCLE; if (mCallbacks != null) { mCallbacks.onMorphEnd(MusicCoverView.this); } } }); mCircleToRectTransition = new MorphTransition(SHAPE_CIRCLE); mCircleToRectTransition.addTarget(this); mCircleToRectTransition.addListener(new TransitionAdapter() { @Override public void onTransitionStart(Transition transition) { mIsMorphing = true; } @Override public void onTransitionEnd(Transition transition) { mIsMorphing = false; mShape = SHAPE_RECTANGLE; if (mCallbacks != null) { mCallbacks.onMorphEnd(MusicCoverView.this); } } }); mSquareToSquareTransition = new MorphTransition(SHAPE_SQUARE); mSquareToSquareTransition.addTarget(this); mSquareToSquareTransition.addListener(new TransitionAdapter() { @Override public void onTransitionStart(Transition transition) { mIsMorphing = true; } @Override public void onTransitionEnd(Transition transition) { mIsMorphing = false; mShape = SHAPE_SQUARE; if (mCallbacks != null) { mCallbacks.onMorphEnd(MusicCoverView.this); } } }); } public void setCallbacks(Callbacks callbacks) { mCallbacks = callbacks; } public int getShape() { return mShape; } public void setShape(@Shape int shape) { if (shape != mShape) { mShape = shape; setScaleType(); if (!isInLayout() && !isLayoutRequested()) { calculateRadius(); resetPaths(); } } } public void setTrackColor(@ColorInt int trackColor) { if (trackColor != getTrackColor()) { int alpha = (mShape == SHAPE_CIRCLE) ? ALPHA_OPAQUE : ALPHA_TRANSPARENT; mTrackPaint.setColor(trackColor); mTrackAlpha = Color.alpha(trackColor); mTrackPaint.setAlpha(alpha * mTrackAlpha / ALPHA_OPAQUE); invalidate(); } } public int getTrackColor() { return mTrackPaint.getColor(); } public void setCenterColor(@ColorInt int centerColor, @ColorInt int centerDecorColor) { if (mDiscPaintCenterDecor != null && mDiscPaintCenter != null) { if (centerColor != getCenterColor()) { mDiscCenterColor = centerColor; mDiscPaintCenter.setColor(centerColor); } mDiscPaintCenterDecor.setColor(centerDecorColor); int alpha = (mShape == SHAPE_CIRCLE) ? ALPHA_OPAQUE : ALPHA_TRANSPARENT; mDiscPaintCenterDecor.setAlpha(alpha); mDiscPaintCenter.setAlpha(alpha); invalidate(); } } public int getCenterColor() { return mDiscPaintCenter.getColor(); } public int getCenterDecorColor() { return mDiscPaintCenterDecor.getColor(); } float getTransitionRadius() { return mRadius; } void setTransitionRadius(float radius) { if (radius != mRadius) { mRadius = radius; resetPaths(); invalidate(); } } int getTransitionAlpha() { return mTrackPaint.getAlpha() * ALPHA_OPAQUE / mTrackAlpha; } void setTransitionAlpha(@IntRange(from = ALPHA_TRANSPARENT, to = ALPHA_OPAQUE) int alpha) { if (alpha != getTransitionAlpha()) { mTrackPaint.setAlpha(alpha * mTrackAlpha / ALPHA_OPAQUE); mDiscPaintCenterDecor.setAlpha(alpha); mDiscPaintCenter.setAlpha(alpha); invalidate(); } } boolean getTransitionSquareAsCircle() { return mDrawSquareAsCircle; } void setTransitionSquareAsCircle(boolean drawSquareAsCircle) { if (drawSquareAsCircle != mDrawSquareAsCircle) { mDrawSquareAsCircle = drawSquareAsCircle; invalidate(); } } float getMinRadius() { final int w = getWidth(); final int h = getHeight(); return Math.min(w, h) / 2f; } float getMaxRadius() { final int w = getWidth(); final int h = getHeight(); return (float) Math.hypot(w / 2f, h / 2f); } float getFixRadius() { return (float) getHeight() / 2f; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); calculateRadius(); resetPaths(); } private void calculateRadius() { if (SHAPE_CIRCLE == mShape) { mRadius = getMinRadius(); } else if (SHAPE_RECTANGLE == mShape) { mRadius = getMaxRadius(); } else if (SHAPE_SQUARE == mShape) { mRadius = getFixRadius(); } } private void setScaleType() { if (SHAPE_CIRCLE == mShape) { setScaleType(ScaleType.CENTER_INSIDE); } else if (SHAPE_RECTANGLE == mShape) { setScaleType(ScaleType.CENTER_CROP); } else if (SHAPE_SQUARE == mShape) { setScaleType(ScaleType.CENTER_CROP); } } private void resetPaths() { final int w = getWidth(); final int h = getHeight(); final float centerX = w / 2f; final float centerY = h / 2f; mClipPath.reset(); mClipPath.addCircle(centerX, centerY, mRadius, Path.Direction.CW); final float trackRadius = mRadius; final int trackCount = (int) (trackRadius / mTrackSize); mTrackPath.reset(); for (int i = 4; i < trackCount; i++) { mTrackPath.addCircle(centerX, centerY, trackRadius * (i / (float) trackCount), Path.Direction.CW); } mClipPathAsCircle.reset(); mClipPathAsCircle.addCircle(centerX, centerY, mRadius, Path.Direction.CW); mDiscPathCenterDecor.reset(); mDiscPathCenterDecor.addCircle(centerX, centerY, trackRadius * (3 / (float) trackCount), Path.Direction.CW); mDiscPathCenter.reset(); mDiscPathCenter.addCircle(centerX, centerY, trackRadius * (1 / (float) trackCount), Path.Direction.CW); mRectSquarePath.reset(); mRectSquarePath.addRect(0, 0, w, h, Path.Direction.CW); } @Override public void setImageDrawable(Drawable drawable) { super.setImageDrawable(drawable); if (drawable != null && ((BitmapDrawable) drawable).getBitmap() != null) { setCenterColor(mDiscCenterColor, Palette.generate(((BitmapDrawable) drawable).getBitmap(), 32) .getLightVibrantColor(DISC_CENTER_DECOR_COLOR)); } else { setCenterColor(mDiscCenterColor, DISC_CENTER_DECOR_COLOR); } } @Override protected void onDraw(Canvas canvas) { if (SHAPE_CIRCLE == mShape) { canvas.clipPath(mClipPath); } else if (SHAPE_RECTANGLE == mShape) { canvas.clipPath(mClipPath); } else if (SHAPE_SQUARE == mShape) { if (getTransitionSquareAsCircle()) { canvas.clipPath(mClipPathAsCircle, Region.Op.REPLACE); } else { canvas.clipPath(mRectSquarePath, Region.Op.REPLACE); } } super.onDraw(canvas); canvas.drawPath(mTrackPath, mTrackPaint); canvas.drawPath(mDiscPathCenterDecor, mDiscPaintCenterDecor); canvas.drawPath(mDiscPathCenter, mDiscPaintCenter); } @Override public WindowInsets onApplyWindowInsets(WindowInsets insets) { // Don't need to consume the system window insets return insets; } public void morph() { if (!isRunning()) if (SHAPE_CIRCLE == mShape) { morphToRect(); } else if (SHAPE_RECTANGLE == mShape) { morphToCircle(); } else if (SHAPE_SQUARE == mShape) { mDrawSquareAsCircle = !getTransitionSquareAsCircle(); morphFromSquareToSquare(); } } private void morphToCircle() { if (mIsMorphing) { return; } TransitionManager.beginDelayedTransition((ViewGroup) getParent(), mRectToCircleTransition); setScaleType(ScaleType.CENTER_INSIDE); } private void morphToRect() { if (mIsMorphing) { return; } TransitionManager.beginDelayedTransition((ViewGroup) getParent(), mCircleToRectTransition); setScaleType(ScaleType.CENTER_CROP); } private void morphFromSquareToSquare() { if (mIsMorphing) { return; } TransitionManager.beginDelayedTransition((ViewGroup) getParent(), mSquareToSquareTransition); setScaleType(ScaleType.CENTER_INSIDE); setScaleType(ScaleType.CENTER_CROP); } @Override public void start() { if (SHAPE_RECTANGLE == mShape) { // Only start rotate when shape is a circle or square return; } if (SHAPE_SQUARE == mShape) { // Start rotate drawing square as circle setTransitionSquareAsCircle(true); setTransitionAlpha(ALPHA_OPAQUE); invalidate(); } if (!isRunning()) { mStartRotateAnimator.start(); } } @Override public void stop() { if (mStartRotateAnimator.isRunning()) { mStartRotateAnimator.cancel(); } } @Override public boolean isRunning() { return mStartRotateAnimator.isRunning() || mEndRotateAnimator.isRunning() || mIsMorphing; } @IntDef({ SHAPE_CIRCLE, SHAPE_RECTANGLE, SHAPE_SQUARE }) @Retention(RetentionPolicy.SOURCE) public @interface Shape { } public interface Callbacks { void onMorphEnd(MusicCoverView coverView); void onRotateEnd(MusicCoverView coverView); } private static class MorphTransition extends TransitionSet { private MorphTransition(int shape) { setOrdering(ORDERING_TOGETHER); addTransition(new MusicCoverViewTransition(shape)); addTransition(new ChangeImageTransform()); addTransition(new ChangeTransform()); } } /** * {@link SavedState} methods */ @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SavedState ss = new SavedState(superState); ss.shape = getShape(); ss.drawSquareAsCircle = getTransitionSquareAsCircle(); ss.trackColor = getTrackColor(); ss.isRotating = mStartRotateAnimator.isRunning(); return ss; } @Override protected void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); setShape(ss.shape); setTransitionSquareAsCircle(ss.drawSquareAsCircle); setTrackColor(ss.trackColor); if (ss.isRotating) { start(); } } public static class SavedState extends AbsSavedState { private int shape; private boolean drawSquareAsCircle; private int trackColor; private boolean isRotating; private SavedState(Parcel in, ClassLoader loader) { super(in, loader); shape = in.readInt(); drawSquareAsCircle = (boolean) in.readValue(Boolean.class.getClassLoader()); trackColor = in.readInt(); isRotating = (boolean) in.readValue(Boolean.class.getClassLoader()); } private SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(shape); dest.writeValue(drawSquareAsCircle); dest.writeInt(trackColor); dest.writeValue(isRotating); } @Override public String toString() { return MusicCoverView.class.getSimpleName() + "." + SavedState.class.getSimpleName() + "{" + Integer.toHexString(System.identityHashCode(this)) + " shape=" + shape + ", drawAsSquare=" + drawSquareAsCircle + ", trackColor=" + trackColor + ", isRotating=" + isRotating + "}"; } public static final Parcelable.Creator<SavedState> CREATOR = ParcelableCompat .newCreator(new ParcelableCompatCreatorCallbacks<SavedState>() { @Override public SavedState createFromParcel(Parcel parcel, ClassLoader loader) { return new SavedState(parcel, loader); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }); } private static class TransitionAdapter implements Transition.TransitionListener { @Override public void onTransitionStart(Transition transition) { } @Override public void onTransitionEnd(Transition transition) { } @Override public void onTransitionCancel(Transition transition) { } @Override public void onTransitionPause(Transition transition) { } @Override public void onTransitionResume(Transition transition) { } } }