Bug 1366672 - part2: Add new custom widgets
* DrawableWrapper - a class similiar to DrawableWrapper in Android v23.
* ShiftDrawable - provide shif-animation for its child drawable
* AnimatedProgressBar - ProgressBar.setProgress(int, boolean) involved
since API v24. This is an implementation for animation.
MozReview-Commit-ID: HjLAXXQdZKO
--- a/mobile/android/app/src/main/res/values/attrs.xml
+++ b/mobile/android/app/src/main/res/values/attrs.xml
@@ -176,10 +176,16 @@
<attr name="drawableTintList" format="color" />
</declare-styleable>
<declare-styleable name="NavButton">
<attr name="borderColor" format="color" />
<attr name="borderColorPrivate" format="color" />
</declare-styleable>
+ <declare-styleable name="AnimatedProgressBar">
+ <attr name="wrapShiftDrawable" format="boolean" />
+ <attr name="shiftDuration" format="reference" />
+ <attr name="shiftInterpolator" format="reference" />
+ </declare-styleable>
+
</resources>
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/drawable/DrawableWrapper.java
@@ -0,0 +1,178 @@
+package org.mozilla.gecko.drawable;
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.PorterDuff;
+import android.graphics.Rect;
+import android.graphics.Region;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.IntRange;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.AttributeSet;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+
+import java.io.IOException;
+
+/**
+ * DrawableWrapper was added since API Level 23. But in v7 support library, it has annotation
+ * "@RestrictTo(LIBRARY_GROUP)". Hence we should not extends it, so we create this wrapper for now.
+ * Once we start to support API 23, or v7-support-library allows us to extends its DrawableWrapper,
+ * then this file can be removed.
+ */
+
+public class DrawableWrapper extends Drawable {
+
+ private final Drawable mWrapped;
+
+ public DrawableWrapper(@NonNull Drawable drawable) {
+ mWrapped = drawable;
+ }
+
+ public Drawable getWrappedDrawable() {
+ return mWrapped;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ mWrapped.draw(canvas);
+ }
+
+ @Override
+ public int getChangingConfigurations() {
+ return mWrapped.getChangingConfigurations();
+ }
+
+ @Override
+ public Drawable.ConstantState getConstantState() {
+ return mWrapped.getConstantState();
+ }
+
+ @Override
+ public Drawable getCurrent() {
+ return mWrapped.getCurrent();
+ }
+
+ @Override
+ public int getIntrinsicHeight() {
+ return mWrapped.getIntrinsicHeight();
+ }
+
+ @Override
+ public int getIntrinsicWidth() {
+ return mWrapped.getIntrinsicWidth();
+ }
+
+ @Override
+ public int getMinimumHeight() {
+ return mWrapped.getMinimumHeight();
+ }
+
+ @Override
+ public int getMinimumWidth() {
+ return mWrapped.getMinimumWidth();
+ }
+
+ @Override
+ public int getOpacity() {
+ return mWrapped.getOpacity();
+ }
+
+ @Override
+ public boolean getPadding(Rect padding) {
+ return mWrapped.getPadding(padding);
+ }
+
+ @Override
+ public int[] getState() {
+ return mWrapped.getState();
+ }
+
+ @Override
+ public Region getTransparentRegion() {
+ return mWrapped.getTransparentRegion();
+ }
+
+ @Override
+ public void inflate(Resources r, XmlPullParser parser, AttributeSet attrs)
+ throws XmlPullParserException, IOException {
+ mWrapped.inflate(r, parser, attrs);
+ }
+
+ @Override
+ public boolean isStateful() {
+ return mWrapped.isStateful();
+ }
+
+ @Override
+ public void jumpToCurrentState() {
+ mWrapped.jumpToCurrentState();
+ }
+
+ @Override
+ public Drawable mutate() {
+ return mWrapped.mutate();
+ }
+
+ @Override
+ public void setAlpha(@IntRange(from = 0, to = 255) int i) {
+ mWrapped.setAlpha(i);
+ }
+
+ @Override
+ public void scheduleSelf(Runnable what, long when) {
+ mWrapped.scheduleSelf(what, when);
+ }
+
+ @Override
+ public void setChangingConfigurations(int configs) {
+ mWrapped.setChangingConfigurations(configs);
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter) {
+ mWrapped.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public void setColorFilter(int color, PorterDuff.Mode mode) {
+ mWrapped.setColorFilter(color, mode);
+ }
+
+ @Override
+ public void setFilterBitmap(boolean filter) {
+ mWrapped.setFilterBitmap(filter);
+ }
+
+ @Override
+ public boolean setVisible(boolean visible, boolean restart) {
+ return mWrapped.setVisible(visible, restart);
+ }
+
+ @Override
+ public void unscheduleSelf(Runnable what) {
+ mWrapped.unscheduleSelf(what);
+ }
+
+ @Override
+ protected void onBoundsChange(Rect bounds) {
+ mWrapped.setBounds(bounds);
+ }
+
+ @Override
+ protected boolean onLevelChange(int level) {
+ return mWrapped.setLevel(level);
+ }
+
+ @Override
+ protected boolean onStateChange(int[] state) {
+ return mWrapped.setState(state);
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/drawable/ShiftDrawable.java
@@ -0,0 +1,156 @@
+package org.mozilla.gecko.drawable;
+
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import android.animation.Animator;
+import android.animation.ValueAnimator;
+import android.graphics.Canvas;
+import android.graphics.Path;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.animation.Interpolator;
+import android.view.animation.LinearInterpolator;
+
+/**
+ * A drawable to keep shifting its wrapped drawable.
+ * Assume the wrapped drawable value is "00000010", this class will keep drawing in this way
+ * <p>
+ * 00000010 -> 00000001 -> 10000000 -> 01000000 -> 00100000 -> ...
+ * <p>
+ * This drawable will keep drawing until be invisible.
+ */
+public class ShiftDrawable extends DrawableWrapper {
+
+ /**
+ * An animator to trigger redraw and update offset-of-shifting
+ */
+ private final ValueAnimator mAnimator = ValueAnimator.ofFloat(0f, 1f);
+
+ /**
+ * Visible rectangle, wrapped-drawable is resized and draw in this rectangle
+ */
+ private final Rect mVisibleRect = new Rect();
+
+ /**
+ * Canvas will clip itself by this Path. Used to draw rounded head.
+ */
+ private final Path mPath = new Path();
+
+ // align to ScaleDrawable implementation
+ private static final int MAX_LEVEL = 10000;
+
+ private static final int DEFAULT_DURATION = 1000;
+
+ public ShiftDrawable(@NonNull Drawable d) {
+ this(d, DEFAULT_DURATION);
+ }
+
+ public ShiftDrawable(@NonNull Drawable d, int duration) {
+ this(d, duration, new LinearInterpolator());
+ }
+
+ public ShiftDrawable(@NonNull Drawable d, int duration, @Nullable Interpolator interpolator) {
+ super(d);
+ mAnimator.setDuration(duration);
+ mAnimator.setRepeatCount(ValueAnimator.INFINITE);
+ mAnimator.setInterpolator((interpolator == null) ? new LinearInterpolator() : interpolator);
+ mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ if (isVisible()) {
+ invalidateSelf();
+ }
+ }
+ });
+ mAnimator.start();
+ }
+
+ public Animator getAnimator() {
+ return mAnimator;
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * override to enable / disable animator as well.
+ */
+ @Override
+ public boolean setVisible(final boolean visible, final boolean restart) {
+ final boolean result = super.setVisible(visible, restart);
+ if (isVisible()) {
+ mAnimator.start();
+ } else {
+ mAnimator.end();
+ }
+ return result;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected void onBoundsChange(Rect bounds) {
+ super.onBoundsChange(bounds);
+ updateBounds();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected boolean onLevelChange(int level) {
+ final boolean result = super.onLevelChange(level);
+ updateBounds();
+ return result;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ final Drawable wrapped = getWrappedDrawable();
+ final float fraction = mAnimator.getAnimatedFraction();
+ final int width = mVisibleRect.width();
+ final int offset = (int) (width * fraction);
+
+ final int stack = canvas.save();
+
+ // To apply mPath, then we have rounded-head
+ canvas.clipPath(mPath);
+
+ // To draw left-half part of Drawable, shift from right to left
+ canvas.save();
+ canvas.translate(-offset, 0);
+ wrapped.draw(canvas);
+ canvas.restore();
+
+ // Then to draw right-half part of Drawable
+ canvas.save();
+ canvas.translate(width - offset, 0);
+ wrapped.draw(canvas);
+ canvas.restore();
+
+ canvas.restoreToCount(stack);
+ }
+
+ private void updateBounds() {
+ final Rect b = getBounds();
+ final int width = (int) ((float) b.width() * getLevel() / MAX_LEVEL);
+ mVisibleRect.set(b.left, b.top, b.left + width, b.height());
+
+ // to create path to help drawing rounded head. mPath is enclosed by mVisibleRect
+ final float radius = b.height() / 2;
+ mPath.reset();
+
+ // The added rectangle width is smaller than mVisibleRect, due to semi-circular.
+ mPath.addRect(mVisibleRect.left,
+ mVisibleRect.top, mVisibleRect.right - radius,
+ mVisibleRect.height(),
+ Path.Direction.CCW);
+ // To add semi-circular
+ mPath.addCircle(mVisibleRect.right - radius, radius, radius, Path.Direction.CCW);
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/AnimatedProgressBar.java
@@ -0,0 +1,341 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.widget;
+
+import android.animation.Animator;
+import android.animation.ValueAnimator;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Handler;
+import android.support.annotation.InterpolatorRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.view.ViewCompat;
+import android.util.AttributeSet;
+import android.view.animation.AnimationUtils;
+import android.view.animation.Interpolator;
+import android.view.animation.LinearInterpolator;
+
+import org.mozilla.gecko.DynamicToolbar;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.drawable.ShiftDrawable;
+import org.mozilla.gecko.gfx.DynamicToolbarAnimator;
+import org.mozilla.gecko.widget.themed.ThemedProgressBar;
+
+/**
+ * A progressbar with some animations on changing progress.
+ * When changing progress of this bar, it does not change value directly. Instead, it use
+ * {@link Animator} to change value progressively. Moreover, change visibility to View.GONE will
+ * cause closing animation.
+ */
+public class AnimatedProgressBar extends ThemedProgressBar {
+
+ /**
+ * Animation duration of progress changing.
+ */
+ private final static int PROGRESS_DURATION = 200;
+
+ /**
+ * Delay before applying closing animation when progress reach max value.
+ */
+ private final static int CLOSING_DELAY = 300;
+
+ /**
+ * Animation duration for closing
+ */
+ private final static int CLOSING_DURATION = 300;
+
+ private ValueAnimator mPrimaryAnimator;
+ private final ValueAnimator mClosingAnimator = ValueAnimator.ofFloat(0f, 1f);
+
+ /**
+ * For closing animation. To indicate how many visible region should be clipped.
+ */
+ private float mClipRatio = 0f;
+ private final Rect mRect = new Rect();
+
+ /**
+ * To store the final expected progress to reach, it does matter in animation.
+ */
+ private int mExpectedProgress = 0;
+
+ /**
+ * setProgress() might be invoked in constructor. Add to flag to avoid null checking for animators.
+ */
+ private boolean mInitialized = false;
+
+ private boolean mIsRtl = false;
+
+ private DynamicToolbar mDynamicToolbar;
+ private EndingRunner mEndingRunner = new EndingRunner();
+
+ private final ValueAnimator.AnimatorUpdateListener mListener =
+ new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ setProgressImmediately((int) mPrimaryAnimator.getAnimatedValue());
+ }
+ };
+
+ public AnimatedProgressBar(@NonNull Context context) {
+ super(context, null);
+ init(context, null);
+ }
+
+ public AnimatedProgressBar(@NonNull Context context,
+ @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ init(context, attrs);
+ }
+
+ public AnimatedProgressBar(@NonNull Context context,
+ @Nullable AttributeSet attrs,
+ int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public AnimatedProgressBar(Context context,
+ AttributeSet attrs,
+ int defStyleAttr,
+ int defStyleRes) {
+ super(context, attrs, defStyleAttr);
+ init(context, attrs);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public synchronized void setMax(int max) {
+ super.setMax(max);
+ mPrimaryAnimator = createAnimator(getMax(), mListener);
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Instead of set progress directly, this method triggers an animator to change progress.
+ */
+ @Override
+ public void setProgress(int nextProgress) {
+ nextProgress = Math.min(nextProgress, getMax());
+ nextProgress = Math.max(0, nextProgress);
+ mExpectedProgress = nextProgress;
+ if (!mInitialized) {
+ setProgressImmediately(mExpectedProgress);
+ return;
+ }
+
+ // if regress, jump to the expected value without any animation
+ if (mExpectedProgress < getProgress()) {
+ cancelAnimations();
+ setProgressImmediately(mExpectedProgress);
+ return;
+ }
+
+ // Animation is not needed for reloading a completed page
+ if ((mExpectedProgress == 0) && (getProgress() == getMax())) {
+ cancelAnimations();
+ setProgressImmediately(0);
+ return;
+ }
+
+ cancelAnimations();
+ mPrimaryAnimator.setIntValues(getProgress(), nextProgress);
+ mPrimaryAnimator.start();
+ }
+
+ @Override
+ public void onDraw(Canvas canvas) {
+ if (mClipRatio == 0) {
+ super.onDraw(canvas);
+ } else {
+ canvas.getClipBounds(mRect);
+ final float clipWidth = mRect.width() * mClipRatio;
+ canvas.save();
+ if (mIsRtl) {
+ canvas.clipRect(mRect.left, mRect.top, mRect.right - clipWidth, mRect.bottom);
+ } else {
+ canvas.clipRect(mRect.left + clipWidth, mRect.top, mRect.right, mRect.bottom);
+ }
+ super.onDraw(canvas);
+ canvas.restore();
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * <p>
+ * Instead of change visibility directly, this method also applies the closing animation if
+ * progress reaches max value.
+ */
+ @Override
+ public void setVisibility(int value) {
+ // nothing changed
+ if (getVisibility() == value) {
+ return;
+ }
+
+ if (value == GONE) {
+ if (mExpectedProgress == getMax()) {
+ setProgressImmediately(mExpectedProgress);
+ animateClosing();
+ } else {
+ setVisibilityImmediately(value);
+ }
+ } else {
+ final Handler handler = getHandler();
+ // if this view is detached from window, the handler would be null
+ if (handler != null) {
+ handler.removeCallbacks(mEndingRunner);
+ }
+
+ if (mClosingAnimator != null) {
+ mClipRatio = 0;
+ mClosingAnimator.cancel();
+ }
+ setVisibilityImmediately(value);
+ }
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ super.onAttachedToWindow();
+ mIsRtl = (ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL);
+ }
+
+ public void setDynamicToolbar(@Nullable DynamicToolbar toolbar) {
+ mDynamicToolbar = toolbar;
+ }
+
+ public void pinDynamicToolbar() {
+ if (mDynamicToolbar == null) {
+ return;
+ }
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setPinned(true, DynamicToolbarAnimator.PinReason.PAGE_LOADING);
+ mDynamicToolbar.setVisible(true, DynamicToolbar.VisibilityTransition.ANIMATE);
+ }
+ }
+
+ public void unpinDynamicToolbar() {
+ if (mDynamicToolbar == null) {
+ return;
+ }
+ if (mDynamicToolbar.isEnabled()) {
+ mDynamicToolbar.setPinned(false, DynamicToolbarAnimator.PinReason.PAGE_LOADING);
+ }
+ }
+
+ private void cancelAnimations() {
+ if (mPrimaryAnimator != null) {
+ mPrimaryAnimator.cancel();
+ }
+ if (mClosingAnimator != null) {
+ mClosingAnimator.cancel();
+ }
+
+ mClipRatio = 0;
+ }
+
+ private void init(@NonNull Context context, @Nullable AttributeSet attrs) {
+ mInitialized = true;
+
+ final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AnimatedProgressBar);
+ final int duration = a.getInteger(R.styleable.AnimatedProgressBar_shiftDuration, 1000);
+ final boolean wrap = a.getBoolean(R.styleable.AnimatedProgressBar_wrapShiftDrawable, false);
+ @InterpolatorRes final int itplId = a.getResourceId(R.styleable.AnimatedProgressBar_shiftInterpolator, 0);
+ a.recycle();
+
+ setProgressDrawable(buildDrawable(getProgressDrawable(), wrap, duration, itplId));
+
+ mPrimaryAnimator = createAnimator(getMax(), mListener);
+
+ mClosingAnimator.setDuration(CLOSING_DURATION);
+ mClosingAnimator.setInterpolator(new LinearInterpolator());
+ mClosingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator valueAnimator) {
+ mClipRatio = (float) valueAnimator.getAnimatedValue();
+ invalidate();
+ }
+ });
+ mClosingAnimator.addListener(new Animator.AnimatorListener() {
+ @Override
+ public void onAnimationStart(Animator animator) {
+ mClipRatio = 0f;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animator) {
+ setVisibilityImmediately(GONE);
+ }
+
+ @Override
+ public void onAnimationCancel(Animator animator) {
+ mClipRatio = 0f;
+ }
+
+ @Override
+ public void onAnimationRepeat(Animator animator) {
+ }
+ });
+ }
+
+ private void setVisibilityImmediately(int value) {
+ super.setVisibility(value);
+ }
+
+ private void animateClosing() {
+ mClosingAnimator.cancel();
+ final Handler handler = getHandler();
+ // if this view is detached from window, the handler would be null
+ if (handler != null) {
+ handler.removeCallbacks(mEndingRunner);
+ handler.postDelayed(mEndingRunner, CLOSING_DELAY);
+ }
+ }
+
+ private void setProgressImmediately(int progress) {
+ super.setProgress(progress);
+ }
+
+ private Drawable buildDrawable(@NonNull Drawable original,
+ boolean isWrap,
+ int duration,
+ @InterpolatorRes int itplId) {
+ if (isWrap) {
+ final Interpolator interpolator = (itplId > 0)
+ ? AnimationUtils.loadInterpolator(getContext(), itplId)
+ : null;
+ return new ShiftDrawable(original, duration, interpolator);
+ } else {
+ return original;
+ }
+ }
+
+ private static ValueAnimator createAnimator(int max, ValueAnimator.AnimatorUpdateListener listener) {
+ ValueAnimator animator = ValueAnimator.ofInt(0, max);
+ animator.setInterpolator(new LinearInterpolator());
+ animator.setDuration(PROGRESS_DURATION);
+ animator.addUpdateListener(listener);
+ return animator;
+ }
+
+ private class EndingRunner implements Runnable {
+ @Override
+ public void run() {
+ mClosingAnimator.start();
+ }
+ }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -613,16 +613,18 @@ gbjar.sources += ['java/org/mozilla/geck
'dlc/DownloadAction.java',
'dlc/DownloadContentService.java',
'dlc/DownloadContentTelemetry.java',
'dlc/StudyAction.java',
'dlc/SyncAction.java',
'dlc/VerifyAction.java',
'DoorHangerPopup.java',
'DownloadsIntegration.java',
+ 'drawable/DrawableWrapper.java',
+ 'drawable/ShiftDrawable.java',
'DynamicToolbar.java',
'EditBookmarkDialog.java',
'Experiments.java',
'feeds/action/CheckForUpdatesAction.java',
'feeds/action/EnrollSubscriptionsAction.java',
'feeds/action/FeedAction.java',
'feeds/action/SetupAlarmsAction.java',
'feeds/action/SubscribeToFeedAction.java',
@@ -957,16 +959,17 @@ gbjar.sources += ['java/org/mozilla/geck
'webapps/WebAppActivity.java',
'webapps/WebAppIndexer.java',
'webapps/WebApps.java',
'widget/ActionModePresenter.java',
'widget/ActivityChooserModel.java',
'widget/AllCapsTextView.java',
'widget/AnchoredPopup.java',
'widget/AnimatedHeightLayout.java',
+ 'widget/AnimatedProgressBar.java',
'widget/BasicColorPicker.java',
'widget/CheckableLinearLayout.java',
'widget/ClickableWhenDisabledEditText.java',
'widget/ContentSecurityDoorHanger.java',
'widget/CropImageView.java',
'widget/DateTimePicker.java',
'widget/DefaultDoorHanger.java',
'widget/DefaultItemAnimatorBase.java',