官方文档对 @Stable 的注释:
稳定用于向组成编译器传达有关某种类型或函数的行为的某些保证。
当应用于类或接口时,[Stable]表示以下条件必须为真:
1)对于两个相同的实例,[equals]的结果将始终返回相同的结果。
2)当类型的公共财产发生变化时,将通知组成。
3)所有公共财产类型都是稳定的。
当应用于函数或属性时,@Stable 注解表示如果传入相同的参数,该函数将返回相同的结果。仅当参数和结果本身为[Stable],[Immutable], 或原始的。
该注解所隐含的不变量由组合编译器用于优化,如果不满足上述假设,则具有未定义的行为。 所以除非他们确定满足这些条件,否则不应该使用此注解。
举一个代码例子如下:
/**
* Main holder of our inset values.
*/
@Stable
class DisplayInsets {
/**
* Inset values which match [WindowInsetsCompat.Type.systemBars]
*/
val systemBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.systemGestures]
*/
val systemGestures = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.navigationBars]
*/
val navigationBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.statusBars]
*/
val statusBars = Insets()
/**
* Inset values which match [WindowInsetsCompat.Type.ime]
*/
val ime = Insets()
}
@Stable
class Insets {
/**
* The left dimension of these insets in pixels.
*/
var left by mutableStateOf(0)
internal set
/**
* The top dimension of these insets in pixels.
*/
var top by mutableStateOf(0)
internal set
/**
* The right dimension of these insets in pixels.
*/
var right by mutableStateOf(0)
internal set
/**
* The bottom dimension of these insets in pixels.
*/
var bottom by mutableStateOf(0)
internal set
/**
* Whether the insets are currently visible.
*/
var isVisible by mutableStateOf(true)
internal set
}
val InsetsAmbient = staticAmbientOf<DisplayInsets>()
/**
* Applies any [WindowInsetsCompat] values to [InsetsAmbient], which are then available
* within [content].
*
* @param consumeWindowInsets Whether to consume any [WindowInsetsCompat]s which are dispatched to
* the host view. Defaults to `true`.
*/
@Composable
fun ProvideDisplayInsets(
consumeWindowInsets: Boolean = true,
content: @Composable () -> Unit
) {
val view = ViewAmbient.current
val displayInsets = remember { DisplayInsets() }
onCommit(view) {
ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
displayInsets.systemBars.updateFrom(windowInsets, WindowInsetsCompat.Type.systemBars())
displayInsets.systemGestures.updateFrom(
windowInsets,
WindowInsetsCompat.Type.systemGestures()
)
displayInsets.statusBars.updateFrom(windowInsets, WindowInsetsCompat.Type.statusBars())
displayInsets.navigationBars.updateFrom(
windowInsets,
WindowInsetsCompat.Type.navigationBars()
)
displayInsets.ime.updateFrom(windowInsets, WindowInsetsCompat.Type.ime())
if (consumeWindowInsets) WindowInsetsCompat.CONSUMED else windowInsets
}
// Add an OnAttachStateChangeListener to request an inset pass each time we're attached
// to the window
val attachListener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) = v.requestApplyInsets()
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(attachListener)
if (view.isAttachedToWindow) {
// If the view is already attached, we can request an inset pass now
view.requestApplyInsets()
}
onDispose {
view.removeOnAttachStateChangeListener(attachListener)
}
}
Providers(InsetsAmbient provides displayInsets) {
content()
}
}
/**
* Updates our mutable state backed [Insets] from an Android system insets.
*/
private fun Insets.updateFrom(windowInsets: WindowInsetsCompat, type: Int) {
val insets = windowInsets.getInsets(type)
left = insets.left
top = insets.top
right = insets.right
bottom = insets.bottom
isVisible = windowInsets.isVisible(type)
}
/**
* Apply additional space which matches the height of the status bars height along the top edge
* of the content.
*/
fun Modifier.statusBarsPadding() = composed {
insetsPadding(insets = InsetsAmbient.current.statusBars, top = true)
}
/**
* Apply additional space which matches the height of the navigation bars height
* along the [bottom] edge of the content, and additional space which matches the width of
* the navigation bars on the respective [left] and [right] edges.
*
* @param bottom Whether to apply padding to the bottom edge, which matches the navigation bars
* height (if present) at the bottom edge of the screen. Defaults to `true`.
* @param left Whether to apply padding to the left edge, which matches the navigation bars width
* (if present) on the left edge of the screen. Defaults to `true`.
* @param right Whether to apply padding to the right edge, which matches the navigation bars width
* (if present) on the right edge of the screen. Defaults to `true`.
*/
fun Modifier.navigationBarsPadding(
bottom: Boolean = true,
left: Boolean = true,
right: Boolean = true
) = composed {
insetsPadding(
insets = InsetsAmbient.current.navigationBars,
left = left,
right = right,
bottom = bottom
)
}
/**
* Declare the height of the content to match the height of the navigation bars, plus some
* additional height passed in via [additional]
*
* As an example, this could be used with `Spacer` to push content above the navigation bar
* and bottom app bars:
*
* ```
* Column {
* // Content to be drawn above navigation bars and bottom app bar (y-axis)
*
* Spacer(Modifier.statusBarHeightPlus(48.dp))
* }
* ```
*
* Internally this matches the behavior of the [Modifier.height] modifier.
*
* @param additional Any additional height to add to the status bars size.
*/
fun Modifier.navigationBarsHeightPlus(additional: Dp) = composed {
InsetsSizeModifier(
insets = InsetsAmbient.current.navigationBars,
heightSide = VerticalSide.Bottom,
additionalHeight = additional
)
}
enum class HorizontalSide {
Left,
Right
}
enum class VerticalSide {
Top,
Bottom
}
/**
* Allows conditional setting of [insets] on each dimension.
*/
private fun Modifier.insetsPadding(
insets: Insets,
left: Boolean = false,
top: Boolean = false,
right: Boolean = false,
bottom: Boolean = false
) = this then InsetsPaddingModifier(insets, left, top, right, bottom)
private data class InsetsPaddingModifier(
private val insets: Insets,
private val applyLeft: Boolean = false,
private val applyTop: Boolean = false,
private val applyRight: Boolean = false,
private val applyBottom: Boolean = false
) : LayoutModifier {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureScope.MeasureResult {
val left = if (applyLeft) insets.left else 0
val top = if (applyTop) insets.top else 0
val right = if (applyRight) insets.right else 0
val bottom = if (applyBottom) insets.bottom else 0
val horizontal = left + right
val vertical = top + bottom
val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
val width = (placeable.width + horizontal)
.coerceIn(constraints.minWidth, constraints.maxWidth)
val height = (placeable.height + vertical)
.coerceIn(constraints.minHeight, constraints.maxHeight)
return layout(width, height) {
placeable.place(left, top)
}
}
}
private data class InsetsSizeModifier(
private val insets: Insets,
private val widthSide: HorizontalSide= null,
private val additionalWidth: Dp = 0.dp,
private val heightSide: VerticalSide= null,
private val additionalHeight: Dp = 0.dp
) : LayoutModifier {
private val Density.targetConstraints: Constraints
get() {
val additionalWidthPx = additionalWidth.toIntPx()
val additionalHeightPx = additionalHeight.toIntPx()
return Constraints(
minWidth = additionalWidthPx + when (widthSide) {
HorizontalSide.Left -> insets.left
HorizontalSide.Right -> insets.right
null -> 0
},
minHeight = additionalHeightPx + when (heightSide) {
VerticalSide.Top -> insets.top
VerticalSide.Bottom -> insets.bottom
null -> 0
},
maxWidth = when (widthSide) {
HorizontalSide.Left -> insets.left + additionalWidthPx
HorizontalSide.Right -> insets.right + additionalWidthPx
null -> Constraints.Infinity
},
maxHeight = when (heightSide) {
VerticalSide.Top -> insets.top + additionalHeightPx
VerticalSide.Bottom -> insets.bottom + additionalHeightPx
null -> Constraints.Infinity
}
)
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureScope.MeasureResult {
val wrappedConstraints = targetConstraints.let { targetConstraints ->
val resolvedMinWidth = if (widthSide != null) {
targetConstraints.minWidth
} else {
constraints.minWidth.coerceAtMost(targetConstraints.maxWidth)
}
val resolvedMaxWidth = if (widthSide != null) {
targetConstraints.maxWidth
} else {
constraints.maxWidth.coerceAtLeast(targetConstraints.minWidth)
}
val resolvedMinHeight = if (heightSide != null) {
targetConstraints.minHeight
} else {
constraints.minHeight.coerceAtMost(targetConstraints.maxHeight)
}
val resolvedMaxHeight = if (heightSide != null) {
targetConstraints.maxHeight
} else {
constraints.maxHeight.coerceAtLeast(targetConstraints.minHeight)
}
Constraints(
resolvedMinWidth,
resolvedMaxWidth,
resolvedMinHeight,
resolvedMaxHeight
)
}
val placeable = measurable.measure(wrappedConstraints)
return layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = measurable.minIntrinsicWidth(height).let {
val constraints = targetConstraints
it.coerceIn(constraints.minWidth, constraints.maxWidth)
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
) = measurable.maxIntrinsicWidth(height).let {
val constraints = targetConstraints
it.coerceIn(constraints.minWidth, constraints.maxWidth)
}
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = measurable.minIntrinsicHeight(width).let {
val constraints = targetConstraints
it.coerceIn(constraints.minHeight, constraints.maxHeight)
}
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
) = measurable.maxIntrinsicHeight(width).let {
val constraints = targetConstraints
it.coerceIn(constraints.minHeight, constraints.maxHeight)
}
}
Jetpack Compose 是谷歌在2019 Google i/o 大会上发布的新的库。可以用更少更直观的代码创建 View,还有更强大的功能,以及还能提高开发速度。 说实话,View/Layout 的模式对安卓工程师来说太过于熟悉,对于学习曲线陡峭的 Jetpack Compose 能不能很好的普及还是有所担心。
如果使用 Jetpack Compose 呢,以下做些简单介绍:
在模块中的 build.gradle 文件根据自己的需要新增下列的库的依赖
// compose
composeVersion : '1.0.0-alpha03',
// compose
implementation "androidx.compose.ui:ui:$versions.composeVersion"
implementation "androidx.compose.material:material:$versions.composeVersion"
implementation "androidx.compose.material:material-icons-extended:$versions.composeVersion"
implementation "androidx.compose.foundation:foundation:$versions.composeVersion"
implementation "androidx.compose.foundation:foundation-layout:$versions.composeVersion"
implementation "androidx.compose.animation:animation:$versions.composeVersion"
implementation "androidx.compose.runtime:runtime:$versions.composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$versions.composeVersion"
implementation "androidx.ui:ui-tooling:$versions.composeVersion"
androidTestImplementation "androidx.ui:ui-test:$versions.composeVersion"
还有在模块的 build.gradle 文件中新增下列的设置。
android {
...
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "${compose_version}"
kotlinCompilerVersion versions.kotlin
}
}
@Compose
所有关于构建 View 的方法都必须添加 @Compose 的注解才可以。并且 @Compose 跟协程的 Suspend 的使用方法比较类似,被 @Compose 的注解的方法只能在同样被 @Comopse 注解的方法中才能被调用。
/**
* A wrapper around [CoilImage] setting a default [contentScale] and loading indicator for loading disney poster images.
*/
@Composable
fun NetworkImage(
url: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop
) {
CoilImageWithCrossfade(
data = url,
modifier = modifier,
contentScale = contentScale,
loading = {
ConstraintLayout(
modifier = Modifier.fillMaxSize()
) {
val indicator = createRef()
CircularProgressIndicator(
modifier = Modifier.constrainAs(indicator) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
)
}
}
)
@Preview
加上 @Preview 注解的方法可以在不运行 App 的情况下就可以确认布局的情况。
@Preview 的注解中比较常用的参数如下:
- name: String: 为该 Preview 命名,该名字会在布局预览中显示。
- showBackground: Boolean: 是否显示背景,true 为显示。
- backgroundColor: Long: 设置背景的颜色。
- showDecoration: Boolean: 是否显示 Statusbar 和 Toolbar,true 为显示。
- group: String: 为该 Preview 设置 group 名字,可以在 UI 中以 group 为单位显示。
- fontScale: Float: 可以在预览中对字体放大,范围是从0.01。
- widthDp: Int: 在 Compose 中渲染的最大宽度,单位为dp。
- heightDp: Int: 在 Compose 中渲染的最大高度,单位为dp。
上面的参数都是可选参数,还有像背景设置等的参数并不是对实际的 App 进行设置,只是对 Preview 中的背景进行设置,为了更容易看清布局。
@Preview(showBackground = true, name = "Home UI", showDecoration = true)
@Composable
fun DefaultPreview() {
MyApplicationTheme(darkTheme = false) {
Greeting("Android")
}
}
在 IDE 的右上角有 Code,Split , Design 三个选项。分别是只显示代码,同时显示代码和布局和只显示布局。
当更改跟 UI 相关的代码时,会显示如下图的一个横条通知,点击 Build&Refresh 即可更新显示所更改代码的UI。
setContent
setContent 的作用是和 Layout/View 中的 setContentView 是一样的。
setContent 的方法也是有 @Compose 注解的方法。所以,在 setContent 中写入关于 UI 的 @Compopse 方法,即可在 Activity 中显示。
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@VisibleForTesting val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// observe toast.
viewModel.toast.observe(this) {
Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
}
// fetch disney posters.
viewModel.fetchDisneyPosterList()
// set disney contents.
setContent {
DisneyComposeTheme {
DisneyMain(
viewModel = viewModel,
backDispatcher = onBackPressedDispatcher
)
}
}
}
}
*Theme
在创建新的 Compose 项目时会自动创建一个项目名+Theme 的 @Compose 方法。 我们可以通过更改颜色来完成对主题颜色的设置。 生成的 Theme 方法的代码如下。
private val DarkColorPalette = darkColors(
background = background,
onBackground = background800,
primary = purple200,
primaryVariant = purple500,
secondary = purple500,
onPrimary = Color.White,
onSecondary = Color.White
)
private val LightColorPalette = lightColors(
background = Color.White,
onBackground = Color.White,
surface = Color.White,
primary = purple200,
primaryVariant = purple500,
secondary = purple500,
onPrimary = Color.White,
onSecondary = Color.White
)
@Composable
fun DisneyComposeTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
val typography = if (darkTheme) {
DarkTypography
} else {
LightTypography
}
MaterialTheme(
colors = colors,
typography = typography,
shapes = shapes,
content = content
)
}
Theme方法中有正常主题和Dark主题的颜色设置,里面还有关于MeterialTheme的设置。
关于Theme方法的用法如下。
// set disney contents.
setContent {
DisneyComposeTheme {
DisneyMain(
viewModel = viewModel,
backDispatcher = onBackPressedDispatcher
)
}
}
在 DisneyComposeTheme 里面的所有 UI 方法都会应用上述主题中指定的颜色。
*Modifier
Modifier 是各个 Compose 的 UI 组件一定会用到的一个类。它是被用于设置 UI 的摆放位置,padding 等信息的类。关于 Modifier 相关的设置实在是太多,在这里只介绍会经常用到的。
- padding 设置各个 UI 的 padding。padding 的重载的方法一共有四个
Modifier.padding(10.dp) // 给上下左右设置成同一个值
Modifier.padding(10.dp, 11.dp, 12.dp, 13.dp) // 分别为上下左右设值
Modifier.padding(10.dp, 11.dp) // 分别为上下和左右设值
Modifier.padding(InnerPadding(10.dp, 11.dp, 12.dp, 13.dp))// 分别为上下左右设值
- plus 可以把其他的Modifier加入到当前的Modifier中。
Modifier.plus(otherModifier) // 把otherModifier的信息加入到现有的modifier中
这里设置的值必须为 dp,Compose为我们在 Int 中扩展了一个方法 dp,帮我们转换成 dp。
- fillMaxHeight, fillMaxWidth, fillMaxSize 类似于 match_parent ,填充整个父 layout 。
例如:Modifier.fillMaxHeight() // 填充整个高度
@Composable
fun PosterDetails(
viewModel: MainViewModel,
pressOnBack: () -> Unit
) {
val details: Posterby viewModel.posterDetails.observeAsState()
details?.let { poster ->
ScrollableColumn(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxHeight()
) {
ConstraintLayout {
val (arrow, image, title, content) = createRefs()
NetworkImage(
url = poster.poster,
modifier = Modifier.constrainAs(image) {
top.linkTo(parent.top)
}.fillMaxWidth()
.aspectRatio(0.85f)
)
Text(
text = poster.name,
style = MaterialTheme.typography.h1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.constrainAs(title) {
top.linkTo(image.bottom)
}.padding(start = 16.dp, top = 16.dp)
)
Text(
text = poster.description,
style = MaterialTheme.typography.body2,
modifier = Modifier.constrainAs(content) {
top.linkTo(title.bottom)
}.padding(16.dp)
)
Icon(
asset = Icons.Filled.ArrowBack,
tint = Color.White,
modifier = Modifier.constrainAs(arrow) {
top.linkTo(parent.top)
}.padding(12.dp)
.clickable(onClick = { pressOnBack() })
)
}
}
}
}
- width, heigh, size 设置 Content 的宽度和高度。
Modifier.widthIn(2.dp) // 设置最大宽度
Modifier.heightIn(3.dp) // 设置最大高度
Modifier.sizeIn(4.dp, 5.dp, 6.dp, 7.dp) // 设置最大最小的宽度和高度
- gravity 在 Column 中元素的位置
Modifier.gravity(Alignment.CenterHorizontally) // 横向居中
Modifier.gravity(Alignment.Start) // 横向居左
Modifier.gravity(Alignment.End) // 横向居右
- rtl, ltr 开始布局UI的方向。
Modifier.rtl // 从右到左
Modifier.ltr // 从左到右
- Modifier的方法都返回Modifier的实例的链式调用,所以只要连续调用想要使用的方法即可。
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!", modifier = Modifier.padding(20.dp).fillMaxSize())
}
*Column,Row
正如其名字一样,Column 和 Row 可以理解为在 View/Layout 体系中的纵向和横向的 ViewGroup。
需要传入的参数一共有四个
- Modifier 用上述的方法传入已经按需求设置好的 Modifier 即可
- Arrangement.Horizontal, Arrangement.Vertical 需要给 Row 传入 Arrangement.Horizontal,为 Column 传入Arrangement.Vertical。
这些值决定如何布置内部 UI 组件。
可传入的值为 Center, Start, End, SpaceEvenly, SpaceBetween, SpaceAround。
重点解释一下SpaceEvenly, SpaceBetween, SpaceAround。
SpaceEvenly:各个元素间的空隙为等比例。
SpaceBetween:第一元素前和最后一个元素之后没有空隙,所有空隙都按等比例放入各个元素之间。
SpaceAround:把整体中一半的空隙平分的放入第一元素前和最后一个元素之后,剩余的一半等比例的放入各个元素之间。 - Alignment.Vertical, Alignment.Horizontal
需要给 Row 传入 Alignment.Vertical,为 Column 传入 Alignment.Horizontal。
使用方法和 Modifier 的 gravity 中传入参数的用法是一样的,这里就略过了。 - @Composable ColumnScope.() -> Unit 需要传入标有 @Compose 的 UI 方法。但是这里我们会有 lamda 函数的写法来实现。
@Composable
fun RadioPosters(
posters: List<Poster>,
selectPoster: (Long) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.statusBarsPadding()
.background(MaterialTheme.colors.background)
) {
LazyColumnFor(
items = posters,
contentPadding = PaddingValues(4.dp),
) { poster ->
RadioPoster(
poster = poster,
selectPoster = selectPoster
)
}
}
}
Column {
Row(modifier = Modifier.ltr.fillMaxWidth(),horizontalArrangement = Arrangement.SpaceAround, verticalGravity = Alignment.Top) {
// ..,...
}
最后的话:
会 flutter 的这个上手就超级快,要是学会这个再去学 flutter 上手也很快