Introduction
For a while, I was looking for a 3D carousel control for Android platform. The only one I found was UltimateFaves at [1]. But as it turned out, it uses OpenGL. And it’s not open source. I thought if it is possible to avoid a use of OpenGL. Continuing my investigations, I stamped on Coverflow Widget at [2]. And it uses standard Android 2D libraries. So the idea was the same – to use Gallery class for the carousel. The Coverflow Widget just rotates images and I wanted to rotate all group of them. Well, at least it implies the use of simple trig methods. More complicated stuff goes with the Gallery class. If you’d look through the article about Coverflow Widget at [3], you’d see a bunch of problems, such as unavailability of default scope variables in AbsSpinner
and AdapterView
classes. So I went the same way and rewrote some classes. And the Scroller
class will be replaced by the Rotator
class which looks like Scroller
but it rotates the group of images.
The Preparations
At first, we should decide what parameters will define a behavior of our Carousel. For example, a min quantity of items in the carousel. It will not look nice if it has only one or two items, won’t it? As for performance issue, we have to define max quantity of items. Also, we will need max theta angle for the carousel, what items will be in there, current selected item and if items will be reflected. So let’s define them in attrs.xml file:
="1.0"="utf-8"
<resources>
<declare-styleable name="Carousel">
<attr name="android:gravity" />
<attr name="android:animationDuration" />
<attr name="UseReflection" format="boolean"/>
<attr name="Items" format="integer"/>
<attr name="SelectedItem" format="integer"/>
<attr name="maxTheta" format="float"/>
<attr name="minQuantity" format="integer"/>
<attr name="maxQuantity" format="integer"/>
</declare-styleable>
</resources>
The Carousel Item Class
To simplify some stuff with carousel, I’ve created CarouselImageView
:
public class CarouselImageView extends ImageView
implements Comparable<carouselimageview> {
private int index;
private float currentAngle;
private float x;
private float y;
private float z;
private boolean drawn;
public CarouselImageView(Context context) {
this(context, null, 0);
}
public CarouselImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CarouselImageView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public int compareTo(CarouselImageView another) {
return (int)(another.z – this.z);
}
…
}
</carouselimageview>
It incapsulates the position in 3D space, the index of an item and the current angle of an item. Also implementing it as Comparable
will be helpful when we’ll determine a draw order of the items.
The Rotator Class
If you’d look at the source code of Scroller
class, you’ll see two modes: the scroll mode and the fling mode supposed just to calculate current offset from the given start point. We’ll just need to remove extra members, add our own and replace the corresponding calculations:
public class Rotator {
private int mMode;
private float mStartAngle;
private float mCurrAngle;
private long mStartTime;
private long mDuration;
private float mDeltaAngle;
private boolean mFinished;
private float mCoeffVelocity = 0.05f;
private float mVelocity;
private static final int DEFAULT_DURATION = 250;
private static final int SCROLL_MODE = 0;
private static final int FLING_MODE = 1;
private final float mDeceleration = 240.0f;
public Rotator(Context context) {
mFinished = true;
}
public final boolean isFinished() {
return mFinished;
}
public final void forceFinished(boolean finished) {
mFinished = finished;
}
public final long getDuration() {
return mDuration;
}
public final float getCurrAngle() {
return mCurrAngle;
}
public float getCurrVelocity() {
return mCoeffVelocity * mVelocity - mDeceleration * timePassed() ;
}
public final float getStartAngle() {
return mStartAngle;
}
public int timePassed() {
return (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
}
public void extendDuration(int extend) {
int passed = timePassed();
mDuration = passed + extend;
mFinished = false;
}
public void abortAnimation() {
mFinished = true;
}
public boolean computeAngleOffset()
{
if (mFinished) {
return false;
}
long systemClock = AnimationUtils.currentAnimationTimeMillis();
long timePassed = systemClock - mStartTime;
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
float sc = (float)timePassed / mDuration;
mCurrAngle = mStartAngle + Math.round(mDeltaAngle * sc);
break;
case FLING_MODE:
float timePassedSeconds = timePassed / 1000.0f;
float distance;
if(mVelocity < 0)
{
distance = mCoeffVelocity * mVelocity * timePassedSeconds -
(mDeceleration * timePassedSeconds * timePassedSeconds / 2.0f);
}
else{
distance = -mCoeffVelocity * mVelocity * timePassedSeconds -
(mDeceleration * timePassedSeconds * timePassedSeconds / 2.0f);
}
mCurrAngle = mStartAngle - Math.signum(mVelocity)*Math.round(distance);
break;
}
return true;
}
else
{
mFinished = true;
return false;
}
}
public void startRotate(float startAngle, float dAngle, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartAngle = startAngle;
mDeltaAngle = dAngle;
}
public void startRotate(float startAngle, float dAngle) {
startRotate(startAngle, dAngle, DEFAULT_DURATION);
}
public void fling(float velocityAngle) {
mMode = FLING_MODE;
mFinished = false;
float velocity = velocityAngle;
mVelocity = velocity;
mDuration = (int)(1000.0f * Math.sqrt(2.0f * mCoeffVelocity *
Math.abs(velocity)/mDeceleration));
mStartTime = AnimationUtils.currentAnimationTimeMillis();
}
}
The CarouselSpinner Differences with the AbsSpinner
First, it extends CarouselAdapter
vs AdapterView
. Those differences I’ll describe later. Second, the modified constructor where the retrieving of AbsSpinner
entries were removed. The third difference is modified setSelection(int)
method. It was just call to setSelectionInt
left. The next change is unavailable variables were replaced with their getters. As for default generated layout parameters, both were set to WRAP_CONTENT
. The main changes concern pointToPosition
method. In AbsSpinner
, it determines if definite item was touched on a screen no matter whether it’s current or not. In CarouselSpinner
, all touches will concern only the current item. So just return selected item index:
public int pointToPosition(int x, int y) {
return mSelectedPosition;
}
The CarouselAdapter vs. AdapterView
The only changes are in updateEmptyStatus
method where unavailable variables were replaced with their getters.
The Carousel Class
Here FlingRunnable
class was replaced with FlingRotateRunnable
which is much like FlingRunnable
but makes deal with angle vs. x-coordinate:
private class FlingRotateRunnable implements Runnable {
private Rotator mRotator;
private float mLastFlingAngle;
public FlingRotateRunnable(){
mRotator = new Rotator(getContext());
}
private void startCommon() {
removeCallbacks(this);
}
public void startUsingVelocity(float initialVelocity) {
if (initialVelocity == 0) return;
startCommon();
mLastFlingAngle = 0.0f;
mRotator.fling(initialVelocity);
post(this);
}
public void startUsingDistance(float deltaAngle) {
if (deltaAngle == 0) return;
startCommon();
mLastFlingAngle = 0;
synchronized(this)
{
mRotator.startRotate(0.0f, -deltaAngle, mAnimationDuration);
}
post(this);
}
public void stop(boolean scrollIntoSlots) {
removeCallbacks(this);
endFling(scrollIntoSlots);
}
private void endFling(boolean scrollIntoSlots) {
synchronized(this){
mRotator.forceFinished(true);
}
if (scrollIntoSlots) scrollIntoSlots();
}
public void run() {
if (Carousel.this.getChildCount() == 0) {
endFling(true);
return;
}
mShouldStopFling = false;
final Rotator rotator;
final float angle;
boolean more;
synchronized(this){
rotator = mRotator;
more = rotator.computeAngleOffset();
angle = rotator.getCurrAngle();
}
float delta = mLastFlingAngle - angle;
trackMotionScroll(delta);
if (more && !mShouldStopFling) {
mLastFlingAngle = angle;
post(this);
} else {
mLastFlingAngle = 0.0f;
endFling(true);
}
}
}
I also added ImageAdapter
class as it is in Coverflow Widget with a possibility to add a reflection to the images. And some new private
variables were added to support Y-axe angle, reflection and so on. The constructor retrieves list of images, creates ImageAdapter
and sets it. The main thing in the constructor is setting the object to support static
transformations. And to place images into their places:
void layout(int delta, boolean animate){
if (mDataChanged) {
handleDataChanged();
}
if (this.getCount() == 0) {
resetList();
return;
}
if (mNextSelectedPosition >= 0) {
setSelectedPositionInt(mNextSelectedPosition);
}
recycleAllViews();
detachAllViewsFromParent();
int count = getAdapter().getCount();
float angleUnit = 360.0f / count;
float angleOffset = mSelectedPosition * angleUnit;
for(int i = 0; i< getAdapter().getCount(); i++){
float angle = angleUnit * i - angleOffset;
if(angle < 0.0f)
angle = 360.0f + angle;
makeAndAddView(i, angle);
}
mRecycler.clear();
invalidate();
setNextSelectedPositionInt(mSelectedPosition);
checkSelectionChanged();
mNeedSync = false;
updateSelectedItemMetadata();
}
Here are the methods to set up images. The height of an image is set three times lesser than parent height to make the carousel fit parent view. It should be reworked later.
private void makeAndAddView(int position, float angleOffset) {
CarouselImageView child;
if (!mDataChanged) {
child = (CarouselImageView)mRecycler.get(position);
if (child != null) {
setUpChild(child, child.getIndex(), angleOffset);
}
else
{
child = (CarouselImageView)mAdapter.getView(position, null, this);
setUpChild(child, child.getIndex(), angleOffset);
}
return;
}
child = (CarouselImageView)mAdapter.getView(position, null, this);
setUpChild(child, child.getIndex(), angleOffset);
}
private void setUpChild(CarouselImageView child, int index, float angleOffset) {
addViewInLayout(child, -1 , generateDefaultLayoutParams());
child.setSelected(index == this.mSelectedPosition);
int h;
int w;
if(mInLayout)
{
h = (this.getMeasuredHeight() -
this.getPaddingBottom() - this.getPaddingTop())/3;
w = this.getMeasuredWidth() -
this.getPaddingLeft() - this.getPaddingRight();
}
else
{
h = this.getHeight()/3;
w = this.getWidth();
}
child.setCurrentAngle(angleOffset);
Calculate3DPosition(child, w, angleOffset);
child.measure(w, h);
int childLeft;
int childTop = calculateTop(child, true);
childLeft = 0;
child.layout(childLeft, childTop, w, h);
}
Let’s look at trackMotionScroll
method in the Gallery
class, it’s called when the widget is being scrolled or flinged and does the necessary stuff for the Gallary animation. But it moves images just by x-coordinate. To make them rotate in 3D space, we must create different functionality. We just change the current angle of an image and calculate it’s position in 3D space:
void trackMotionScroll(float deltaAngle) {
if (getChildCount() == 0) {
return;
}
for(int i = 0; i < getAdapter().getCount(); i++){
CarouselImageView child = (CarouselImageView)getAdapter().getView(i, null, null);
float angle = child.getCurrentAngle();
angle += deltaAngle;
while(angle > 360.0f)
angle -= 360.0f;
while(angle < 0.0f)
angle += 360.0f;
child.setCurrentAngle(angle);
Calculate3DPosition(child, getWidth(), angle);
}
mRecycler.clear();
invalidate();
}
And after images were flinged or scrolled, we have to place them into the corresponding places:
private void scrollIntoSlots(){
if (getChildCount() == 0 || mSelectedChild == null) return;
float angle;
int position;
ArrayList<carouselimageview> arr = new ArrayList<carouselimageview>();
for(int i = 0; i < getAdapter().getCount(); i++)
arr.add(((CarouselImageView)getAdapter().getView(i, null, null)));
Collections.sort(arr, new Comparator<carouselimageview>(){
@Override
public int compare(CarouselImageView c1, CarouselImageView c2) {
int a1 = (int)c1.getCurrentAngle();
if(a1 > 180)
a1 = 360 - a1;
int a2 = (int)c2.getCurrentAngle();
if(a2 > 180)
a2 = 360 - a2;
return (a1 - a2) ;
}
});
angle = arr.get(0).getCurrentAngle();
if(angle > 180.0f)
angle = -(360.0f - angle);
if(angle != 0.0f)
{
mFlingRunnable.startUsingDistance(-angle);
}
else
{
position = arr.get(0).getIndex();
setSelectedPositionInt(position);
onFinishedMovement();
}
}
</carouselimageview></carouselimageview></carouselimageview>
And to scroll to the definite item:
void scrollToChild(int i){
CarouselImageView view = (CarouselImageView)getAdapter().getView(i, null, null);
float angle = view.getCurrentAngle();
if(angle == 0)
return;
if(angle > 180.0f)
angle = 360.0f - angle;
else
angle = -angle;
mFlingRunnable.startUsingDistance(-angle);
}
Here’s the Calculate3DPosition
method:
private void Calculate3DPosition
(CarouselImageView child, int diameter, float angleOffset){
angleOffset = angleOffset * (float)(Math.PI/180.0f);
float x = -(float)(diameter/2*Math.sin(angleOffset));
float z = diameter/2 * (1.0f - (float)Math.cos(angleOffset));
float y = - getHeight()/2 + (float) (z * Math.sin(mTheta));
child.setX(x);
child.setZ(z);
child.setY(y);
}
Some methods that don’t have a sense with 3D gallery were removed: offsetChildrenLeftAndRight
, detachOffScreenChildren
, setSelectionToCenterChild
, fillToGalleryLeft
, fillToGalleryRight
.
So, the main thing that happens with images is in getChildStaticTransformation
method, where they are transformed in 3D space. It just takes a ready to use position from CarouselImage
class that was calculated by Calculate3DPosition
while flinging/scrolling and moves an image there:
protected boolean getChildStaticTransformation
(View child, Transformation transformation) {
transformation.clear();
transformation.setTransformationType(Transformation.TYPE_MATRIX);
float centerX = (float)child.getWidth()/2, centerY = (float)child.getHeight()/2;
mCamera.save();
final Matrix matrix = transformation.getMatrix();
mCamera.translate(((CarouselImageView)child).getX(),
((CarouselImageView)child).getY(),
((CarouselImageView)child).getZ());
mCamera.getMatrix(matrix);
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);
mCamera.restore();
return true;
}
One thing to know is that if you will just rotate images and position them in 3D space, they can overlap each other in the wrong order. For example, an image with 100.0 z-coordinate can be drawn in front of image with 50.0 z-coordinate. To resolve this trouble, we can override getChildDrawingOrder
:
protected int getChildDrawingOrder(int childCount, int i) {
ArrayList<carouselimageview> sl = new ArrayList<carouselimageview>();
for(int j = 0; j < childCount; j++)
{
CarouselImageView view = (CarouselImageView)getAdapter().
getView(j,null, null);
if(i == 0)
view.setDrawn(false);
sl.add((CarouselImageView)getAdapter().getView(j,null, null));
}
Collections.sort(sl);
int idx = 0;
for(CarouselImageView civ : sl)
{
if(!civ.isDrawn())
{
civ.setDrawn(true);
idx = civ.getIndex();
break;
}
}
return idx;
}
</carouselimageview></carouselimageview>
Ok, it still has a lot to do, like bugs catching and optimization. I didn’t yet test all the functionality, but in the first approximation, it works.
Icons were taken from here: [4].
P.S. Fixed bug in Rotator
class. Jerky "scroll into slots" was made more soft and fluid.
Reworked the Rotator
class. It uses only angular acceleration now.
Fixed Jelly Bean issue.
Resources
- http://ultimatefaves.com/
- http://www.inter-fuser.com/2010/02/android-coverflow-widget-v2.html
- http://www.inter-fuser.com/2010/01/android-coverflow-widget.html
- http://www.iconsmaster.com/Plush-Icons-Set/