在HarmonyOS中,控件最终展示到用户界面上,会经历测量(Estimate)、布局(Arrange)、绘制(Draw)等过程。这里我们来分析一下测量与布局的用法,并且结合上一篇文章事件分发一起实现一个简单的滚动视差布局ParallaxLayout。
ParallaxLayout效果图:
测量Estimate
如何自定义测量过程
首先通过setEstimateSizeListener(Component.EstimateSizeListener listener)来设置测量的回调:
- setEstimateSizeListener(new EstimateSizeListener() {
- @Override
- public boolean onEstimateSize(int widthEstimateSpec, int heightEstimateSpec) {
- return false;
- }
- });
在EstimateSizeListener 中,第一个参数为宽度测量参数,第二个为高度测量参数,我们可以通过EstimateSpec来获取宽度、高度的模式(mode)与大小(size):
- int mode = EstimateSpec.getMode(widthEstimateSpec);
- int size = EstimateSpec.getSize(widthEstimateSpec);
计算了控件最终大小之后,我们可以调用setEstimatedSize(int estimatedWidth, int estimatedHeight)函数来设置最终的测量大小。注意:setEstimatedSize函数需要的是具体的大小,而非测量参数,即EstimateSpec.getSize后的具体大小
- setEstimatedSize(widthSize, heightSize);
最后,如果需要让设置的测量大小生效,我们应该在onEstimateSize中返回true:
- setEstimateSizeListener(new EstimateSizeListener() {
- @Override
- public boolean onEstimateSize(int widthEstimateSpec, int heightEstimateSpec) {
- int widthSize = EstimateSpec.getSize(widthEstimateSpec);
- int heightSize = EstimateSpec.getSize(heightEstimateSpec);
- setEstimatedSize(widthSize, heightSize);
- return true;
- }
- });
如果onEstimateSize返回true,那么最终系统不会在native层来测量大小。如果返回了false,系统还是会继续测量大小,最终的大小可能与setEstimatedSize设置的结果不一致。
在setEstimatedSize后我们就可以通过下面的函数来获取我们设置的大小:
- getEstimatedWidth(); // 获取测量的宽度
- getEstimatedHeight(); // 获取测量的高度
EstimateSpec
EstimateSpec是Component的内部类,EstimateSpec提供了一系列的操作测量参数的方法。
测量参数
测量参数是一个int值,它封装了来自父控件的测量需求。测量参数由模式与大小两个int值组合而成,公式如下:
- (size & ~EstimateSpec.ESTIMATED_STATE_BIT_MASK) | (mode & EstimateSpec.ESTIMATED_STATE_BIT_MASK)
其中size为当前控件的大小,mode为模式。也可以通过下面的函数来,通过size和mode来生成一个测量参数:
- int spec = EstimateSpec.getSizeWithMode(size, mode);
模式mode
通过EstimateSpec获取的mode有三种取值:
- EstimateSpec.NOT_EXCEED 不超过:该模式表示父控件已经规定了当前控件大小的最大值
- EstimateSpec.PRECISE 精确:该模式表示父控件已经规定了当前控件大小的值
- EstimateSpec.UNCONSTRAINT 无约束:该模式表示父控件对当前控件大小没有约束,控件可以想要任何大小
在不同模式下,控件的大小是如何确定的呢?可以简单的通过下面的代码来理解:
- // size变量为控件期望的大小,estimateSpec变量为父控件的测量参数
- final int specMode = EstimateSpec.getMode(estimateSpec);
- final int specSize = EstimateSpec.getSize(estimateSpec);
- final int result;
- switch (specMode) {
- case EstimateSpec.NOT_EXCEED:
- result = Math.min(specSize, size);
- break;
- case EstimateSpec.PRECISE:
- result = specSize;
- break;
- case EstimateSpec.UNCONSTRAINT:
- default:
- result = size;
- }
- 当mode为NOT_EXCEED时,控件的期望大小应该小于等于父控件给定的size
- 当mode为PRECISE时,控件的大小应该等于父控件给定的size
- 当mode为UNCONSTRAINT时,控件的大小可以为他期望的size
自定义布局
在自定义布局时,我们不仅仅要测量自己的大小,还需要测量子控件的大小。子控件可以通过estimateSize(int widthEstimatedConfig, int heightEstimatedConfig)函数来设置测量参数:
- child.estimateSize(widthEstimatedConfig, heightEstimatedConfig);
注意:estimateSize的两个参数需要的是测量参数,而非具体的大小。这两个参数会传递到子控件的onEstimateSize(int widthEstimateSpec, int heightEstimateSpec)回调中。
默认情况下,子控件会根据widthEstimatedConfig与heightEstimatedConfig来确认自己的最终大小,子控件也可以通过setEstimateSizeListener来自定义其测量过程,最终其参考的测量参数就是我们通过estimateSize函数设置的测量参数。
接下来我们只需要遍历所有子控件来为他们设置测量参数就达到了测量子控件的大小的目的。自定义布局的测量过程基本就是包含了这两个步骤:为所有子控件设置测量参数以及测量自己的大小。
子控件的测量参数
那么,最重要的问题是,我们如何确定子控件的测量参数到底应该是多少,换句话说我们如何生成或者获取子控件的测量参数呢?子控件的测量参数与很多因素有关,如父控件的测量参数、父控件的padding值、子控件自己的期望大小。我们可以根据这几个参数来确定子控件的测量参数。
这里我们通过一个帮助函数来生成子控件的测量参数,首先函数的定义应该如下:
- /**
- * 根据父component的spec、padding以及子component的期望大小,生成子component的spec
- * @param spec 父component的spec
- * @param padding 父component的padding
- * @param childDimension 子component的期望大小
- * @return 子component的spec
- */
- public static int getChildEstimateSpec(int spec, int padding, int childDimension);
注意:childDimension应该怎么获取呢?实际上就是ComponentContainer.LayoutConfig中的width或者height,测量高度就取height、宽度就取width。
接下来我们应该根据父控件的mode以及childDimension来确定子控件的mode与size,并生成测量参数。具体参考如下代码:
- public static int getChildEstimateSpec(int spec, int padding, int childDimension) {
- int specMode = EstimateSpec.getMode(spec);
- int specSize = EstimateSpec.getSize(spec);
- int size = Math.max(0, specSize - padding);
- int resultSize = 0;
- int resultMode = 0;
- switch (specMode) {
- // Parent has imposed an exact size on us
- case EstimateSpec.PRECISE:
- if (childDimension >= 0) {
- resultSize = childDimension;
- resultMode = EstimateSpec.PRECISE;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_PARENT) {
- // Child wants to be our size. So be it.
- resultSize = size;
- resultMode = EstimateSpec.PRECISE;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_CONTENT) {
- // Child wants to determine its own size. It can't be
- // bigger than us.
- resultSize = size;
- resultMode = EstimateSpec.NOT_EXCEED;
- if (size == 0) {
- // size will not be 0 when resultMode is NOT_EXCEED, don't know why
- resultMode = EstimateSpec.PRECISE;
- }
- }
- break;
- // Parent has imposed a maximum size on us
- case EstimateSpec.NOT_EXCEED:
- if (childDimension >= 0) {
- // Child wants a specific size... so be it
- resultSize = childDimension;
- resultMode = EstimateSpec.PRECISE;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_PARENT) {
- // Child wants to be our size, but our size is not fixed.
- // Constrain child to not be bigger than us.
- resultSize = size;
- resultMode = EstimateSpec.NOT_EXCEED;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_CONTENT) {
- // Child wants to determine its own size. It can't be
- // bigger than us.
- resultSize = size;
- resultMode = EstimateSpec.NOT_EXCEED;
- if (size == 0) {
- // size will not be 0 when resultMode is NOT_EXCEED, don't know why
- resultMode = EstimateSpec.PRECISE;
- }
- }
- break;
- // Parent asked to see how big we want to be
- case EstimateSpec.UNCONSTRAINT:
- if (childDimension >= 0) {
- // Child wants a specific size... let him have it
- resultSize = childDimension;
- resultMode = EstimateSpec.PRECISE;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_PARENT) {
- // Child wants to be our size... find out how big it should
- // be
- resultSize = size;
- resultMode = EstimateSpec.UNCONSTRAINT;
- } else if (childDimension == ComponentContainer.LayoutConfig.MATCH_CONTENT) {
- // Child wants to determine its own size.... find out how
- // big it should be
- resultSize = size;
- resultMode = EstimateSpec.UNCONSTRAINT;
- }
- break;
- }
- return makeEstimateSpec(resultSize, resultMode);
- }
makeEstimateSpec函数实际就是(size & ~EstimateSpec.ESTIMATED_STATE_BIT_MASK) | (mode & EstimateSpec.ESTIMATED_STATE_BIT_MASK)的值。
测量过程到此就基本结束,接下来看看稍微简单一点的布局。
布局Arrange
如何自定义布局过程
首先通过setArrangeListener(ComponentContainer.ArrangeListener listener)来设置测量的回调:
- setArrangeListener(new ArrangeListener() {
- @Override
- public boolean onArrange(int l, int t, int width, int height) {
- return false;
- }
- });
与测量类似,布局也是通过回调函数的方式来自定义。其中第一个参数为该控件的left值,第二个为top值,第三个为宽度,第四个为高度。
setArrangeListener是在ComponentContainer中定义的,Component中没有。
与测量不同的是,控件本身的位置与宽高已经由父控件确定了,即为onArrange回调中的四个参数。
在Arrange过程中,我们需要做的就是递归为每个子控件设置位置。通过调用子控件的arrange(int left, int top, int width, int height)函数来排列子元素:
- child.arrange(left, top, child.getEstimatedWidth(), child.getEstimatedHeight());
同样的,onArrange回调需要返回true,才会使布局生效。
在调用了child的arrange函数后,就能通过child.getWidth()与child.getHeight()来获取子控件的宽高了。
一个简单的垂直顺序排列布局的简化代码如下:
- @Override
- public boolean onArrange(int l, int t, int width, int height) {
- int childCount = getChildCount();
- int childTop = t;
- for(int i = 0; i < childCount; i++) {
- Component child = getComponentAt(i);
- int childHeight = child.getEstimatedHeight();
- child.arrange(l, childTop, child.getEstimatedWidth(), childHeight);
- childTop += childHeight;
- }
- return true;
- }
注意:不管是onArrange回调还是子控件的arrange函数,最后两个参数都是宽与高,而不是right与bottom。
综合
接下来我们结合我们前一篇自定义控件之触摸事件,与测量、布局一起,来自定义一个简单的滚动视差布局ParallaxLayout。
- ParallaxLayout包含有两个子控件,第一个固定150vp的Image。第二个是高度为match_parent的Text控件
- 在onEstimateSize中,主要是遍历子控件为其设置测量参数,并为自己设置测量结果。Image的测量高度为固定150vp,Text的高度与布局一致,我们需要通过测量参数与LayoutConfig计算出所有子控件的高度与自己的高度。
- 在onArrange中,按顺序垂直排列子控件。由于Image+Text的高度已经超出了自己的高度,因此Text的底部会有一部分显示不出来。
- 在onTouchEvent中,通过计算手指的移动距离,为每个子控件setTranslateY,来实现位移的效果。最大位移距离为Image的高度。
- 通过为Image设置一半的translateY,为Text设置全部的translateY来实现滚动视差效果,关键代码如下:
- for (int i = 0; i < childCount; i++) {
- Component child = getComponentAt(i);
- if (i == 0) {
- child.setTranslationY(deltaY / 2);
- } else {
- child.setTranslationY(deltaY);
- }
- }
具体代码参考:parallax-layout