Android 3D 轮播图






4.91/5 (67投票s)
如何在 Android 平台上实现 3D 轮播图。
介绍
一段时间以来,我一直在寻找用于 Android 平台的 3D 旋转木马控件。我找到的唯一一个是在 [1] 处的 UltimateFaves。但事实证明,它使用了 OpenGL。而且它不是开源的。我想,是否可以避免使用 OpenGL。在继续调查的过程中,我遇到了 [2] 处的 Coverflow Widget。它使用了标准的 Android 2D 库。所以想法是一样的——使用 Gallery 类作为旋转木马。Coverflow Widget 只是旋转图像,而我想旋转所有图像组。好吧,至少它暗示了使用简单的三角函数。更复杂的东西与 Gallery 类有关。如果你查看关于 Coverflow Widget 的文章 [3],你会看到一堆问题,例如 AbsSpinner
和 AdapterView
类中缺少默认范围变量。所以我走了相同的道路,重写了一些类。而 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 没有意义的方法被删除了:offsetChildrenLeftAndRight
、detachOffScreenChildren
、setSelectionToCenterChild
、fillToGalleryLeft
、fillToGalleryRight
。
所以,图像主要发生的事情是在 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 问题。