不可否认,随着
flutter
和
compose
的流行,组件式编程有望取代老旧的view体系,而view的动画,尤其是复杂的动画可以使用
lottie
替代。但学习view基本逻辑依旧是有必要的:
compose
是基于view开发的,对
compose
了解越深入,你就越需要接触
view
知识,况且,
lottie
要想替代简单动画也是不可能的事情。
本专栏作为“小叙”,也只是“稍微深入”,更多注重的是广度。所有专栏会有更新,会结合
kotlin(实践)
和
java(源码)
学习。
值得注意
本文最后修改于
2022年7月20日
View基本体系
ViewGroup与View
结论:
所有的控件都是基于View;非ViewGroup外,所有布局基于ViewGroup,ViewGroup基于View。
一般来说,开发并不会直接使用View和ViewGroup,而是使用之派生类。
View坐标系
Android坐标系
结论:
安卓坐标系以左上角为圆点,Z轴向上,X轴向右,Y轴向下。
如图所示,把红色区域看成
屏幕
,正方向如标志一致。
View坐标系
坐标系与安卓坐标系并不冲突,
他们的值也是相对屏幕坐标获取的
,这很容易理解:超出屏幕的控件获取的值可以是负数。
示例
控件的宽高
我们以View的类为例,窥其代码,可见一斑,这是view的宽和高代码:
/**
* Return the width of your view.
*
* @return The width of your view, in pixels.
*/
@ViewDebug.ExportedProperty(category = "layout")
public final int getWidth() {
return mRight - mLeft;
}
/**
* Return the height of your view.
*
* @return The height of your view, in pixels.
*/
@ViewDebug.ExportedProperty(category = "layout")
public final int getHeight() {
return mBottom - mTop;
}
基于父坐标的参数
以下方法获得的是到**父控件(ViewGroup和派生类)**的距离:
方法名 | 说明 |
---|---|
getTop() |
自身 顶部 到父布局 顶部 距离 |
getBottom() |
自身 底部 到父布局 顶部 距离 |
getRight() |
自身 右边 到父布局 左边 距离 |
getLeft() |
自身 左边 到父布局 左边 距离 |
来自MotionEvent的方法
假设触摸点就是上图蓝色圆点,我们在MotionEvent的方法如下:
方法名 | 说明 | 所属坐标系 |
---|---|---|
getX() |
点击事件距离 处理控件左边 的距离 |
View |
getY() |
点击事件距离 处理控件顶部 边的距离 |
View |
getRawX() |
点击事件距离 屏幕左边 距离 |
Android |
getRawY() |
点击事件距离 屏幕顶部 距离 |
Android |
View滑动
自定义控件,特别是较屏幕大的控件,都需要实现view自己的滑动(一般来说,通过设置控件大小,从而引导外部布局滑动才是一般解),比如笔者的
代码编辑器控件
:
https://github.com/FrmsClY/CodeView
滑动原理基本相同:VIew可以获取点击位置、松开位置,
经过的时间(松开时间-点击时间)
,可以由前两项计算偏移量,并以此修正View坐标。
这里主要讲解以下一种滑动方法。
- layout()方法
- offsetLetfAndRight()、offsetTopAndBottom()方法
- LayoutParams类
- 动画
- scrollTo() 、 scrollBy()方法
- Scroller类
layout()方法
/**
* Assign a size and position to a view and all of its descendants.
*
* @param l Left position, relative to parent
* @param t Top position, relative to parent
* @param r Right position, relative to parent
* @param b Bottom position, relative to parent
*/
public void layout(int l, int t, int r, int b);
该方法是View的
放置方法
,在View类实现。调用该方法需要传入放置View的矩形空间左上角left、top值和右下角right、bottom值。这四个值是
相对于父控件而言
的。例如传入的是(10, 10, 100, 100),则该View在距离父控件的左上角位置(10, 10)处显示,显示的大小是宽高是90(参数r,b是相对左上角的)。
可以如下实现
控件跟随手指移动:
class MyView(context: Context) : View(context)
{
// 注意,不要放在onTouchEvent方法内,
// 因为onTouchEvent是实时调用的。
private var lastX = 0
private var lastY = 0
override fun onTouchEvent(event: MotionEvent): Boolean
{
// 只允许有一个触碰点
if(event.pointerCount != 1) return super.onTouchEvent(event)
// 获取当前手指位置
val x = event.x.toInt()
val y = event.y.toInt()
when(event.action)
{
MotionEvent.ACTION_DOWN -> {
lastX = x
lastY = y
}
MotionEvent.ACTION_MOVE -> {
// 计算移动距离
val offsetX = x - lastX
val offsetY = y - lastY
// 重新放置
layout(
left + offsetX,
top + offsetY,
right + offsetX,
bottom + offsetY
)
}
}
return true
}
init {
// 设置背景颜色,方便区分
setBackgroundColor(Color.RED)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)
{
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 设置控件大小
setMeasuredDimension(100, 100)
}
}
运行结果如图所示,
可以随着手指滑动改变自己位置
:
offsetLetfAndRight()、offsetTopAndBottom()方法
说明:两者方法大意解释是
通过给定像素补偿(Offset)控件水平、垂直位置
。
/**
* Offset this view's horizontal location by the specified amount of pixels.
*
* @param offset the number of pixels to offset the view by
*/
public void offsetLeftAndRight(int offset) {}
/**
* Offset this view's vertical location by the specified number of pixels.
*
* @param offset the number of pixels to offset the view by
*/
public void offsetTopAndBottom(int offset) {}
这两者调用方法差不多,
因此
可以在上文代码如下改写layout方法:
但要注意,方法意思和作用位置
有细微差别
,请注意使用。
MotionEvent.ACTION_MOVE -> {
// 计算移动距离
val offsetX = x - lastX
val offsetY = y - lastY
// 重新放置
offsetLeftAndRight(offsetX)
offsetTopAndBottom(offsetY)
}
LayoutParams类
LayoutParams主要保存控件的布局参数,比如控件大小、位置。它封装了Layout的位置、高、宽等信息。假设在屏幕上一块区域是由一个Layout占领的,如果将一个View添加到一个Layout中,最好告诉Layout用户期望的布局方式,也就是将一个
认可的
layoutParams传递进去。
控件所处的布局不同,
认可
使用的LayoutParams派生类也不同:
- 父控件是 LinearLayout , 则使用LinearLayout.LayoutParams
-
不知名的,可以使用ViewGroup.
MarginLayoutParams
MotionEvent.ACTION_MOVE -> {
// 计算移动距离
val offsetX = x - lastX
val offsetY = y - lastY
// 重新放置
with(layoutParams as ViewGroup.MarginLayoutParams)
{
leftMargin = left + offsetX
topMargin = top + offsetY
layoutParams = this
}
}
这里是直接使用
setContentView
来加载控件,不清楚其父布局为何(其实是FrameLayout),所以采用ViewGroup.MarginLayoutParams,而使用其他派生布局的layoutParams,会报错。
scrollTo() 、 scrollBy()方法
scrollTo表示
控件移动到指定参数坐标
,而csrollBy表示
移动参数增量
,并且scrollBy也是最终调用scrollTo实现的:
/**
* 设置(Set)你控件最终的滚动位置,这将会调用onScrollChanged(int, int, int, int)
* 方法,并且这个视图将会失效(invalidated)
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
/**
* 移动(Move)你的视图到滚动位置。
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
scrollTo() 、 scrollBy()方法移动View内容,如果是ViewGroup及其派生类,则是
移动其所有子View
我们可以有下再次改动:
MotionEvent.ACTION_MOVE -> {
// 计算移动距离
val offsetX = x - lastX
val offsetY = y - lastY
// 重新放置
(parent as View).scrollBy(-offsetX, -offsetY)
}
我们来解释两点问题:
-
为什么是父类的scrollBy而不是本体:
– 因为scrollTo (int x, int y) 是将View中
内容滑动到相应的位置
-
这里使用的是移动的参考系不同,解释起来则太过啰嗦,记住
取负
即可。
Scroller类
简述
scrollTo() 、 scrollBy()方法是瞬时完成,体验并不好,可以使用此类实现过度效果的滑动动画。
Scroller本身不能实现滑动,而是依托于View的computeScroll方法配合才能实现弹性滑动效果。
具体方法如下:
class MyView(context: Context) : View(context)
{
// 注意,不要放在onTouchEvent方法内,
// 因为onTouchEvent是实时调用的。
private var lastX = 0
private var lastY = 0
private val mScroller = Scroller(context)
init {
// 设置背景颜色,方便区分
setBackgroundColor(Color.RED)
}
fun smoothScrollTo(destX : Int, destY : Int)
{
val deltaX = destX - scrollX
val deltaY = destY - scrollY
mScroller.startScroll(scrollX, scrollY, deltaX, deltaY, 8000)
invalidate()
}
/**
* 系统会在绘制View时在draw调用本重写方法。
* mScroller.computeScrollOffset()主要在内部判断将要实现的
* 动画是否还没结束,然后移动一点距离(以此过度),继而调用invalidate,
* invalidate又会调用draw,以此循环。
* 原理图是:
* draw -> computeScroll -> computeScrollOffset(invalidate) -> draw
*/
override fun computeScroll()
{
super.computeScroll()
if(mScroller.computeScrollOffset())
{
(parent as View).scrollTo(
mScroller.currX,
mScroller.currY
)
invalidate()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)
{
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 设置控件大小
setMeasuredDimension(500, 500)
// 以此沿着X轴向右平移500像素。
smoothScrollTo(-500, 0)
}
}
源码解析
Scroller使用范围十分宽泛,是学习自定义View必学内容,这里依托源码,稍作解析。
-
Scroller 一般我们只传入
context
,实际上还可以传入
插值器(后面回说,可以理解为自定义滑动距离与时间的函数 x = f(t))
以便控制,默认传入的插值器(Interpolator)是
ViscousFluidInterpolator
- ViscousFluidInterpolator貌似使用的是粘性流体影响(viscous fluid effect)函数,这里不做细究。
-
startScroll方法,并没有调用滑动方法,而是
做前期准备,并不能使View滑动
。 - 关键是startScroll之后调用invalidate导致view重绘,view重绘使draw调用,draw之后调用computeScroll,如此滑动。
-
computeScroll
主要是计算动画已运行时间,然后根据
插值器
计算出需要移动的距离,并传递给
mCurrX
和
mCurrY
以便移动。 -
另外,
computeScroll
返回值表示滑动是
否没结束
。以便进行一小段的位置移动。
动画
安卓动画多种多样,单论表现,可以是
淡入淡出
、
旋转
、移动等等,而且实现手法也各有不同,这里主要说说
View动画
、
属性动画
以及其代码和xml简单实现。
View动画
xml写法
在res/anim/translate.xml文件下如下设置移动动画:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="MissingDefaultResource">
<translate
<!--从补偿x位置0处开始-->
android:fromXDelta="0"
<!--移动到x300像素处 -->
android:toXDelta="300"
<!--移动时间1000ms-->
android:duration="1000"
/>
</set>
当然,动画执行结束会直接“瞬移”到原点,为了保留位置,应该如下添加到set标签内:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="MissingDefaultResource"
android:fillAfter="true">
......
</set>
使用方法也很简单,注意AnimationUtils所在的包:
import android.view.animation.AnimationUtils
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
val myView = MyView(this)
myView.animation = AnimationUtils.loadAnimation(this, R.anim.translate)
setContentView(myView)
}
}
代码动态写法
代码中动画定义了透明度(AlphaAnimation)、旋转(RotateAnimation)、缩放(ScaleAnimation)和位移(TranslateAnimation)几种常见的动画,并提供了
AnimationSet
动画集来混合使用多种动画。我们以xml改写为例:
class MainActivity : AppCompatActivity()
{
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
val myView = Button(this)
val translateAnimation = TranslateAnimation(0f, 300f, 0f, 0f)
translateAnimation.duration = 1000
translateAnimation.fillAfter = true
myView.startAnimation(translateAnimation)
setContentView(myView)
}
}
动画缺陷
View动画不能改变其位置参数。
也就是说,如果对某控件进行此操作,不论该控件之后在屏幕何处,抑或者消失,其实它的位置并没有改变。比如按钮因此移出了屏幕,但点击按钮初始位置,依旧可以触发点击事件,而现在按钮所在的位置,可能
无法点击
(可以点击的特例:按钮过大,有重叠位置)
属性动画
简述
自Android3.0开始,属性动画就解决了此问题,他不仅可以实现动画,也能改变控件位置参数。另外,属性动画通过调用get/set方法来真实控制一个view的值,因此能实现大部分动画效果。
ViewPropertyAnimator
属性代码里最简单的是view的方法
animate()
传入的
ViewPropertyAnimator
,有兴趣的可以自己看具体Api,这部分最简单,所以我推荐你看朱凯的视频进行学习:
属性动画-朱凯
ObjectAnimator
ObjectAnimator
作为属性动画最重要的类,创建一个ObjectAnimator只用以静态工厂类直接返回一个ObjectAnimator对象。
参数主要包括
控件·
、
控件属性名(必须要有get/set方法)
。内部会通过
Java反射机制
调用
比如上述代码可以如下写:
override fun onCreate(savedInstanceState: Bundle?)
{
super.onCreate(savedInstanceState)
val myView = Button(this)
ObjectAnimator.ofFloat(
myView,
"translationX",
0f, 300f
).start()
setContentView(myView)
}
构造函数如下,
float... values
的值是数值的取值变化,当然内置了显示时长、插值器等属性值。
public static ObjectAnimator ofFloat(Object target, String propertyName, float... values) {
ObjectAnimator anim = new ObjectAnimator(target, propertyName);
anim.setFloatValues(values);
return anim;
}
translationX等属性值有关内容如下:
属性值 | 释义 |
---|---|
translationX | 沿着X平移 |
translationY | 沿着Y平移 |
rotation | 用来围绕View支点进行旋转 |
rotationX | 同上 |
rotationY | 同上 |
PrivotX/Privot |
控制View对象的支点位置,围绕这个支点进行旋转和缩放变换处理,
默认该支点位置是对象的中心点 |
alpha | [0, 1] 透明度 |
x / y | setX() / setY() |
注意的事,操作的属性值要修改
必须要有get/set方法
,如若没有,可以对控件进行封装,自己写get/set方法,间接地增加访问属性。
ValueAnimator
ValueAnimator不提供任何动画效果,更像是数值发生器,用来产生一定规律的数字,从而让调用者控制动画过程。通常在AnimatorUpdateListener中监听数值变化,从而完成动画的变换,如示:
fun test()
{
with(ValueAnimator.ofFloat(0f, 100f))
{
setTarget(view)
duration = 1000
start()
addUpdateListener {
val float = it.animatedValue
}
}
}
动画监听
完整动画有
Start
、
Repeat
、
End
、
Cancel
四个过程,如示:
fun test()
{
ObjectAnimator.ofFloat([params...])
.addListener(object : Animator.AnimatorListener {
override fun onAnimationStart(animation: Animator) {}
override fun onAnimationEnd(animation: Animator?) {}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationRepeat(animation: Animator?) {}
})
}
大部分我们只关心
onAnimation
事件,由此,也可以使用
AnimatorListenerAdapter
来
选择需要的事件
进行监听:
fun test()
{
ObjectAnimator.ofFloat(view, "alpha", 1.5f)
.addListener(object : AnimatorListenerAdapter()
{
override fun onAnimationCancel(animation: Animator?)
{
super.onAnimationCancel(animation)
}
})
}
AnimatorSet组合动画
简介
AnimatorSet,顾名思义,是动画的集合,我们用的最多的,是把不同Animator传入进行,进行一定顺序(可以是同时)播放。
通过用法,我们来探究一般原理:
这里执行的顺序是:animator1与animator2同时执行(也可以使用
set.
playTogether(
animator1
,
animator2
)),animator3才执行。
fun test(pView : View)
{
val animator1 = ObjectAnimator.ofFloat(pView, "x", 0f, 100f)
val animator2 = ObjectAnimator.ofFloat(pView, "y", 0f, 100f)
val animator3 = ObjectAnimator.ofFloat(pView, "alpha", 0f, 1f)
AnimatorSet(). apply {
duration = 1000
play(animator1).with(animator2).after(animator3)
start()
}
}
这里主要讲
play
方法,
它是建造者模式
,返回的
Builder
自身用于重新构建,主要有以下4个方法:
方法 | 说明 |
---|---|
after(Animator) |
将动画插入到已有的动画
执行 |
after(long) | 传入的动画指定延迟某毫秒后执行 |
before(Animator) |
将动画插入到已有的动画
执行 |
with(Animator) |
将动画插入到已有的动画
执行 |
PropertyValuesHolder组合动画与
PropertyValuesHolder与AnimatorSet类似,
但只能做到一并执行
,但需要结合ObjectAnimator.
ofPropertyValuesHolder
使用,其参数与上文打同小异。
fun test(pView : View)
{
val holder1 = PropertyValuesHolder.ofFloat("x", 0f, 10f)
val holder2 = PropertyValuesHolder.ofFloat("y", 0f, 10f)
val holder3 = PropertyValuesHolder.ofFloat("alpha", 0f, 1f)
ObjectAnimator.ofPropertyValuesHolder(
pView,
holder1, holder2, holder3
).apply {
duration = 100
start()
}
}
XML使用属性动画
xml用法与view动画写法也是相同:
在
res/animator/scale.xml
<?xml version="1.0" encoding="utf-8"?>
<objectAnimator xmlns:tools="http://schemas.android.com/tools">
</objectAnimator>
使用方法为:
fun test(pView : View)
{
AnimatorInflater.loadAnimator(pView.context, R.animator.scale)
.apply {
setTarget(pView)
start()
}
}
具体方法这里不再说,有兴趣可以参见官方文档。
部分图片来源如下,如有侵权,请告知以便删除:
https://segmentfault.com/a/1190000004233074
https://blog.csdn.net/rfgreeee/article/details/79087954