65.9K
CodeProject 正在变化。 阅读更多。
Home

Android 3D 轮播图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (67投票s)

2011 年 1 月 14 日

CPOL

4分钟阅读

viewsIcon

1236278

downloadIcon

16987

如何在 Android 平台上实现 3D 轮播图。

 

介绍 

一段时间以来,我一直在寻找用于 Android 平台的 3D 旋转木马控件。我找到的唯一一个是在 [1] 处的 UltimateFaves。但事实证明,它使用了 OpenGL。而且它不是开源的。我想,是否可以避免使用 OpenGL。在继续调查的过程中,我遇到了 [2] 处的 Coverflow Widget。它使用了标准的 Android 2D 库。所以想法是一样的——使用 Gallery 类作为旋转木马。Coverflow Widget 只是旋转图像,而我想旋转所有图像组。好吧,至少它暗示了使用简单的三角函数。更复杂的东西与 Gallery 类有关。如果你查看关于 Coverflow Widget 的文章 [3],你会看到一堆问题,例如 AbsSpinnerAdapterView 类中缺少默认范围变量。所以我走了相同的道路,重写了一些类。而 Scroller 类将被 Rotator 类取代,该类看起来像 Scroller,但它会旋转图像组。

准备工作

首先,我们应该决定哪些参数将定义我们旋转木马的行为。例如,旋转木马中项目的最小数量。如果它只有一两个项目,看起来不会很好,不是吗?至于性能问题,我们必须定义项目的最大数量。此外,我们需要旋转木马的最大 theta 角,其中将包含哪些项目,当前选定的项目以及项目是否会反射。所以让我们在 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>

旋转木马项类

为了简化旋转木马的一些事情,我创建了 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>

它封装了 3D 空间中的位置、项目的索引以及项目的当前角度。实现为 Comparable 在我们确定项目的绘制顺序时也会很有用。

Rotator 类

如果你查看 Scroller 类的源代码,你会看到两种模式:滚动模式和fling 模式,主要用于计算从给定起始点的当前偏移量。我们只需要删除多余的成员,添加我们自己的成员,并替换相应的计算。

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。这些区别我稍后会描述。其次,修改后的构造函数删除了对 AbsSpinner 条目的检索。第三个区别是修改后的 setSelection(int) 方法。它只是调用 setSelectionInt。下一个变化是不可用的变量被它们的 getter 替换。至于默认生成的布局参数,两者都设置为 WRAP_CONTENT。主要变化涉及 pointToPosition 方法。在 AbsSpinner 中,它确定屏幕上的特定项目是否被触摸,无论它是否是当前项目。在 CarouselSpinner 中,所有触摸都只与当前项目有关。所以只需返回选定的项目索引。

public int pointToPosition(int x, int y) {    	
  	// All touch events are applied to selected item
   	return mSelectedPosition;
}

CarouselAdapter 与 AdapterView

唯一的改变是在 updateEmptyStatus 方法中,其中不可用的变量被它们的 getter 替换。

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 中一样,可以为图像添加反射。还添加了一些新的 private 变量来支持 Y 轴角度、反射等。构造函数检索图像列表,创建 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();
        }

这里是设置图像的方法。图像的高度设置为父高度的三分之一,以使旋转木马适合父视图。以后需要对其进行改进。

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);
    } 

让我们看看 Gallery 类中的 trackMotionScroll 方法,当控件正在滚动或fling 时调用它,并为 Gallery 动画执行必要的操作。但它只通过 x 坐标移动图像。要使它们在 3D 空间中旋转,我们必须创建不同的功能。我们只需改变图像的当前角度,并计算它在 3D 空间中的位置。

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();
    }	

在图像被 fling 或滚动之后,我们必须将它们放置到相应的位置。

/**
     * 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>

并滚动到指定项目。

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);
}

这是 Calculate3DPosition 方法。

    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);
    }

一些对于 3D Gallery 没有意义的方法被删除了:offsetChildrenLeftAndRightdetachOffScreenChildrensetSelectionToCenterChildfillToGalleryLeftfillToGalleryRight

所以,图像主要发生的事情是在 getChildStaticTransformation 方法中,在那里它们在 3D 空间中被转换。它只是从 CarouselImage 类获取一个现成的位置,该位置在 fling/滚动过程中由 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;
}    

需要知道的一点是,如果你只旋转图像并在 3D 空间中定位它们,它们可能会以错误的顺序重叠。例如,z 坐标为 100.0 的图像可能会绘制在 z 坐标为 50.0 的图像前面。为了解决这个问题,我们可以覆盖 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>

好吧,还有很多工作要做,比如捕捉 bug 和优化。我还没有测试所有功能,但初步来看,它是有效的。

图标来自这里:[4]。

附注:修复了 Rotator 类中的 bug。“滚动到槽”时的卡顿现象变得更加柔和流畅。

重构了 Rotator 类。它现在只使用角加速度。

修复了 Jelly Bean 问题。

资源 

  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/
© . All rights reserved.