| Index: chrome/android/java/src/org/chromium/chrome/browser/firstrun/ImageCarousel.java
|
| diff --git a/chrome/android/java/src/org/chromium/chrome/browser/firstrun/ImageCarousel.java b/chrome/android/java/src/org/chromium/chrome/browser/firstrun/ImageCarousel.java
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..1edd5122b547a8966c7163babb5dd140aa1e1810
|
| --- /dev/null
|
| +++ b/chrome/android/java/src/org/chromium/chrome/browser/firstrun/ImageCarousel.java
|
| @@ -0,0 +1,410 @@
|
| +// Copyright 2015 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
| +
|
| +package org.chromium.chrome.browser.firstrun;
|
| +
|
| +import android.animation.Animator;
|
| +import android.animation.AnimatorSet;
|
| +import android.animation.ObjectAnimator;
|
| +import android.content.Context;
|
| +import android.graphics.Bitmap;
|
| +import android.util.AttributeSet;
|
| +import android.util.Property;
|
| +import android.view.GestureDetector;
|
| +import android.view.Gravity;
|
| +import android.view.MotionEvent;
|
| +import android.view.View;
|
| +import android.view.animation.DecelerateInterpolator;
|
| +import android.widget.FrameLayout;
|
| +import android.widget.ImageView;
|
| +
|
| +import org.chromium.chrome.R;
|
| +
|
| +import java.util.Arrays;
|
| +
|
| +/**
|
| + * Account chooser that displays profile images in a carousel and allows users to rotate it to
|
| + * select an account.
|
| + *
|
| + * Internally it is implemented using four ImageViews that get translated along the X axis based
|
| + * on the current carousel position.
|
| + *
|
| + * |'''''| |'''''| |'''''| |'''''| |'''''|
|
| + * |'''| |'''| |'''| |'''| |'''| |'''| |'''| |'''| |'''| |'''|
|
| + * |IM3| IM0 |IM1| -> |IM0| IM1 |IM2| -> |IM1| IM2 |IM3| -> |IM2| IM3 |IM0| -> |IM3| IM0 |IM1|
|
| + * |,,,| |,,,| |,,,| |,,,| |,,,| |,,,| |,,,| |,,,| |,,,| |,,,|
|
| + * |,,,,,| |,,,,,| |,,,,,| |,,,,,| |,,,,,|
|
| + *
|
| + * mPosition=0 mPosition=1 mPosition=2 mPosition=3 mPosition=4
|
| + *
|
| + * IM0 is mViews[0]
|
| + * IM1 is mViews[1]
|
| + * IM2 is mViews[2]
|
| + * IM3 is mViews[3]
|
| + *
|
| + * Each ImageView is displaying a profile image if there is one, however it is not necessarily true
|
| + * that IM0 is showing mImages[0] and IM1 is showing mImages[1], and so on. This changes when there
|
| + * are more than 4 accounts and ImageViews get reused for new accounts.
|
| + */
|
| +public class ImageCarousel extends FrameLayout implements GestureDetector.OnGestureListener {
|
| +
|
| + /**
|
| + * Constant used together image width to calculate how far should should each image move in
|
| + * x axis. This value was tweaked until images did not overlap with each other when scrolling.
|
| + */
|
| + private static final float TRANSLATION_FACTOR = 0.64f;
|
| +
|
| + /**
|
| + * Constant used together with carousel width to calculate how should fling velocity in x axis
|
| + * be scaled when changing ImageCarousel position. It was tweaked for flings to look natural.
|
| + */
|
| + private static final float FLING_FACTOR = 20f * 0.92f / 2f;
|
| +
|
| + /**
|
| + * Constant used together with carousel width to calculate how should scroll distance in x axis
|
| + * be scaled when changing ImageCarousel position. It was tweaked for image to follow user's
|
| + * finger when scrolling.
|
| + */
|
| + private static final float SCROLL_FACTOR = 0.92f / 2f;
|
| +
|
| + /**
|
| + * Listener to ImageCarousel center position changes.
|
| + */
|
| + public interface ImageCarouselPositionChangeListener {
|
| + /**
|
| + * @param position The new center position of the ImageCarousel. It is a number in
|
| + * range [0, mImages.length).
|
| + */
|
| + void onPositionChanged(int position);
|
| + }
|
| +
|
| + private static final int SCROLL_ANIMATION_DURATION_MS = 200;
|
| + private static final int ACCOUNT_SIGNED_IN_ANIMATION_DURATION_MS = 200;
|
| +
|
| + /**
|
| + * Number of ImageViews used in ImageCarousel.
|
| + */
|
| + private static final int VIEW_COUNT = 4;
|
| +
|
| + private static final int[] ORDER_OFFSETS = {2, 1, 3, 0};
|
| +
|
| + private static final int[] POSITION_OFFSETS = {0, -1, 2, 1};
|
| +
|
| + private static final int[] BITMAP_OFFSETS = {2, 1, -1, 0};
|
| +
|
| + /**
|
| + * Property used to animate scrolling of the ImageCarousel.
|
| + */
|
| + private static final Property<ImageCarousel, Float> POSITION_PROPERTY =
|
| + new Property<ImageCarousel, Float>(Float.class, "") {
|
| + @Override
|
| + public Float get(ImageCarousel object) {
|
| + return object.mPosition;
|
| + }
|
| +
|
| + @Override
|
| + public void set(ImageCarousel object, Float value) {
|
| + object.setPosition(value);
|
| + }
|
| + };
|
| +
|
| + /**
|
| + * Property used to animate the alpha value of the images that are currently on the left and
|
| + * the right of the center image.
|
| + */
|
| + private static final Property<ImageCarousel, Float> BACKGROUND_IMAGE_ALPHA =
|
| + new Property<ImageCarousel, Float>(Float.class, "") {
|
| + @Override
|
| + public Float get(ImageCarousel object) {
|
| + return object.mViews[object.getChildDrawingOrder(VIEW_COUNT, 1)].getAlpha();
|
| + }
|
| +
|
| + @Override
|
| + public void set(ImageCarousel object, Float value) {
|
| + object.mViews[object.getChildDrawingOrder(VIEW_COUNT, 1)].setAlpha(value);
|
| + object.mViews[object.getChildDrawingOrder(VIEW_COUNT, 2)].setAlpha(value);
|
| + }
|
| + };
|
| +
|
| + /**
|
| + * Gesture detector used to capture scrolls, flings and taps on the image carousel.
|
| + */
|
| + private GestureDetector mGestureDetector;
|
| +
|
| + /**
|
| + * Array that holds four ImageViews that are used to display images in the carousel.
|
| + */
|
| + private ImageView[] mViews = new ImageView[VIEW_COUNT];
|
| +
|
| + /**
|
| + * Images that shown in the image carousel.
|
| + */
|
| + private Bitmap[] mImages;
|
| +
|
| + private Animator mScrollAnimator;
|
| + private Animator mFadeInOutAnimator;
|
| +
|
| + private float mPosition = 0f;
|
| +
|
| + private ImageCarouselPositionChangeListener mListener;
|
| + private int mLastPosition = 0;
|
| + private boolean mNeedsPositionUpdates = true;
|
| +
|
| + private int mCarouselWidth;
|
| + private int mImageWidth;
|
| + private float mScrollScalingFactor;
|
| + private float mFlingScalingFactor;
|
| + private float mTranslationFactor;
|
| +
|
| + private boolean mScrollingDisabled;
|
| + private boolean mAccountSelected;
|
| +
|
| + public ImageCarousel(Context context, AttributeSet attrs) {
|
| + super(context, attrs);
|
| + mGestureDetector = new GestureDetector(getContext(), this);
|
| + }
|
| +
|
| + /**
|
| + * Scrolls ImageCarousel to the closest whole position for the desired position.
|
| + * @param position Desired ImageCarousel position.
|
| + * @param decelerate Whether animation should be decelerating.
|
| + * @param needsPositionUpdates Whether this scroll should trigger position update calls to
|
| + * mListener.
|
| + */
|
| + public void scrollTo(float position, boolean decelerate, boolean needsPositionUpdates) {
|
| + mNeedsPositionUpdates = needsPositionUpdates;
|
| + if (mScrollAnimator != null) mScrollAnimator.cancel();
|
| +
|
| + position = Math.round(position);
|
| + mScrollAnimator = ObjectAnimator.ofFloat(this, POSITION_PROPERTY, mPosition, position);
|
| + mScrollAnimator.setDuration(SCROLL_ANIMATION_DURATION_MS);
|
| + if (decelerate) mScrollAnimator.setInterpolator(new DecelerateInterpolator());
|
| + mScrollAnimator.start();
|
| + }
|
| +
|
| + /**
|
| + * @param listener Listener that should be notified on ImageCarousel center position changes.
|
| + */
|
| + public void setListener(ImageCarouselPositionChangeListener listener) {
|
| + mListener = listener;
|
| + }
|
| +
|
| + /**
|
| + * @param images Images that should be displayed in the ImageCarousel.
|
| + */
|
| + public void setImages(Bitmap[] images) {
|
| + switch (images.length) {
|
| + case 0:
|
| + mImages = null;
|
| + mScrollingDisabled = true;
|
| + break;
|
| + case 1:
|
| + mScrollingDisabled = true;
|
| + mImages = Arrays.copyOf(images, images.length);
|
| + break;
|
| + default:
|
| + // Enable scrolling only if no account has already been selected.
|
| + mScrollingDisabled = mAccountSelected;
|
| + mImages = Arrays.copyOf(images, images.length);
|
| + break;
|
| + }
|
| +
|
| + updateImageViews();
|
| + }
|
| +
|
| + /**
|
| + * Sets the ImageCarousel to signed in mode that disables scrolling, animates away the
|
| + * background images, and displays a checkmark next to the account image that was chosen.
|
| + */
|
| + public void setSignedInMode() {
|
| + mScrollingDisabled = true;
|
| + mAccountSelected = true;
|
| + setPosition(getCenterPosition());
|
| +
|
| + ImageView checkmark = new ImageView(getContext());
|
| + checkmark.setImageResource(R.drawable.verify_checkmark);
|
| + setLayoutParamsForCheckmark(checkmark);
|
| + addView(checkmark);
|
| +
|
| + if (mFadeInOutAnimator != null) mFadeInOutAnimator.cancel();
|
| + AnimatorSet animatorSet = new AnimatorSet();
|
| + animatorSet.playTogether(
|
| + ObjectAnimator.ofFloat(this, BACKGROUND_IMAGE_ALPHA, 0),
|
| + ObjectAnimator.ofFloat(checkmark, View.ALPHA, 0.0f, 1.0f));
|
| + mFadeInOutAnimator = animatorSet;
|
| + mFadeInOutAnimator.setDuration(ACCOUNT_SIGNED_IN_ANIMATION_DURATION_MS);
|
| + mFadeInOutAnimator.start();
|
| + }
|
| +
|
| + @Override
|
| + public void onFinishInflate() {
|
| + super.onFinishInflate();
|
| +
|
| + mImageWidth = getResources().getDimensionPixelSize(R.dimen.fre_image_carousel_height);
|
| + for (int i = 0; i < VIEW_COUNT; ++i) {
|
| + ImageView view = new ImageView(getContext());
|
| + FrameLayout.LayoutParams params =
|
| + new FrameLayout.LayoutParams(mImageWidth, mImageWidth);
|
| + params.gravity = Gravity.CENTER;
|
| + view.setLayoutParams(params);
|
| + mViews[i] = view;
|
| + addView(view);
|
| + }
|
| +
|
| + mCarouselWidth = getResources().getDimensionPixelSize(R.dimen.fre_image_carousel_width);
|
| + mScrollScalingFactor = SCROLL_FACTOR * mCarouselWidth;
|
| + mFlingScalingFactor = FLING_FACTOR * mCarouselWidth;
|
| + mTranslationFactor = TRANSLATION_FACTOR * mImageWidth;
|
| +
|
| + setChildrenDrawingOrderEnabled(true);
|
| + setPosition(0f);
|
| + }
|
| +
|
| + /**
|
| + * @return The index of the view that should be drawn on the given iteration.
|
| + */
|
| + @Override
|
| + protected int getChildDrawingOrder(int childCount, int iteration) {
|
| + // Draw the views that are not our 4 ImagesViews in their normal order.
|
| + if (iteration >= VIEW_COUNT) return iteration;
|
| +
|
| + // Draw image views in the correct z order based on the current position.
|
| + return (Math.round(mPosition) + ORDER_OFFSETS[iteration]) % VIEW_COUNT;
|
| + }
|
| +
|
| + @Override
|
| + public boolean onTouchEvent(MotionEvent event) {
|
| + if (mScrollingDisabled) return false;
|
| + if (mGestureDetector.onTouchEvent(event)) return true;
|
| +
|
| + if (event.getAction() == MotionEvent.ACTION_UP
|
| + || event.getAction() == MotionEvent.ACTION_CANCEL) {
|
| + scrollTo(mPosition, false, true);
|
| + }
|
| +
|
| + return false;
|
| + }
|
| +
|
| + // Implementation of GestureDetector.OnGestureListener
|
| +
|
| + @Override
|
| + public boolean onDown(MotionEvent motionEvent) {
|
| + return true;
|
| + }
|
| +
|
| + @Override
|
| + public void onShowPress(MotionEvent motionEvent) {}
|
| +
|
| + @Override
|
| + public boolean onSingleTapUp(MotionEvent motionEvent) {
|
| + mNeedsPositionUpdates = true;
|
| + if (motionEvent.getX() < (mCarouselWidth - mImageWidth) / 2f) {
|
| + scrollTo(mPosition - 1, false, true);
|
| + return true;
|
| + } else if (motionEvent.getX() > (mCarouselWidth + mImageWidth) / 2f) {
|
| + scrollTo(mPosition + 1, false, true);
|
| + return true;
|
| + }
|
| + return false;
|
| + }
|
| +
|
| + @Override
|
| + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
|
| + // Once the user has started scrolling, prevent the parent view from handling touch events.
|
| + // This allows the ImageCarousel to be behave reasonably when nested inside a ScrollView.
|
| + getParent().requestDisallowInterceptTouchEvent(true);
|
| +
|
| + mNeedsPositionUpdates = true;
|
| + setPosition(mPosition + distanceX / mScrollScalingFactor);
|
| + return true;
|
| + }
|
| +
|
| + @Override
|
| + public void onLongPress(MotionEvent motionEvent) {}
|
| +
|
| + @Override
|
| + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
|
| + mNeedsPositionUpdates = true;
|
| + scrollTo(mPosition - velocityX / mFlingScalingFactor, true, true);
|
| + return true;
|
| + }
|
| +
|
| + // Internal methods
|
| +
|
| + /**
|
| + * Updates the position, scale, alpha and image shown for all four ImageViews used by
|
| + * the ImageCarousel.
|
| + */
|
| + private void updateImageViews() {
|
| + if (mImages == null) return;
|
| +
|
| + for (int i = 0; i < VIEW_COUNT; i++) {
|
| + if (mAccountSelected && i != getCenterPosition()) continue;
|
| +
|
| + ImageView image = mViews[i];
|
| +
|
| + updateBitmap(i);
|
| +
|
| + final float position = mPosition + POSITION_OFFSETS[i];
|
| +
|
| + // X translation is a sin function with a period of 4 and with range
|
| + // [-mTranslationFactor, mTranslationFactor]
|
| + image.setTranslationX(
|
| + -mTranslationFactor * ((float) Math.sin(position * Math.PI / 2f)));
|
| +
|
| + // scale is a cos function with a period of 4 and range [1/3, 1]
|
| + // scale is 1 when the image is in the front and 1/3 when the image is behind other
|
| + // images.
|
| + final float scale = (float) Math.cos(position * Math.PI / 2f) / 3f + 2f / 3f;
|
| + image.setScaleY(scale);
|
| + image.setScaleX(scale);
|
| +
|
| + // alpha is a cos^2 function with a period of 2 and range [0, 1]
|
| + // alpha is 1 when the image is in the center in the front and 0 when it is in the back.
|
| + final float alpha = (float) Math.pow(Math.cos(position * Math.PI / 4f), 2);
|
| + image.setAlpha(alpha);
|
| + }
|
| + }
|
| +
|
| + private void updateBitmap(int i) {
|
| + if (mImages.length == 1 && i < 3) return;
|
| + ImageView image = mViews[getChildDrawingOrder(VIEW_COUNT, i)];
|
| + image.setImageBitmap(mImages[
|
| + (mImages.length + Math.round(mPosition) + BITMAP_OFFSETS[i]) % mImages.length]);
|
| + }
|
| +
|
| + private void setPosition(float position) {
|
| + if (mImages != null) {
|
| + mPosition = ((position % mImages.length) + mImages.length) % mImages.length;
|
| + }
|
| +
|
| + int adjustedPosition = getCenterPosition();
|
| + if (adjustedPosition != mLastPosition) {
|
| + mLastPosition = adjustedPosition;
|
| + if (mListener != null && mNeedsPositionUpdates) {
|
| + mListener.onPositionChanged(adjustedPosition);
|
| + }
|
| + }
|
| +
|
| + // Need to call invalidate() for getChildDrawingOrder() to be called since the image
|
| + // order has changed.
|
| + updateImageViews();
|
| + invalidate();
|
| + }
|
| +
|
| + private int getCenterPosition() {
|
| + if (mImages == null) return 0;
|
| + return Math.round(mPosition) % mImages.length;
|
| + }
|
| +
|
| + private void setLayoutParamsForCheckmark(View view) {
|
| + int size = getResources().getDimensionPixelSize(R.dimen.fre_checkmark_size);
|
| + FrameLayout.LayoutParams params =
|
| + new FrameLayout.LayoutParams(size, size);
|
| + params.gravity = Gravity.CENTER;
|
| + view.setLayoutParams(params);
|
| + view.setTranslationX((mImageWidth - size) / 2f);
|
| + view.setTranslationY((mImageWidth - size) / 2f);
|
| + }
|
| +}
|
|
|