среда, 2 ноября 2011 г.

Android. 3D карусель



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].

Ресурсы

  1. http://ultimatefaves.com/
  2. http://www.inter-fuser.com/2010/02/android-coverflow-widget-v2.html
  3. http://www.inter-fuser.com/2010/01/android-coverflow-widget.html
  4. http://www.iconsmaster.com/Plush-Icons-Set/

Комментариев нет:

Отправить комментарий