前言
在之前的开发中,现已完成了登陆页、主页、朋友圈等部分内容。在一篇文章中,我将运用 Jetpack Compose 完成谈天界面,介绍 Jetpack 库的 Room 组件的运用以及Room 结合 Paging 分页组件完成本地数据分页查询。
界面拆分
我将界面首要分为三部分,顶部标题、谈天记录列表和底部输入框部分,如下图:
谈天记录列表
谈天记录的内容首要包括当时用户的头像,好友的头像,对话内容和对话时刻;当时用户的头像从个人信息获取,好友的头像从主页的对话列表获取,所以这儿实体类不用管头像的事,只需要完成其他字段即可,在这儿我将运用 Room 组件完成谈天记录的保存。
Jetpack Room 介绍
Jetpack Room 是Google官方在SQLite基础上封装的一款数据耐久库,是Jetpack全家桶的一员,和Jetpack其他库有着可以高度搭配和谐的天然优势。它抽象了SQLite,通过提供方便的API来查询数据库,并在编译时验证。并且可以运用SQLite的悉数功用,同时拥有Java SQL查询生成器提供的类型安全。
引入 Room 相关依靠
val room_version = "2.5.0"
implementation "androidx.room:room-ktx:$room_version"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
testImplementation("androidx.room:room-testing:$room_version")
implementation("androidx.room:room-paging:$room_version")
Entity类 – 谈天记录实体类 ChatSmsEntity 创建
@Entity(tableName = "chatsms")
data class ChatSmsEntity (
/**
* 主键,自增id
*/
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var id: Long = 0,
/**
* 用户id
*/
@ColumnInfo(name = "userId")
var userId: Long = 0,
/**
* 音讯内容
*/
@ColumnInfo(name = "message")
var message: String,
/**
* 音讯内容类型,与枚举类MediaType对应
*/
@ColumnInfo(name = "mediaType")
var mediaType: Int = 1,
/**
* 音讯类型,与枚举类MessageType对应
*/
@ColumnInfo(name = "messageType")
var messageType: Int = 0,
/**
* 创建时刻
*/
@ColumnInfo(name = "createBy")
var createBy: Long = 0,
)
这儿的实体类其实就是表的结构,表名为 chatsms ,首要运用 @Entity 注解和 @ColumnInfo 注解分别标识表名和字段名。
Dao接口 – ChatSmsDao创建
@Dao
interface ChatSmsDao {
/**
* 新增谈天记录
*/
@Insert
suspend fun insert(entity: ChatSmsEntity): Long
/**
* 删去指定谈天记录
*/
@Query("DELETE FROM chatsms WHERE id=:id")
suspend fun delete(id: Long)
/**
* 删去指定用户谈天记录
*/
@Query("DELETE FROM chatsms WHERE userId =:userId")
suspend fun deleteAllByUserId(userId: Long)
/**
* 删去一切谈天记录
*/
@Query("DELETE FROM chatsms")
suspend fun deleteAll()
/**
* 分页查询谈天记录
*/
@Query("SELECT * FROM chatsms WHERE userId =:userId ORDER BY id DESC")
fun getAllByPagingSource(userId: Long): PagingSource<Int, ChatSmsEntity>
}
这儿首要运用注解 @Dao 标识接口,然后写CRUD的逻辑,除了代码中的 @Insert 和 @Query,还有 @Delete 和 @Update 等常用注解,这儿接口我首要运用了 新增谈天记录 和 分页查询谈天记录,在分页查询谈天记录时我运用了 PagingSource<Int, ChatSmsEntity>,PagingSource是分页组件 Paging 的内容,在前面的文章中我有介绍。
枚举类 MediaType
enum class MediaType(var value: Int) {
TEXT(1),
VIDEO(2),
IMAGE(3),
AUDIO(4);
}
枚举类 MessageType
enum class MessageType(val value: Int) {
/**
* 接纳的信息
*/
RECEIVE(0),
/**
* 发送的信息
*/
SEND(1)
}
RoomDatabase数据库类 – COMChatDataBase创建
@Database(
entities = [ChatSmsEntity::class],
version = 1,
exportSchema = false
)
abstract class COMChatDataBase: RoomDatabase() {
abstract fun chatSmsDao(): ChatSmsDao
companion object {
val instance = Room.databaseBuilder(ComposeWechatApp.instance, COMChatDataBase::class.java, "compose_chat_db").build()
}
}
这儿首要是用注解 @Database 标识相关的表(entities),数据库版本号(version)等。
到这儿,谈天记录保存的逻辑现已完成,咱们测试新增一些数据,然后运用Android Studio的工具 App Inspection 查看,成果如下:
页面的完成
运用脚手架 Scaffold 来完成页面布局,其中 topBar 完成顶部标题,content 完成对话列表,bottomBar 完成底部输入框。
顶部标题的完成
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
session.name,
maxLines = 1,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
color = Color(0xff000000)
)
},
actions = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xff000000)
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
scrolledContainerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
navigationIconContentColor = Color.White,
titleContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
actionIconContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
),
navigationIcon = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.ArrowBackIos,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clickable {
context.finish()
},
tint = Color(0xff000000)
)
}
}
)
},
这儿包含返回图标,中间的好友称号以及右边的navigationIcon操作图标。
对话列表的完成
这儿运用 LazyColumn 完成列表的展现,通过ViewModel来处理交互的逻辑,其中:
ChatViewModel的完成
class ChatViewModel(userId: Long) : ViewModel() {
private val _userId: Long = userId
val chatSmsItems: Flow<PagingData<ChatSmsEntity>> =
Pager(PagingConfig(pageSize = 10, prefetchDistance = 1)) {
/** 查询表里的谈天记录 */
COMChatDataBase.instance.chatSmsDao().getAllByPagingSource(_userId)
}.flow
.flowOn(Dispatchers.IO)
.cachedIn(viewModelScope)
/**
* 模仿对方发来的随机对话
*/
private val _mockReceiveSms = listOf(
"你好啊,最近过得怎么样?",
"嗨,我挺好的,谢谢关心。最近作业有点忙,但也在努力调整自己的状态。",
"是啊,作业总是让人有些疲乏。你有什么放松的办法吗?",
"你对未来有什么规划吗?",
"其实我在考虑出国留学,但还在考虑中。你呢?有什么计划?",
"我计划创业,自己开一家咖啡店。期望能在未来几年内完成这个愿望",
"你觉得什么样的人最招引你?",
"是啊,和风趣的人在一起总是让人感到高兴。我也很喜欢和风趣的人交朋友",
"我觉得不诚实、虚伪、不尊重别人的人最不招引我。我觉得人与人之间的尊重和理解很重要",
"您能告诉我您的时刻组织吗?"
)
/**
* 发送信息(保存到本地数据库)
*/
fun sendMessage (message: String, messageType: MessageType) {
val entity: ChatSmsEntity
if (messageType == MessageType.SEND) {
entity = ChatSmsEntity(
userId = _userId,
mediaType = MediaType.TEXT.value,
message = message,
messageType = messageType.value,
createBy = currentTimeMillis()
)
} else {
val random = Random()
/** 生成0-9之间的随机数*/
val index = random.nextInt(10)
entity = ChatSmsEntity(
userId = _userId,
mediaType = MediaType.TEXT.value,
message = _mockReceiveSms[index],
messageType = MessageType.RECEIVE.value,
createBy = currentTimeMillis()
)
}
viewModelScope.launch(Dispatchers.IO) {
val id = COMChatDataBase.instance.chatSmsDao().insert(entity)
println("id============$id")
}
}
}
这儿的逻辑首要运用 Kotlin Flow 来处理分页查询的谈天记录数据以及保存谈天记录到本地数据库,由于没有接 IM 功用,所以仅仅运用模仿数据来模仿对方发送的信息。
列表的页面完成
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(ContextCompat.getColor(context, R.color.nav_bg))),
contentAlignment = Alignment.BottomEnd
) {
LazyColumn(
state = scrollState,
contentPadding = innerPadding,
modifier = Modifier.padding(start = 15.dp, end = 15.dp,bottom = 60.dp),
reverseLayout = true,
verticalArrangement = Arrangement.Top,
) {
items(lazyChatItems) {
it?.let {
MessageItemView(it, session)
}
}
lazyChatItems.apply {
when (loadState.append) {
is LoadState.Loading -> {
item { Loading() }
} else -> {}
}
}
}
}
}
这儿需要阐明的是列表是从底部向上的,所以设置了容器Box的特点 contentAlignment = Alignment.BottomEnd(位于底部),LazyColumn的特点 reverseLayout = true(倒序)。
底部输入框部分的完成
bottomBar = {
NavigationBar(
modifier = Modifier.height(60.dp),
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
) {
Column(modifier = Modifier.fillMaxSize()) {
CQDivider()
Row(modifier = Modifier
.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PauseCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.padding(6.dp)
.fillMaxHeight()
.weight(6f)
) {
BasicTextField(
value = inputText,
onValueChange = {
inputText = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp, minWidth = 280.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
innerTextField()
}
},
/** 光标色彩*/
cursorBrush = SolidColor(Color(0xff5ECC71))
)
}
if (inputText == "") {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (!sheetState.isVisible )Icons.Filled.TagFaces else Icons.Filled.BlurCircular,
contentDescription = null,
modifier = Modifier
.size(30.dp)
.click {
focusRequester.requestFocus()
scope.launch {
if (!sheetState.isVisible) {
keyboardController?.hide()
sheetState.show()
} else {
sheetState.hide()
keyboardController?.show()
}
}
},
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
} else {
Box(modifier = Modifier
.padding(
start = 10.dp,
top = 12.dp,
bottom = 12.dp,
end = 10.dp
)
.fillMaxHeight()
.weight(2f),
contentAlignment = Alignment.Center ) {
Text(
text = "发送",
fontSize = 15.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xff5ECC71))
.padding(top = 4.dp)
.clickable {
viewModel.sendMessage(inputText, MessageType.SEND)
// 发送信息后滚动到最底部
scope.launch {
scrollState.scrollToItem(0)
inputText = ""
sheetState.hide()
/** 自己发送一条信息后保存一条对面回复模仿信息*/
delay(200)
viewModel.sendMessage(
"",
MessageType.RECEIVE
)
}
},
textAlign = TextAlign.Center
)
}
}
}
}
}
}
这儿首要是一个文本输入框和左右的操作图标,输入框运用了组件BasicTextField,没有运用TextField和OutlinedTextField的原因是发现他们的内边距比较大,现在处理不了。除了完成文本输入外,这儿还完成了表情包的选择弹窗。
表情包弹窗面板的完成
我这儿运用了 emoji2,通过底部弹窗组件 ModalBottomSheetLayout 装载emoji布局,emoji2依靠引入的方式为:
implementation 'androidx.emoji2:emoji2-emojipicker:1.4.0-beta05'
EmojiPicker的完成
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun EmojiPicker(
modalBottomSheetState: ModalBottomSheetState,
onPicked: (emoji: String) -> Unit
) {
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp),
sheetContent = {
Column {
/** Spacer: 处理报错 java.lang.IllegalArgumentException:
* The initial value must have an associated anchor.
*/
Spacer(modifier = Modifier.height(1.dp))
Box(modifier = Modifier.fillMaxWidth().wrapContentHeight()) {
AndroidView(
factory = { context ->
EmojiPickerView(context)
},
modifier = Modifier.fillMaxWidth()
) { it ->
it.setOnEmojiPickedListener {
onPicked(it.emoji)
}
}
}
}
}
){}
}
看下表情包面板的完成作用
页面ChatScreen的悉数代码为:
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class,
ExperimentalComposeUiApi::class
)
@Composable
fun ChatScreen(viewModel: ChatViewModel, session: ChatSession) {
val context = LocalContext.current as Activity
val scrollState = rememberLazyListState()
var inputText by remember { mutableStateOf("") }
/** 谈天音讯 */
val lazyChatItems = viewModel.chatSmsItems.collectAsLazyPagingItems()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
rememberSystemUiController().setStatusBarColor(Color(ContextCompat.getColor(context, R.color.nav_bg)), darkIcons = true)
Surface(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.autoCloseKeyboard()
) {
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
session.name,
maxLines = 1,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
color = Color(0xff000000)
)
},
actions = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.MoreHoriz,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xff000000)
)
}
},
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
scrolledContainerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
navigationIconContentColor = Color.White,
titleContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
actionIconContentColor = Color(ContextCompat.getColor(context, R.color.black_10)),
),
navigationIcon = {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.ArrowBackIos,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.clickable {
context.finish()
},
tint = Color(0xff000000)
)
}
}
)
},
bottomBar = {
NavigationBar(
modifier = Modifier.height(60.dp),
containerColor = Color(ContextCompat.getColor(context, R.color.nav_bg)),
) {
Column(modifier = Modifier.fillMaxSize()) {
CQDivider()
Row(modifier = Modifier
.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PauseCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.padding(6.dp)
.fillMaxHeight()
.weight(6f)
) {
BasicTextField(
value = inputText,
onValueChange = {
inputText = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.defaultMinSize(minHeight = 45.dp, minWidth = 280.dp)
.focusRequester(focusRequester),
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
innerTextField()
}
},
/** 光标色彩*/
cursorBrush = SolidColor(Color(0xff5ECC71))
)
}
if (inputText == "") {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (!sheetState.isVisible )Icons.Filled.TagFaces else Icons.Filled.BlurCircular,
contentDescription = null,
modifier = Modifier
.size(30.dp)
.click {
focusRequester.requestFocus()
scope.launch {
if (!sheetState.isVisible) {
keyboardController?.hide()
sheetState.show()
} else {
sheetState.hide()
keyboardController?.show()
}
}
},
tint = Color(0xff000000)
)
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff000000)
)
}
} else {
Box(modifier = Modifier
.padding(
start = 10.dp,
top = 12.dp,
bottom = 12.dp,
end = 10.dp
)
.fillMaxHeight()
.weight(2f),
contentAlignment = Alignment.Center ) {
Text(
text = "发送",
fontSize = 15.sp,
color = Color.White,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xff5ECC71))
.padding(top = 4.dp)
.clickable {
viewModel.sendMessage(inputText, MessageType.SEND)
// 发送信息后滚动到最底部
scope.launch {
scrollState.scrollToItem(0)
inputText = ""
sheetState.hide()
/** 自己发送一条信息后保存一条对面回复模仿信息*/
delay(200)
viewModel.sendMessage(
"",
MessageType.RECEIVE
)
}
},
textAlign = TextAlign.Center
)
}
}
}
}
}
},
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(ContextCompat.getColor(context, R.color.nav_bg))),
contentAlignment = Alignment.BottomEnd
) {
LazyColumn(
state = scrollState,
contentPadding = innerPadding,
modifier = Modifier.padding(start = 15.dp, end = 15.dp,bottom = 60.dp),
reverseLayout = true,
verticalArrangement = Arrangement.Top,
) {
items(lazyChatItems) {
it?.let {
MessageItemView(it, session)
}
}
lazyChatItems.apply {
when (loadState.append) {
is LoadState.Loading -> {
item { Loading() }
} else -> {}
}
}
}
EmojiPicker(
modalBottomSheetState = sheetState,
onPicked = { emoji ->
inputText += emoji
}
)
}
}
)
}
}
@Composable
fun MessageItemView(it: ChatSmsEntity, session: ChatSession) {
Box(
contentAlignment = if (it.messageType == MessageType.RECEIVE.value) Alignment.CenterStart else Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(
top = 30.dp,
start = if (it.messageType == MessageType.RECEIVE.value) 0.dp else 40.dp,
end = if (it.messageType == MessageType.SEND.value) 0.dp else 40.dp
),
) {
Column(modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
/*** 对话时刻*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp),
contentAlignment = Alignment.Center
) {
Text(
text = toTalkTime(it.createBy),
fontSize = 12.sp,
color = Color(0xff888888)
)
}
/*** 对话信息*/
Row(modifier = Modifier
.wrapContentWidth()
.wrapContentHeight()
) {
/*** 别人头像(左面)*/
if(it.messageType == MessageType.RECEIVE.value) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.weight(1f)
) {
Image(
painter = rememberCoilPainter(request = session.avatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
)
}
}
Box(
modifier = Modifier
.weight(6f)
.wrapContentHeight(),
contentAlignment =
if (it.messageType == MessageType.RECEIVE.value) Alignment.TopStart
else Alignment.TopEnd
) {
/*** 尖角*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
contentAlignment = if (it.messageType == MessageType.RECEIVE.value) Alignment.TopStart else Alignment.TopEnd
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
modifier = Modifier
.size(20.dp)
.rotate(if (it.messageType == MessageType.SEND.value) 0f else 180f),
tint = if (it.messageType == MessageType.RECEIVE.value) Color.White
else Color(0xffA9EA7A)
)
}
/*** 文本内容*/
Box(
modifier = Modifier
.padding(
start = if (it.messageType == MessageType.RECEIVE.value) 12.dp else 0.dp,
end = if (it.messageType == MessageType.RECEIVE.value) 0.dp else 12.dp,
)
.clip(RoundedCornerShape(4.dp))
.wrapContentWidth()
.wrapContentHeight(Alignment.CenterVertically)
.background(
if (it.messageType == MessageType.RECEIVE.value) Color.White else Color(
0xffA9EA7A
)
),
) {
Text(
modifier = Modifier.padding(8.dp),
text = it.message,
fontSize = 16.sp
)
}
}
/*** 自己头像(右边)*/
if(it.messageType == MessageType.SEND.value) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White)
.weight(1f)
) {
Image(
painter = rememberCoilPainter(request = myAvatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(4.dp))
)
}
}
}
}
}
}
到这儿,谈天页面的基本功用就完成了,咱们看下作用
总结
在这一篇文章中,首要介绍了数据库 SQLite 基础上封装的一款数据耐久库Jetpack Room 组件的运用,以及Room 结合 Paging 组件完成本地数据分页查询的功用。此外,还介绍了表情包 Emoji2 组件的运用。