👆关注“博文视点Broadview”,获取更多书讯
以下内容节选自《Android自定义控件高级进阶与精彩实例》一书!
--正文--
《Android自定义控件高级进阶与精彩实例》一书中有一个使用Camera类(书中有对该类的详细讲解)实现3D卡片翻转效果的例子(效果如下所示)。
项目地址:请移步GitHub并搜索DialogFlipTest。
为了便于讲解实现原理,本文将通过通过一个简单的示例来进行展示,该示例的效果如下所示。
其实这个示例最初是Google给出的API Demos里的示例,具体路径为:src/com/example/android/apis/animation/Rotate3dAnimation.java
其中具体讲解了Rotate3dAnimation的实现原理,为了方便起见,这里会稍做修改,但最终的实现效果是完全相同的。
01
框架搭建
在框架阶段,我们做了一个非常简单的demo,实现一张图片的来回切换,效果如下。
如效果图所示,当点击按钮时,图像从0°旋转至180°,当再点击按钮时,图像会旋转回来。
1.XML布局
Activity的布局非常简单,就是一个按钮和一个ImageView,代码如下(activity_rotate_ 3d.xml):
<?xml version="1.0" encoding="utf-8"?><LinearLayout 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" android:orientation="vertical" android:gravity="top|center_horizontal" tools:context=".Rotate3DActivity">
<Button android:id="@+id/btn_open" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" android:onClick="onClickView" android:text="翻转" android:textColor="@android:color/black" android:textSize="16sp"/>
<LinearLayout android:id="@+id/content" android:layout_width="300dp" android:layout_height="200dp" android:layout_below="@id/btn_open" android:orientation="vertical" android:gravity="center_horizontal" android:layout_marginTop="16dp">
<ImageView android:id="@+id/iv_logo" android:layout_width="match_parent" android:layout_height="match_parent" android:src="@mipmap/photo1" android:scaleType="centerCrop"/> </LinearLayout>
</LinearLayout>
大家可能会觉得,在ImageView的外围又包了一个LinearLayout,这样做多此一举。
是的,从这里来看,是没有必要,但后面我们会修改这个布局文件,到时候LinearLayout就有用了。为了讲解方便,此处提前进行布局。
需要注意ImageView外围所包装的id为content的LinearLayout,注意它的位置,我们将会在后续的代码中用到。
2.Activity代码
因为我们是通过自定义Animation来旋转控件的,所以肯定会在onCreate函数中对Animation进行初始化,然后在点击按钮时执行startAnimation。
下面先列出完整的代码:
public class Rotate3DActivity extends AppCompatActivity { private View mContentRoot;
private int duration = 600; private Rotate3dAnimation openAnimation; private Rotate3dAnimation closeAnimation;
private boolean isOpen = false; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_rotate_3d);
mContentRoot = findViewById(R.id.content);
initOpenAnim(); initCloseAnim(); }
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 180); openAnimation.setDuration(duration); openAnimation.setFillAfter(true); }
private void initCloseAnim() { closeAnimation = new Rotate3dAnimation(180, 0); closeAnimation.setDuration(duration); closeAnimation.setFillAfter(true); }
public void onClickView(View v) { if (openAnimation.hasStarted() && !openAnimation.hasEnded()) { return; } if (closeAnimation.hasStarted() && !closeAnimation.hasEnded()) { return; }
if (isOpen) { mContentRoot.startAnimation(closeAnimation); }else { mContentRoot.startAnimation(openAnimation); } isOpen = !isOpen; }}
在代码中,我们自定义的Animation叫Rotate3dAnimation,具体实现会在后面详细讲解。
在onCreate函数中,是初始化环节:
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_rotate_3d);
mContentRoot = findViewById(R.id.content);
initOpenAnim(); initCloseAnim();}
注意这里的mContentRoot,它就是XML中包裹ImageView的LinearLayout,表示需要旋转的控件的根布局。
从效果图可以看出,从0°到180°和从180°到0°,是两个不同的动画过程,分别用openAnimation和closeAnimation来表示。
下面只讲解openAnimation动画过程:
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 180); openAnimation.setDuration(duration); openAnimation.setFillAfter(true);}
从这里大概可以看出,Rotate3dAnimation有两个参数,分别是fromDegrees和endDegrees。因为我们需要在完成动画之后,让View保持完成动画时的状态,所以要用到setFillAfter(true)函数。
3.自定义Animation函数
该自定义Animation函数的主要作用是实现控件在中间位置从fromDegrees旋转到endDegrees。
重写Animation的函数比较简单,主要是重写如下两个函数:
public class Rotate3dAnimation extends Animation { public Rotate3dAnimation(float fromDegrees, float endDegrees) { } @Override public void initialize(int width, int height, int parentWidth, int parentHeight) { super.initialize(width, height, parentWidth, parentHeight); …// 在这里执行初始化操作
}
@Override protected void applyTransformation(float interpolatedTime, Transformation t) { …// 执行自定义动画操作
super.applyTransformation(interpolatedTime, t); }}
上面就是自定义Animation的框架,其中主要涉及3个函数。
我们知道一般通过Animation.setDuration(long durationMillis)来设置动画时长,在applyTransformation函数中,会将时长转化为进度来表示,这个进度就是interpolatedTime,它是一个浮点数,取值范围为0~1。
动画的进度一般是从0到1,假设动画的最小更新进度为0.001,即进度每隔0.001更新一次界面,每次更新界面都是通过调用applyTransformation函数来实现的。
所以,在每次更新动画时,当前的动画进度就是这里的interpolatedTime,而这个进度对应的需要对View控件所做的操作,全部保存在参数Transformation t中。
自定义Animation就是通过上面的步骤完成的,下面来看看如何实现Rotate3dAnimation。
4.Rotate3dAnimation
Rotate3dAnimation的代码比较简单,下面先全部列出,然后逐个讲解:
public class Rotate3dAnimation extends Animation { private final float mFromDegrees; private final float mEndDegree;
private float mCenterX,mCenterY; private Camera mCamera;
public Rotate3dAnimation(float fromDegrees, float endDegree) { mFromDegrees = fromDegrees; mEndDegree = endDegree; }
@Override public void initialize(int width, int height, int parentWidth, int parentHeight) { super.initialize(width, height, parentWidth, parentHeight); mCenterX = width/2; mCenterY = height/2; mCamera = new Camera(); }
@Override protected void applyTransformation(float interpolatedTime, Transformation t) { float degrees = mFromDegrees + ((mEndDegree - mFromDegrees) * interpolatedTime);
mCamera.save(); final Matrix matrix = t.getMatrix(); mCamera.rotateY(degrees); mCamera.getMatrix(matrix); mCamera.restore();
matrix.preTranslate(-mCenterX, -mCenterY); matrix.postTranslate(mCenterX, mCenterY);
super.applyTransformation(interpolatedTime, t); }}
首先,在构造函数中,传入两个参数fromDegrees和endDegree,fromDegrees表示开始旋转的角度,endDegree表示结束旋转的角度。
然后,在initialize函数中执行初始化操作。根据本书1.2节的讲解可知,我们要围绕控件中心点旋转,因此需要获取控件中心点的位置坐标。所以,在初始化时,计算出控件中心点的位置坐标:
public void initialize(int width, int height, int parentWidth, int parentHeight) { super.initialize(width, height, parentWidth, parentHeight); mCenterX = width/2; mCenterY = height/2; mCamera = new Camera();}
最后,执行applyTransformation函数中的操作。
其中:
第1步,根据当前进度计算出当前的旋转角度:
float degrees = mFromDegrees + ((mEndDegree - mFromDegrees) * interpolatedTime);
第2步,利用Camera将图片绕Y轴旋转degrees的角度:
mCamera.save();final Matrix matrix = t.getMatrix();mCamera.rotateY(degrees);mCamera.getMatrix(matrix);mCamera.restore();
第3步,将旋转中心移到控件中心点位置:
matrix.preTranslate(-mCenterX, -mCenterY);matrix.postTranslate(mCenterX, mCenterY);
第4步,调用super.applyTransformation(interpolatedTime, t)来执行改变过的动画操作,以将操作最终体现在控件上。
到此,就实现了我们想要的效果,如下所示。
02
效果改进
从最后实现的效果图可以看出一个问题,翻转时的图像效果与开始时看到的效果不完全相同,不同点在于后面实现的翻转效果,翻转过程中图像很大,如图1所示。
图1
而本文开始时看到的效果的翻转过程截图如图2所示。
图2
可以看到,在图2中,翻转过程中的图像没有那么大,基本保持原大小不变。
从本书1.2节可以知道,图像旋转时的大小跟其与Z轴的距离有关,View与Camera的距离越大,显示的图像越小。
所以,在图像从0°旋转到180°的过程中,图像与Camera的距离关系如图3所示。
图3
从当前的效果图可以看出,随着旋转角度的增加,倾斜之后的图像会变大,在旋转角度达到90°时图像最大。
同样地,要解决这个问题,就得随着图像变大,将View与Camera的距离增大,这样View就会变小。所以,这个View与Camera的距离变化过程就形成了上面的曲线。
当图像需要从0°旋转至90°时,View与Camera的距离需要越来越大,并在旋转到90°时达到最大。而当图像需要从90°旋转至180°时,整个距离变化过程与从0°旋转至90°时的相反,这点从曲线的变化情况就可以看出。
因此需要将图像从0°至180°的整个旋转过程分为两段,从0°旋转至90°时执行下面的代码,使View与Camera的距离逐渐增大:
z = mDepthZ * interpolatedTime;camera.translate(0.0f, 0.0f, z);
这里的mDepthZ是固定数值,默认值为400。如果动画中图像的旋转角度区间就是从0°旋转至90°,那么View与Camera的距离会随着动画的播放越变越大,在旋转角度达到90°时距离达到最大,这与图3中的情况相同。
而在第2段过程中,即从90°旋转至180°时,整个View与Camera的距离变化情况就要反过来,在90°时距离达到最大,在180°时距离回归到初始值:
z = mDepthZ * (1.0f - interpolatedTime);camera.translate(0.0f, 0.0f, z);
很明显,这段代码是符合要求的。所以,后面我们为了区分是从0°旋转至90°的逐渐增大曲线还是从90°旋转至180°的逐渐减小曲线,引入了一个reverse变量来进行标识。
2.改造Rotate3dAnimation
根据上面的原理,我们对Rotate3dAnimation函数进行改造,改造后的代码如下。下面先列出完整代码,然后详细讲解:
public class Rotate3dAnimation extends Animation { private final float mFromDegrees; private final float mEndDegree; private float mDepthZ = 400; private float mCenterX,mCenterY; private final boolean mReverse; private Camera mCamera;
public Rotate3dAnimation(float fromDegrees, float toDegrees, boolean reverse) { mFromDegrees = fromDegrees; mEndDegree = toDegrees; mReverse = reverse; }
@Override public void initialize(int width, int height, int parentWidth, int parentHeight) { super.initialize(width, height, parentWidth, parentHeight); mCamera = new Camera(); mCenterX = width/2; mCenterY = height/2; }
@Override protected void applyTransformation(float interpolatedTime, Transformation t) { float degrees = mFromDegrees + ((mEndDegree - mFromDegrees) * interpolatedTime);
mCamera.save(); float z; if (mReverse) { z = mDepthZ * interpolatedTime; mCamera.translate(0.0f, 0.0f, z); } else { z = mDepthZ * (1.0f - interpolatedTime); mCamera.translate(0.0f, 0.0f, z); } final Matrix matrix = t.getMatrix(); mCamera.rotateY(degrees); mCamera.getMatrix(matrix); mCamera.restore();
matrix.preTranslate(-mCenterX, -mCenterY); matrix.postTranslate(mCenterX, mCenterY);
super.applyTransformation(interpolatedTime, t); }}
首先看初始化函数,在初始化函数中有一个boolean reverse参数,这个参数用于标识曲线是逐渐增大的还是逐渐减小的。reverse为true时,表示距离逐渐增大;reverse为false时,表示距离逐渐减小。
然后在applyTransformation中,增加了沿Z轴移动的代码:
float z;if (mReverse) { z = mDepthZ * interpolatedTime; mCamera.translate(0.0f, 0.0f, z);} else { z = mDepthZ * (1.0f - interpolatedTime); mCamera.translate(0.0f, 0.0f, z);}
很明显,当mReverse为true时,View沿Z轴的移动距离随动画的播放而增大,在动画结束(interpolatedTime等于1)时达到最大。当mReverse为false时,View沿Z轴的移动距离随动画的播放而减小,在动画结束时,View沿Z轴的移动距离回归到0。
3.改造Activity
因为我们把原本从0°旋转至180°的动画拆成了两段,所以需要先执行从0°旋转至90°的动画,结束后接着执行从90°旋转至180°的动画,即核心代码如下:
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 90, true); openAnimation.setDuration(duration); openAnimation.setFillAfter(true); openAnimation.setAnimationListener(new AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationRepeat(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { mLogoIv.setVisibility(View.GONE); mDescTv.setVisibility(View.VISIBLE);
Rotate3dAnimation rotateAnimation = new Rotate3dAnimation(90, 180,false); rotateAnimation.setDuration(duration); rotateAnimation.setFillAfter(true); mContentRl.startAnimation(rotateAnimation); } });}
同样地,closeAnimation先执行从180°旋转至90°的动画,结束后再执行从90°旋转至0°的动画。这里就不再列出相关代码了。
通过扫码查看右侧的效果图可以看出,基本上完成了动画图像大小不变的旋转动作,但在图像旋转到90°的时候,会明显地卡一下,这是因为此处有一个停顿以便过渡到下一个动画过程,我们可以使用加速器来解决这个问题:
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 90,true); … openAnimation.setInterpolator(new AccelerateInterpolator()); openAnimation.setAnimationListener(new AnimationListener() { @Override public void onAnimationEnd(Animation animation) { Rotate3dAnimation rotateAnimation = new Rotate3dAnimation(90, 180,false); … rotateAnimation.setInterpolator(new DecelerateInterpolator()); mContentRoot.startAnimation(rotateAnimation); } … });}
由以上代码可见,从0°旋转至90°时使用加速器,从90°旋转至180°时使用减速器,在90°时旋转速度最快。同样地,closeAnimation也使用加速器来解决这个问题,效果如下。
从效果图可以看到,这样就初步实现了开始时的效果,但还是有所不同,开始时的效果在旋转至90°后,显示的是另一张图像,这是怎么做到的呢?
03
正背面显示不同的内容
方案一:通过替换图像资源实现
因为我们已经将从0°至180°的旋转过程划分为从0°至90°和从90°至180°这两个过程,所以在90°时为ImageView替换图像,即可实现背面显示另一张图像的效果,可扫码查看效果图。
首先,在点击“翻转”按钮的时候,给ImageView配置上初始图像:
public void onClickView(View v) { … if (isOpen) { ((ImageView)findViewById(R.id.iv_logo)).setImageResource(R.mipmap.photo2); mContentRoot.startAnimation(closeAnimation); }else { ((ImageView)findViewById(R.id.iv_logo)).setImageResource(R.mipmap.photo1); mContentRoot.startAnimation(openAnimation); } isOpen = !isOpen;}
然后,在90°时,开始下一个动画前,给ImageView配置上另一张图像:
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 90,true); openAnimation.setDuration(duration); openAnimation.setFillAfter(true); openAnimation.setInterpolator(new AccelerateInterpolator()); openAnimation.setAnimationListener(new AnimationListener() { @Override public void onAnimationEnd(Animation animation) { ((ImageView)findViewById(R.id.iv_logo)).setImageResource(R.mipmap.photo2); … mContentRoot.startAnimation(rotateAnimation); } … });}
整个代码的难度不大,这里就不再详述了。这样处理后,就实现了我们想要的效果。
方案二:使用多控件显示/隐藏实现
方案一只能解决同一个控件中显示不同内容的问题,但若要正背面显示不同的控件,就没办法了。
这时可以使用方案二,即在布局中引入两个ImageView控件,用从0°旋转至90°时显示一个控件而从90°旋转至180°时显示另一个控件的方式来实现。
将Activity的布局代码改为如下代码(activity_rotate_3d.xml):
<?xml version="1.0" encoding="utf-8"?><LinearLayout 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" android:orientation="vertical" android:gravity="top|center_horizontal" tools:context=".Rotate3DActivity">
<Button android:id="@+id/btn_open" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" android:onClick="onClickView" android:text="翻转" android:textColor="@android:color/black" android:textSize="16sp"/>
<LinearLayout android:id="@+id/content" android:layout_width="300dp" android:layout_height="200dp" android:layout_below="@id/btn_open" android:orientation="vertical" android:gravity="center_horizontal" android:layout_marginTop="16dp">
<ImageView android:id="@+id/iv_logo" android:layout_width="match_parent" android:layout_height="match_parent" android:src="@mipmap/photo1" android:scaleType="centerCrop"/>
<ImageView android:id="@+id/iv_logo_2" android:layout_width="match_parent" android:layout_height="match_parent" android:src="@mipmap/photo2" android:scaleType="centerCrop" android:visibility="gone"/> </LinearLayout>
</LinearLayout>
可见,相比原来的布局代码,这里在实现动画的容器(id为content的LinearLayout)中增加了一个ImageView,它的资源是photo2。然后在动画中,在openAnimation结束时,将image1隐藏并显示image2,这时的动画效果就是切换到图片二了:
private void initOpenAnim() { openAnimation = new Rotate3dAnimation(0, 90,true); openAnimation.setDuration(duration); openAnimation.setFillAfter(true); openAnimation.setInterpolator(new AccelerateInterpolator()); openAnimation.setAnimationListener(new AnimationListener() { … @Override public void onAnimationEnd(Animation animation) { ((ImageView)findViewById(R.id.iv_logo)).setVisibility(View.GONE); ((ImageView)findViewById(R.id.iv_logo_2)).setVisibility(View.VISIBLE); … mContentRoot.startAnimation(rotateAnimation); } });}
同样地,在翻转动画中,在closeAnimation结束时,将image2隐藏并显示image1,这时的动画效果就是切换到图片一了:
private void initCloseAnim() { closeAnimation = new Rotate3dAnimation(180, 90,true); closeAnimation.setDuration(duration); closeAnimation.setFillAfter(true); closeAnimation.setInterpolator(new AccelerateInterpolator()); closeAnimation.setAnimationListener(new AnimationListener() { … @Override public void onAnimationEnd(Animation animation) { ((ImageView)findViewById(R.id.iv_logo)).setVisibility(View.VISIBLE); ((ImageView)findViewById(R.id.iv_logo_2)).setVisibility(View.GONE); … mContentRoot.startAnimation(rotateAnimation); } });}
这样,ImageView显示图像的功能就实现了,通过这种方式实现的控件可以实现正背面不同的布局效果,如图4所示。
图4
根据以上的原理,我们若要实现这个效果,只需要在图像旋转至90°时显示/隐藏不同的控件即可。
▼
想要了解更多自定义控件的使用?那就赶紧去看一下《Android自定义控件高级进阶与精彩实例》这本书吧!
▊《Android自定义控件高级进阶与精彩实例》
启舰 著
读者可以通过本书从宏观层面、源码层面对Android自定义控件建立完整的认识。
本书主要内容有3D特效的实现、高级矩阵知识、消息处理机制、派生类型的选择方法、多点触控及辅助类、RecyclerView的使用方法及3D卡片的实现、动画框架Lottie的讲解与实战等。本书适合中高级从业者对Android自定义控件相关知识进行查漏补缺和深入学习。
(京东满100减50,快快扫码抢购吧!)
如果喜欢本文欢迎 在看丨留言丨分享至朋友圈 三连
热文推荐
书单 | 阿里技术书单,满足你的“大厂情结”
《漫画算法2》2021全新进阶版来袭!
一文了解预训练语言模型!
Flink+Alink,当大数据遇见机器学习!
▼点击阅读原文,获取本书详情~
本文分享自 博文视点Broadview 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!