前语

在上篇 鬼话Compose炼体(1) 中,借助ModalNavigationDrawer{}+Scaffold{}布局脚手架快速搭建了一个带侧边抽屉,顶部应用栏等组件的一个常规首页。然而实践中的大多数状况,咱们面临的十分规页面居多,这就需求咱们具有一定的才能去做自定义适配。正好Scaffold本身其实便是一个很好的自定义布局的完结。所以本篇终究会经过改写Scaffold的源码来完结一个Snackbar显现在FAB下面的自定义Scaffold

在开端之前,需求了解以下几个知识点:

WindowInsets

什么是WindowsInsets?咱们拆开来看,先看Window,在android中Window抽象类的实例是添加到window manager的尖端view,Window担任尖端窗口外观和行为战略,供给标准的 UI 战略,例如背景、标题区域、默认键处理等。framework会代表应用程序实例化这个类的完结。再来看Insets,Inset的复数,Inset翻译过来是插入物。合起来了解一下,WindowInsets便是应用程序尖端view的一组插入物。插入物有哪些?能够了解为不是应用程序产出的视图和手势都归于插入物!例如状态栏,虚拟导航栏,体系软键盘,体系全局手势导航,刘海屏的剪切区域都归于WindowsInsets。

WindowInsets有什么用?它们包含着插入物的宽高信息,运用这些信息咱们能够最大程度的处理应用程序的内容和这些插入物的覆盖或抵触问题。

来看下面这个简略的例子

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    WindowCompat.setDecorFitsSystemWindows(window, false)
    setContent {
        val systemUiController = rememberSystemUiController()
        val useDarkIcons = !isSystemInDarkTheme()
        LaunchedEffect(systemUiController, useDarkIcons) {
            systemUiController.setSystemBarsColor(
                color = Color.Transparent,
                darkIcons = useDarkIcons,
            )
        }
        WaTheme {
            Box(modifier = Modifier.background(alpha=0.4f, brush = SolidColor(Color.Blue)).fillMaxSize()) {
                Text(
                    text = "我假如不行长你就看不清我了!",
                    modifier = Modifier.background(Color.Yellow),
                )
            }
        }
    }
}
大话Compose炼体(2)-把三碗饭吐出来
运转后很明显的textview的内容和状态栏重叠了。

这儿假如知道状态栏的高度就好了。textview空出这个间隔不就能够了么?状态栏无疑也是windowInset的一种,那么怎样经过windowsInsets拿到呢?

先来收拾一下compose里面临windowsInsets的分类:

  1. 剪切区域:刘海屏裁切区域
  2. 可点击元素:接触角
  3. 强制体系手势:体系强制处理的接触区域
  4. 体系手势:体系处理的接触区域
  5. 输入法:软键盘
  6. 标题栏:手机上不承认有没有,好像是tv端的(不承认)
  7. 导航栏:底部的3金刚虚拟导航栏或许全面屏的药丸导航栏
  8. 状态栏:顶部的状态栏
  9. 瀑布:曲面屏的侧面(瀑布这个描述不错!)
  10. 体系栏:状态栏+导航栏+标题栏
  11. 安全制作:体系栏+剪切区域+软键盘
  12. 安全手势:体系手势+体系强制手势+瀑布滑动+接触角
  13. 安全内容:安全制作+安全手势

那么要处理上面的问题咱们只用选择状态栏即可

Text(
    text = "我假如不行长你就看不清我了!",
    modifier = Modifier.background(Color.Yellow).windowInsetsPadding(WindowInsets.statusBars),
)

windowInsetsPadding(WindowInsets.statusBars)作用契合预期

大话Compose炼体(2)-把三碗饭吐出来

再来换安全内容来试试作用

Text(
    text = "我假如不行长你就看不清我了!",
    modifier = Modifier.background(Color.Yellow).windowInsetsPadding(WindowInsets.safeContent),
)
大话Compose炼体(2)-把三碗饭吐出来

windowInsetsPadding(WindowInsets.safeContent)契合预期,左右两边空出来了体系侧滑回来的间隔,上面空出了状态栏的间隔,下面空出了药丸的间隔。

布局模型

Compose呈现在页面上有3个进程,组合->布局->制作,其间在界面树中布局每个节点的进程又分为三个进程。每个节点必须:

  1. 丈量一切子项
  2. 承认自己的尺度
  3. 放置其子项
    大话Compose炼体(2)-把三碗饭吐出来

在以上布局模型中,经过单次传递即可完结界面树布局。首要,体系会要求每个节点对本身进行丈量,然后以递归办法完结一切子节点的丈量,并将尺度束缚条件沿着树向下传递给子节点。再后,承认叶节点的尺度和放置方位,并将经过解析的尺度和放置指令沿着树向上回传。

简而言之,父节点会在其子节点之前进行丈量,但会在其子节点的尺度和放置方位承认之后再对本身进行调整。

请参考以下SearchResult函数。

@Composable
fun SearchResult(...) {
 Row(...) {
  Image(...)
  Column(...) {
   Text(...)
   Text(..)
  }
 }
}

此函数会生成以下界面树。

SearchResult
  Row
    Image
    Column
      Text
      Text

SearchResult示例中,界面树布局遵从以下次序:

  1. 体系要求根节点Row对本身进行丈量。
  2. 根节点Row要求其榜首个子节点(即Image)进行丈量。
  3. Image是一个叶节点(也便是说,它没有子节点),因此该节点会陈述尺度并回来放置指令。
  4. 根节点Row要求其第二个子节点(即Column)进行丈量。
  5. 节点Column要求其榜首个子节点Text进行丈量。
  6. 由于榜首个节点Text是叶节点,因此该节点会陈述尺度并回来放置指令。
  7. 节点Column要求其第二个子节点Text进行丈量。
  8. 由于第二个节点Text是叶节点,因此该节点会陈述尺度并回来放置指令。
  9. 现在,节点Column已丈量其子节点,并已承认其子节点的尺度和放置方位,接下来它能够承认自己的尺度和放置方位了。
  10. 现在,根节点Row已丈量其子节点,并已承认其子节点的尺度和放置方位,接下来它能够承认自己的尺度和放置方位了。

大话Compose炼体(2)-把三碗饭吐出来

Layout可组合项

Layout可组合项是布局环节中重要的中心组成。此可组合项允许手动丈量安置子项。ColumnRow等一切较高级级的布局都运用Layout可组合项构建而成。

咱们来构建一个十分基本的Column。大多数自定义布局都遵从以下模式:

@Composable
fun MyBasicColumn(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit
) {
  Layout(
    modifier = modifier,
    content = content
  ) { measurables, constraints ->
    // 在这处理用供给的束缚条件去丈量和安置子项的逻辑
  }
}

measurables是装着一切需求丈量的子项的列表,而constraints是来自父项的束缚条件。按照与前面相同的逻辑,可按如下办法完结MyBasicColumn

@Composable
fun MyBasicColumn(
  modifier: Modifier = Modifier,
  content: @Composable () -> Unit
) {
  Layout(
    modifier = modifier,
    content = content
  ) { measurables, constraints ->
        //回来一切子项在父布局中的可放置布局
    val placeables = measurables.map { measurable ->
      // 在束缚条件下丈量每个子项
      measurable.measure(constraints)
    }
    // 一般在束缚条件下能设置多大就设置多大
        //不过这两个参数会影响父布局的大小,有时分要按需传递
    layout(constraints.maxWidth, constraints.maxHeight) {
      // y轴方位坐标
      var yPosition = 0
      // 在父布局安置子项
      placeables.forEach { placeable ->
        placeable.placeRelative(x = 0, y = yPosition)
        // 每安置一个子项后递加y坐标
        yPosition += placeable.height
      }
    }
  }
}

可组合子项受Layout束缚条件(没有minHeight束缚条件)的束缚,它们的放置基于前一个可组合项的yPosition

把下面的代码放在上一篇的示例代码的Scaffold的content块下:

MyBasicColumn(Modifier.padding(padding)) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}
大话Compose炼体(2)-把三碗饭吐出来

看看上面的MyBasicColumn可组合项中的自定义布局的layout(constraints.maxWidth, constraints.maxHeight) {}函数传入的宽高参数,由于咱们父布局没有束缚束缚,所以这个时分的layout的宽高是能多大就取多大。咱们给它加个边框来看看作用。

MyBasicColumn(Modifier.padding(padding).border(1.dp, Companion.Red)) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}

大话Compose炼体(2)-把三碗饭吐出来
发现果然是把宽高设置成了能用的最大范围(由于体系栏的padding,减去了topbar和bottombar的高度)。但是Compose给咱们供给的Column可组合项却没有这个问题,中心逻辑其实也便是在调用layout(width,height){放置子项逻辑}前,丈量一切子项,然后传入经过一切子项宽高核算出来的值来指定layout的宽高。咱们稍微修正一下也能完结相似wrapcontent的作用出来。

@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        //用来记载一切子项的高
        var maxHeight=0
        //用来记载子项最大宽度
        var maxWeight=0
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints).apply {
                //丈量每个子项的时分累加高
                maxHeight+=height
                //丈量每个子项的时分取宽的最大值
                maxWeight=width.coerceAtLeast(maxWeight)
            }
        }
         //指定咱们经过子项核算出来的宽高
        layout(maxWeight, maxHeight) {
            var yPosition = 0
            //放置子项,并终究承认本身的布局大小
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = yPosition)
                yPosition += placeable.height
            }
        }
    }
}

运转一下看看作用,契合预期了吧

大话Compose炼体(2)-把三碗饭吐出来

固有特性丈量

除了上面那种直接设置layout宽高的值来指定布局的宽高外,其实还能够借用Compose中的固有特性丈量来完结同样的作用。不同的是,经过固有特性丈量咱们能够在进行正式丈量之前就获取到子项的宽高级信息。这样有个好处是很多时分咱们能够直接经过父布局的宽高来操控子项的宽高 (子项用fillMax相关api直接适配)。在完结前先简略认识一下固有特性的几个关键概念。

/**
 * 1.参数束缚的高度下,核算出能让内容正确显现的最小宽度 
 */
fun minIntrinsicWidth(height: Int): Int
/**
 * 2.核算出添加宽度也不会削减高度的最小宽度 
 */
fun maxIntrinsicWidth(height: Int): Int
/**
 * 3.参数束缚的宽度下,核算出能让内容正确显现的最小高度
 */
fun minIntrinsicHeight(width: Int): Int
/**
 * 4.核算出添加高度也不会削减宽度的最小高度
 */
fun maxIntrinsicHeight(width: Int): Int

说实话,有点不好了解。或许大家或多或少会有这样的疑问。为什么max min最初的办法回来的都是最小的宽或许高呢?它们的区别又是什么呢? 下面咱们经过改造一下前面的例子来帮助了解:


MyBasicColumn(
    Modifier
        .padding(top =padding.calculateTopPadding())
        .height(Min)
        .width(Min)
        .border(1.dp, Companion.Red),
) {
    Text("MyBasicColumn")
    Text("places items")
    Text("vertically.")
    Text("We've done it by hand!")
}
//用固有特性丈量完结
@Composable
fun MyBasicColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content,
        measurePolicy = object : MeasurePolicy {
            override fun MeasureScope.measure(
                measurables: List<Measurable>,
                constraints: Constraints,
            ): MeasureResult {
                //丈量和安置逻辑
                val placeables = measurables.map { measurable ->
                    // Measure each children
                    measurable.measure(constraints.copy(minWidth = 0, minHeight = 0))
                }
                return layout(constraints.maxWidth, constraints.maxHeight) {
                    var yPosition = 0
                    placeables.forEach { placeable ->
                        placeable.placeRelative(x = 0, y = yPosition)
                        yPosition += placeable.height
                    }
                }
            }
            override fun IntrinsicMeasureScope.minIntrinsicHeight(
                measurables: List<IntrinsicMeasurable>,
                width: Int,
            ): Int {
                var maxHeight = 0
                measurables.map {
                    //1.经过一切子项的固有特性核算,累加后回来为父布局的束缚高
                    maxHeight += it.minIntrinsicHeight(width)
                }
                return maxHeight
            }
            override fun IntrinsicMeasureScope.minIntrinsicWidth(
                measurables: List<IntrinsicMeasurable>,
                height: Int,
            ): Int {
                var maxWidth = 0
                measurables.map {
                    //2.经过一切子项的固有特性核算,取最大值回来为父布局的束缚宽
                    maxWidth = it.maxIntrinsicWidth(height).coerceAtLeast(maxWidth)
                }
                return maxWidth
            }
        },
    )
}

运转后作用跟之前的如出一辙。

留意:注释1的子项调用的是minIntrinsicHeight,注释2子项调用的是maxIntrinsicWidth 看看把注释2的调用改成minIntrinsicWidth的作用

大话Compose炼体(2)-把三碗饭吐出来

哎!”We’ve done it by hand!”的Text子项怎样换行了!?是父布局的高算错了么?并不是!由于这个高度完全能够正确显现的一切子项的内容。text内容最长的子项换行才是导致了高度看上去不对的原因!那么为什么它会换行呢?

大话Compose炼体(2)-把三碗饭吐出来
如图所示,绿色框是当时子项的宽高范围。

Modifier函数的次序十分重要。由于每个函数都会对上一个函数回来的Modifier进行更改,咱们先调用的height(Min)

MyBasicColumn(
    Modifier
        .padding(top =padding.calculateTopPadding())
        .height(Min)//先调用
        .width(Min)//后调用
        .border(1.dp, Companion.Red),
)

获取固有特性丈量的最小高的时分,width没有任何束缚,然后经过核算一切子项固有特性的高得到父布局能够正确显现一切子项的最小高,如红色框所示。后调用width(Min)再去核算固有特性的宽,这个时分办法里面的height就不是无束缚了,而是前面算出来的值了,所以在这个高度的束缚下,这时假如子项用minIntrinsicWidth,那么获取的是在父布局束缚的高度内能正确显现的内容的最小宽也便是绿色框的宽。再来看一下maxIntrinsicWidth的注释

/** * 2.核算出添加宽度也不会削减高度的最小宽度 */
fun maxIntrinsicWidth(height: Int): Int

那么请想象一下。假如把绿框的宽度添加到一定宽度,是不是 “by hand!”就能够在“ We’ve done it”这一行接着显现了!带来的影响是什么呢?是不是框的高度变了?假如此刻再添加宽度,也没有什么含义了,由于高度不会再变了!而刚刚能让父布局高度不变并能正确显现子项内容的宽便是最小宽!

现在咱们能够回答最初提出的问题了。maxIntrinsicWidth or maxIntrinsicHeight核算的值,其实便是父布局高度或许宽度不变时子项的最小宽或许最小高。而minIntrinsicWidthor minIntrinsicHeight核算的是值,是当时父布局的束缚条件下能正确显现子项内容的最小宽高。

所以咱们用maxIntrinsicWidth来父布局高度不变时的子项的最小宽,来完结预期作用。

固有特性丈量并不复杂,便是开端了解起来的时分容易造成混淆和困惑。主张仔细看示例代码,这玩意懂了也就懂了,多看几遍想通后自然就把握了。

SubcomposeLayout

到这一步的时分估计也吐的差不多了。咱们缓一下,捋一捋上面的内容。

  1. 自定义布局起手来个Layout函数,咱们能够先丈量子项,然后再在layout()的时分设置父布局的宽高并完结子项的安置
  2. 自定义布局起手来个Layout函数,咱们能够经过固有特性丈量获取子项的宽高级信息,然后回来父布局的宽高,然后子项完结真正的丈量安置

这两种办法都是经过先获取子项的宽高来核算父布局的宽高,简略的父与子的双向联系。假定父布局的部分子项在丈量安置的时分需求依据其他子项的丈量成果来决定呢?就好比Scaffold中,FAB是怎样做到一定会显现在bottomBar上面?Snackbar又一定会显现在FAB上面?要回答这些问题就必须让SubcomposeLayout进场了!

SubcomposeLayout允许子项的组合进程延迟到父组件实践产生丈量机遇进行,为咱们供给了更强的丈量定制才能。怎样了解?一句话能够归纳为:SubcomposeLayout让其子项的组合进程能够在本身的layout(width,height){}函数内,经过subcompose(soltId,content)组合延迟产生。Scaffold的完结正是运用了SubcomposeLayout这一特性。

自定义Scaffold

总算能够开端正题了!假如前面的内容假如都弄理解了,其实到这儿就很简略了。几乎照搬了Scaffold源码,只是稍作了修正完结。咱们直接上关键代码:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WAScaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit,
) {
    Surface(modifier = modifier, color = containerColor, contentColor = contentColor) {
        SubcomposeLayout { constraints ->
            val layoutWidth = constraints.maxWidth
            val layoutHeight = constraints.maxHeight
            val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
            layout(layoutWidth, layoutHeight) {
                  ...省略
                //组合Snackbar并丈量,回来一个丈量成果。
                val snackbarPlaceables =
                    subcompose(ScaffoldLayoutContent.Snackbar, snackbarHost).map {
                        // respect only bottom and horizontal for snackbar and fab
                        val leftInset = contentWindowInsets
                            .getLeft(this@SubcomposeLayout, layoutDirection)
                        val rightInset = contentWindowInsets
                            .getRight(this@SubcomposeLayout, layoutDirection)
                        val bottomInset = contentWindowInsets.getBottom(this@SubcomposeLayout)
                        // offset the snackbar constraints by the insets values
                        it.measure(
                            looseConstraints.offset(
                                -leftInset - rightInset,
                                -bottomInset,
                            ),
                        )
                    }
                val snackbarHeight = snackbarPlaceables.maxByOrNull { it.height }?.height ?: 0
                val snackbarWidth = snackbarPlaceables.maxByOrNull { it.width }?.width ?: 0
                //组合bottomBar并丈量
                val bottomBarPlaceables = subcompose(ScaffoldLayoutContent.BottomBar) {
                    CompositionLocalProvider(
                        LocalFabPlacement provides fabPlacement,
                        content = bottomBar,
                    )
                }.map { it.measure(looseConstraints) }
                //bottomBar高度
                val bottomBarHeight = bottomBarPlaceables.maxByOrNull { it.height }?.height
                //核算出Snackbar底部偏移量
                val snackbarOffsetFromBottom = if (snackbarHeight != 0) {
                    //本身的高度+bottomBar的高度,假如没有bottomBar,那么便是本身+体系栏底部的插边高度
                    snackbarHeight +
                        (bottomBarHeight ?: contentWindowInsets.getBottom(this@SubcomposeLayout))
                } else {
                    //不显现高度为0
                    0
                }
                //核算是FAB底部偏移量
                val fabOffsetFromBottom = fabPlacement?.let {
                    if (snackbarOffsetFromBottom == 0) {
                        it.height + FabSpacing.roundToPx() + (bottomBarHeight
                            ?: contentWindowInsets.getBottom(this@SubcomposeLayout))
                    } else {
                        snackbarOffsetFromBottom + it.height + FabSpacing.roundToPx()
                    }
                }
               ...省略
                snackbarPlaceables.forEach {
                    it.place(
                        (layoutWidth - snackbarWidth) / 2 +
                            contentWindowInsets.getLeft(this@SubcomposeLayout, layoutDirection),
                        layoutHeight - snackbarOffsetFromBottom,
                    )
                }
                // The bottom bar is always at the bottom of the layout
                bottomBarPlaceables.forEach {
                    it.place(0, layoutHeight - (bottomBarHeight ?: 0))
                }
                fabPlacement?.let { placement ->
                    fabPlaceables.forEach {
                        it.place(placement.left, layoutHeight - fabOffsetFromBottom!!)
                    }
                }
            }
        }
    }
}

这样就完结了一个Snackbar永远在FAB下显现的自定义Scaffold了

大话Compose炼体(2)-把三碗饭吐出来

附完整代码地址:鬼话Compose炼体(2)相关类 (github.com)

本文内容部分参考和引证以下链接

Compose 布局中的固有特性丈量 | Jetpack Compose | Android Developers

自定义布局 | Jetpack Compose | Android Developers

固有特性丈量 | 你好 Compose (jetpackcompose.cn)