首页 文章详情

Android实现八大行星绕太阳3D旋转效果

龙旋 | 331 2022-06-26 17:06 0 0 0
UniSMS (合一短信)

先上最终效果图:



本文目的:


  • 巩固/练习 自定义View

  • 分析解决问题的思路


需要解决的问题:


1、行星的整体布局,3D的视觉效果

2、行星转到太阳后面时,会被太阳挡住,转到太阳前面时,会挡住太阳

3、行星自动旋转,并且可以根据手势滑动,滑动完之后继续自动旋转

4、中间的太阳有照射的旋转动画


分析问题:


1、行星的整体布局,3D的视觉效果


如果我们draw()的之前通过Camera将Canvas绕x轴旋转60°是不是就可以搞定?这种方式实则是不可行的。因为draw()之前Canvas的变化会作用于子View,从效果图可以看出,子View并没有rotateX的变换,只有缩放变换。所以我们通过子View layout时变化其位置,即计算子View的left、top、right、bottom四个值


行星绕太阳旋转其轨迹实际上就是圆形,如下图:


我们看手机,其实是沿着z轴方向。想象一下,如果让坐标系沿着x轴旋转60°,不就能达到我们想要的效果了嘛。


旋转60°,我们再沿着x轴方向看,如下图:



图中蓝色是旋转前的轨迹,紫色是旋转之后的轨迹。假设P点是地球,P旋转前的y坐标是y0,则旋转之后地球的y坐标是:


y0 * 旋转角度的余切值,即:

y1 = y0* cos(60°)

现在的结论是,只需要把图1的所有行星的y 坐标 * cos60°,就能达到效果了。


而图1中,计算各个行星旋转之前的x 、y坐标比较简单。

x0 = Radius * cos60°
y0 = Radius * sin60°


2、行星转到太阳后面时,会被太阳挡住,转到太阳前面时,会挡住太阳


刚看到这个效果,觉得这个问题是个比较难的点,如果所有行星的父容器和太阳是平级关系,结果就是要么所有的行星都会挡住太阳,要么就是太阳都会挡住行星。不能达到行星转到太阳后面时,会被太阳挡住,转到太阳前面时,会挡住太阳 * 的这种效果。


但是如果所有的行星和太阳是平级关系,即他们是同一个父容器下的子View,那么我们就可以达到这个效果,方法有三种:


1、重写父容器dispatchDraw()方法,改变子View的绘制顺序(图3中先draw土星,再draw太阳,再draw地球);


2、在子View draw之前依次调用bringToFront()方法(图3中先调用土星的bringToFront()方法,再调用太阳的bringToFront()方法,最后调用地球的bringToFront()方法);


3、通过改变所有子View的z值(高度)以改变View的绘制顺序。


这三种方法理论是都可以实现,但是方法1 成本太高、风险也高,重新dispatchDraw()可能会发生未知问题,至于方法2,细心的朋友可能发现,每次调用bringToFront()方法,都会出发requestLayout(),降低了测量布局绘制效率,更重要的原因是在layout(问题1的解决需要重新layout方法)之后再调用requestLayout()方法,会导致循环layout-draw-layout-draw-layout-draw....


综上,我们选择方法3,简单,风险小。


3、行星自动旋转,并且可以根据手势滑动,滑动完之后继续自动旋转


  • 自动滑动:在父容器中设置一个成员变量:角度偏移量sweepAngle,计算子View的位置时将偏移量也考虑进去。然后定时不断增加或者减小sweepAngle(增加或减小 将决定子View是顺时针or逆时针旋转)


  • 手势:用的比较多,从后面的代码中体现。


4、中间的太阳有照射的旋转动画


效果图中的太阳由两张图片组成,一张是前景,一张是背景带亮光,让背景图绕着z轴无限旋转即可。


开始编码


核心就是行星的父容器

/** * 行星和太阳的父容器 */public class StarGroupView extends FrameLayout {
// 从这个角度开始画View ,可以调整 private static final float START_ANGLE = 270f; // 270° // 父容器的边界 单位dp private static final int PADDING = 80; // 绕x轴旋转的角度 70°对应的弧度 private static final double ROTATE_X = Math.PI * 7 / 18; // 以上几个值都可以根据最终效果调整
/** * 角度偏差值 */ private float sweepAngle = 0f;
/** * 行星轨迹的半径 */ private float mRadius;
/** * 父容器的边界 ,单位px */ private int mPadding;
public StarGroupView(@NonNull Context context) { this(context, null); }
public StarGroupView(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); }
public StarGroupView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // 边距转换为px mPadding = (int) (context.getResources().getDisplayMetrics().density * PADDING); }
@Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) {// super.onLayout(changed, left, top, right, bottom); mRadius = (getMeasuredWidth() / 2f - mPadding); layoutChildren(); }
private void layoutChildren() { int childCount = getChildCount(); if (childCount == 0) return; // 行星之间的角度 float averageAngle = 360f / childCount; for (int index = 0; index < childCount; index++) { View child = getChildAt(index); int childWidth = child.getMeasuredWidth(); int childHeight = child.getMeasuredHeight();
// 第index 个子View的角度 double angle = (START_ANGLE - averageAngle * index + sweepAngle) * Math.PI / 180; double sin = Math.sin(angle); double cos = Math.cos(angle);
double coordinateX = getMeasuredWidth() / 2f - mRadius * cos; // * Math.cos(ROTATE_X) 代表将y坐标转换为旋转之后的y坐标 double coordinateY = mRadius / 2f - mRadius * sin * Math.cos(ROTATE_X);
child.layout((int) (coordinateX - childWidth / 2), (int) (coordinateY - childHeight / 2), (int) (coordinateX + childWidth / 2), (int) (coordinateY + childHeight / 2));
// 假设view的最小缩放是原来的0.3倍,则缩放比例和角度的关系是 float scale = (float) ((1 - 0.3f) / 2 * (1 - Math.sin(angle)) + 0.3f); child.setScaleX(scale); child.setScaleY(scale); } }}


然后再xml中配置View

<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"    xmlns:tools="http://schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="match_parent"    tools:context=".LandActivity">
<com.glong.demo.view.StarGroupView android:layout_width="match_parent" android:layout_height="match_parent">
<TextView android:id="@+id/tv1" android:layout_width="100dp" android:layout_height="100dp" android:background="@color/colorAccent" android:gravity="center" android:text="1" />
<TextView android:id="@+id/tv2" android:layout_width="100dp" android:layout_height="100dp" android:background="@android:color/darker_gray" android:gravity="center" android:text="2" />
<TextView android:id="@+id/tv3" android:layout_width="100dp" android:layout_height="100dp" android:background="@android:color/holo_green_dark" android:gravity="center" android:text="3" />
<TextView android:id="@+id/tv4" android:layout_width="100dp" android:layout_height="100dp" android:background="@android:color/holo_blue_dark" android:gravity="center" android:text="4" />
<TextView android:id="@+id/tv5" android:layout_width="100dp" android:layout_height="100dp" android:background="@android:color/holo_green_light" android:gravity="center" android:text="5" />
<TextView android:id="@+id/tv6" android:layout_width="100dp" android:layout_height="100dp" android:background="@android:color/holo_orange_light" android:gravity="center" android:text="6" />
<TextView android:id="@+id/tv7" android:layout_width="100dp" android:layout_height="100dp" android:background="#ff3311" android:gravity="center" android:text="7" />
<TextView android:id="@+id/tv8" android:layout_width="100dp" android:layout_height="100dp" android:background="#11aa44" android:gravity="center" android:text="8" />
<TextView android:id="@+id/tv9" android:layout_width="100dp" android:layout_height="100dp" android:background="#ff99cc" android:gravity="center" android:text="9" />
</com.glong.demo.view.StarGroupView>
</androidx.constraintlayout.widget.ConstraintLayout>


运行,效果如下:



上述代码正如前面分析的,计算所有子View的left 、top 、right 、bottom,注释写的也详细。说明两点:


1、其中,64行

double angle = (START_ANGLE - averageAngle * index + sweepAngle) * Math.PI / 180;


公式中- averageAngle * index代表逆时针添加,如果是+ averageAngle * index则是顺时针添加。


2、78到80行,计算子View的scale,这里说明下角度和scale的计算公司

float scale = (float) ((1 - 0.3f) / 2 * (1 - Math.sin(angle)) + 0.3f);


假如View的最小scale是0.3f,最大scale是1。按照效果View在270°时scale最大,在90°时scale最小,并且从270°到90°scale越来越小。正玄曲线如下:



正玄曲线中,270°最小,90°时最大,我们把正玄值取反然后再加1,那么[90°,270°]对应的值就是[0,1]


即,设z = -sin(angle) + 1 当angle在90°到270°变化时 ,z将在0到1之间变化


z在0~1之间变化时,scale 要在0.3~1之间变化,如下图:



显然,

scale = (1 - 0.3) * z + 0.3 = (1-0.3)*(-sin(angle) + 1)+0.3

接下来,再把中间的太阳加进去


太阳也是StarGroupView的子View,但是和其他子View 不同的是,太阳在最中间,不参与类似行星的位置计算


简单期间我们使用tag=“center"来标识子View是中间的太阳。


修改xml文件:

    <com.glong.demo.view.StarGroupView        android:layout_width="match_parent"        android:layout_height="match_parent">
<!-- 增加太阳View --> <ImageView android:layout_width="130dp" android:layout_height="130dp" android:src="@drawable/ic_launcher_background" android:tag="center" />
<!--省略行星--> </com.glong.demo.view.StarGroupView>


修改StarGroupView.java

public class StarGroupView extends FrameLayout {  // ... 省略部分代码
private void layoutChildren() { int childCount = getChildCount(); if (childCount == 0) return; // 行星之间的角度 View centerView = centerView(); float averageAngle; if (centerView == null) { averageAngle = 360f / childCount; } else { // centerView 不参与计算角度 averageAngle = 360f / (childCount - 1); }
int number = 0; for (int index = 0; index < childCount; index++) { View child = getChildAt(index); int childWidth = child.getMeasuredWidth(); int childHeight = child.getMeasuredHeight();
// 如果是centerView 直接居中布局 if ("center".equals(child.getTag())) { child.layout(getMeasuredWidth() / 2 - childWidth / 2, getMeasuredHeight() / 2 - childHeight / 2, getMeasuredWidth() / 2 + childWidth / 2, getMeasuredHeight() / 2 + childHeight / 2); } else { // 第index 个子View的角度 double angle = (START_ANGLE - averageAngle * number + sweepAngle) * Math.PI / 180; double sin = Math.sin(angle); double cos = Math.cos(angle);
double coordinateX = getMeasuredWidth() / 2f - mRadius * cos; // * Math.cos(ROTATE_X) 代表将y坐标转换为旋转之后的y坐标 double coordinateY = mRadius / 2f - mRadius * sin * Math.cos(ROTATE_X);
child.layout((int) (coordinateX - childWidth / 2), (int) (coordinateY - childHeight / 2), (int) (coordinateX + childWidth / 2), (int) (coordinateY + childHeight / 2));
// 假设view的最小缩放是原来的0.3倍,则缩放比例和角度的关系是 float scale = (float) ((1 - 0.3f) / 2 * (1 - Math.sin(angle)) + 0.3f); child.setScaleX(scale); child.setScaleY(scale); number++; } } }
/** * 获取centerView * * @return 太阳 */ private View centerView() { View result = null; for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if ("center".equals(child.getTag())) { return child; } } return null; }}


代码注释写的很全面,不做过多解释了,这个时候我们把PADDING改大一点,改成160,运行如下:



问题很明显,3应该在4的上面, 2 应该在3的上面,中间的View应该在5,6的上面。


这是因为系统默认按照View的添加顺序画View的,即我们xml文件里面的顺序。xml里面我们centerView在第一个,所以就先画centerView,导致centerView被其他View覆盖。按照上面的分析,动态改变View的z值以改变View的draw顺序。


修改StarGroupView.java代码

public class StarGroupView extends FrameLayout {
private void layoutChildren() { // ...省略之前代码 changeZ(); }
/** * 改变子View的z值以改变子View的绘制优先级,z越大优先级越低(最后绘制) */ private void changeZ() { View centerView = centerView(); float centerViewScaleY = 1f; if (centerView != null) { centerViewScaleY = centerView.getScaleY(); centerView.setScaleY(0.5f); } List<View> children = new ArrayList<>(); for (int i = 0; i < getChildCount(); i++) { children.add(getChildAt(i)); } // 按照scaleY排序 Collections.sort(children, new Comparator<View>() { @Override public int compare(View o1, View o2) { return (int) ((o1.getScaleY() - o2.getScaleY())*1000000); } }); float z = 0.1f; for (int i = 0; i < children.size(); i++) { children.get(i).setZ(z); z += 0.1f; } if (centerView != null) { centerView.setScaleY(centerViewScaleY); }    }}


我们先给所有子View根据他的scaleY排序,由于centerView的scaleY 在layoutChildren()时并没有改变,我们把centerView的scaleY设置为0.5f,最后再还原回去。现在运行,效果如下:



到这里基本已经达到了我们想要的效果啦,接下来让其自动旋转和响应手势,肯定就难不倒我们啦。


加入自动旋转


子StarGroupView中循环postDelayed(runnable,16)即可,这里为什么是16ms,大家都懂


修改StarGroupView.java

public class StarGroupView extends FrameLayout {    // ...省略已有代码
//自动旋转角度,16ms(一帧)旋转的角度,值越大转的越快 private static final float AUTO_SWEEP_ANGLE = 0.1f;
private Runnable autoScrollRunnable = new Runnable() { @Override public void run() { sweepAngle += AUTO_SWEEP_ANGLE; // 取个模 防止sweepAngle爆表 sweepAngle %= 360; Log.d("guolong", "auto , sweepAngle == " +sweepAngle); layoutChildren(); postDelayed(this, 16); } };
public StarGroupView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); // ...省略已有代码 postDelayed(autoScrollRunnable,100); }}


这样就开始自动旋转了,调节AUTO_SWEEP_ANGLE的值 改变旋转速度


加入手势


老写法,先上代码


在StarGroupView.java中增加


public class StarGroupView extends FrameLayout {
//px转化为angle的比例 ps:一定要给设置一个转换,不然旋转的太欢了 private static final float SCALE_PX_ANGLE = 0.2f;

/** * 手势处理 */ private float downX = 0f; /** * 手指按下时的角度 */ private float downAngle = sweepAngle; /** * 速度追踪器 */ private VelocityTracker velocity = VelocityTracker.obtain(); /** * 滑动结束后的动画 */ private ValueAnimator velocityAnim = new ValueAnimator();
public StarGroupView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { // ... initAnim(); }
private void initAnim() { velocityAnim.setDuration(1000); velocityAnim.setInterpolator(new DecelerateInterpolator()); velocityAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); // 乘以SCALE_PX_ANGLE是因为如果不乘 转得太欢了 sweepAngle += (value * SCALE_PX_ANGLE); layoutChildren(); } }); }
@Override public boolean onTouchEvent(MotionEvent event) { float x = event.getX(); velocity.addMovement(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX = x; downAngle = sweepAngle;
// 取消动画和自动旋转 velocityAnim.cancel(); removeCallbacks(autoScrollRunnable); return true; case MotionEvent.ACTION_MOVE: float dx = downX - x ; sweepAngle = (dx * SCALE_PX_ANGLE + downAngle); layoutChildren(); break; case MotionEvent.ACTION_UP: velocity.computeCurrentVelocity(16); // 速度为负值代表顺时针 scrollByVelocity(velocity.getXVelocity()); postDelayed(autoScrollRunnable, 16); } return super.onTouchEvent(event); }
private void scrollByVelocity(float velocity) { float end; if (velocity < 0) end = -AUTO_SWEEP_ANGLE; else end = 0f; velocityAnim.setFloatValues(-velocity, end); velocityAnim.start(); }}


手势处理的代码比较简单,这里就不再赘述了,需要注意的是:


1、ACTION_DOWN需返回true,不然收不到后续的ACTION_MOVE事件;


2、ACTION_DOWN时需要暂停动画和自动旋转


3、这里根据手指离开屏幕时的速度做Animator动画,当然你也可以用scroller实现。


4、第59行,我们给dx * SCALE_PX_ANGLE代表一个像素可以转换成SCALE_PX_ANGLE角度


最后,加上中间太阳旋转的动画


在res/anim/sun_anim.xml

<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://schemas.android.com/apk/res/android"    android:shareInterpolator="true"    android:interpolator="@android:interpolator/linear">    <rotate        android:duration="8000"        android:fromDegrees="0"        android:pivotX="50%"        android:pivotY="50%"        android:repeatCount="-1"        android:toDegrees="360" /></set>


在Activity中:

public class LandActivity extends AppCompatActivity {
@Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); // ....省略部分代码
View sunView = findViewById(R.id.sun_view); sunView.startAnimation((AnimationUtils.loadAnimation(this, R.anim.sun_anim))); }}


最后的最后,我们可以给外部提供start和pause方法用来暂停和开始动画

public class StarGroupView extends FrameLayout {
// 省略... public void pause() { velocityAnim.cancel(); removeCallbacks(autoScrollRunnable); }
public void start() { postDelayed(autoScrollRunnable, 16); }}


最终不到算上注释260代码搞定!


最终效果:



源码地址:

https://github.com/glongdev/Demos


到这里就结束啦。

good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter