Jetpack Compose

声明式UI

特点:

  • 关注于UI的描述,当需要渲染的数据发生变化时,框架会自动完成UI更新
  • 基于Kotlin DSL,开发效率高

与传统XML布局相比:

  • 传统XML布局是静态的,无法根据状态变化而变化,我们需要通过findViewById等方式获取到View,然后通过代码驱动更新
  • Compose使用声明式UI,我们只需要根据状态描述UI,当状态变化时,Compose会自动更新UI

@Composable

使用@Composable修饰的函数,可以理解为UI组件,用于代替传统的xml布局文件
@Composable修饰的函数,可以嵌套使用来实现复杂的UI结构

Compose中自带的组件都是用@Composable修饰的函数,如Text

@Composable
fun Text(..){
    ...
}

我们自己写的组件,也需要用@Composable修饰

Activity的变化

使用View体系时,默认使用AppCompatActivity,在Activity中使用setContentView()来设置布局文件
在Compose中,默认使用的是ComposeActivity,使用setContent()来设置Composable组件

class TestActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Text("Hello World")
        }
    }
}

组合优于继承

传统的View体系中,Button是继承自TextView,这意味着它继承了 TextView 的所有功能,但其中一些功能可能对 Button 并不适用
并且如果TextView 的设计发生变化,可能会影响到 Button 的行为,这造成了设计上的耦合

在Compose中,按钮被设计为具有点击事件的组件(查看源码,onClick和content没有默认值,需要实现)

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    shape: Shape = ButtonDefaults.shape,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
    border: BorderStroke? = null,
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    content: @Composable RowScope.() -> Unit
) {
    ...
}

如果想要显示文本,不是通过使用text之类的属性,而是通过content参数来显示文本

Button(onClick = { /*TODO*/ }) {
    Text(text = "Click")
}

img

另外,Text默认是不可以复制文字的,想要复制文字,需要使用SelectionContainer包装

SelectionContainer(modifier = Modifier.padding(20.dp)) {
    Text("Hello")
}

img

这些设计使得组件的职责更加单一,也不会出现可以复制文字的按钮,这也是为什么组合优于继承

Compose和View互操作

Compose中使用View

需要添加依赖

implementation("androidx.compose.ui:ui-viewbinding:1.7.8")

定义xml布局

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:id="@+id/btn"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Click"/>

</LinearLayout>

定义Compose组件,使用AndroidViewBinding实现添加View

@Composable
fun UseXmlLayout() {
    AndroidViewBinding(TestLayoutBinding::inflate) {
        btn.setOnClickListener {
            Log.d("yimin", "Clicked")
        }
    }
}

img

View中使用Compose

定义xml布局,使用ComposeView作为桥梁

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity2">

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

在Activity中获取ComposeView,通过setContent使用Compose组件

class MainActivity2 : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main2)
        // ...
        findViewById<ComposeView>(R.id.compose_view).setContent {
            androidx.compose.material3.Text("Hello World")
        }
    }
}

总结

  • Compose 中使用 View, 借助 AndroidViewBinding
  • View 中使用 Compose,借助 ComposeView

主题

创建Compose项目,会自动生成一个文件夹:ui.theme,里面有三个文件:Color.kt,Type.kt和Theme.kt,其中Color.kt定义了颜色,Type.kt定义了字体,Theme.kt定义了主题

img

自动生成的主题如下

@Composable
fun MyComposeTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}
  • 安卓12及以上添加了动态配色功能,会使用dynamicColorScheme
  • 当系统处于暗色模式时,会使用DarkColorScheme
  • 当系统处于亮色模式时,会使用LightColorScheme

其中DarkColorScheme和LightColorScheme我们可以自定义,如果重写了颜色则会使用自定义的颜色,否则会使用系统默认的颜色

private val LightColorScheme = lightColorScheme(
    // 自定义
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40,
    primaryContainer = Purple40

    /* Other default colors to override
    background = Color(0xFFFFFBFE),
    surface = Color(0xFFFFFBFE),
    onPrimary = Color.White,
    onSecondary = Color.White,
    onTertiary = Color.White,
    onBackground = Color(0xFF1C1B1F),
    onSurface = Color(0xFF1C1B1F),
    */
)

所有的颜色都有一个对应的onXXX,比如primary和onPrimary

  • primary为主要颜色,比如按钮的背景色
  • onPrimary为在primary颜色上显示的内容的颜色,比如按钮上的文本颜色

主题同样是一个组件,一般在setContent的顶层调用

class TestActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MyComposeTheme { // 应用自定义的主题
                Scaffold { // Scaffold和Column在这里的作用是为了配合enableEdgeToEdge(),让内容避开状态栏和导航栏
                    Column(modifier = Modifier.padding(it)) {
                        Button(onClick = {}) {
                            Text("Hello")
                        }
                    }
                }
            }
        }
    }
}

img

img

此时并没有使用自定义的颜色,因为当前系统支持动态配色
如果把动态配色代码删除,就能看到效果了

img

Scaffold

以上测试主题时,使用了Scaffold(脚手架),这个组件可以很方便地创建一个具有顶部导航栏、底部导航栏、悬浮按钮等样式的页面

MyComposeTheme {
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(
                title = { Text("Jetpack Compose App") },
                modifier = Modifier.shadow(10.dp), // 阴影
                colors = TopAppBarDefaults.topAppBarColors(
                    // containerColor = Purple80
                ),// 背景色
                actions = {
                    IconButton(onClick = { /* TODO: handle action */ }) {
                        Icon(Icons.Default.Settings, contentDescription = "Settings")
                    }
                }
            )
        },
        bottomBar = {
            // BottomAppBar {
            //     Text("Bottom Bar Content")
            // }
            // 底部导航栏
            NavigationBar {
                // Tab
                NavigationBarItem(
                    selected = true,
                    onClick = { },
                    icon = { Icon(Icons.Default.Home, contentDescription = null) },
                    label = { Text("Home") }
                )
                // Tab
                NavigationBarItem(
                    selected = false,
                    onClick = { },
                    icon = { Icon(Icons.Default.Settings, contentDescription = null) },
                    label = { Text("Settings") }
                )
            }
        },
        floatingActionButton = { // 右下角的悬浮按钮
            FloatingActionButton(onClick = { /*do something*/ }) {
                Icon(
                    Icons.Filled.Favorite,
                    contentDescription = "Localized description"
                )
            }
        }
    ) { innerPadding ->
        Column(
            modifier = Modifier.padding(innerPadding),
        ) {
            Button(onClick = {}) {
                Text("Hello")
            }
        }

    }
}

img

布局

Clonum

相当于纵向的LinearLayout

@Composable
fun ColumnLayout() {
    Column {
        Text("Hello World")
        Text("Hello World")
    }
}

img

Row

相当于横向的LinearLayout

@Composable
fun RowLayout(){
    Row {
        Text("Hello World")
        Text("Hello World")
    }
}

img

Box

相当于FragmentLayout,组件会叠加在一起

modifier用来修改组件的的尺寸、外观或添加交互(如点击事件),比如这里用来修改了Box的宽度,背景颜色

@Composable
fun BoxLayout() {
    Box {
        Box(
            modifier = Modifier
                .width(200.dp)
                .aspectRatio(1f) // 保持宽高比为1:1
                .background(color = Color.Red)
        ) {
        }
        Box(modifier = Modifier
            .width(100.dp)
            .aspectRatio(1f)
            .background(Color.Green)) {
        }
    }
}

img

案例

其中用到的组件

  • Image
    • painterResource:加载资源图片
    • contentDescription:用于无障碍
  • Spacer:空白组件,用于填充空间
@Composable
fun MessageCard(msg: Message){
    Row(
        modifier = Modifier.padding(all = 8.dp) // 添加 padding
    ) {
        Image(
            painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = "profile picture", // 无障碍
            modifier = Modifier
                .size(50.dp) // 图片大小
                .clip(RoundedCornerShape(20)) // 剪裁圆角
                .border(1.5.dp, MaterialTheme.colorScheme.primary, shape = CircleShape) // 添加边框
        )
        Spacer(modifier = Modifier.padding(horizontal = 8.dp)) // 用于填充空间
        Column {
            Text(
                text = msg.author,
                color = MaterialTheme.colorScheme.primary
            )
            Spacer(modifier = Modifier.padding(vertical = 2.dp))
            Text(
                text = msg.body
            )
        }
    }
}
data class Message(val author: String, val body: String)

img

列表 LazyColumn

列表分为可滚动列表和不可滚动列表,不可滚动列表使用Column,可滚动列表使用LazyColumn

LazyColumn只会加载屏幕上可见的项目,而不是一次性加载所有项。使用 items 函数来显示列表中的每一项。

@Composable
fun MessageCardList(messages: List<Message>) {
    LazyColumn {
        items(messages.size) {
            MessageCard(msg = messages[it])
        }
    }
}
object MsgData {
    private const val author = "Jetpack Compose 博物馆"
    val messages = listOf(
        Message(author, "我们开始更新啦"),
        Message(
            author,
            "为了给广大的读者一个更好的体验,从今天起,我们公众号决定陆续发一些其他作者的高质量文章"
        ),
        Message(author, "每逢佳节倍思亲,从今天起,参加我们公众号活动的同学可以获得精美礼品一份!!"),
        Message(author, "荣华梦一场,功名纸半张,是非海波千丈,马蹄踏碎禁街霜,听几度头鸡唱"),
        Message(
            author,
            "唤归来,西湖山上野猿哀。二十年多少风流怪,花落花开。望云霄拜将台,袖星斗安邦策,破烟月迷魂寨。酸斋笑我,我笑酸斋"
        ),
        Message(
            author,
            "伤心尽处露笑颜,醉里孤单写狂欢。两路殊途情何奈,三千弱水忧忘川。花开彼岸朦胧色,月过长空爽朗天。青鸟思飞无侧羽,重山万水亦徒然"
        ),
        Message(
            author,
            "又到绿杨曾折处,不语垂鞭,踏遍清秋路。衰草连天无意绪,雁声远向萧关去。恨天涯行役苦,只恨西风,吹梦成今古。明日客程还几许,沾衣况是新寒雨"
        ),
        Message(
            author,
            "莫笑农家腊酒浑,丰年留客足鸡豚。山重水复疑无路,柳暗花明又一村。箫鼓追随春社近,衣冠简朴古风存。从今若许闲乘月,拄杖无时夜叩门"
        )
    ).run {
        val mList = this.toMutableList()
        mList.addAll(this)
        mList
    }
}

img

Card

可以使用Card组件进一步封装,实现卡片效果

@Composable
fun MessageCardWrapper(msg: Message) {
    Card(
        shape = RoundedCornerShape(8.dp), // 圆角
        modifier = Modifier
            .fillMaxWidth() // 宽度填满
            .padding(horizontal = 6.dp, vertical = 3.dp), // 内边距
        elevation =CardDefaults.cardElevation(
            // defaultElevation = 5.dp, 
        ) // 设置阴影
    ) {
        MessageCard(msg)
    }
}

@Composable
fun MessageCardList(messages: List<Message>) {
    LazyColumn {
        items(messages.size) {
            MessageCardWrapper(msg = messages[it])
        }
    }
}

img

要实现卡片内容默认只显示一行,当点击时展开,并且带有动画效果

  1. 每个card都需要持有一个状态,判断是否展开
  2. 给最外层的Row添加点击事件,点击时改变状态
  3. 使用maxLines属性控制文本显示的行数
  4. 使用overflow属性控制文本溢出时的显示方式
  5. 使用animateContentSize实现展开动画
@Composable
fun MessageCard(msg: Message) {

    var isExpanded by remember { mutableStateOf(false) } // 创建一个能够检测卡片是否被展开的变量

    Row(
        modifier = Modifier
            .padding(all = 8.dp) // 在我们的 Card 周围添加 padding
            .clickable { isExpanded = !isExpanded }
    ) {
        Image(
            painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = "profile picture", // 无障碍
            modifier = Modifier
                .size(50.dp)
                .clip(RoundedCornerShape(20)) // 剪裁圆角
                .border(1.5.dp, MaterialTheme.colorScheme.primary, shape = CircleShape) // 添加边框
        )
        Spacer(modifier = Modifier.padding(horizontal = 8.dp)) // 用于填充空间
        Column {
            Text(
                text = msg.author,
                color = MaterialTheme.colorScheme.primary
            )
            Spacer(modifier = Modifier.padding(vertical = 2.dp))
            Text(
                text = msg.body,
                maxLines = if (isExpanded) Int.MAX_VALUE else 1, // 最大行
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier.animateContentSize() // 动画
            )
        }
    }
}

img

这里用到了remember函数,它能够记住一个可组合项(组件)的状态,即使可组合项被重新组合(渲染),它也会保持状态不变。通常配合mutableStateOf函数一起使用,构造状态变量。

重组

重组指的是当Compose可组合项的状态发生变化时,Compose会重新计算并重新渲染该可组合项,这个过程称为重组。

@Composable
fun CounterExample() {
    // 定义一个可变状态,初始值为0
    var count by remember { mutableIntStateOf(0)  }

    Column {
        // 显示当前计数值
        Text("Clicked $count times")

        // 当点击时增加计数器的值
        Text(
            text = "点击这里",
            modifier = Modifier.clickable {
                count++
            }
        )
    }
}

在这个例子中,定义了一个可变状态count,初始值为0。当点击文本时,count的值会增加1,这会触发CounterExample的重组。

因为第一个Text组件依赖了count,所以会重新渲染,显示新的count值。

状态

状态也就是数据,Compose组件根据状态显示UI,状态变化更新UI。

在View体系中,要实现一个计数器,很容易写出以下代码

val textView = findViewById<TextView>(R.id.tv)
val btnInc = findViewById<Button>(R.id.btn_inc)
val btnDec = findViewById<Button>(R.id.btn_dec)

btnInc.setOnClickListener {
    textView.text = (textView.text.toString().toInt() + 1).toString()
}

btnDec.setOnClickListener {
    textView.text = (textView.text.toString().toInt() - 1).toString()
}

但是存在一个问题,逻辑和UI高度耦合

于是后面出现了ViewModel,用ViewModel优化上面的代码

class CountViewModel: ViewModel() {
    // 修改私有可变的Flow
    private var _count: MutableStateFlow<Int> = MutableStateFlow(0)
    // 暴露不可变的Flow
    val count: StateFlow<Int> get() = _count

    fun increase() {
        _count.value++
    }

    fun decrease() {
        _count.value--
    }
}
class MainActivity2 : AppCompatActivity() {

    private val countViewModel by viewModels<CountViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main2)

        // ...

        btnInc.setOnClickListener {
            countViewModel.increase()
        }

        btnDec.setOnClickListener {
            countViewModel.decrease()
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                countViewModel.count.collect {
                    textView.text = it.toString()
                }
            }
        }

    }
}

此时逻辑被放到了ViewMode中,相应地,数据也被放到了ViewModel中,这本质上是一种状态提升,用来解耦。

按照这个思路,实现Compose版本的计数器,可以得到以下实现:

@Composable
fun Counter() {

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var count = 0

        Log.d(TAG, "into Counter => ${count}")

        Text(text = "$count")
        Button(onClick = {
            count++
            Log.d(TAG, "count++ => ${count}")
        }) {
            Text(text = "increase")
        }
        Button(onClick = {
            count--
            Log.d(TAG, "count-- => ${count}")
        }) {
            Text(text = "decease")
        }
    }
}

但是点击并没有效果,查看日志,点击事件是生效的,但是没有触发重组(只有一次into Counter日志)
所以原因很显然就是普通变量的变化不能被Compose感知,需要将其包装为状态变量

15:30:53.524  D  into Counter => 0
15:30:58.033  D  count++ => 1
15:30:59.402  D  count++ => 2
15:31:00.713  D  count++ => 3
15:31:02.060  D  count++ => 4
15:32:52.307  D  count-- => 3

类似于StateFlow 或者 LiveData 将变量包装成一个可观察类型的对象,Compose提供了mutableStateOf将一个普通变量包装为状态变量
这样当这个变量发生变化时,使用了这个变量的组件都会自动更新。

fun Counter() {

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        val count = mutableStateOf(0)

        Log.d(TAG, "into Counter => ${count}")

        Text(text = "${count.value}")
        Button(onClick = {
            count.value++
            Log.d(TAG, "count++ => ${count.value}")
        }) {
            Text(text = "increase")
        }
        Button(onClick = {
            count.value--
            Log.d(TAG, "count-- => ${count.value}")
        }) {
            Text(text = "decease")
        }
    }
}

这时点击按钮,界面依然不会更新
但是看日志确实是发生重组了,每次点按钮都会有into Counter日志,只是count的值每次都重置了

12:56:52.312  D  into Counter => 0
12:56:54.825  D  count++ => 1
12:56:54.829  D  into Counter => 0
12:56:56.269  D  count++ => 1
12:56:56.273  D  into Counter => 0
12:56:57.742  D  count-- => -1
12:56:57.746  D  into Counter => 0

所以还要解决如何让count的值不重置的问题,这需要用到另一个方法:remember
remember的作用是对状态变量进行缓存,在后续发生重组时,不会重新初始化

val count = remember { mutableStateOf(0) }

到此就实现了点击按钮改变数字的效果
img

以上写法,可以进一步使用kotlin的委托写法简化
最终版本如下

@Composable
fun Counter() {

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        var count by remember { mutableStateOf(0) }

        Text(text = "${count}")
        Button(onClick = {
            count++
        }) {
            Text(text = "increase")
        }
        Button(onClick = {
            count--
        }) {
            Text(text = "decease")
        }
    }
}

总结一下,Compose中的状态变量,使用remember和mutableStateOf定义

案例

  • 想要将一个Image显示为圆形,一种方式是使用Surface组件包装,并设置其shape属性为CircleShape,另外也能控制大小,颜色等
  • 将Column中的组件居中显示,可以使用Modifier.align(Alignment.CenterVertically)属性
  • 使用CompositionLocalProvider可以修改作用域内的组件样式,实现隐式传参的效果
@Composable
fun PhotoCard() {
    Row(
        modifier = Modifier
            .clickable {} // 点击水波纹效果
            .padding(10.dp)
    ) {
        Surface( // 包装Image,使其为圆形
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            // 修改透明度,默认onSurface为黑色,这里设置为0.2f,显示效果为灰色
            color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
        ) {
            Image(
                painter = painterResource(id = com.example.mycompose.R.drawable.ic_launcher_foreground),
                contentDescription = null
            )
        }

        Spacer(modifier = Modifier.padding(horizontal = 10.dp))

        Column(
            // 内容垂直居中显示
            modifier = Modifier.align(Alignment.CenterVertically),
        ) {

            // 隐式传参
            // 将范围内的文本样式修改为粗体
            CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold)) {
                Text(text = "Alfred Sisley")

                // 隐式传参
                // 将范围内的文本颜色修改为onSurfaceVariant,透明度为0.6
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) {
                    Text(
                        text = "3 minutes ago",
                        // style = MaterialTheme.typography.bodySmall
                    )
                }
            }
        }
    }
}

img

CompositionLocalProvider的实现原理,和修改全局变量类似

提供一个全局变量作为默认值

当需要修改默认值时,通过provider函数传入新的值修改全局变量,并使用try-finally在函数结束后恢复原来的值,这就实现了只在函数作用域内修改默认值,而不会影响其他地方

var color = "Red"

fun provider(value: String, content:()-> Unit) {
    val oldColor = color
    color = value
    try {
        content()
    } finally {
        color = oldColor
    }
}

fun Text(){
    println("Color is $color")
}

fun main(){
    Text()
    provider("Blue") {
        Text()
        Text()
    }
    Text()
}
Color is Red
Color is Blue
Color is Blue
Color is Red

基础组件

Slider

滑竿组件,可以用作进度条,音量、亮度调整等场景

@Composable
fun SliderDemo() {
    var process by remember {
        mutableFloatStateOf(0f)
    }
    Column(
        modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp)
    ) {
        Slider(
            value = process,
            onValueChange = { process = it },
            steps = 2 // 分段,段数为steps+1
        )
        Text(text = (process * 100).roundToInt().toString() + "%")
    }
}

img

AlertDialog

一个 Material Design 风格的对话框

和传统对话框最大的区别是,需要通过状态控制显示和隐藏,而不是show,dismiss方法

@Composable
fun AlertDialogDemo(openDialog: MutableState<Boolean>){

    val ctx = LocalContext.current

    if (openDialog.value) { // 需要靠状态控制显示
        AlertDialog(
            onDismissRequest = { // 点击Dialog以外的区域
                Toast.makeText(ctx, "场外不可操作", Toast.LENGTH_SHORT).show()
            },
            title = { // 标题
                Text(
                    text = "开启位置服务",
                    fontWeight = FontWeight.W700,
                    style = MaterialTheme.typography.titleMedium
                )
            },
            text = { // 正文
                Text(
                    text = "这将意味着,我们会给您提供精准的位置服务,并且您将接受关于您订阅的位置信息",
                    fontSize = 16.sp
                )
            },
            confirmButton = { // 确定
                TextButton(
                    onClick = {
                        Toast.makeText(ctx, "Confirm", Toast.LENGTH_SHORT).show()
                        openDialog.value = false
                    },
                ) {
                    Text(
                        "确认",
                        fontWeight = FontWeight.W700,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            },
            dismissButton = { //取消
                TextButton(
                    onClick = {
                        Toast.makeText(ctx, "Dismiss", Toast.LENGTH_SHORT).show()
                        openDialog.value = false
                    }
                ) {
                    Text(
                        "取消",
                        fontWeight = FontWeight.W700,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        )
    }
}

img

ProgressIndicator

进度条组件,分为圆形和线性两种

不设置值时,默认为无限循环

@Composable
fun ProcessDemo(){
    Column {
        CircularProgressIndicator() // 圆形进度条
        LinearProgressIndicator() // 线性进度条
    }
}

img

通过process属性可以控制进度条进度

这里的Button用于增加进度

@Composable
fun ProcessDemo() {

    var process by remember { mutableFloatStateOf(0f) }
    // 相比直接使用process,会有动画效果
    val animatedProcess by animateFloatAsState(
        targetValue = process,
    )

    Column {
        CircularProgressIndicator() // 圆形进度条
        LinearProgressIndicator(progress = animatedProcess) // 线性进度条

        Button(onClick = {
            if (process < 1f) { // 进度小于1f,则加0.1f
                process += 0.1f
            }
        }) {
            Text("Add")
        }
    }
}

img

Modifier

作用域

Compose基于kotlin的DSL,通常组件的最后一个参数为content,用于放置子组件

如Column

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {...}

并且,content会指定作用域,这里即ColumnScope,这就表示content中可以调用任何ColumnScope中的方法

@LayoutScopeMarker
@Immutable
@JvmDefaultWithCompatibility
interface ColumnScope {
    @Stable
    fun Modifier.weight(
        weight: Float,
        fill: Boolean = true
    ): Modifier

    @Stable
    fun Modifier.align(alignment: Alignment.Horizontal): Modifier

    @Stable
    fun Modifier.alignBy(alignmentLine: VerticalAlignmentLine): Modifier

    @Stable
    fun Modifier.alignBy(alignmentLineBlock: (Measured) -> Int): Modifier
}

Box也有类似的作用域:BoxScope

@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
) {...}
@LayoutScopeMarker
@Immutable
interface BoxScope {

    @Stable
    fun Modifier.align(alignment: Alignment): Modifier


    @Stable
    fun Modifier.matchParentSize(): Modifier
}

可以看到BoxScope和ColumnScope中都有Modifier.align方法(参数不一样)

当在Column中嵌套使用Box时,如果需要用到Modifier.align,是不是意味着以上的两个作用域的Modifier.align都可以使用呢?

@Composable
fun ScopeDemo() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("Hello1")
        Text("Hello2")

        Box(modifier = Modifier
            .size(100.dp)
            .background(Color.Cyan)) {
            Text("Hello3", modifier = Modifier.align(Alignment.TopStart)) // 这是BoxScope中的Modifier.align
            Text("Hello4", modifier = Modifier.align(Alignment.BottomEnd)) 
        }
    }
}

img

虽然IDE会同时提示两个方法,但是最终只能使用BoxScope中的Modifier.align,使用ColumnScope中的Modifier.align,IDE会报错编译不通过

img

原因是ColumnScope和BoxScope都继使用@LayoutScopeMarker修饰,@LayoutScopeMarker使用了@DslMarker注解,这个注解的作用就是为了在DSL中防止跨级访问

也就是说,在Box的作用域中,只能使用BoxScope中的Modifier.align,而不能跨级访问上一级ColumnScope中的Modifier.align,这就避免了跨级访问带来的混乱,保证类型安全。

关于@DslMarker的用法,可以通过以下例子理解

@MyAnnotation
class AScope {
    fun visitA(){}
}

@MyAnnotation
class Bscope {
    fun visitB(){}
}

fun funA(scope: AScope.()-> Unit) {
    scope(AScope())
}

fun funB(scope: Bscope.()-> Unit) {
    scope(Bscope())
}

// 自定义注解,使用 @DslMarker 标记
// 之后用此注解标注的类,在作用域中不能使用其他作用域的方法
@DslMarker
@LayoutScopeMarker
annotation class MyAnnotation

fun main(){
    funA {
        visitA()
        funB {
            // visitA() 报错,不能使用上一级作用域的方法
            visitB()
        }
    }
}

Compose组件渲染

  • 组合:执行@Composable函数,生成LayoutNode
  • 布局:LayoutNode执行measure、layout,确定位置和大小
  • 绘制:LayoutNode执行draw,绘制内容

组合

在setContent的时候,执行代码块中所有的Composable函数体,生成LayoutNode视图树

如果Composable依赖的状态发生了变化,会触发当前Composable重新进行组合阶段,并且子Composable也会重新调用

但是子Composable不一定会重组,因为Compose有智能重组机制,只有参数变化时,才会重组,否则会复用之前的LayoutNode

所以通过重组,可以自动维护LayoutNode视图树,保持UI最新

布局

LayoutNode通过父LayoutNode的约束(constraints)进行自我测量(类似MeasureSpec),确定自身大小

可以定制组件的布局阶段实现自己的需求,比如要实现Text的Baseline到父布局顶部的距离,使用padding是无法实现的,这时可以尝试自定义布局阶段来实现

TODO Baseline图片示例

自定义布局通过Modifier.layout实现。使用layout时,传入两个回调参数,分别为measurable和constraints

  • measurable:表示被测量的LayoutNode
  • constraints:表示父LayoutNode的约束

定义一个Modifier的扩展函数BaselineToTop,表示Baseline到父布局顶部的距离

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) =
    Modifier.layout { measurable, constraints ->
        // 测量后,可摆放,可以获取到宽高
        val placeable = measurable.measure(constraints)
        // 获取第一个基线
        val firstBaseline = placeable.get(FirstBaseline)
        // 计算在想要的firstBaselineToTop前提下,组件的y坐标
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        // 必须调用MeasureScope中的layout,否则Modifier.layout会报错
        // 重新定义组件的宽高
        layout(width = placeable.width, height = placeableY + placeable.height){
            // 重新定义组件的x,y坐标
            placeable.placeRelative(0, placeableY)
        }

    }

使用时可以和padding对比

Row {
    Box (modifier = Modifier.background(Color.Yellow)){
        Text(
            text = "Hello,World",
            modifier = Modifier
                .firstBaselineToTop(30.dp) // Baseline到父布局顶部的距离
                .background(Color.Red)
        )
    }

    Box (modifier = Modifier.background(Color.Yellow)){
        Text(
            text = "Hello,World",
            modifier = Modifier
                .padding(top = 30.dp)  // 自己顶部到父布局顶部的距离
                .background(Color.Red)
        )
    }
}

img

如果要自定义布局,比如自己实现一个Column组件,需要使用Layout实现

Layout本身也是一个@Composable函数,所以可以在自定义的@Composable函数中使用

和定义Modifier扩展函数时使用的layout很类似,layout是针对普通组件使用的,而Layout是针对布局使用的,回调参数中是一个子LayoutNode的集合

@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    // 针对布局使用,回调参数中是一个子LayoutNode的集合
    Layout(modifier = modifier, content = content) {measurableList, constraints ->
        // 遍历测量子LayoutNode
        val placeableList = measurableList.map {
            it.measure(constraints)
        }

        var y = 0

        // 布局的宽高取父LayoutNode约束的最大值即可
        // 这里主要关注的是子LayoutNode如何摆放
        layout(constraints.maxWidth,constraints.maxHeight) {
            // 排放位置
            placeableList.forEach {
                it.placeRelative(x = 0, y = y)
                // 下一个子LayoutNode的y坐标,一定是要加上当前子LayoutNode的高度,才能实现Column效果
                y += it.height
            }
        }
    }
}

在使用时可以像Column那样摆放组件,同时也能修改Modifier

Column(
    modifier = Modifier.padding(innerPadding),
) {
    MyColumn(modifier = Modifier.padding(10.dp)) {
        Text("Hello World")
        Text("Hello World")
        Text("Hello World")
    }
}

img

Compose中的每个LayoutNode是不允许被多次测量的,但有些场景确实需要,比如以下这个场景:

@Composable
fun TwoText() {
    Row(
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Text("Hello")
        Divider(
            color = Color.Black,
            modifier = Modifier
                .width(1.dp)
                .fillMaxHeight()
        )
        Text(
            "World",
            style = LocalTextStyle.current.copy(fontSize = 30.sp)
        )
    }
}

Row中放了两个文本和一个分割线,如果直接指定分割线的高度为fillMaxHeight(),会占用整个屏幕高度,因为测量和布局Divider时,并不知道这个高度应该为多少,就直接用了最大值

img

而现在希望的是分割线高度匹配两个文本的高度(取两者中的较大值),所以需要提前测量两个文本的高度,确定Row的高度,之后在使用fillMaxHeight()时,就是想要的效果了

为了实现这个需求,要用到固有特性测量Intrinsic,它可以预先测量所有子组件,确定自身的大小,并在正式测量阶段也会产生影响

对于内置的组件,大多已经实现了固有特性测量,比如上面使用的Row,只需要使用IntrinsicSize.Min或者IntrinsicSize.Max即可

现在修改代码,给Row添加固有特性测量,确定Row的高度

@Composable
fun TwoText() {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.height(IntrinsicSize.Min) // 添加固有特性测量
    ) {
        Text("Hello")
        Divider(
            color = Color.Black,
            modifier = Modifier
                .width(1.dp)
                .fillMaxHeight()
        )
        Text(
            "World",
            style = LocalTextStyle.current.copy(fontSize = 30.sp)
        )
    }
}

结果符合预期

img

绘制

TODO

动画

可见性动画AnimatedVisibility

通过visible属性控制content可见性,当content出现和消失时,会有过度动画效果

@Composable
fun AnimatedDemo() {
    var visible by remember {
        mutableStateOf(true)
    }
    Column {
        AnimatedVisibility(
            visible = visible,
            // 多种动画效果可以组合使用
            // 滑动进入,展开,缩放,淡入
            enter = slideInVertically() + expandVertically() + scaleIn()+ fadeIn(initialAlpha = 0.3f),
            exit = slideOutVertically() + shrinkVertically() + scaleOut() + fadeOut(),
        ) {
            Text("Hello World")
        }
        Button(onClick = { visible = !visible }) {
            Text("Change visible")
        }
    }
}

img

监听动画状态

AnimatedVisibility还有个重载的方法,可以通过MutableTransitionState监听动画状态,当当前状态和目标状态不同时,会触发动画执行

可以利用这个特性实现自动动画,如开屏动画之类

@Composable
fun AnimatedDemo() {

    var visibleState = remember {
        MutableTransitionState(false).apply {
            targetState = true
        }
    }

    Column {
        AnimatedVisibility(
            visibleState = visibleState,
            // 多种动画效果可以组合使用
            // 滑动进入,展开,缩放,淡入
            enter = slideInVertically() + expandVertically() + scaleIn() + fadeIn(initialAlpha = 0.3f),
            exit = slideOutVertically() + shrinkVertically() + scaleOut() + fadeOut(),
        ) {
            Text("Hello World")
        }
    }
}

img

自定义Enter/Exit动画

自定义动画使用transition,会和AnimatedVisibility动画同时进行
以下添加了一个背景色变化动画,当content出现时,背景色变为红色,消失时,背景色从红色变为绿色

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedDemo() {
    var visible by remember {
        mutableStateOf(true)
    }

    Column {
        AnimatedVisibility(
            visible = visible,
        ) {

            // 自定义动画
            // 需要在AnimatedVisibilityScope内使用
            val bg by transition.animateColor {
                if (it == EnterExitState.Visible) Color.Red else Color.Green
            }

            Box(modifier = Modifier.background(bg)) {
                Text("Hello World")
            }
        }
        Button(onClick = { visible = !visible }) {
            Text("Change visible")
        }
    }
}

img

AnimatedContent

AnimatedContent可以监听content的变化,当content变化时,会触发动画效果

需要注意的是,content中必须使用targetState,否则会报错

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedDemo() {

    var counter by remember {
        mutableStateOf(0)
    }

    Column {
        AnimatedContent(
            targetState = counter,
        ) {
            Text("Hello World ${it}") // 一定要使用targetState
        }
        Button(onClick = {counter++ }) {
            Text("Add")
        }
    }
}

img

默认的动画效果是淡入淡出,可以通过transitionSpec属性自定义动画效果

比如实现从下到上切换

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedDemo() {

    var counter by remember {
        mutableStateOf(0)
    }

    Column {
        AnimatedContent(
            targetState = counter,
            transitionSpec = {
                // 使用togetherWith组合Enter动画和Exit动画
                // 进场指定初始位置,出场指定结束位置
                // 这里实现的是从下到上切换的效果
                slideInVertically { height -> height } + fadeIn() togetherWith
                        slideOutVertically { height -> -height } + fadeOut()
            }
        ) {
            Text("Hello World ${it}")
        }
        Button(onClick = { counter++ }) {
            Text("Add")
        }
    }
}

img

类似的,也能实现从右到左切换

@OptIn(ExperimentalAnimationApi::class)
@Composable
fun AnimatedDemo() {

    var counter by remember {
        mutableStateOf(0)
    }

    Column {
        AnimatedContent(
            targetState = counter,
            transitionSpec = {
                // 这里实现的是从右到左切换的效果
                slideInHorizontally { width -> width } + fadeIn() togetherWith
                        slideOutHorizontally { width -> -width } + fadeOut()
            }
        ) {
            Text("Hello World ${it}")
        }
        Button(onClick = { counter++ }) {
            Text("Add")
        }
    }
}

img

使用SizeTranstion定义大小动画

当content的大小发生变化时,使用AnimatedContent也会有一个动画效果

@Composable
fun SizeTransitionDemo() {
    var isExpanded by remember { mutableStateOf(false) }

    Surface(
        color = MaterialTheme.colorScheme.primary,
        onClick = { isExpanded = !isExpanded },
        shape = RoundedCornerShape(16.dp),
    ) {
        AnimatedContent(
            targetState = isExpanded,
        ) {
            if (it) {
                Text(
                    text = "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n",
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.padding(16.dp)
                )
            } else {
                Text(
                    text = "点击展开/收缩",
                    style = MaterialTheme.typography.headlineSmall,
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

img

但有时候我们希望自定义大小变化动画,这时可以使用SizeTransition

@Composable
fun SizeTransitionDemo() {
    var isExpanded by remember { mutableStateOf(false) }

    Surface(
        color = MaterialTheme.colorScheme.primary,
        onClick = { isExpanded = !isExpanded },
        shape = RoundedCornerShape(16.dp),
    ) {
        AnimatedContent(
            targetState = isExpanded,
            transitionSpec = {
                fadeIn() togetherWith fadeOut() using
                        SizeTransform { initSize, targetSize ->
                    if (targetState) { // 展开时
                        // 关键帧,指定在某一个时间点的尺寸
                        // 持续时间为300ms
                        // 在150ms之前,高度保持不变,宽度逐渐变大
                        // 等宽度达到目标值时,高度再逐渐变大
                        keyframes {
                            IntSize(
                                targetSize.width,
                                initSize.height
                            ) at 150
                            durationMillis = 300
                        }
                    } else {
                        // 同理
                        keyframes {
                            IntSize(
                                initSize.width,
                                targetSize.height
                            ) at 150
                            durationMillis = 300
                        }
                    }
                }
            }
        ) {
            if (it) {
                Text(
                    text = "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n" +
                            "这是一个详细的展开内容区域,当组件尺寸变化时会触发 SizeTransform 动画。\n",
                    style = MaterialTheme.typography.bodyMedium,
                    modifier = Modifier.padding(16.dp)
                )
            } else {
                Text(
                    text = "点击展开/收缩",
                    style = MaterialTheme.typography.headlineSmall,
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

img

内容大小动画Modifier.animateContentSize

animateContentSize 是一个 Modifier方法,用于在组件内容尺寸变化时自动触发动画,开箱即用。在本文的MessageCardList中已经使用过它。

animateAsState

animateAsState 可以自动完成从当前值到目标值的过度计算,以animateColorAsState为例,targetValue表示目标值,animationSpec定义动画效果

@Composable
fun ColorAnimationDemo() {
    var isGreenTheme by remember { mutableStateOf(true) }

    val backgroundColor by animateColorAsState(
        targetValue = if (isGreenTheme) {
            Color.Green
        } else {
            Color.Red
        },
        animationSpec = tween(
            durationMillis = 1000, // 动画时长 1 秒
            easing = FastOutSlowInEasing // 快速开始缓慢结束的效果
        ),
        label = "background_color_animation"
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(backgroundColor)
    ) {
        Button(
            onClick = { isGreenTheme = !isGreenTheme },
            modifier = Modifier
                .align(Alignment.Center)
                .padding(16.dp)
        ) {
            Text(
                text = if (isGreenTheme) "切换红色主题" else "切换绿色主题",
                color = Color.White
            )
        }
    }
}

img

多值动画Transition

animate*AsState 只能同时处理一个值,如果需要同时处理多个值,可以使用 updateTransition 函数,它接受一个踏入targetState,返回一个 Transition 对象,当targetState发生变化时,将执行其所有的子动画

Transition可以通过animateXXX添加子动画

例如,实现一个可以点击展开和收缩的卡片,在展开/收缩的时候,卡片的高度,颜色和卡片文字的左边距都会随之变化,并且带有过度动画效果

@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

    // 过渡动画控制器
    val transition = updateTransition(targetState = expanded, label = "expandTransition")

    // 高度变化动画
    val cardHeight by transition.animateDp(
        transitionSpec = {
            if (targetState) {
                spring(stiffness = Spring.StiffnessLow) // 弹性动画
            } else {
                tween(durationMillis = 1000) // 持续时间为1000ms
            }
        }, label = "heightAnimation"
    ) { isExpanded ->
        if (isExpanded) 200.dp else 80.dp
    }

    // 颜色变化动画
    val cardColor by transition.animateColor(
        transitionSpec = { tween(800) },
        label = "colorAnimation"
    ) { isExpanded ->
        if (isExpanded) Color(0xFFB39DDB) else Color(0xFFFFAB91)
    }

    // 文字间距变化动画
    val textPadding by transition.animateDp(
        transitionSpec = {
            if (targetState) {
                spring(stiffness = Spring.StiffnessLow) // 弹性动画
            } else {
                tween(durationMillis = 1000) // 持续时间为1000ms
            }
        },
        label = "paddingAnimation"
    ) { isExpanded ->
        if (isExpanded) 100.dp else 10.dp
    }

    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .height(cardHeight) // 使用带有动画的高度
                .clickable { expanded = !expanded },
            colors = CardDefaults.cardColors(containerColor = cardColor), // 使用带有动画的颜色
            shape = RoundedCornerShape(16.dp)
        ) {
            Text(
                text = if (expanded) "点击收起" else "点击展开",
                modifier = Modifier.padding(start = textPadding), // 使用带有动画的文字间距
                style = MaterialTheme.typography.headlineSmall
            )
        }
    }
}

img

重复动画

通过rememberInfiniteTransition获取重复动画控制器,然后通过animateXXX添加子动画

重复动画会无限循环,通常需要指定两个值:initialValue和targetValue,分别表示初始值和目标值,当动画从初始值执行到目标值时,再根据设置的循环模式(RepeatMode)决定是回到初始值重复还是反过来执行

可以通过animationSpec参数设置动画效果

@Composable
fun InfiniteLoadingAnimation() {
    // 创建无限动画控制器
    val infiniteTransition = rememberInfiniteTransition(label = "infiniteLoader")

    // 无限动画会有两个值(initialValue,targetValue),在这两个值之间循环
    // 定义旋转动画(0°-360°无限循环)
    val rotation by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable( // 无限循环的动画效果
            animation = tween(1500, easing = LinearEasing), // 1500ms内,线性(匀速)转动
            repeatMode = RepeatMode.Restart // 重复模式,Restart:从初始值开始
        ),
        label = "rotationAnimation"
    )

    // 定义透明度动画(脉冲效果)
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.3f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            // 关键帧,定义一个时间点内的动画效果
            animation = keyframes {
                durationMillis = 1000 // 一次动画时间为1s
                0f at 0 // 0s为0f
                1f at 1000 // 1s为1f
                // 即0-1s内由0f变为1f,后续设置重复模式为Reverse,表示1-2s内由1f变为0f
                // 这就实现了一个均匀消失和出现的动画效果
            },
            repeatMode = RepeatMode.Reverse // 重复模式,Reverse:反过来
        ),
        label = "alphaAnimation"
    )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.7f)),
        contentAlignment = Alignment.Center
    ) {
        // 旋转的外圈
        CircularProgressIndicator(
            modifier = Modifier
                .size(64.dp)
                .rotate(rotation) // 带有动画的旋转
                .alpha(alpha), // 带有动画的透明度
            color = Color.Green,
            strokeWidth = 4.dp // 宽度
        )

        // 中心脉冲圆点
        Box(
            modifier = Modifier
                .size(16.dp)
                .alpha(alpha) // 带有动画的透明度
                .background(
                    color = Color.Green,
                    shape = CircleShape
                )
        )
    }
}

img

手势

点击

简单的点击手势,通过Modifier.clickable实现

可以通过pointerInput实现监听更复杂的点击手势,例如长按、双击等

@Composable
fun PointerInputExample() {
    // 记录点击次数
    var tapCount by remember { mutableIntStateOf(0) }

    // 记录点击事件
    var str by remember { mutableStateOf("") }

    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.LightGray)
            .clickable{}
            .pointerInput(Unit) { // 使用 Unit 表示不依赖任何 key,只初始化一次
                detectTapGestures(
                    onPress = {
                        // 按下还没抬起
                        str = "按下"
                    },
                    onDoubleTap = {
                        // 双击事件
                        tapCount += 2
                        str = "双击"
                    },
                    onTap = {
                        // 单击事件
                        tapCount += 1
                        str = "单击"
                    },
                    onLongPress = {
                        // 长按事件
                        str = "长按"
                    }
                )
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(
            text = "Taps: $tapCount\n$str",
            color = Color.Red
        )
    }
}

img

滑动

滑动用于显示列表场景,普通的Cloumn可以显示一个列表,但是默认情况下,列表无法滑动,需要通过Modifier.verticalScroll实现

@Composable
fun ScrollExample() {
    // 滚动状态
    val scrollState = rememberScrollState()
    // 协程作用域
    val scope = rememberCoroutineScope()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 控制滚动的按钮
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp),
            horizontalArrangement = Arrangement.SpaceBetween // 两端对齐
        ) {
            Button(onClick = {
                // 滚动到顶部
                scope.launch {
                    scrollState.animateScrollTo(0)
                }
            }) {
                Text("Scroll to Top")
            }

            Button(onClick = {
                // 滚动到底部
                scope.launch {
                    scrollState.animateScrollTo(scrollState.maxValue)
                }
            }) {
                Text("Scroll to Bottom")
            }
        }

        // 可滚动内容区域
        Column(
            modifier = Modifier
                // 普通的Column可以通过添加滚动状态来实现滚动能力
                .verticalScroll(scrollState) // 绑定滚动状态
                .border(1.dp, Color.Gray)
                .padding(8.dp)
        ) {
            // 生成 50 个文本项作为滚动内容
            repeat(50) { index ->
                Text(
                    text = "Item $index",
                    modifier = Modifier
                        .padding(8.dp)
                        .fillMaxWidth(),
                    fontSize = 18.sp
                )
                Divider(color = Color.LightGray, thickness = 1.dp)
            }
        }
    }
}

img

拖动

拖动通过Modifier.draggable实现,本质上是通过获取拖动的偏移量,然后改变组件自身的偏移量

注意使用Modifier.draggable有局限性,只能在水平或垂直方向上拖动,不能随意拖动

@Composable
fun DraggableExample() {
    // 记录水平和垂直偏移量
    var horizontalOffset by remember { mutableFloatStateOf(0f) }
    var verticalOffset by remember { mutableFloatStateOf(0f) }
    var isDragging by remember { mutableStateOf(false) }


    // 可拖动方块
    Box(
        modifier = Modifier
            // 拖动的本质就是改变偏移量
            .offset {
                IntOffset(horizontalOffset.roundToInt(), verticalOffset.roundToInt())
            }
            .size(100.dp)
            .background(Color.Red)
            .draggable( // x轴拖动
                orientation = Orientation.Horizontal,
                state = rememberDraggableState { delta ->
                    horizontalOffset += delta
                }
            )
            .draggable( // y轴拖动
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    verticalOffset += delta
                }
            ),
        contentAlignment = Alignment.Center,
    ) {
        // 显示坐标信息
        Text(
            text = "X: ${horizontalOffset.roundToInt()}px\nY: ${verticalOffset.roundToInt()}px",
            color = Color.White
        )
    }


}

img

如果需要实现任意方向的拖动,需要使用Modifier.pointerInput,配合detectDragGestures实现


@Composable
fun DraggableExample() {
    // 记录水平和垂直偏移量
    var horizontalOffset by remember { mutableFloatStateOf(0f) }
    var verticalOffset by remember { mutableFloatStateOf(0f) }

    // 记录是否正在拖动,给个颜色变化
    var isDragging by remember { mutableStateOf(false) }


    // 可拖动方块
    Box(
        modifier = Modifier
            // 拖动的本质就是改变偏移量
            .offset {
                IntOffset(horizontalOffset.roundToInt(), verticalOffset.roundToInt())
            }
            .size(100.dp)
            .background(if (isDragging) Color.Red else Color.Green)
            .background(Color.Red)
            // .draggable(
            //     orientation = Orientation.Horizontal,
            //     state = rememberDraggableState { delta ->
            //         horizontalOffset += delta
            //     }
            // )
            // .draggable(
            //     orientation = Orientation.Vertical,
            //     state = rememberDraggableState { delta ->
            //         verticalOffset += delta
            //     }
            // ),
            .pointerInput(Unit) {
                detectDragGestures( // 监听拖动事件
                    onDragStart = { isDragging = true },
                    onDragEnd = { isDragging = false },
                    onDragCancel = {},
                    onDrag = { change, offset ->
                        horizontalOffset += offset.x
                        verticalOffset += offset.y
                        change.consume() // 表示消费了事件
                    }
                )
            },
        contentAlignment = Alignment.Center,
    ) {
        // 显示坐标信息
        Text(
            text = "X: ${horizontalOffset.roundToInt()}px\nY: ${verticalOffset.roundToInt()}px",
            color = Color.White
        )
    }


}

img

posted @ 2025-03-13 20:40  yimina  阅读(132)  评论(0)    收藏  举报