Jetpack Compose 初体验(中)

846 阅读14分钟

四、图形

Compose 给我们提供了丰富的组件来构建出好看好用的界面,但是天大地大,再丰富的组件库也不可能无所不包,有时候我们需要一些组件库没有提供现成的解决方案的组件,可能就需要自己绘制了。

在 View 系统中,绘制自定义图形,需要在 onDraw() 函数中,通过维护的状态调用 Canvas 的方法动态绘制想要的图形和图像,并辅助以 Paint 设置颜色和笔画属性。Compose 将 Canvas 变成可组合控件,所有的属性都通过 DrawScopeCanvas 的一个 lambda 参数) 传递和维护,不再需要 Paint 等对象的辅助,将这些工作交付于框架,减轻使用者的负担。

@Preview
@Composable
fun ComposeCanvas() {
    Canvas(modifier = Modifier.fillMaxSize()){
        val canvasWidth = size.width
        val canvasHeight = size.height

        drawLine(
            start = Offset(x=canvasWidth, y = 0f),
            end = Offset(x = 0f, y = canvasHeight),
            color = Color.Blue,
            strokeWidth = 5F
        )
    }
}

上面的代码绘制了一条直线,根据 startend 属性,确定直线的起点和终点,color 确定直线的颜色,strokeWidth 确定直线的线宽。后两者在 View 系统中则是通过 Paint 确定的。

Screenshot_1620188919.png

DrawScope 还支持做一些变换操作,例如,将一个绘制好的图像进行旋转:

@Preview
@Composable
fun ComposeCanvas() {
    Canvas(modifier = Modifier.fillMaxSize()){
        val canvasWidth = size.width
        val canvasHeight = size.height

        rotate(degrees = 45F){
            drawRect(
                color = Color.Gray,
                topLeft = Offset(x = canvasWidth / 3F , y = canvasHeight / 3F),
                size = size / 3F
            )
        }
    }
}

通过 rotate() 函数将一个绘制好的图形顺时针旋转 45°,就得到了一个歪着的矩形:

Screenshot_1620189392.png

其他的变换还包括诸如 translatescale 等。对于多个变换,不建议以嵌套的方式进行,可以使用方法 withTransform() 组合变换,如下:

withTransform({
    translate(left = canvasWidth/5F)
    rotate(degrees=45F)
}) {
    drawRect(
        color = Color.Gray,
        topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
        size = canvasSize / 3F
    )
}

五、动画

动画,相比于前面的部分,可能没有那么基础,没有那么常用,但是在提升交互体验上依然有非常重要的应用。

借鉴 Flutter 中一切皆 Widget 的思想,动画被封装成 Composable 组件就很合情合理了,它们由以 Kotlin 协程所构建的底层 API 提供支持。

下面有一张表可以帮助我们决定在不同的场景下使用怎样的动画实现方式,当然,这只是一个

graph TB
	st(开始) --> op1{布局过程中内容改变}
	op1 --是--> op11{进出动画}
	op11 --是--> ani11[AnimationVisibility]
	op11 --否--> op12{改变内容尺寸}
	op12 --是--> ani12[Modifier.contentSize]
	op12 --否--> ani13[Crossfade]
	op1 --否--> op2{基于状态}
	op2 --是--> op21{发生于组合期间}
	op21 --是--> op211{无尽动画}
	op211 --是--> ani21[rememberInfiniteTransition]
	op211 --否--> op212{同时多值驱动}
	op212 --是--> ani22[updateTransition]
	op212 --否--> ani23[animate*AsState]
	op2 --否--> op3{动画期间可控}
	op3 --是--> ani3[Animation]
	op3 --否--> op4{动画是唯一可信来源}
	op4 --是--> ani4[Animatable]
	op4 --否--> ani5[AnimationState 或 animate]

下面对这些实现方式一一说明。

1.动画 API 简介

  • AnimatedVisibility(实验性功能[^ 1])

    localhostAnimatedVisibility 可组合项可为内容的出现和消失添加动画效果。

    var editable by remember { mutableStateOf(true) }
    AnimatedVisibility(visible = editable) {
        Text(text = "Edit")
    }
    

    默认情况下,内容以淡入和扩大的方式出现,以淡出和缩小的方式消失。您可以通过指定 EnterTransitionExitTransition 来自定义这种过渡效果。

    var visible by remember { mutableStateOf(true) }
    AnimatedVisibility(
        visible = visible,
        enter = slideInVertically(
            initialOffsetY = { -40 }
        ) + expandVertically(
            expandFrom = Alignment.Top
        ) + fadeIn(initialAlpha = 0.3f),
        exit = slideOutVertically() + shrinkVertically() + fadeOut()
    ) {
        Text("Hello", Modifier.fillMaxWidth().height(200.dp))
    }
    

    这里有意思的一点是,enterexit 属性对应的过渡效果,可以通过 「+」运算符实现组合。这些过渡动画函数有以下这些:

    • EnterTransition
      • fadeIn
      • slideIn
      • expandIn
      • expandHorizontally
      • expandVertically
      • slideInHorizontally
      • slideInVertically
    • ExitTransition
      • fadeOut
      • slideOut
      • shrinkOut
      • shrinkHorizontally
      • shrinkVertically
      • slideOutHorizontally
      • slideOutVertically
  • animateContentSize

    animateContentSize 在尺寸变化时显示动画效果。

    var message by remember { mutableStateOf("Hello") }
    Box(
        modifier = Modifier.background(Color.Blue).animateContentSize()
    ) {
        Text(text = message)
    }
    
  • Crossfade

    Crossfade 可使用淡入淡出动画在两个布局之间添加动画效果。通过切换传递给 current 参数的值,可以使用淡入淡出动画切换内容。

    var currentPage by remember { mutableStateOf("A") }
    Crossfade(targetState = currentPage) { screen ->
        when (screen) {
            "A" -> Text("Page A")
            "B" -> Text("Page B")
        }
    }
    
  • animate*AsState

    上面几个动画都是高级别动画,而从这个动画开始,都是低级别动画。低级别意味着抽象性更强,可自定义效果越多。

    animate*AsState 函数是最简单的 API,可将即时值变化呈现为动画值。它由 Animatable 提供支持,后者是一种基于协程的 API,用于为单个值添加动画效果。

    有点类似于 View 体系中的值属性动画,只需提供结束值,该 API就会从当前值开始向指定值开始播放动画。

    val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
    Box(
        Modifier.fillMaxSize()
            .graphicsLayer(alpha = alpha)
            .background(Color.Red)
    )
    

    针对这个「*」,Compose 已经提供了一些开箱即用的属性,如 FloatColorDpSizeBoundsOffsetRectIntIntOffsetIntSize,当然,你也可以通过为接受通用类型的 animateValueAsState 函数提供 typeConverter: TwoWayConverter<T, V> 参数,即可实现对其他数据类型的支持。

  • Animatable

    Animatable 是一个值容器,可以通过 animateTo 函数更改值时为值添加动画效果。

    Animatable 的许多功能(包括 animateTo)以挂起函数的形式提供。这意味着,它们需要封装在适当的协程作用域内。例如,可以使用 LaunchedEffect可组合项针对指定键值的时长创建一个作用域。

    // Start out gray and animate to green/red based on `ok`
    val color = remember { Animatable(Color.Gray) }
    LaunchedEffect(ok) {
        color.animateTo(if (ok) Color.Green else Color.Red)
    }
    Box(Modifier.fillMaxSize().background(color.value))
    

    AnimatableFloatColor 提供了开箱即用的支持,不过同样可以通过 typeConverter: TwoWayConverter<T, V> 参数实现自定义任何数据类型。

  • updateTransition

    Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。

    这里的状态可以是任何数据类型。在很多情况下,您可以使用自定义 enum 类型来确保类型安全,如下例所示:

    private enum class BoxState {
        Collapsed,
        Expanded
    }
    

    updateTransition 可创建并记住 Transition 的实例,并更新其状态。

    var currentState by remember { mutableStateOf(BoxState.Collapsed) }
    val transition = updateTransition(currentState)
    

    然后,您可以使用某个 animate* 扩展函数来定义此过渡效果中的子动画。为每个状态指定目标值。这些 animate* 函数会返回一个动画值,在动画播放过程中,当使用 updateTransition 更新过渡状态时,该值将逐帧更新。

    val rect by transition.animateRect { state ->
        when (state) {
            BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
            BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
        }
    }
    val borderWidth by transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 1.dp
            BoxState.Expanded -> 0.dp
        }
    }
    

    您也可以传递 transitionSpec 参数,为过渡状态变化的每个组合指定不同的 AnimationSpec

    val color by transition.animateColor(
        transitionSpec = {
            when {
                BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                    spring(stiffness = 50f)
                else ->
                    tween(durationMillis = 500)
            }
        }
    ) { state ->
        when (state) {
            BoxState.Collapsed -> MaterialTheme.colors.primary
            BoxState.Expanded -> MaterialTheme.colors.background
        }
    }
    

    过渡到目标状态后,Transition.currentState 将与 Transition.targetState 相同。这可以用作指示是否已完成过渡的信号。

    有时,我们会希望初始状态与第一个目标状态不同。我们可以通过结合使用 updateTransitionMutableTransitionState 来实现这一点。例如,它允许我们在代码进入组合阶段后立即开始播放动画。

    // Start in collapsed state and immediately animate to expanded
    var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
    currentState.targetState = BoxState.Expanded
    val transition = updateTransition(currentState)
    // ...
    
  • rememberInfiniteTransition

    InfiniteTransition 可以像 Transition 一样保存一个或多个子动画,但是,这些动画一进入组合阶段就开始运行,除非被移除,否则不会停止。您可以使用 rememberInfiniteTransition 创建 InfiniteTransition 实例。可以使用 animateColoranimatedFloatanimatedValue 添加子动画。您还需要指定 infiniteRepeatable 以指定动画规范。

    val infiniteTransition = rememberInfiniteTransition()
    val color by infiniteTransition.animateColor(
        initialValue = Color.Red,
        targetValue = Color.Green,
        animationSpec = infiniteRepeatable(
            animation = tween(1000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        )
    )
    
    Box(Modifier.fillMaxSize().background(color))
    
  • TargetBasedAnimation

    TargetBasedAnimation 是我们目前看到的最低级别的动画 API。其他 API 可满足大多数用例的需要,但使用 TargetBasedAnimation 可以直接让您自己控制动画的播放时间。在下面的示例中,TargetAnimation 的播放时间将根据 withFrameMillis 提供的帧时间手动控制。

    val anim = remember {
        TargetBasedAnimation(
            animationSpec = tween(200),
            typeConverter = Float.VectorConverter,
            initialValue = 200f,
            targetValue = 1000f
        )
    }
    var playTime by remember { mutableStateOf(0L) }
    
    LaunchedEffect(anim) {
        val startTime = withFrameNanos { it }
    
        do {
            playTime = withFrameNanos { it } - startTime
            val animationValue = anim.getValueFromNanos(playTime)
        } while (someCustomCondition())
    }
    

2.自定义动画

上面的动画是 Compose 预设动画的一些简单的使用,但是它们中的有些支持更深度的定制。许多动画函数都支持指定 AnimationSpec 对象作为参数。

AnimationSpec 是一个接口,这意味着我们可以实现它来自定义我们需要的动画。但是也不要担心,系统内部预设了一些实现类。

image-20210509154820385.png

对上面介绍的动画,我画了张表格,可以直观地看到都有哪些 API 接收 AnimationSpec 对象作为参数。

API接收的 AnimationSpec 参数
animateContentSizeFiniteAnimationSpec
CrossfadeFiniteAnimationSpec
animate*AsStateAnimationSpec
TargetBasedAnimationAnimationSpec

而对于 FiniteAnimationSpec 的子类,每一个类都有一个对应的构建方法可以直接构建相应的对象。

  • keyframes()

    帧动画基于过程中的任意时间戳对应的值,在任何给定的时间,动画的值由两个最近帧的值经过差值算法计算而来。当然你也可以在某一帧指定动画曲线从而改变其线性变化的过程。

    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = keyframes {
            durationMillis = 375
            0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
            0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
            0.4f at 75 // ms
            0.4f at 225 // ms
        }
    )
    
  • snap()

    咔嚓!这个动画可以在瞬间从一个值转变成另一个值,没有过程,突如其来。但是,你可以设置延迟时间,给自己一个缓刑!

    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = snap(delayMillis = 50)
    )
    
  • tween()

    tween 动画可以理解为一个只有首尾帧的帧动画(keyframes),动画以一个曲线安详地从起点走向终点,完成它平凡而伟大的一生。

    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            delayMillis = 50,
            easing = LinearOutSlowInEasing
        )
    )
    
  • spring()

    spring 动画可以理解为弹性动画,模拟由弹簧系数带来的加速度驱动的速度的变化。它不是线性的运动趋势,能让动画过程更贴近物理特性。

image-20210517094643212.png

spring() 函数接收三个参数,其中 dampingRatio 参数定义弹簧弹性,默认值为 Spring.DampingRatioNoBouncystiffness 定义弹簧应向结束值移动的速度,默认值为 Spring.StiffnessMedium。

animation-spring.gif

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)
  • repeatable()

    repeatable 动画像是一个 buff,施加给其他的 ADC 动画,完成助攻。repeatable() 函数接收一个 DurationBasedAnimationSpec 类型的动画描述模型对象,使持续动画能够达到结束后重复进行的效果。 image-20210516231439874.png

    重复效果支持下面两种模式: image-20210516231657582.png

    可以通过 iterations 参数指定迭代次数

  • infiniteRepeatable()

    infiniteRepeatable 动画和 repeatable 动画类似,但是它不可以指定重复次数,会无限次进行动画的重复工作。

上面介绍 keyframes 动画的时候有说到改变动画运行过程的曲线,并且使用了 LinearOutSlowInEasingFastOutLinearInEasing 类。

基于时长的 AnimationSpec 操作(如 tweenkeyframes)使用 Easing 来调整动画的小数值。这样可让动画值加速和减速,而不是以恒定的速率移动。小数是介于 0(起始值)和 1.0(结束值)之间的值,表示动画中的当前点。

Easing 实际上是一个函数,它取一个介于 0 和 1.0 之间的小数值并返回一个浮点数。返回的值可能位于边界之外,表示过冲或下冲。您可以使用如下所示的代码创建一个自定义 Easing。

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

六、手势

1. 点击

一切还是先从神奇的 Modifier 说起,这个小东西不仅可以控制控件的位置、间距和颜色等图形属性,还可以控制点击和滚动等交互事件,也真是个小机灵鬼。

最简单的交互事件,莫过于点击事件。在 Compose 中,如果要轻松地使用点击事件,我们可以很简单地使用 Button 组件来完成。

Button(onClick = {
    Log.d(TAG, "啊,臭流氓!")
}) {
    Text("不许动!")
}

但是,凡事都有个但是,如果我们就是要给一个图片添加点击事件呢?那也不是没有办法,你可以用 IconButtonModifier

Image(
    painter = painterResource(id = R.mipmap.model),
    contentDescription = null,
    modifier = Modifier.clickable {
		Log.d(TAG, "只准看,不准碰")
    }
)

其实,ButtononClick 也是偷的 Modifier 的秘籍学来的。

2. 滚动

有一天,我在写 Compose 的练习 demo 的时候,遇到了一个问题,我想给一个 list item 添加右滑显示的菜单,所以我给 item 设置了 Row 布局,左边占据全部屏幕的是正常显示的 item,右边隐藏在屏幕之外的是侧滑后显示的菜单。一切准备就绪,当我运行的时候才发现,Row 是没有办法滑动的,而我又不想大费周章地用 LazyRow,所以只能另辟蹊径。

@Composable
fun TestScroll() {
    val scrollState = rememberScrollState()
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Row(
            modifier = Modifier.horizontalScroll(
                scrollState
            )
        ) {
            Box(
                modifier = Modifier
                .width(360.dp)
                .height(64.dp)
                .background(color = Color.Blue)
            )

            Text("你看不到我")
        }
    }
}

运行起来是下面的效果: scroll.gif

假如你需要精准把控滚动的距离和程度,那么你可以使用 Modifier.scrollable() 属性。

@Composable
fun TestScroll() {
    var offsetX by remember {
        mutableStateOf(0f)
    }
    val scrollableState = rememberScrollableState { delta ->
        offsetX += delta
        delta
    }
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        Row(
            modifier = Modifier
                .scrollable(
                    scrollableState, Orientation.Horizontal
                )
                .offset {
                    IntOffset(offsetX.roundToInt(), 0)
                }
        ) {
            Box(
                modifier = Modifier
                    .width(360.dp)
                    .height(64.dp)
                    .background(color = Color.Blue)
            )
        }
    }
}

放上效果:

scrollable.gif

当然,嵌套滚动肯定是滚动机制里跳不过去的坎,毕竟控件不会永远自己跟自己玩儿。简单的嵌套滚动完全不需要做更多额外的工作,启动滚动操作的手势会自动从子级传播到父级,这样一来,当子级无法进一步滚动时,手势就会由其父元素处理。当然更复杂和更多个性化的嵌套滚动,可以通过 Modifier.nestedScroll() 属性来完成,用法有点类似 View 架构中的 NestedScrollView

你永远可以选择相信 Modifier

3. 其他

其他的更复杂的手势包括拖动、滑动以及多指触控。

在继续下面的探索之前,我们要先认识一个新朋友,他同样来自 Modifier 家族——Modifier.pointerInput()。此方法接收两个参数,一个是任意类型的 key,另一个是 suspend PointerInputScope.() -> Unit 类型的拓展方法参数,这就意味着,在这个方法中,我们有 PointerInputScope 的内部作用域。

PointerInputScope 是一个接口,继承自 Density 接口,这表明,在它的作用域内,我们可以直接获取一些和 density 相关的属性。除此之外,它自己声明了两个属性和一个挂起函数。val size: IntSize 可以获得可触控的区域,val viewConfiguration: ViewConfiguration 属性标识了一些触控配置项。而挂起函数会阻塞直到事件传递后会第一时间响应。

suspend fun <R> awaitPointerEventScope(
    block: suspend AwaitPointerEventScope.() -> R
): R

在这个函数中,我们能够获取到原始的 touch 事件,然后根据场景自定义个性化的或者更复杂的手势。

另外,它还有一些很重要很好用的扩展函数。

image-20211009110549790.png

这些以「detect」为前缀的挂起扩展函数,封装好了一些常用的高阶手势,参数中会有相应的回调方法,如果需要,可以选择适合的函数使用。

做好了上述的铺垫,我们继续我们的手势学习。

1) 拖拽

Modifier 类除了有上面介绍的 clickable()scrollable() 方法,还有一些 *able() 方法是和手势相关的。比如 Modifier.draggable() 方法就是对拖拽手势的高级封装。但是它只支持单一的方向,要么水平,要么垂直。但是你可以使用两次来分别设置水平和垂直方向的拖拽手势。

@Composable
fun TestGesture() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        var offsetX by remember { mutableStateOf(0f) }
        var offsetY by remember { mutableStateOf(0f) }
        Box(modifier = Modifier
            .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
            .size(128.dp)
            .background(Color.Red)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    offsetX += delta
                }
            )
            .draggable(
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    offsetY += delta
                }
            )
        )
    }
}

draggable.gif

如果想要实现自由地拖拽,请使用 Modifier.pointerInput(),借助 PointerInputScope.detectDragGestures() 方法。

2) 滑动

滑动和滚动类似,但是不同的是,滚动是无极的,可以在任意位置停下,在任意位置重新开始。但是,滑动是有极的,它不能在任意位置停下,而必须停靠在专有的「港口」。这些港口定义为一个个的锚点,锚点之外的地方,它终究只是个过客。

@Composable
fun TestGesture() {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        val width = 96.dp
        val squareSize = 48.dp

        val swipeableState = rememberSwipeableState(0)
        val sizePx = with(LocalDensity.current) { squareSize.toPx() }
        val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states

        Box(
            modifier = Modifier
                .width(width)
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    thresholds = { _, _ -> FractionalThreshold(0.3f) },
                    orientation = Orientation.Horizontal
                )
                .background(Color.LightGray)
        ) {
            Box(
                Modifier
                    .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                    .size(squareSize)
                    .background(Color.DarkGray)
            )
        }
    }
}

当然,锚点的数量和次序是没有关系的,只要情不休,处处皆可留!所以你还可以设置三个锚点 val anchors = mapOf(0f to 0, sizePx to 1, sizePx / 2 to 2)

thresholds 参数用来设置开关滑向下一个锚点的阈值,可以是百分比(FractionalThreshold)或者固定值(FixedThreshold)。

swipeable.gif

需要注意的是,直到 compose 1.0.3 版本,这还只是个实验功能。

3) 多指触控

下一个要介绍的是 Modifier 家族的 transformable() 方法。话不多说,直接上官网的 demo。

@Composable
fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

可以看到,transformable() 方法是「缩放」、「位移」和旋转的结合。

transformable

当然,更复杂更精细的控制,还是可以在 pointerInput() 中配置。