Bài viết này hướng dẫn cách tạo một giao diện người dùng tương tự tính năng "Nhật ký" của WeChat bằng cách sử dụng Jetpack Compose, một bộ công cụ UI hiện đại cho Android.
1. Giao diện Chính (MainScreen)
Đây là thành phần trung tâm điều phối toàn bộ giao diện, bao gồm danh sách các bài đăng, thanh nhập liệu bình luận, cửa sổ xem trước ảnh và các hộp thoại.
package com.example.composecircledemo.ui
import android.Manifest
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import coil.ImageLoader
import com.example.composecircledemo.PlayVideoActivity
import com.example.composecircledemo.ui.model.CircleBean
import com.example.composecircledemo.ui.model.CommentListBean
import com.example.composecircledemo.ui.model.LikeListBean
import com.example.composecircledemo.ui.model.WeatherEvent
import com.example.composecircledemo.utils.Constants
import com.example.composecircledemo.utils.ImageSaveUtil
import com.example.composecircledemo.utils.RxBus
import com.example.composecircledemo.utils.ToastUtil
import com.example.composecircledemo.utils.rememberStoragePermissionLauncher
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.launch
/**
* Màn hình chính hiển thị danh sách bài đăng và các thành phần tương tác.
*/
@Composable
fun MainScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val keyboardController = LocalSoftwareKeyboardController.current
val lazyListState = rememberLazyListState()
val compositeDisposable = remember { CompositeDisposable() }
// Quản lý trạng thái toàn cục
var circleData by remember { mutableStateOf<List<CircleBean.DataBean>>(emptyList()) }
var isCommentInputVisible by remember { mutableStateOf(false) }
var commentInputContent by remember { mutableStateOf("") }
var currentReplyPostIndex by remember { mutableStateOf(-1) }
var currentReplyToUserId by remember { mutableStateOf("") }
var currentReplyToUserName by remember { mutableStateOf("") }
var isImagePreviewVisible by remember { mutableStateOf(false) }
var previewImages by remember { mutableStateOf(emptyList<String>()) }
var previewImageIndex by remember { mutableStateOf(0) }
var showDeleteConfirmationDialog by remember { mutableStateOf(false) }
var deletePostIndex by remember { mutableStateOf(-1) }
var showActionMenuDialog by remember { mutableStateOf(false) } // Trạng thái hộp thoại menu hành động
var currentActionPostIndex by remember { mutableStateOf(-1) } // Vị trí bài đăng đang thao tác
// Launcher yêu cầu quyền lưu trữ
val storagePermissionLauncher = rememberStoragePermissionLauncher(
onGranted = {
scope.launch {
// Sửa lỗi: tránh crash khi danh sách ảnh xem trước rỗng
val targetImage = previewImages.getOrNull(previewImageIndex) ?: return@launch
val success = ImageSaveUtil.saveImageToGallery(
context, targetImage, ImageLoader(context)
)
ToastUtil.show(context, if (success) "Lưu ảnh thành công" else "Lưu ảnh thất bại")
}
},
onDenied = { ToastUtil.show(context, "Thiếu quyền lưu trữ, không thể lưu ảnh") }
)
// 1. Khởi tạo dữ liệu
LaunchedEffect(Unit) {
circleData = loadMockCircleData()
}
// 2. Sửa lỗi RxBus trùng lặp đăng ký (bản gốc đăng ký trùng lặp & hủy sớm, dẫn đến lỗi nhận sự kiện)
LaunchedEffect(Unit) {
val disposable = RxBus.toObservable<WeatherEvent>()
.subscribe(
{ event ->
println("Weather: ${event.cityName} ${event.temperature}℃")
},
{ error -> error.printStackTrace() }
)
compositeDisposable.add(disposable) // Chỉ thêm vào, không hủy sớm
}
// 3. Cấu trúc bố cục: Danh sách + Hộp thoại hành động + Thanh nhập liệu bình luận + Xem trước ảnh
Box(modifier = Modifier.fillMaxSize()) {
// Danh sách bài đăng
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) {
item { CircleHeader() }
itemsIndexed(circleData, key = { _, item -> item.id ?: "default_key" }) { index, item ->
CircleItem(
data = item,
// Quan trọng: đảm bảo "nút hành động" (như dấu ba chấm ở góc trên bên phải) liên kết với callback này
onActionClick = {
currentActionPostIndex = index // Ghi lại vị trí bài đăng đang thao tác
showActionMenuDialog = true // Mở hộp thoại
},
onReplyClick = { comment ->
currentReplyPostIndex = index
currentReplyToUserId = comment.user_id ?: ""
currentReplyToUserName = comment.user_name ?: ""
isCommentInputVisible = true
commentInputContent = ""
keyboardController?.show()
scope.launch {
lazyListState.scrollToItem(index + 1)
}
},
onDeleteClick = {
deletePostIndex = index
showDeleteConfirmationDialog = true
},
onVideoClick = { videoUrl ->
val intent = Intent(context, PlayVideoActivity::class.java)
intent.putExtra("url", videoUrl)
startActivity(context, intent, null)
},
onImageClick = { images, imgIndex ->
previewImages = images
previewImageIndex = imgIndex
isImagePreviewVisible = true
},
onCommentLongClick = { comment ->
ToastUtil.show(context, "Đã sao chép: ${comment.content ?: ""}")
}
)
}
}
// 4. Mới thêm: Hộp thoại hành động Thích/Bình luận (hiện từ dưới lên, nhấn nút hành động để hiển thị)
if (showActionMenuDialog && currentActionPostIndex != -1) {
// Nền hộp thoại (nhấn vào khoảng trống để đóng)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable { showActionMenuDialog = false }
)
// Nội dung hộp thoại (hiển thị ở dưới, thiết kế bo góc)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.White)
.padding(16.dp)
) {
// Lấy dữ liệu bài đăng đang thao tác (kiểm tra an toàn)
val currentItem = circleData.getOrNull(currentActionPostIndex) ?: run {
showActionMenuDialog = false
return@Column
}
// Nút Thích
Text(
text = if (currentItem.is_like == 1) "Bỏ thích" else "Thích",
fontSize = 18.sp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable {
// Thực hiện logic Thích/Bỏ thích
val updatedLikeList =
currentItem.like_list?.toMutableList() ?: mutableListOf()
if (currentItem.is_like == 1) {
// Bỏ thích: xóa người dùng hiện tại
updatedLikeList.removeAll { it.user_id == "current_user_id" }
} else {
// Thích: thêm người dùng hiện tại
updatedLikeList.add(
LikeListBean(
user_id = "current_user_id",
user_name = "Người dùng hiện tại",
circle_id = currentItem.id ?: ""
)
)
}
// Cập nhật dữ liệu danh sách
circleData = circleData.toMutableList().apply {
this[currentActionPostIndex] = currentItem.copy(
is_like = if (currentItem.is_like == 1) 0 else 1,
like_list = updatedLikeList
)
}
ToastUtil.show(
context,
if (currentItem.is_like == 1) "Bỏ thích thành công" else "Thích thành công"
)
showActionMenuDialog = false // Đóng hộp thoại
}
)
// Dòng phân cách
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.LightGray.copy(alpha = 0.5f))
)
// Nút Bình luận
Text(
text = "Bình luận",
fontSize = 18.sp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable {
// Kích hoạt thanh nhập liệu bình luận
currentReplyPostIndex = currentActionPostIndex
currentReplyToUserId = ""
currentReplyToUserName = ""
isCommentInputVisible = true
commentInputContent = ""
keyboardController?.show()
// Cuộn đến bài đăng hiện tại, tránh che khuất
scope.launch {
lazyListState.scrollToItem(currentActionPostIndex + 1)
}
showActionMenuDialog = false // Đóng hộp thoại
}
)
// Dòng phân cách
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.LightGray.copy(alpha = 0.5f))
)
// Nút Hủy
Text(
text = "Hủy",
fontSize = 18.sp,
color = Color.Red,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.clickable {
showActionMenuDialog = false
}
)
}
}
// 5. Thanh nhập liệu bình luận dưới cùng
if (isCommentInputVisible && currentReplyPostIndex != -1) { // Sửa lỗi: thêm điều kiện currentReplyPostIndex, tránh NullPointerException
CommentInputBar(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Color.White)
.padding(8.dp),
hint = if (currentReplyToUserId.isBlank()) "Nói điều gì đó..." else "Trả lời ${currentReplyToUserName}...",
content = commentInputContent,
onContentChange = { commentInputContent = it },
onSendClick = {
if (commentInputContent.isBlank()) {
ToastUtil.show(context, "Vui lòng nhập nội dung bình luận")
return@CommentInputBar
}
val newComment = CommentListBean(
id = System.currentTimeMillis().toString(),
user_id = "current_user_id",
user_name = "Người dùng hiện tại",
to_user_id = currentReplyToUserId,
to_user_name = currentReplyToUserName,
circle_id = circleData[currentReplyPostIndex].id ?: "",
content = commentInputContent,
type = "0",
createon = System.currentTimeMillis().toString(),
replyUser = CommentListBean.CommentsUser(
userId = currentReplyToUserId.takeIf { it.isNotEmpty() } ?: "0",
userName = currentReplyToUserName.takeIf { it.isNotEmpty() } ?: ""
),
commentsUser = CommentListBean.CommentsUser(
userId = "current_user_id",
userName = "Người dùng hiện tại"
),
is_del = "0",
deleteon = ""
)
// Cập nhật danh sách bình luận (sửa lỗi: đảm bảo tập hợp có thể thay đổi)
circleData = circleData.toMutableList().apply {
val targetItem = this[currentReplyPostIndex]
val newComments =
targetItem.comments_list?.toMutableList() ?: mutableListOf()
newComments.add(newComment)
this[currentReplyPostIndex] = targetItem.copy(comments_list = newComments)
}
// Đặt lại trạng thái
isCommentInputVisible = false
commentInputContent = ""
keyboardController?.hide()
ToastUtil.show(
context,
if (currentReplyToUserId.isBlank()) "Bình luận thành công" else "Trả lời thành công"
)
},
onDismiss = {
isCommentInputVisible = false
keyboardController?.hide()
}
)
}
// 6. Cửa sổ xem trước ảnh
if (isImagePreviewVisible && previewImages.isNotEmpty()) { // Sửa lỗi: kiểm tra danh sách xem trước không rỗng
ImagePreview(
images = previewImages,
currentIndex = previewImageIndex,
onDismiss = { isImagePreviewVisible = false },
onLongClick = {
storagePermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
)
}
// 7. Hộp thoại xác nhận xóa
if (showDeleteConfirmationDialog && deletePostIndex != -1) {
AlertDialog(
title = { Text("Thông báo") },
text = { Text("Bạn có chắc muốn xóa bài đăng này không?") },
onDismissRequest = { showDeleteConfirmationDialog = false },
confirmButton = {
Button(onClick = {
circleData = circleData.toMutableList().apply { removeAt(deletePostIndex) }
showDeleteConfirmationDialog = false
deletePostIndex = -1
ToastUtil.show(context, "Xóa thành công")
}) {
Text("Xác nhận")
}
},
dismissButton = {
Button(onClick = { showDeleteConfirmationDialog = false }) {
Text("Hủy")
}
}
)
}
}
// 8. Dọn dẹp tài nguyên (hủy đăng ký RxBus khi trang được hủy)
DisposableEffect(Unit) {
onDispose {
compositeDisposable.dispose()
}
}
}
// Giả lập tải dữ liệu bài đăng (thay thế AssetsUtil.getStates cũ)
private fun loadMockCircleData(): List<CircleBean.DataBean> {
// Hằng số danh sách rỗng thống nhất, tránh tạo lại nhiều lần và xác định rõ kiểu generic
val emptyStringList: MutableList<String> = mutableListOf<String>()
val emptyCommentList: MutableList<CommentListBean> = mutableListOf<CommentListBean>()
val emptyLikeList: MutableList<LikeListBean> = mutableListOf<LikeListBean>()
return listOf(
// Bài đăng 1: Bài đăng web (sửa lỗi: bổ sung tham số video, xác định rõ kiểu generic cho danh sách rỗng)
CircleBean.DataBean(
id = "190383",
user_id = "12",
type = Constants.TYPE_WEB, // "4" (bài đăng web)
content = "Kiểm tra sửa lỗi nóng",
files = emptyStringList,
share_title = "Biên giới của Trường Bạch Sơn Hán và đồng cỏ, rừng Hạo Thủy, dưới chân núi, bao quanh bởi rừng nguyên sinh là vài chục hộ gia đình, nhà tranh, giường đối diện, ống khói đặt sau nhà. Ở cuối làng phía đông có một ngôi nhà riêng, đó là điểm tập kết của thanh niên, trước cửa sổ có một con suối nhỏ chảy qua. Học sinh ăn ở đây, hàng ngày xuất phát từ đây đi làm việc với xã viên. Công việc hoặc là đốn gỗ, vận chuyển gỗ trên núi, hoặc là chặt cây liễu để khai hoang trồng trọt. Trên núi, có thể nghe tiếng hô: \"Núi đổ theo chiều dọc!\" Cẩn thận với gậy từ trên cây rơi xuống! Cành khô trên cây làm gãy các cây khác và bật trở lại, gậy này đánh người rất mạnh.",
share_image = "http://imgsrc.baidu.com/forum/w=580/sign=852e30338435e5dd902ca5d746c7a7f5/bd3eb13533fa828b6cf24d82fc1f4134960a5afa.jpg",
share_url = "",
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "3 ngày trước",
user_name = "Mèo Máy",
head_img = "http://b167.photo.store.qq.com/psb?/V14EhGon2OmAUI/hQN450lNoDPF.dO82PVKEdFw0Qj5qyGeyN9fByKgWd0!/m/dJWKmWNZEwAAnull",
comments_list = emptyCommentList,
like_list = emptyLikeList,
is_like = 0,
video = "" // Bổ sung tham số video cho bài đăng không phải video (chuỗi rỗng)
),
// Bài đăng 2: Bài đăng văn bản (sửa lỗi: bổ sung tham số video, xác định rõ kiểu generic cho danh sách rỗng)
CircleBean.DataBean(
id = "190382", // Sửa ID (190383 trùng với bài đăng 1)
user_id = "12",
type = Constants.TYPE_TEXT, // "1" (bài đăng văn bản)
content = "Biên giới của Trường Bạch Sơn Hán và đồng cỏ, rừng Hạo Thủy, dưới chân núi, bao quanh bởi rừng nguyên sinh là vài chục hộ gia đình, nhà tranh, giường đối diện, ống khói đặt sau nhà. Ở cuối làng phía đông có một ngôi nhà riêng, đó là điểm tập kết của thanh niên, trước cửa sổ có một con suối nhỏ chảy qua. Học sinh ăn ở đây, hàng ngày xuất phát từ đây đi làm việc với xã viên. Công việc hoặc là đốn gỗ, vận chuyển gỗ trên núi, hoặc là chặt cây liễu để khai hoang trồng trọt. Trên núi, có thể nghe tiếng hô: \"Núi đổ theo chiều dọc!\" Cẩn thận với gậy từ trên cây rơi xuống! Cành khô trên cây làm gãy các cây khác và bật trở lại, gậy này đánh người rất mạnh.",
files = emptyStringList,
share_title = "Quá trình phân phối sự kiện",
share_image = "http://imgsrc.baidu.com/forum/w=580/sign=852e30338435e5dd902ca5d746c7a7f5/bd3eb13533fa828b6cf24d82fc1f4134960a5afa.jpg",
share_url = "",
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "3 ngày trước",
user_name = "Mèo Máy",
head_img = "http://b167.photo.store.qq.com/psb?/V14EhGon2OmAUI/hQN450lNoDPF.dO82PVKEdFw0Qj5qyGeyN9fByKgWd0!/m/dJWKmWNZEwAAnull",
comments_list = emptyCommentList,
like_list = emptyLikeList,
is_like = 0,
video = "" // Bổ sung tham số video
),
// Bài đăng 3: Bài đăng ảnh (sửa lỗi: loại bỏ ép kiểu, xác định rõ kiểu generic cho files)
CircleBean.DataBean(
id = "190381",
user_id = "3372840",
type = Constants.TYPE_IMAGE, // "2" (bài đăng ảnh)
content = "",
// Sử dụng trực tiếp List<String>, không cần ép kiểu MutableList
files = listOf(
"http://gips1.baidu.com/it/u=1410005327,4082018016&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280",
"https://gips3.baidu.com/it/u=3732338995,3528391142&fm=3028&app=3028&f=JPEG&fmt=auto&q=100&size=f600_800",
"http://pic.3h3.com/up/2014-3/20143314140858312456.gif",
"http://gips2.baidu.com/it/u=1506371362,2755593126&fm=3028&app=3028&f=JPEG&fmt=auto?w=1440&h=2560",
"http://gips1.baidu.com/it/u=2778784063,1731001818&fm=3028&app=3028&f=JPEG&fmt=auto?w=1920&h=2560",
"http://gips2.baidu.com/it/u=1192674964,3939660937&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960",
"http://gips1.baidu.com/it/u=2178851102,884542160&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280",
"http://gips2.baidu.com/it/u=1757244148,2197425658&fm=3028&app=3028&f=JPEG&fmt=auto?w=1440&h=2560",
"https://gips1.baidu.com/it/u=926030969,4240391978&fm=3028&app=3028&f=JPEG&fmt=auto&q=100&size=f576_1024"
) as MutableList<String>,
share_title = "",
share_image = "",
share_url = "",
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "4 ngày trước",
user_name = "Quán nhỏ mỉm cười",
head_img = "http://b167.photo.store.qq.com/psb?/V14EhGon2OmAUI/hQN450lNoDPF.dO82PVKEdFw0Qj5qyGeyN9fByKgWd0!/m/dJWKmWNZEwAAnull",
comments_list = emptyCommentList,
like_list = emptyLikeList,
is_like = 0,
video = "" // Bổ sung tham số video
),
// Bài đăng 4: Bài đăng web (sửa lỗi: bổ sung tham số video, xác định rõ kiểu generic cho danh sách rỗng)
CircleBean.DataBean(
id = "190380",
user_id = "3372793",
type = Constants.TYPE_WEB, // "4" (bài đăng web)
content = "",
files = emptyStringList,
share_title = "《Vương Giả Vinh Diệu》 thực sự rất hay, hãy cùng chơi nào! Trong cuộc sống thực, tôi đối mặt một mình với mưa gió cuộc đời, nhưng chỉ cần vào chiến trường, sẽ có đồng đội che chắn cho tôi. Hôm nay thời tiết thật đẹp, ánh nắng lười biếng chiếu xuống đường lát đá, khắp đường đầy những cặp đôi và tôi cô đơn, khiến tôi buồn bã. Nhưng nghĩ đến chỉ còn thiếu 1 sao nữa là lên Vương Giả, tôi lập tức hồi phục toàn bộ sức lực! Chơi không?",
share_image = "http://imgsrc.baidu.com/forum/w=580/sign=852e30338435e5dd902ca5d746c7a7f5/bd3eb13533fa828b6cf24d82fc1f4134960a5afa.jpg",
share_url = "", // Bổ sung tham số share_url bị thiếu (theo thứ tự constructor)
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "4 ngày trước",
user_name = "Thái Cung Nhất Hào",
head_img = "http://b167.photo.store.qq.com/psb?/V14EhGon2OmAUI/hQN450lNoDPF.dO82PVKEdFw0Qj5qyGeyN9fByKgWd0!/m/dJWKmWNZEwAAnull",
comments_list = listOf(
CommentListBean(
id = "15563",
circle_id = "190380",
user_id = "3191031",
to_user_id = "0",
type = "0",
content = "Nịnh hót",
is_del = "0",
deleteon = "0",
createon = "1577085821",
to_user_name = "",
user_name = "Quán nhỏ mỉm cười",
// Thông tin người được trả lời: tương ứng với to_user_id và to_user_name
replyUser = CommentListBean.CommentsUser(
userId = "0",
userName = "",
),
// Thông tin người bình luận: tương ứng với user_id và user_name của bình luận hiện tại
commentsUser = CommentListBean.CommentsUser(
userId = "0",
userName = ""
)
)
) as MutableList<CommentListBean>,
like_list = listOf(
LikeListBean(
user_id = "3191031",
circle_id = "190380",
user_name = "Quán nhỏ mỉm cười"
)
) as MutableList<LikeListBean>,
is_like = 1,
video = "" // Bổ sung tham số video
),
// Bài đăng 5: Bài đăng video (bình thường, giữ nguyên mã gốc)
CircleBean.DataBean(
id = "190379",
user_id = "3372793",
type = Constants.TYPE_VIDEO, // "3" (bài đăng video)
content = "Haha",
files = listOf(
"http://img.my.csdn.net/uploads/201701/17/1484647897_1978.jpg"
) as MutableList<String>,
share_title = "",
share_image = "",
share_url = "",
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "4 ngày trước",
user_name = "Cười hì hì",
head_img = "http://b167.photo.store.qq.com/psb?/V14EhGon2OmAUI/hQN450lNoDPF.dO82PVKEdFw0Qj5qyGeyN9fByKgWd0!/m/dJWKmWNZEwAAnull",
video = "https://vjs.zencdn.net/v/oceans.mp4", // Liên kết video
comments_list = emptyCommentList,
like_list = emptyLikeList,
is_like = 0
),
// Bài đăng 6: Bài đăng ảnh (sửa lỗi: id circle_list trong like_list thống nhất, xác định rõ kiểu generic)
CircleBean.DataBean(
id = "190378",
user_id = "3372793",
type = Constants.TYPE_IMAGE, // "2" (bài đăng ảnh)
content = "",
files = listOf(
"https://inews.gtimg.com/news_bt/O_qHdht-j-Pc6-oSPkO0Nh5d2-gPK6YTa-ruo4P1aDQ5MAA/1000",
"https://img1.baidu.com/it/u=2208277039,107339662&fm=253&fmt=auto&app=138&f=JPEG?w=219&h=332",
"https://img1.baidu.com/it/u=3651140891,1638849478&fm=253&fmt=auto&app=120&f=JPEG?w=1280&h=800",
"https://img2.baidu.com/it/u=3143194608,2162960312&fm=253&fmt=auto&app=138&f=JPEG?w=499&h=300",
"https://img1.baidu.com/it/u=174076537,4056466491&fm=253&fmt=auto&app=120&f=JPEG?w=800&h=1201",
"https://img2.baidu.com/it/u=3121773192,1157460465&fm=253&fmt=auto&app=138&f=JPEG?w=333&h=500",
"https://img1.baidu.com/it/u=2377094960,1480051748&fm=253&fmt=auto&app=138&f=JPEG?w=759&h=389",
"http://gips2.baidu.com/it/u=195724436,3554684702&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960",
"http://gips1.baidu.com/it/u=3874647369,3220417986&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280",
) as MutableList<String>,
share_title = "",
share_image = "",
share_url = "",
longitude = "",
latitude = "",
position = "",
is_del = "0",
deleteon = "0",
createon = "4 ngày trước",
user_name = "Thái Cung Nhị Hào",
head_img = "http://b162.photo.store.qq.com/psb?/V14EhGon4cZvmh/z2WukT5EhNE76WtOcbqPIgwM2Wxz4Tb7Nub.rDpsDgo!/b/dOaanmAaKQAA",
comments_list = emptyCommentList,
like_list = emptyLikeList,
is_like = 0,
video = ""
),
)
}
2. Xem trước ảnh (ImagePreview)
Composables này hiển thị một hoặc nhiều ảnh trong chế độ toàn màn hình, cho phép vuốt để xem ảnh kế tiếp và hỗ trợ thao tác nhấn đúp hoặc giữ để lưu ảnh.
package com.example.composecircledemo.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import coil.compose.rememberAsyncImagePainter
/**
* Hiển thị ảnh xem trước toàn màn hình với khả năng vuốt và tương tác.
*/
@Composable
fun ImagePreview(
images: List<String>,
currentIndex: Int,
onDismiss: () -> Unit,
onLongClick: () -> Unit
){
val pagerState = rememberPagerState(initialPage = currentIndex) { images.size }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onDismiss() }, // Sự kiện nhấn
onLongPress = { onLongClick() } // Sự kiện nhấn giữ
)
},
) {
// Xem trước ảnh theo trang ngang
HorizontalPager(state = pagerState) { page ->
Image(
// Sửa lỗi: sử dụng ImageRequest cấu hình crossfade (hiệu ứng mờ dần)
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(images[page]) // URL ảnh
.crossfade(true) // Hiệu ứng mờ dần (cách dùng đúng của phiên bản mới)
.build()
),
contentDescription = "Ảnh xem trước $page",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
// Chỉ báo số trang
Text(
text = "${pagerState.currentPage + 1}/${images.size}",
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(24.dp)
)
}
}
3. Hiển thị ảnh dạng lưới 9 ô (NineGridImages)
Thành phần này chịu trách nhiệm hiển thị tối đa 9 ảnh trong một bố cục lưới, với kích thước ảnh được tính toán dựa trên chiều rộng màn hình và khoảng cách giữa các ảnh.
package com.example.composecircledemo.ui
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
/**
* Hiển thị danh sách ảnh trong bố cục lưới 9 ô.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun NineGridImages(
images: List<Any?>,
modifier: Modifier = Modifier,
onImageClick: (List<String>, Int) -> Unit,
overallPadding: Dp = 14.dp,
itemSpacing: Dp = 6.dp
){
val imageCount = images.size
val screenWidth = androidx.compose.ui.platform.LocalConfiguration.current.screenWidthDp.dp
val totalOverallPadding = overallPadding.times(2)
val totalItemSpacing = itemSpacing.times(2)
val availableWidth = screenWidth.minus(totalOverallPadding).minus(totalItemSpacing)
val itemSize = availableWidth.div(3)
Log.d("NineGridImages", "Tổng số ảnh: ${images.size}")
androidx.compose.foundation.layout.FlowRow(
maxItemsInEachRow = 3,
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth().padding(overallPadding)
) {
images.take(9).forEachIndexed { index, url ->
val imageUrl = url?.toString() ?: ""
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.build()
),
contentDescription = "Ảnh bài đăng $index",
modifier = Modifier
.size(itemSize)
.aspectRatio(1f) // Đảm bảo là hình vuông
.clickable {
// Chuyển đổi kiểu danh sách an toàn
val stringList = images.filterIsInstance<String>()
onImageClick(stringList, index)
},
contentScale = ContentScale.Crop
)
}
}
}
4. Văn bản có thể mở rộng (ExpandableText)
Composables này cho phép hiển thị một đoạn văn bản có giới hạn dòng ban đầu, với nút "Mở rộng" hoặc "Thu gọn" để người dùng có thể xem toàn bộ nội dung.
package com.example.composecircledemo.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Hiển thị văn bản có thể mở rộng/thu gọn.
*/
@Composable
fun ExpandableText(
text: String,
modifier: Modifier = Modifier,
maxLines: Int = 2,
style: TextStyle = TextStyle(fontSize = 14.sp)
){
var isExpanded by remember { mutableStateOf(false) }
var textLayoutResult by remember { mutableStateOf<androidx.compose.ui.text.TextLayoutResult?>(null) }
val hasMoreLines = textLayoutResult?.lineCount ?: 0 > maxLines
Column(modifier = modifier) {
Text(
text = text,
style = style,
maxLines = if (isExpanded) Int.MAX_VALUE else maxLines,
onTextLayout = { textLayoutResult = it },
modifier = Modifier.fillMaxWidth()
)
if (hasMoreLines) {
Text(
text = if (isExpanded) "Thu gọn" else "Mở rộng",
style = TextStyle(
fontSize = 12.sp,
color = Color.Blue
),
modifier = Modifier
.padding(top = 4.dp)
.clickable { isExpanded = !isExpanded }
)
}
}
}
5. Mục bình luận (CommentItem)
Composables này hiển thị một bình luận đơn lẻ, bao gồm tên người bình luận, tên người được trả lời (nếu có), và nội dung bình luận. Nó cũng hỗ trợ nhấn để trả lời và nhấn giữ để sao chép.
package com.example.composecircledemo.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.composecircledemo.ui.model.CommentListBean
/**
* Hiển thị một mục bình luận.
*/
@Composable
fun CommentItem(
comment: CommentListBean,
onReplyClick: () -> Unit,
onLongClick: () -> Unit
){
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onReplyClick() }, // Sự kiện nhấn
onLongPress = { onLongClick() } // Sự kiện nhấn giữ
)
},
verticalAlignment = Alignment.CenterVertically
) {
// Nội dung bình luận (phân biệt bình luận thường và trả lời)
val commentText = if (comment.to_user_id.isNullOrBlank()) {
"${comment.user_name}:${comment.content}"
} else {
"${comment.user_name} Trả lời ${comment.to_user_name}:${comment.content}"
}
Text(
text = commentText,
style = TextStyle(
fontSize = 12.sp,
color = Color.DarkGray
),
modifier = Modifier.weight(1f)
)
Text(
text = "Trả lời",
style = TextStyle(
fontSize = 12.sp,
color = Color.Blue
),
modifier = Modifier.padding(start = 8.dp)
)
}
}
6. Thanh nhập liệu bình luận (CommentInputBar)
Composables này cung cấp một giao diện nhập liệu bình luận ở dưới cùng màn hình, bao gồm một trường văn bản và nút gửi.
package com.example.composecircledemo.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Thanh nhập liệu bình luận.
*/
@Composable
fun CommentInputBar(
modifier: Modifier = Modifier,
hint: String,
content: String,
onContentChange: (String) -> Unit,
onSendClick: () -> Unit,
onDismiss: () -> Unit
){
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)) // Lớp phủ đen mờ
.clickable { onDismiss() } // Nhấn bên ngoài để đóng
) {
Row(
modifier = modifier
.align(Alignment.BottomCenter)
.clickable(
onClick = { /* Không làm gì khi nhấn vào khu vực nhập liệu */ },
indication = null, // Loại bỏ phản hồi khi nhấn
interactionSource = androidx.compose.foundation.interaction.MutableInteractionSource()
),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = content,
onValueChange = onContentChange,
placeholder = {
Text(text = hint, fontSize = 14.sp)
},
modifier = Modifier.weight(1f),
maxLines = 3,
singleLine = false,
textStyle = TextStyle(fontSize = 14.sp),
shape = TextFieldDefaults.outlinedShape,
minLines = 1,
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onSendClick,
enabled = content.isNotBlank(),
modifier = Modifier.padding(horizontal = 8.dp)
) {
Text(text = "Gửi", fontSize = 14.sp)
}
}
}
}
7. Mục bài đăng (CircleItem)
Đây là composable chính để hiển thị một bài đăng riêng lẻ, bao gồm thông tin người dùng, nội dung, ảnh/video/link web, vị trí, danh sách thích và bình luận.
package com.example.composecircledemo.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import com.example.composecircledemo.R
import com.example.composecircledemo.ui.model.CircleBean
import com.example.composecircledemo.ui.model.CommentListBean
import com.example.composecircledemo.utils.Constants
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import coil.request.ImageRequest
import com.example.composecircledemo.ui.model.LikeListBean
/**
* Hiển thị một mục bài đăng trong danh sách.
*/
@Composable
fun CircleItem(
data: CircleBean.DataBean,
onActionClick: () -> Unit,
onReplyClick: (CommentListBean) -> Unit,
onDeleteClick: () -> Unit,
onVideoClick: (String) -> Unit,
onImageClick: (List<String>, Int) -> Unit,
onCommentLongClick: (CommentListBean) -> Unit
){
val likeList = data.like_list
Column(modifier = Modifier.fillMaxWidth()) {
// 1. Thanh thông tin người dùng (avatar + tên + thời gian + nút xóa)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp, 16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar người dùng
Image(
painter = rememberAsyncImagePainter(data.head_img ?: "https://picsum.photos/200/200?default"),
contentDescription = "Avatar người dùng",
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.clickable { /* Nhấn vào avatar */ },
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
// Tên người dùng + thời gian
Column(modifier = Modifier.weight(1f)) {
Text(
text = data.user_name.toString(),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
)
Text(
text = data.createon.toString(),
style = TextStyle(
fontSize = 12.sp,
color = Color.Gray
),
modifier = Modifier.padding(top = 2.dp)
)
}
// Nút xóa (chỉ hiển thị cho bài đăng của chính mình)
if (data.id == "current_user_id") {
IconButton(onClick = onDeleteClick) {
Icon(
painter = painterResource(id = R.drawable.message_bg_delete), // Cần tự thêm tài nguyên icon
contentDescription = "Xóa",
tint = Color.Gray
)
}
}
}
// 2. Nội dung bài đăng (văn bản có thể mở rộng)
if (!data.content.isNullOrBlank()) {
ExpandableText(
text = data.content,
modifier = Modifier.padding(horizontal = 12.dp),
maxLines = 2
)
}
// 3. Khu vực nội dung bài đăng (hiển thị theo loại)
when (data.type) {
Constants.TYPE_IMAGE -> {
NineGridImages(
images = data.files,
onImageClick = onImageClick,
overallPadding = 14.dp,
itemSpacing = 6.dp
)
}
Constants.TYPE_VIDEO -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
.clickable { data.video.let { onVideoClick(it) } }
) {
// Ảnh bìa video
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data("https://picsum.photos/800/450") // Thay thế bằng ảnh kiểm thử đáng tin cậy
.crossfade(true)
.build()
),
contentDescription = "Ảnh bìa video",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f), // Đặt tỷ lệ khung hình, đảm bảo ảnh hiển thị
contentScale = ContentScale.Crop
)
// Biểu tượng phát video
Icon(
painter = painterResource(id = R.mipmap.ic_video_play),
contentDescription = "Phát video",
modifier = Modifier
.size(48.dp)
.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.3f), CircleShape)
.padding(8.dp),
tint = Color.White
)
}
}
Constants.TYPE_WEB -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color(0xFFF5F5F5), shape = RoundedCornerShape(8.dp))
.clickable { /* Sự kiện nhấn vào web */ },
elevation = CardDefaults.cardElevation(2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp), // Thêm khoảng đệm để nội dung không sát viền
verticalAlignment = Alignment.Top
) {
// Icon web - kích thước cố định
Image(
painter = rememberAsyncImagePainter(data.share_image ?: "https://picsum.photos/80/80?web"),
contentDescription = "Icon web",
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(4.dp)), // Tùy chọn: thêm bo góc
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(12.dp))
// Khu vực tiêu đề web - sử dụng weight để chiếm không gian còn lại
Box(
modifier = Modifier
.weight(1f) // Quan trọng: chiếm toàn bộ không gian còn lại trong Row
.padding(vertical = 4.dp) // Khoảng đệm theo chiều dọc
) {
Text(
text = data.share_title ?: "Chia sẻ web",
style = TextStyle(
fontSize = 14.sp,
color = Color.Black // Đảm bảo màu văn bản tương phản tốt với nền
),
maxLines = 5,
overflow = TextOverflow.Ellipsis, // Hiển thị dấu ba chấm cho phần vượt quá
softWrap = true, // Cho phép xuống dòng tự động
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
// 4. Địa điểm
if (!data.position.isNullOrBlank() && data.position != "Thông tin vị trí này tạm thời không có") {
Text(
text = data.position,
style = TextStyle(
fontSize = 12.sp,
color = Color.Gray
),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
// 5. Khu vực Thích + Bình luận
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp)) {
// Danh sách thích
if (data.like_list.isNotEmpty()) {
Row(modifier = Modifier.padding(bottom = 8.dp)) {
Icon(
painter = painterResource(id = R.mipmap.menu_add), // Cần tự thêm tài nguyên icon
contentDescription = "Thích",
modifier = Modifier.size(16.dp),
tint = if (data.is_like == 1) Color.Red else Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
LazyRow(
modifier = Modifier.fillMaxWidth(), // Tùy chọn: thêm sửa đổi chiều rộng, tránh tràn nội dung
contentPadding = PaddingValues(horizontal = 4.dp) // Tùy chọn: thêm khoảng đệm
) {
// 1. Sử dụng itemsIndexed để lấy trực tiếp chỉ số, tránh vấn đề indexOf
itemsIndexed(likeList) { index, item ->
// item chỉ định rõ kiểu LikeListBean (hoặc thông qua suy luận kiểu)
val likeItem = item as LikeListBean // Nếu suy luận thất bại, ép kiểu rõ ràng (đảm bảo kiểu tập hợp chính xác)
// 2. Sửa lỗi user_name → userName (khớp tên trường LikeListBean)
Text(
text = likeItem.user_name ?: "Người dùng không xác định",
style = TextStyle(
fontSize = 12.sp,
color = if (data.is_like == 1) Color.Red else Color.Gray
),
modifier = Modifier.padding(horizontal = 2.dp)
)
// 3. Sử dụng index để xác định có phải là phần tử cuối cùng không (không cần indexOf)
if (index != likeList.lastIndex) {
Text(
text = "、",
fontSize = 12.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 2.dp)
)
}
}
}
}
}
// Danh sách bình luận
if (data.comments_list.isNotEmpty() == true) {
Column(modifier = Modifier.padding(bottom = 8.dp)) {
data.comments_list.forEach { comment ->
CommentItem(
comment = comment,
onReplyClick = { onReplyClick(comment) },
onLongClick = { onCommentLongClick(comment) }
)
}
}
}
// Nút hành động (Thích + Bình luận)
IconButton(onClick = onActionClick, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Icon Thích + văn bản
Icon(
painter = painterResource(id = if (data.is_like == 1) R.mipmap.live_gift else R.mipmap.live_share),
contentDescription = "Thích",
modifier = Modifier.size(16.dp),
tint = if (data.is_like == 1) Color.Red else Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (data.is_like == 1) "Đã thích" else "Thích",
fontSize = 12.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.weight(1f)) // Chỗ giữ chỗ, đẩy nút bình luận sang phải
// Icon Bình luận + văn bản
Icon(
painter = painterResource(id = R.mipmap.live_comment),
contentDescription = "Bình luận",
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Bình luận(${data.comments_list.size})",
fontSize = 12.sp,
color = Color.Gray
)
}
}
}
// Dòng phân cách
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color.LightGray.copy(alpha = 0.3f))
)
}
}
8. Tiêu đề bài đăng và thông tin người dùng (CircleHeader)
Composables này hiển thị phần đầu của trang, bao gồm ảnh nền, ảnh đại diện người dùng và thông tin về tin nhắn mới.
package com.example.composecircledemo.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
/**
* Hiển thị phần đầu của trang nhật ký người dùng.
*/
@Composable
fun CircleHeader(){
Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.height(120.dp)) {
Image(
painter = rememberAsyncImagePainter("https://picsum.photos/1080/300"),
contentDescription = "Ảnh nền nhật ký",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp),
contentAlignment = Alignment.BottomEnd
) {
Row(
verticalAlignment = Alignment.CenterVertically, // Căn giữa dọc tên người dùng và avatar
modifier = Modifier
.align(Alignment.TopEnd) // Định vị từ trên cùng
.padding(end = 16.dp) // Khoảng cách bên phải
.offset(y = 30.dp)
) {
// Tên người dùng (bên trái avatar)
Text(
text = "Người dùng hiện tại",
color = Color.White,
modifier = Modifier.padding(end = 12.dp). offset(x=10.dp,y = (-8).dp)
)
// Avatar người dùng
Image(
painter = rememberAsyncImagePainter("https://picsum.photos/200/200?user"),
contentDescription = "Avatar người dùng",
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.clickable { /* Sự kiện nhấn vào avatar */ }
.align(Alignment.Bottom),
contentScale = ContentScale.Crop
)
}
}
}
Card(
modifier = Modifier
.width(220.dp)
.height(80.dp)
.padding(12.dp)
.offset(x = 20.dp)
.clickable { /* Sự kiện nhấn vào tin nhắn */ },
elevation = CardDefaults.cardElevation(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberAsyncImagePainter("https://picsum.photos/40/40?msg"),
contentDescription = "Icon tin nhắn",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "10 tin nhắn mới")
}
}
}
}
9. Trang chi tiết phát video (PlayVideoActivity)
Hoạt động này sử dụng ExoPlayer để phát video được cung cấp qua một URL.
package com.example.composecircledemo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.ui.StyledPlayerView
import kotlinx.coroutines.awaitCancellation
/**
* Hoạt động để phát video.
*/
class PlayVideoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val videoUrl = intent.getStringExtra("url") ?: ""
setContent {
VideoPlayerScreen(videoUrl = videoUrl)
}
}
@Composable
fun VideoPlayerScreen(videoUrl: String) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(videoUrl)
// val mediaItem = MediaItem.Builder().setUri(videoUrl).build()
setMediaItem(mediaItem)
prepare() // Chuẩn bị phát
playWhenReady = true // Tự động phát
}
}
AndroidView(
factory = { ctx ->
StyledPlayerView(ctx).apply {
player = exoPlayer
layoutParams = android.view.ViewGroup.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.MATCH_PARENT
)
}
},
modifier = Modifier.fillMaxSize()
)
LaunchedEffect(Unit) {
try {
awaitCancellation()
} finally {
// Giải phóng player khi coroutine bị hủy
exoPlayer.release()
}
}
}
}
10. Gọi trong MainActivity
Trong hoạt động `MainActivity`, `MainScreen` được gọi để hiển thị giao diện chính.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeCircleDemoTheme {
MainScreen()
}
}
}
}
11. Kết quả
Giao diện cuối cùng mô phỏng các chức năng chính của WeChat Moments.
12. Mã nguồn dự án
Bạn có thể tìm thấy mã nguồn đầy đủ của dự án tại đây: Gitee