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

  • Post author:
  • Post category:其他


不可否认,随着

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)
}

我们来解释两点问题:

  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

    主要是计算动画已运行时间,然后根据

    插值器

    计算出需要移动的距离,并传递给

    mCurrX



    mCurrY

    以便移动。
  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 / 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