Source Code
English translation
Это моя статья, изначально опубликованная на сайте The Code Project: Codeproject.
Введение
Некоторое время назад я искал как можно сделать 3D карусель на под Андроид. Единственное, что я нашел - это была карусель на UltimateFaves : [1]. Но, как оказалось, она ипользует OpenGL. И без исходников. Хотелось бы сделать ее без использования OpenGL. Казалось, что она не должна быть настолько тяжеловесной, для его использования. Продолжая поиски, я наткнулся на Coverflow Widget : [2]. Это виджет использовал лишь 2D библиотеки. В итоге идея оформилась - использовать класс галереи для карусели. Coverflow Widget просто вращает изображения, а я хотел вращать группу. Ok, по крайней мере, будет использоваться простая тригонометрия. Небольшие сложности возники с классом галереи. Если посмотреть статью о Coverflow Widget : [3], можно увеидеть, что там описаны несколько проблем, таких как: недоступность членов классовAbsSpinner
и AdapterView
. Я пошел тем же путем, что и там и переписал некоторые классы. Класс Scroller
был заменен классом Rotator
, который похож на не него Scroller
, но вместо сдвига изображений вращает их группу.Подготовка
Сначала, необходимо определиться с параметрами, которые будут определять поведение карусели. Например, Например минимальное количество элементов в карусели. Она будет выглядеть стрёмно, если в ней будет только 2 элемента, не так ли? Ну, и, чтобы она не сильно грузила девайс определим максимальное количество элементов. Также определим максимальный угол наклона относительно оси Y, набор элементов, текущий элемент, и будет ли отражение. Определим все аттрибуты в файле attrs.xml:<?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>
Класс CarouselItem
Дабы упростить жизнь, создадим классCarouselItem
:public class CarouselItem extends FrameLayout implements Comparable<CarouselItem> { private ImageView mImage; private TextView mText; private int index; private float currentAngle; private float x; private float y; private float z; private boolean drawn; // It's needed to find screen coordinates private Matrix mMatrix; public CarouselItem(Context context) { super(context); FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); this.setLayoutParams(params); LayoutInflater inflater = LayoutInflater.from(context); View itemTemplate = inflater.inflate(R.layout.item, this, true); mImage = (ImageView)itemTemplate.findViewById(R.id.item_image); mText = (TextView)itemTemplate.findViewById(R.id.item_text); } public String getName(){ return mText.getText().toString(); } public void setIndex(int index) { this.index = index; } public int getIndex() { return index; } public void setCurrentAngle(float currentAngle) { if(index == 0 && currentAngle > 5){ Log.d("", ""); } this.currentAngle = currentAngle; } public float getCurrentAngle() { return currentAngle; } public int compareTo(CarouselItem another) { return (int)(another.z - this.z); } … }
В нем добавляем позицию в пространстве, индекс элемента и текущий угол поворота. Также сообразим имплементацию
Comparable
, она будет полезна для порядка отрисовки элементов. The Rotator Class
Если посмотреть на исходники классаScroller
class, то можно увидеть два режима: режим скроллинга и режим "броска", в которых вычисляется смещение от исходной точки. Необходимо убрать лишние члены, добавить нужные и заменить вычисления на наши собственные: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; /** * Create a Scroller with the specified interpolator. * If the interpolator is null, the default (viscous) * interpolator will be used. */ public Rotator(Context context) { mFinished = true; } /** * * Returns whether the scroller has finished scrolling. * * @return True if the scroller has finished scrolling, * false otherwise. */ public final boolean isFinished() { return mFinished; } /** * Force the finished field to a particular value. * * @param finished The new finished value. */ public final void forceFinished(boolean finished) { mFinished = finished; } /** * Returns how long the scroll event will take, in milliseconds. * * @return The duration of the scroll in milliseconds. */ public final long getDuration() { return mDuration; } /** * Returns the current X offset in the scroll. * * @return The new X offset as an absolute distance from the origin. */ public final float getCurrAngle() { return mCurrAngle; } /** * @hide * Returns the current velocity. * * @return The original velocity less the deceleration. * Result may be negative. */ public float getCurrVelocity() { return mCoeffVelocity * mVelocity - mDeceleration * timePassed() /* / 2000.0f*/; } /** * Returns the start X offset in the scroll. * * @return The start X offset as an absolute distance from the origin. */ public final float getStartAngle() { return mStartAngle; } /** * Returns the time elapsed since the beginning of the scrolling. * * @return The elapsed time in milliseconds. */ public int timePassed() { return (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime); } /** * Extend the scroll animation. This allows * a running animation to scroll further and longer, * when used with {@link #setFinalX(int)} * or {@link #setFinalY(int)}. * * @param extend Additional time to scroll in milliseconds. * @see #setFinalX(int) * @see #setFinalY(int) */ public void extendDuration(int extend) { int passed = timePassed(); mDuration = passed + extend; mFinished = false; } /** * Stops the animation. Contrary to {@link #forceFinished(boolean)}, * aborting the animating cause the scroller to * move to the final x and y position * * @see #forceFinished(boolean) */ public void abortAnimation() { mFinished = true; } /** * 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; } 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; } } /** * Start scrolling by providing a starting point * and the distance to travel. * * @param startX Starting horizontal scroll * offset in pixels. Positive numbers will * scroll the content to the left. * @param startY Starting vertical scroll * offset in pixels. Positive numbers * will scroll the content up. * @param dx Horizontal distance to travel. * Positive numbers will scroll the * content to the left. * @param dy Vertical distance to travel. * Positive numbers will scroll the content up. * @param duration Duration of the scroll * in milliseconds. */ public void startRotate(float startAngle, float dAngle, int duration) { mMode = SCROLL_MODE; mFinished = false; mDuration = duration; mStartTime = AnimationUtils.currentAnimationTimeMillis(); mStartAngle = startAngle; mDeltaAngle = dAngle; } /** * 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. * * @param startX Starting horizontal scroll * offset in pixels. Positive numbers will * scroll the content to the left. * @param startY Starting vertical scroll * offset in pixels. Positive numbers * will scroll the content up. * @param dx Horizontal distance to travel. * Positive numbers will scroll the * content to the left. * @param dy Vertical distance to travel. * Positive numbers will scroll the content up. */ 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. * * @param velocityAngle Initial velocity of the fling (X) * measured in pixels per second. */ 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(); } }
Отличия CarouselSpinner от AbsSpinner
Во-первых он наследуется отCarouselAdapter
, а не от AdapterView
. Во-вторых, модифицированный конструктор. В-третьих, измененный метод setSelection(int)
. Оставляем только вызов метода setSelectionInt
. В-четвертых, добавляем геттеры для недоступных членов. Параметры лэйаута - только WRAP_CONTENT
. Основные изменения касаются метода pointToPosition
. В классе AbsSpinner
, он определяет какой элемент был нажат по экранным координатам. Нам, для этого, необходимо спроецировать элементы на экран:public int pointToPosition(int x, int y) { ArrayList<CarouselItem> fitting = new ArrayList<CarouselItem>(); for(int i = 0; i < mAdapter.getCount(); i++){ CarouselItem item = (CarouselItem)getChildAt(i); Matrix mm = item.getMatrix(); float[] pts = new float[3]; pts[0] = item.getLeft(); pts[1] = item.getTop(); pts[2] = 0; mm.mapPoints(pts); int mappedLeft = (int)pts[0]; int mappedTop = (int)pts[1]; pts[0] = item.getRight(); pts[1] = item.getBottom(); pts[2] = 0; mm.mapPoints(pts); int mappedRight = (int)pts[0]; int mappedBottom = (int)pts[1]; if(mappedLeft < x && mappedRight > x & mappedTop < y && mappedBottom > y) fitting.add(item); } Collections.sort(fitting); if(fitting.size() != 0) return fitting.get(0).getIndex(); else return mSelectedPosition; }
CarouselAdapter vs. AdapterView
Единственные изменения - в методеupdateEmptyStatus
недоступные члены заменены геттерами. Класс Carousel
КлассFlingRunnable
заменен классом FlingRotateRunnable
, который похож на FlingRunnable
, но имеет дело с углами, а не с x-координатой: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(); 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) { /* * 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; //////// Should be reworked trackMotionScroll(delta); if (more && !mShouldStopFling) { mLastFlingAngle = angle; post(this); } else { mLastFlingAngle = 0.0f; endFling(true); } } }Также я добавил класс
ImageAdapter
, который дает возможность делать отражения элементов, как в Coverflow Widget. Также несколько вспомогательных приватных переменных. Конструктор получает список картинок и создает на их основе ImageAdapter
. Самое важное в конструкторе - установить поддержку static
трансформаций. И разместить картинки по своим местам:/** * 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(); }
Далее методы установки элементов. Высоту картинок устанавливаем в 1/3 высоты родительского окна(чтобы влезали при наклоне). Потом надо будет придумать
что-нибудь более оригинальное.
private void makeAndAddView(int position, float angleOffset) { CarouselItem child; if (!mDataChanged) { child = (CarouselItem)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 = (CarouselItem)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 = (CarouselItem)mAdapter. getView(position, null, this); // Position the view setUpChild(child, child.getIndex(), angleOffset); } private void setUpChild(CarouselItem child, int index, float angleOffset) { // Ignore any layout parameters for child, // use wrap content addViewInLayout(child, -1 /*index*/, generateDefaultLayoutParams()); child.setSelected(index == mSelectedPosition); int h; int w; int d; if(mInLayout) { w = child.getMeasuredWidth(); h = child.getMeasuredHeight(); d = getMeasuredWidth(); } else { w = child.getMeasuredWidth(); h = child.getMeasuredHeight(); d = getWidth(); } child.setCurrentAngle(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); Calculate3DPosition(child, d, angleOffset); }
Посмотрим на метод
trackMotionScroll
в классе Gallery
. Он вызывается в режиме скроллинга и "броска" и выполняет всю необходимую анимацию. Но, двигает элементы только вдоль х-координаты. But it moves images just by x-coordinate. Для вращения в пространстве, его надо переработать. Просто меняем текущий угол и вычисляем позицию в пространстве:void trackMotionScroll(float deltaAngle) { if (getChildCount() == 0) { return; } for(int i = 0; i < getAdapter().getCount(); i++){ CarouselItem child = (CarouselItem)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(); }Также, по окончании анимации, необходимо расставить элементы по своим местам:
/** * 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<CarouselItem> arr = new ArrayList<CarouselItem>(); for(int i = 0; i < getAdapter().getCount(); i++) arr.add(((CarouselItem)getAdapter().getView(i, null, null))); Collections.sort(arr, new Comparator<CarouselItem>(){ @Override public int compare(CarouselItem c1, CarouselItem 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(); } }Вращение к определенному элементу:
void scrollToChild(int i){ CarouselItem view = (CarouselItem)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); }Код метода
Calculate3DPosition
: private void Calculate3DPosition(CarouselItem child, int diameter, float angleOffset){ angleOffset = angleOffset * (float)(Math.PI/180.0f); float x = - (float)(diameter/2 * Math.sin(angleOffset)) + diameter/2 - child.getWidth()/2; 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); }Удаляем некоторые методы, которые не имеют смысла в нашем случае:
offsetChildrenLeftAndRight
, detachOffScreenChildren
, setSelectionToCenterChild
, fillToGalleryLeft
, fillToGalleryRight
. Самая важная часть находится в методе getChildStaticTransformation
. В нем выполняется размещение объектов в пространстве. Он просто берет готовую позицию из элемента, которая была рассчитана в методе Calculate3DPosition:
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;
}
Также, необходимо учитывать, что, если просто вращать элементы, они могут перекрыть друг друга.
Например элемент с z-координатой 100.0 может быть отрисован перед элементом с координатой 50.0.
Чтобы избежать этого, надо переопределить метод getChildDrawingOrder
:
protected int getChildDrawingOrder(int childCount, int i) {
// Sort Carousel items by z coordinate in reverse order
ArrayList<CarouselItem> sl = new ArrayList<CarouselItem>();
for(int j = 0; j < childCount; j++)
{
CarouselItem view = (CarouselItem)getAdapter().getView(j,null, null);
if(i == 0)
view.setDrawn(false);
sl.add((CarouselItem)getAdapter().getView(j,null, null));
}
Collections.sort(sl);
// Get first undrawn item in array and get result index
int idx = 0;
for(CarouselItem civ : sl)
{
if(!civ.isDrawn())
{
civ.setDrawn(true);
idx = civ.getIndex();
break;
}
}
return idx;
}
Ok, здесь еще много надо переработать, отловить баги и оптимизировать, но в первом приближении это работает. Иконки позаимствованы здесь: [4].
Ресурсы
Комментариев нет:
Отправить комментарий