Android 3D Carousel
Introduction
For a while I was looking for 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 wich 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:
<?xml version="1.0" encoding="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 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 corresponding calculations:
public class Rotator {
private int mMode;
private float mStartAngle;
private float mFinalAngle;
private float mMaxAngle;
private float mMinAngle;
private float mCurrAngle;
private long mStartTime;
private int mDuration;
private float mDeltaAngle;
private float mViscousFluidScale;
private float mViscousFluidNormalize;
private boolean mFinished;
private float mCoeffAngle = 0.0f;
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;
public Rotator(Context context) {
mFinished = true;
float ppi = context.getResources().getDisplayMetrics().density * 160.0f;
mDeceleration = SensorManager.GRAVITY_EARTH // g (m/s^2)
* 39.37f // inch/meter
* ppi // pixels per inch
* ViewConfiguration.getScrollFriction();
}
public final boolean isFinished() {
return mFinished;
}
public final void forceFinished(boolean finished) {
mFinished = finished;
}
public final int getDuration() {
return mDuration;
}
public final float getCurrAngle() {
return mCurrAngle;
}
public float getCurrVelocity() {
return mVelocity - mDeceleration * timePassed() / 2000.0f;
}
public final float getStartAngle() {
return mStartAngle;
}
public final float getFinalAngle() {
return mFinalAngle;
}
public int timePassed() {
return (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
}
public void extendDuration(int extend) {
int passed = timePassed();
mDuration = passed + extend;
mFinished = false;
}
public void abortAnimation() {
mCurrAngle = mFinalAngle;
mFinished = true;
}
private float viscousFluid(float x)
{
x *= mViscousFluidScale;
if (x < 1.0f) {
x -= (1.0f - (float)Math.exp(-x));
} else {
float start = 0.36787944117f; // 1/e == exp(-1)
x = 1.0f - (float)Math.exp(1.0f - x);
x = start + x * (1.0f - start);
}
x *= mViscousFluidNormalize;
return x;
}
public void setFinalAngle(int newAngle) {
mFinalAngle = newAngle;
mDeltaAngle = mFinalAngle - mStartAngle;
mFinished = false;
}
/**
* Call this when you want to know the new location. If it returns true,
* the animation is not yet finished. loc will be altered to provide the
* new location.
*/
public boolean computeAngleOffset()
{
if (mFinished) {
return false;
}
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
int sc = timePassed / mDuration;
mCurrAngle = mStartAngle + Math.round(mDeltaAngle * sc);
break;
case FLING_MODE:
float timePassedSeconds = timePassed / 1000.0f;
float distance = (mVelocity * timePassedSeconds)
- (mDeceleration * timePassedSeconds * timePassedSeconds / 2.0f);
mCurrAngle = mStartAngle + Math.round(distance * mCoeffAngle);
// Pin to mMinX <= mCurrX <= mMaxX
mCurrAngle = Math.min(mCurrAngle, mMaxAngle);
mCurrAngle = Math.max(mCurrAngle, mMinAngle);
break;
}
}
else
{
mCurrAngle = mFinalAngle;
mFinished = true;
}
return true;
}
/**
* Start scrolling by providing a starting point and the distance to travel.
*/
public void startRotate(float startAngle, float dAngle, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartAngle = startAngle;
mFinalAngle = startAngle + dAngle;
mDeltaAngle = dAngle;
// This controls the viscous fluid effect (how much of it)
mViscousFluidScale = 8.0f;
// must be set to 1.0 (used in viscousFluid())
mViscousFluidNormalize = 1.0f;
mViscousFluidNormalize = 1.0f / viscousFluid(1.0f);
}
/**
* Start scrolling by providing a starting point and the distance to travel.
* The scroll will use the default value of 250 milliseconds for the
* duration.
*
*/
public void startRotate(float startAngle, float dAngle) {
startRotate(startAngle, dAngle, DEFAULT_DURATION);
}
/**
* Start scrolling based on a fling gesture. The distance travelled will
* depend on the initial velocity of the fling.
*
*/
public void fling(float startAngle, float velocityAngle,
float minAngle, float maxAngle) {
mMode = FLING_MODE;
mFinished = false;
float velocity = velocityAngle;
mVelocity = velocity;
mDuration = (int) (1000 * velocity / mDeceleration); // Duration is in
// milliseconds
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartAngle = startAngle;
mCoeffAngle = 1.0f;
int totalDistance = (int) ((velocity * velocity) / (2 * mDeceleration));
mMinAngle = minAngle;
mMaxAngle = maxAngle;
mFinalAngle = startAngle + Math.round(totalDistance * mCoeffAngle);
// Pin to mMinX <= mFinalX <= mMaxX
mFinalAngle = Math.min(mFinalAngle, mMaxAngle);
mFinalAngle = Math.max(mFinalAngle, mMinAngle);
}
}
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 call 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 it’s current or not. In CarouselSpinner all touches will concern only current item. So just return selected item index:
public int pointToPosition(int x, int y) {
// All touch events are applied to selected item
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 {
/**
* Tracks the decay of a fling rotation
*/
private Rotator mRotator;
/**
* Angle value reported by mRotator on the previous fling
*/
private float mLastFlingAngle;
/**
* Constructor
*/
public FlingRotateRunnable(){
mRotator = new Rotator(getContext());
}
private void startCommon() {
// Remove any pending flings
removeCallbacks(this);
}
public void startUsingVelocity(float initialVelocity) {
if (initialVelocity == 0) return;
startCommon();
float initialAngle = initialVelocity < 0 ? 360.0f : 0.0f;
mLastFlingAngle = initialAngle;
mRotator.fling(initialAngle, initialVelocity, 0.0f, 0.0f);
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) {
/*
* Force the scroller's status to finished (without setting its
* position to the end)
*/
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();
}
// Flip sign to convert finger direction to list items direction
// (e.g. finger moving down means list is moving towards the top)
float delta = mLastFlingAngle - angle;
// Pretend that each frame of a fling scroll is a touch scroll
if (delta > 0) { //CW
// Moving towards the left. Use first view as mDownTouchPosition
mDownTouchPosition = mFirstPosition;
// Don't fling more than 1 screen
delta = Math.min(getWidth() - getPaddingLeft() - getPaddingRight() - 1, delta);
}
else //CCW
{
///// Don't know yet what for it is
// Moving towards the right. Use last view as mDownTouchPosition
int offsetToLast = getChildCount() - 1;
mDownTouchPosition = mFirstPosition + offsetToLast;
// Don't fling more than 1 screen
delta = Math.max(-(getWidth() - getPaddingRight() - getPaddingLeft() - 1), delta);
}
//////// Shoud be reworked
trackMotionScroll(delta);
if (more && !mShouldStopFling) {
mLastFlingAngle = angle;
post(this);
} else {
endFling(true);
}
}
}
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 constructor is setting the object to support static transformations. And to place images into their places:
/**
* Setting up images
*/
void layout(int delta, boolean animate){
if (mDataChanged) {
handleDataChanged();
}
// Handle an empty gallery by removing all views.
if (this.getCount() == 0) {
resetList();
return;
}
// Update to the new selected position.
if (mNextSelectedPosition >= 0) {
setSelectedPositionInt(mNextSelectedPosition);
}
// All views go in recycler while we are in layout
recycleAllViews();
// Clear out old views
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);
}
// Flush any cached views that did not get reused above
mRecycler.clear();
invalidate();
setNextSelectedPositionInt(mSelectedPosition);
checkSelectionChanged();
////////mDataChanged = false;
mNeedSync = false;
updateSelectedItemMetadata();
}
Here’s 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) {
// Position the view
setUpChild(child, child.getIndex(), angleOffset);
}
else
{
// Nothing found in the recycler -- ask the adapter for a view
child = (CarouselImageView)mAdapter.getView(position, null, this);
// Position the view
setUpChild(child, child.getIndex(), angleOffset);
}
return;
}
// Nothing found in the recycler -- ask the adapter for a view
child = (CarouselImageView)mAdapter.getView(position, null, this);
// Position the view
setUpChild(child, child.getIndex(), angleOffset);
}
private void setUpChild(CarouselImageView child, int index, float angleOffset) {
// Ignore any layout parameters for child, use wrap content
addViewInLayout(child, -1 /*index*/, 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);
// Measure child
child.measure(w, h);
int childLeft;
// Position vertically based on gravity setting
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 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);
}
// Clear unused views
mRecycler.clear();
invalidate();
}
And after images were flinged or scrolled, we have to place them into corresponding places:
/**
* Brings an item with nearest to 0 degrees angle to this angle and sets it selected
*/
private void scrollIntoSlots(){
// Nothing to do
if (getChildCount() == 0 || mSelectedChild == null) return;
// get nearest item to the 0 degrees angle
// Sort itmes and get nearest angle
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();
// Make it minimum to rotate
if(angle > 180.0f)
angle = -(360.0f - angle);
// Start rotation if needed
if(angle != 0.0f)
{
mFlingRunnable.startUsingDistance(-angle);
}
else
{
// Set selected position
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 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);
// Center of the item
float centerX = (float)child.getWidth()/2, centerY = (float)child.getHeight()/2;
// Save camera
mCamera.save();
// Translate the item to it's coordinates
final Matrix matrix = transformation.getMatrix();
mCamera.translate(((CarouselImageView)child).getX(), ((CarouselImageView)child).getY(),
((CarouselImageView)child).getZ());
// Align the item
mCamera.getMatrix(matrix);
matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);
// Restore camera
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 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) {
// Sort Carousel items by z coordinate in reverse order
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);
// Get first undrawn item in array and get result index
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 the all functionality, but in the first approximation it works. And here we are:

Icons were taken from here: [4]
Resources
Post Comment
fDehb1 This web site truly has all of the info I wanted concerning this subject and didn at know who to ask.
dhZCa4 Thank you for your article.Thanks Again. Awesome.
PoP9Qr Im grateful for the blog article.Much thanks again. Fantastic.
xQzaY2 Wow, marvelous blog layout! How long have you been blogging for? you made blogging look easy. The overall look of your site is excellent, let alone the content!
kBcHT7 Thanks so much for the blog article.Really looking forward to read more. Really Cool.
rYTQq2 Utterly written articles, Really enjoyed examining.
brisIO Very useful information particularly the last part I care for such
O9IXIX Received the letter. I agree to exchange the articles.
wQ9GX0 I see something truly special in this site.
I truly appreciate this article.Really thank you! Fantastic.
wZ2or2 Very good info. Lucky me I came across your site by chance (stumbleupon). I ave book-marked it for later!
Ibm65r Very good article! We are linking to this particularly great post on our website. Keep up the great writing.
qmPwe5 What sort of camera is that? That is certainly a decent high quality.
swiJ87 That as what i call аАТаЂаgreat postаАТаЂа. Thank you so much.
Xi2ZJJ
b7gqdM Fantastic blog post. Keep writing.
tPVIUh Thanks for sharing, this is a fantastic article post.Much thanks again. Really Cool.
XLSTzv Thanks a bunch for sharing this with all people you really know what you are talking about! Bookmarked. Kindly additionally discuss with my site =). We may have a hyperlink change agreement among us!
xNeo15 Thank you ever so for you blog.Really thank you! Fantastic.
Z1pdpx It's actually a nice and helpful piece of info. I'm happy that you just shared this useful info with us. Please stay us up to date like this. Thanks for sharing.
asVBDE Thank you for another fantastic post. Where else could anyone get that kind of info in such an ideal way of writing? I've a presentation next week, and I'm on the look for such information.
1olGnU I like what you guys are up too. Such smart work and reporting! Keep up the superb works guys I've incorporated you guys to my blogroll. I think it'll improve the value of my web site :)
iWNPsH Wow, great post.Thanks Again.
ULKj4k I really like and appreciate your article.Really looking forward to read more.
LQa1jZ I think this is a real great article.Really thank you! Great.
hbyrSm Thank you ever so for you article post.Much thanks again. Cool.
9qHkuA Enjoyed every bit of your blog article.Really looking forward to read more. Fantastic.