【安卓小叙】详论View体系(一)


 不可否认,随着 fluttercompose的流行,组件式编程有望取代老旧的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)
}

我们来解释两点问题:

  1. 为什么是父类的scrollBy而不是本体:
    – 因为scrollTo (int x, int y) 是将View中内容滑动到相应的位置
  2. 这里使用的是移动的参考系不同,解释起来则太过啰嗦,记住取负即可。

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必学内容,这里依托源码,稍作解析。

  1. Scroller 一般我们只传入context,实际上还可以传入插值器(后面回说,可以理解为自定义滑动距离与时间的函数 x = f(t)) 以便控制,默认传入的插值器(Interpolator)是ViscousFluidInterpolator
  2. ViscousFluidInterpolator貌似使用的是粘性流体影响(viscous fluid effect)函数,这里不做细究。
  3. startScroll方法,并没有调用滑动方法,而是做前期准备,并不能使View滑动
  4. 关键是startScroll之后调用invalidate导致view重绘,view重绘使draw调用,draw之后调用computeScroll,如此滑动。
  5. computeScroll 主要是计算动画已运行时间,然后根据插值器计算出需要移动的距离,并传递给mCurrXmCurrY以便移动。
  6. 另外,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 / ysetX() / 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
		}
	}
}

动画监听

 完整动画有 StartRepeatEndCancel四个过程,如示:

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值