2024年已经过半了,我作为聋人独立开发者,我经常会时不时反思:自己这半年到底进步了多少?我全身心投入在 Jetpack Compose 和 Material Design 3(M3)的学习和实践中,这是一个用 Jetpack Compose、M3 和 Kotlin 语言实现了NimReplyApp 的开发过程。无论你是刚入门的开发者,还是有经验的开发者,相信这篇文章能给大家很多启发。
NimReplyApp 是一个模拟电子邮件应用的案例项目,用户可以浏览邮件、查看详细内容和发送回复,在日常工作和生活中都很常见。
为什么选择 Jetpack Compose 和 Material Design 3?这是因为带来了开发模式的改革,开发效率很高,UI 代码很容易理解和维护,而且能实现复杂的动画和状态管理,省去大量传统 UI 开发中的手动操作。
dependencies {
implementation "androidx.compose.ui:ui:1.5.0"
implementation "androidx.compose.material3:material3:1.2.0"
implementation "androidx.compose.ui:ui-tooling-preview:1.5.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
implementation "androidx.activity:activity-compose:1.7.2"
implementation "androidx.navigation:navigation-compose:2.5.3"
}
ReplyApp 的整体架构使用了 MVVM(Model-View-ViewModel)模式。通过这个架构,了解 UI 逻辑、数据处理和界面状态分开管理,让代码很整洁和可维护。
项目的主要目录结构如下:
一个对象,它表示一个可以属于一个用户的帐户,一个单个用户可以拥有多个帐户
data class Account(
val id: Long,
val uid: Long,
val firstName: String,
val lastName: String,
val email: String,
val altEmail: String,
@DrawableRes val avatar: Int,
var isCurrentAccount: Boolean = false
) {
val fullName: String = "$firstName $lastName"
}
Email数据类用于表示电子邮件。
data class Email(
val id: Long,
val sender: Account,
val recipients: List<Account> = emptyList(),
val subject: String,
val body: String,
val attachments: List<EmailAttachment> = emptyList(),
var isImportant: Boolean = false,
var isStarred: Boolean = false,
var mailbox: MailboxType = MailboxType.INBOX,
val createdAt: String,
val threads: List<Email> = emptyList()
)
这个Demo中,我开发了几个关键的 UI 组件,包括搜索栏、邮件列表、邮件详情等,且通过 Preview 实现了实时预览功能,提升了开发效率。下面重点介绍 ReplyDockedSearchBar
、ReplyEmailListItem
以及 ReplyEmailThreadItem
等核心组件。
ReplyProfileImage
——用户头像展示组件ReplyProfileImage
是一个简单的头像展示组件,通常用于展示发送者或收件人的头像。该组件使用了 Image
组件,结合了 Modifier
实现圆形头像的效果。
@Composable
fun ReplyProfileImage(
drawableResource: Int,
description: String,
modifier: Modifier = Modifier
) {
Image(
modifier = modifier
.size(40.dp)
.clip(CircleShape),
painter = painterResource(id = drawableResource),
contentDescription = description
)
}
Image
组件:用于显示用户的头像图片,通过 painterResource
加载指定的资源文件。Modifier
和 CircleShape
:通过 Modifier.clip(CircleShape)
,头像裁剪成圆形效果,图片大小使用 size(40.dp)
进行控制。Modifier
:这个组件接收外部传入的 modifier
,组件在使用时可以根据不同的布局需求进行扩展和调整。@Preview(showBackground = true)
@Composable
fun PreviewReplyProfileImage() {
ReplyProfileImage(
drawableResource = R.drawable.nim,
description = "NimDrawable"
)
}
ReplyDockedSearchBar
——带有搜索功能的邮件搜索栏ReplyDockedSearchBar
是一个支持实时搜索的顶部搜索栏组件,在这里输入关键词来筛选出对应的邮件。这个组件使用了 Jetpack Compose 提供的状态管理和 LazyColumn
展示搜索结果。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyDockedSearchBar(
emails: List<Email>,
onSearchItemSelected: (Email) -> Unit,
modifier: Modifier = Modifier
) {
var query by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
val searchResults = remember { mutableStateListOf<Email>() }
LaunchedEffect(query) {
searchResults.clear()
if (query.isNotEmpty()) {
searchResults.addAll(
emails.filter {
it.subject.startsWith(query, ignoreCase = true) ||
it.sender.fullName.startsWith(query, ignoreCase = true)
}
)
}
}
DockedSearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = { query = it },
onSearch = { expanded = false },
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text(text = "搜索Email...") }
)
},
expanded = expanded,
onExpandedChange = { expanded = it },
content = {
if (searchResults.isNotEmpty()) {
LazyColumn {
items(searchResults) { email ->
ListItem(
headlineContent = { Text(email.subject) },
supportingContent = { Text(email.sender.fullName) },
leadingContent = { ReplyProfileImage(drawableResource = email.sender.avatar, description = email.sender.fullName) }
)
}
}
} else {
Text(text = if (query.isNotEmpty()) "未找到结果" else "开始输入搜索")
}
}
)
}
LazyColumn
,它用于展示筛选后的邮件列表。LaunchedEffect
用于监听 query
(搜索关键词)的变化,根据输入的内容动态更新搜索结果。DockedSearchBar
是 M3 提供的搜索栏组件,用它实现搜索功能,通过自定义的 InputField
处理搜索输入。@Preview(showBackground = true)
@Composable
fun PreviewReplyDockedSearchBar() {
ReplyDockedSearchBar(emails = sampleEmailList, onSearchItemSelected = {})
}
EmailDetailAppBar
——用于显示邮件详情的顶栏EmailDetailAppBar
是邮件详情页面的顶部导航栏,通常用于显示邮件的标题、回复数以及返回操作等功能。用了 M3 提供的 TopAppBar
组件,通过自定义样式和内容。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmailDetailAppBar(
email: Email,
isFullScreen: Boolean,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit
) {
TopAppBar(
modifier = modifier,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.inverseOnSurface
),
title = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isFullScreen) Alignment.CenterHorizontally else Alignment.Start
) {
Text(
text = email.subject,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = "${email.threads.size} ${stringResource(id = R.string.messages)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
},
navigationIcon = {
if (isFullScreen) {
FilledIconButton(
onClick = onBackPressed,
modifier = Modifier.padding(8.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.back_button),
modifier = Modifier.size(14.dp)
)
}
}
},
actions = {
IconButton(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(id = R.string.more_options_button),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
)
}
TopAppBar
:这个组件是 M3 提供的顶栏导航组件,用于显示应用的标题、导航图标和操作按钮,根据是否是全屏模式调整标题的对齐方式:当全屏显示时,标题居中对齐,非全屏时左对齐。subject
,下面显示了邮件的线程(回复数),通过 Column
组合排列标题和子标题。onBackPressed
回调,通知父组件进行返回操作。MoreVert
图标(更多选项按钮),用于扩展后续的功能(如收藏、分享等)。@Preview(showBackground = true)
@Composable
fun PreviewEmailDetailAppBar() {
EmailDetailAppBar(
email = sampleEmail,
isFullScreen = true,
onBackPressed = {}
)
}
ReplyEmailListItem
——用于展示邮件列表项ReplyEmailListItem
组件是每封邮件的列表项展示组件,通过 Card
包装,用户点击列表项时可以进入邮件详情页面。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReplyEmailListItem(
email: Email,
navigateToDetail: (Long) -> Unit,
toggleSelection: (Long) -> Unit,
isOpened: Boolean = false,
isSelected: Boolean = false
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.combinedClickable(
onClick = { navigateToDetail(email.id) },
onLongClick = { toggleSelection(email.id) }
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else if (isOpened) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = email.subject, style = MaterialTheme.typography.titleMedium)
Text(text = email.sender.fullName, style = MaterialTheme.typography.bodyMedium)
}
}
}
Card
是 Jetpack Compose 提供的一个非常实用的 UI 组件,每一封邮件封装成一个带阴影的卡片样式。combinedClickable
用于处理单击和长按事件,单击进入详情页面,长按选择邮件。@Preview(showBackground = true)
@Composable
fun PreviewReplyEmailListItem() {
ReplyEmailListItem(
email = sampleEmail,
navigateToDetail = {},
toggleSelection = {},
isOpened = false,
isSelected = false
)
}
SelectedProfileImage
——选中状态的头像显示在需要对选中的进行特殊处理,比如让选中的状态具有不同的背景颜色或显示一个 Check
图标表明它已被选中。SelectedProfileImage
需要目的。
@Composable
fun SelectedProfileImage(modifier: Modifier = Modifier) {
Box(
modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.align(Alignment.Center),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Box
:用于头像的背景和 Check
图标组合在一起。Box
是一个简单的容器,可以重叠内容对进行对齐。用 Box
圆形头像背景和 Check
图标进行叠加。size
和 clip(CircleShape)
:首先设置头像的大小为 40.dp
,通过 clip(CircleShape)
裁剪为圆形。CircleShape
是 Compose 提供的预定义形状,用于创建圆形视图。background(MaterialTheme.colorScheme.primary)
:设置背景颜色为主题的主色调,表示处于选中状态。Icon
:在头像的中央显示一个 Check
图标,图标的颜色使用 MaterialTheme.colorScheme.onPrimary
,和背景色形成对比,很显眼。@Preview(showBackground = true)
@Composable
fun PreviewSelectedProfileImage() {
SelectedProfileImage()
}
每个邮件项目(ReplyEmailListItem
)在被选中时,还需要使用 SelectedProfileImage
代替默认的用户头像,表示已经被选中。
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReplyEmailListItem(
email: Email,
navigateToDetail: (Long) -> Unit,
toggleSelection: (Long) -> Unit,
modifier: Modifier = Modifier,
isOpened: Boolean = false,
isSelected: Boolean = false,
) {
Card(
modifier = modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.semantics { selected = isSelected }
.combinedClickable(
onClick = { navigateToDetail(email.id) },
onLongClick = { toggleSelection(email.id) }
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
else if (isOpened) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(modifier = Modifier.fillMaxWidth()) {
val clickModifier = Modifier.clickable { toggleSelection(email.id) }
if (isSelected) {
SelectedProfileImage(modifier = clickModifier) // 显示选中状态的头像
} else {
ReplyProfileImage(
drawableResource = email.sender.avatar,
description = email.sender.fullName,
modifier = clickModifier
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = email.sender.firstName,
style = MaterialTheme.typography.labelMedium
)
Text(
text = email.createdAt,
style = MaterialTheme.typography.labelMedium,
)
}
IconButton(
onClick = { /*TODO*/ },
modifier = Modifier.clip(CircleShape)
) {
Icon(
imageVector = Icons.Default.StarBorder,
contentDescription = "Favorite",
tint = MaterialTheme.colorScheme.outline
)
}
}
Text(
text = email.subject,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)
)
Text(
text = email.body,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
isSelected
参数:通过这个布尔值来判断当前邮件是否处于选中状态。当 isSelected
为 true
时,显示 SelectedProfileImage
,否则显示正常的 ReplyProfileImage
。isSelected
或 isOpened
状态动态改变。SelectedProfileImage
:通过条件判断,当 isSelected
为 true
时,显示选中状态的头像。@Preview(showBackground = true)
@Composable
fun PreviewReplyEmailListItemSelected() {
ReplyEmailListItem(
email = sampleEmail,
navigateToDetail = {},
toggleSelection = {},
modifier = Modifier.fillMaxWidth(),
isOpened = true, // 表示邮件已打开
isSelected = true // 表示邮件已选中
)
}
ReplyEmailThreadItem
——邮件线程展示组件当用户查看某封邮件的详细内容时,可能会涉及邮件的多个回复,需要用到 ReplyEmailThreadItem
组件展示每条回复。
@Composable
fun ReplyEmailThreadItem(
email: Email,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(20.dp)) {
Row {
ReplyProfileImage(drawableResource = email.sender.avatar, description = email.sender.fullName)
Column(modifier = Modifier.weight(1f).padding(12.dp)) {
Text(text = email.sender.firstName, style = MaterialTheme.typography.labelMedium)
Text(text = "1天前", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.outline)
}
}
Text(text = email.subject, style = MaterialTheme.typography.bodyMedium)
Text(text = email.body, style = MaterialTheme.typography.bodyLarge)
}
}
}
ReplyEmailThreadItem
用于展示邮件的具体内容,适合显示邮件的多个回复。它通过 Card
和 Row
、Column
组件实现了信息的排布。ReplyProfileImage
用于显示发送者的头像信息。@Preview(showBackground = true)
@Composable
fun PreviewReplyEmailThreadItem() {
ReplyEmailThreadItem(email = sampleEmailThread)
}
未完待续,中篇介绍如何实现NimReplyAppLogic的中篇),敬请期待。
有任何问题欢迎提问,感谢大家阅读 )
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。