动态模块新增推荐Tab,UI优化及调整
- 新增推荐Tab,采用垂直滑动样式,展示推荐动态内容。 - 推荐Tab支持预加载周围图片,提升滑动体验,并增加loading和错误状态指示。 - 优化评论弹窗UI,移除自动聚焦,调整背景色和输入框样式。 - 动态Tab样式调整,使用下划线指示当前选中Tab。 - 调整MomentLoaderExtraArgs,增加trend参数用于推荐动态加载。 - 新增字符串资源 `index_recommend`。
This commit is contained in:
@@ -327,7 +327,8 @@ class MomentLoaderExtraArgs(
|
|||||||
val explore: Boolean? = false,
|
val explore: Boolean? = false,
|
||||||
val timelineId: Int? = null,
|
val timelineId: Int? = null,
|
||||||
val authorId : Int? = null,
|
val authorId : Int? = null,
|
||||||
val newsOnly: Boolean? = false
|
val newsOnly: Boolean? = false,
|
||||||
|
val trend: Boolean? = false
|
||||||
)
|
)
|
||||||
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
||||||
override suspend fun fetchData(
|
override suspend fun fetchData(
|
||||||
@@ -341,7 +342,9 @@ class MomentLoader : DataLoader<MomentEntity,MomentLoaderExtraArgs>() {
|
|||||||
explore = if (extra.explore == true) "true" else "",
|
explore = if (extra.explore == true) "true" else "",
|
||||||
timelineId = extra.timelineId,
|
timelineId = extra.timelineId,
|
||||||
authorId = extra.authorId,
|
authorId = extra.authorId,
|
||||||
newsFilter = if (extra.newsOnly == true) "news_only" else ""
|
newsFilter = if (extra.newsOnly == true) "news_only" else "",
|
||||||
|
trend = if (extra.trend == true) "1" else ""
|
||||||
|
|
||||||
)
|
)
|
||||||
val data = result.body()?.let {
|
val data = result.body()?.let {
|
||||||
ListContainer(
|
ListContainer(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.aiosman.ravenow.LocalAppTheme
|
||||||
import com.aiosman.ravenow.R
|
import com.aiosman.ravenow.R
|
||||||
import com.aiosman.ravenow.entity.CommentEntity
|
import com.aiosman.ravenow.entity.CommentEntity
|
||||||
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
|
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
|
||||||
@@ -79,6 +80,7 @@ fun CommentModalContent(
|
|||||||
onCommentAdded: () -> Unit = {},
|
onCommentAdded: () -> Unit = {},
|
||||||
onDismiss: () -> Unit = {}
|
onDismiss: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
val model = viewModel<CommentModalViewModel>(
|
val model = viewModel<CommentModalViewModel>(
|
||||||
key = "CommentModalViewModel_$postId",
|
key = "CommentModalViewModel_$postId",
|
||||||
factory = object : ViewModelProvider.Factory {
|
factory = object : ViewModelProvider.Factory {
|
||||||
@@ -113,7 +115,7 @@ fun CommentModalContent(
|
|||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showCommentMenu = false
|
showCommentMenu = false
|
||||||
},
|
},
|
||||||
containerColor = Color.White,
|
containerColor = AppColors.background,
|
||||||
sheetState = rememberModalBottomSheetState(
|
sheetState = rememberModalBottomSheetState(
|
||||||
skipPartiallyExpanded = true
|
skipPartiallyExpanded = true
|
||||||
),
|
),
|
||||||
@@ -142,24 +144,8 @@ fun CommentModalContent(
|
|||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.background(AppColors.background)
|
||||||
) {
|
) {
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp)
|
|
||||||
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.comment),
|
|
||||||
fontSize = 18.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
color = Color(0xFFF7F7F7)
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -170,7 +156,8 @@ fun CommentModalContent(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.comment_count, commentCount),
|
text = stringResource(id = R.string.comment_count, commentCount),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Color(0xff666666)
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = AppColors.nonActiveText
|
||||||
)
|
)
|
||||||
OrderSelectionComponent {
|
OrderSelectionComponent {
|
||||||
commentViewModel.order = it
|
commentViewModel.order = it
|
||||||
@@ -205,7 +192,7 @@ fun CommentModalContent(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color(0xfff7f7f7))
|
.background(AppColors.background)
|
||||||
) {
|
) {
|
||||||
EditCommentBottomModal(replyComment) {
|
EditCommentBottomModal(replyComment) {
|
||||||
commentViewModel.viewModelScope.launch {
|
commentViewModel.viewModelScope.launch {
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ fun EditCommentBottomModal(
|
|||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
// 移除自动聚焦,避免自动弹出键盘
|
||||||
focusRequester.requestFocus()
|
// LaunchedEffect(Unit) {
|
||||||
}
|
// focusRequester.requestFocus()
|
||||||
|
// }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -69,20 +70,6 @@ fun EditCommentBottomModal(
|
|||||||
.background(AppColors.background)
|
.background(AppColors.background)
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (replyComment == null) "Comment" else "Reply",
|
|
||||||
fontWeight = FontWeight.W600,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
color = AppColors.text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
if (replyComment != null) {
|
if (replyComment != null) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -129,9 +116,9 @@ fun EditCommentBottomModal(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.clip(RoundedCornerShape(20.dp))
|
.clip(RoundedCornerShape(20.dp))
|
||||||
.background(Color.White)
|
.background(AppColors.inputBackground)
|
||||||
.border(1.dp, Color.Black, RoundedCornerShape(20.dp))
|
.border(1.dp, AppColors.text.copy(alpha = 0.2f), RoundedCornerShape(20.dp))
|
||||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
@@ -146,7 +133,7 @@ fun EditCommentBottomModal(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester),
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
color = Color.Black,
|
color = AppColors.text,
|
||||||
fontWeight = FontWeight.Normal
|
fontWeight = FontWeight.Normal
|
||||||
),
|
),
|
||||||
minLines = 1
|
minLines = 1
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -45,17 +47,24 @@ import com.aiosman.ravenow.ui.index.tabs.moment.tabs.dynamic.Dynamic
|
|||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.Explore
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot.HotMomentsList
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.News
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.news.News
|
||||||
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend.Recommend
|
||||||
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
|
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
|
||||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
|
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
|
||||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import com.aiosman.ravenow.ui.composables.TabItem
|
import com.aiosman.ravenow.ui.composables.TabItem
|
||||||
import com.aiosman.ravenow.ui.composables.TabSpacer
|
import com.aiosman.ravenow.ui.composables.TabSpacer
|
||||||
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
import com.aiosman.ravenow.ui.composables.rememberDebouncer
|
||||||
|
|
||||||
|
data class TabData(
|
||||||
|
val text: String,
|
||||||
|
val index: Int
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态列表
|
* 动态列表
|
||||||
*/
|
*/
|
||||||
@@ -67,8 +76,8 @@ fun MomentsList() {
|
|||||||
val navigationBarPaddings =
|
val navigationBarPaddings =
|
||||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
|
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
|
||||||
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
|
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||||
// 游客模式下显示3个tab:Worldwide、Hot、News,非游客模式显示4个tab:Worldwide、Following、Hot、News
|
// 游客模式下显示4个tab:Worldwide、Hot、News、Recommend,非游客模式显示5个tab:Worldwide、Following、Hot、News、Recommend
|
||||||
val tabCount = if (AppStore.isGuest) 3 else 4
|
val tabCount = if (AppStore.isGuest) 4 else 5
|
||||||
var pagerState = rememberPagerState { tabCount }
|
var pagerState = rememberPagerState { tabCount }
|
||||||
var scope = rememberCoroutineScope()
|
var scope = rememberCoroutineScope()
|
||||||
Column(
|
Column(
|
||||||
@@ -144,117 +153,49 @@ fun MomentsList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(23.dp))
|
// Spacer(modifier = Modifier.height(23.dp))
|
||||||
|
|
||||||
Row(
|
val tabDebouncer = rememberDebouncer()
|
||||||
|
|
||||||
|
// 创建tab数据列表
|
||||||
|
val tabs = if (AppStore.isGuest) {
|
||||||
|
listOf(
|
||||||
|
TabData(stringResource(R.string.index_worldwide), 0),
|
||||||
|
TabData(stringResource(R.string.index_hot), 1),
|
||||||
|
TabData(stringResource(R.string.index_news), 2),
|
||||||
|
TabData(stringResource(R.string.index_recommend), 3)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
TabData(stringResource(R.string.index_worldwide), 0),
|
||||||
|
TabData(stringResource(R.string.index_following), 1),
|
||||||
|
TabData(stringResource(R.string.index_hot), 2),
|
||||||
|
TabData(stringResource(R.string.index_news), 3),
|
||||||
|
TabData(stringResource(R.string.index_recommend), 4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.wrapContentHeight()
|
.wrapContentHeight()
|
||||||
.padding(start = 16.dp, bottom = 16.dp),
|
.padding(start = 16.dp, bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
val tabDebouncer = rememberDebouncer()
|
items(tabs) { tab ->
|
||||||
|
UnderlineTabItem(
|
||||||
// 新探索标签
|
text = tab.text,
|
||||||
Box {
|
isSelected = pagerState.currentPage == tab.index,
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_worldwide),
|
|
||||||
isSelected = pagerState.currentPage == 0,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
tabDebouncer {
|
tabDebouncer {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pagerState.animateScrollToPage(0)
|
pagerState.animateScrollToPage(tab.index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TabSpacer()
|
|
||||||
|
|
||||||
// 只有非游客用户才显示"关注"tab
|
|
||||||
if (!AppStore.isGuest) {
|
|
||||||
Box {
|
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_following),
|
|
||||||
isSelected = pagerState.currentPage == 1,
|
|
||||||
onClick = {
|
|
||||||
tabDebouncer {
|
|
||||||
scope.launch {
|
|
||||||
pagerState.animateScrollToPage(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TabSpacer()
|
|
||||||
|
|
||||||
// 热门标签
|
|
||||||
Box {
|
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_hot),
|
|
||||||
isSelected = pagerState.currentPage == 2,
|
|
||||||
onClick = {
|
|
||||||
tabDebouncer {
|
|
||||||
scope.launch {
|
|
||||||
pagerState.animateScrollToPage(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TabSpacer()
|
|
||||||
|
|
||||||
// 新闻标签
|
|
||||||
Box {
|
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_news),
|
|
||||||
isSelected = pagerState.currentPage == 3,
|
|
||||||
onClick = {
|
|
||||||
tabDebouncer {
|
|
||||||
scope.launch {
|
|
||||||
pagerState.animateScrollToPage(3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 热门标签 (游客模式)
|
|
||||||
Box {
|
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_hot),
|
|
||||||
isSelected = pagerState.currentPage == 1,
|
|
||||||
onClick = {
|
|
||||||
tabDebouncer {
|
|
||||||
scope.launch {
|
|
||||||
pagerState.animateScrollToPage(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TabSpacer()
|
|
||||||
|
|
||||||
// 新闻标签 (游客模式)
|
|
||||||
Box {
|
|
||||||
CustomTabItem(
|
|
||||||
text = stringResource(R.string.index_news),
|
|
||||||
isSelected = pagerState.currentPage == 2,
|
|
||||||
onClick = {
|
|
||||||
tabDebouncer {
|
|
||||||
scope.launch {
|
|
||||||
pagerState.animateScrollToPage(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
@@ -264,7 +205,7 @@ fun MomentsList() {
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
if (AppStore.isGuest) {
|
if (AppStore.isGuest) {
|
||||||
// 游客模式:Worldwide(0), Hot(1), News(2)
|
// 游客模式:Worldwide(0), Hot(1), News(2), Recommend(3)
|
||||||
when (it) {
|
when (it) {
|
||||||
0 -> {
|
0 -> {
|
||||||
Dynamic()
|
Dynamic()
|
||||||
@@ -275,9 +216,12 @@ fun MomentsList() {
|
|||||||
2 -> {
|
2 -> {
|
||||||
News()
|
News()
|
||||||
}
|
}
|
||||||
|
3 -> {
|
||||||
|
Recommend()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 正常用户:Worldwide(0), Following(1), Hot(2), News(3)
|
// 正常用户:Worldwide(0), Following(1), Hot(2), News(3), Recommend(4)
|
||||||
when (it) {
|
when (it) {
|
||||||
0 -> {
|
0 -> {
|
||||||
Dynamic()
|
Dynamic()
|
||||||
@@ -291,11 +235,49 @@ fun MomentsList() {
|
|||||||
3 -> {
|
3 -> {
|
||||||
News()
|
News()
|
||||||
}
|
}
|
||||||
|
4 -> {
|
||||||
|
Recommend()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Composable
|
||||||
|
fun UnderlineTabItem(
|
||||||
|
text: String,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.noRippleClickable { onClick() },
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = if (isSelected) 18.sp else 16.sp,
|
||||||
|
color = if (isSelected) AppColors.text else AppColors.nonActiveText,
|
||||||
|
fontWeight = FontWeight.W600
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(34.dp)
|
||||||
|
.height(4.dp)
|
||||||
|
.background(
|
||||||
|
color = if (isSelected) AppColors.text else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(2.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CustomTabItem(
|
fun CustomTabItem(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImagePainter
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.CachePolicy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载图片组件 - 专门用于推荐页面的图片预加载
|
||||||
|
* 支持预加载周围页面的图片,提升滑动体验
|
||||||
|
* 增加了loading状态指示器
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AsyncImage(
|
||||||
|
imageUrl: String,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Crop,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(imageUrl)
|
||||||
|
.crossfade(true)
|
||||||
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
|
.diskCachePolicy(CachePolicy.ENABLED)
|
||||||
|
.build(),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = contentScale,
|
||||||
|
loading = {
|
||||||
|
// Loading 状态
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.8f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error = {
|
||||||
|
// 错误状态
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.6f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
text = "图片加载失败",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.aiosman.ravenow.GuestLoginCheckOut
|
||||||
|
import com.aiosman.ravenow.GuestLoginCheckOutScene
|
||||||
|
import com.aiosman.ravenow.LocalAppTheme
|
||||||
|
import com.aiosman.ravenow.LocalNavController
|
||||||
|
import com.aiosman.ravenow.R
|
||||||
|
import com.aiosman.ravenow.ui.NavigationRoute
|
||||||
|
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||||
|
import com.aiosman.ravenow.entity.MomentEntity
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐动态列表 - 垂直滑动样式
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun Recommend() {
|
||||||
|
val model = RecommendViewModel
|
||||||
|
val moments = model.moments
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
model.refreshPager()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moments.isEmpty()) {
|
||||||
|
// 空状态
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "暂无推荐内容",
|
||||||
|
color = AppColors.text,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 使用垂直滑动的Pager
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
RecommendPager(
|
||||||
|
moments = moments,
|
||||||
|
model = model,
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
|
|
||||||
|
// 加载更多状态指示器
|
||||||
|
if (model.refreshing) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.3f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "加载更多中...",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RecommendPager(
|
||||||
|
moments: List<MomentEntity>,
|
||||||
|
model: RecommendViewModel,
|
||||||
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
val pagerState = remember {
|
||||||
|
RecommendPagerState(
|
||||||
|
currentPage = 0,
|
||||||
|
minPage = 0,
|
||||||
|
maxPage = moments.size - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var showCommentModal by remember { mutableStateOf(false) }
|
||||||
|
var currentMoment by remember { mutableStateOf<MomentEntity?>(null) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val appColor = LocalAppTheme.current
|
||||||
|
|
||||||
|
// 当moments列表变化时,更新pagerState的maxPage
|
||||||
|
LaunchedEffect(moments.size) {
|
||||||
|
pagerState.maxPage = moments.size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
RecommendPager(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = pagerState,
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
offscreenLimit = 1,
|
||||||
|
onLoadMore = {
|
||||||
|
if (!model.refreshing) {
|
||||||
|
model.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val momentItem = moments[page]
|
||||||
|
|
||||||
|
SingleRecommendItemContent(
|
||||||
|
momentEntity = momentItem,
|
||||||
|
pagerState = pagerState,
|
||||||
|
page = page,
|
||||||
|
onCommentClick = {
|
||||||
|
currentMoment = momentItem
|
||||||
|
showCommentModal = true
|
||||||
|
},
|
||||||
|
onLikeClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (momentItem.liked) {
|
||||||
|
model.dislikeMoment(momentItem.id)
|
||||||
|
} else {
|
||||||
|
model.likeMoment(momentItem.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFavoriteClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (momentItem.isFavorite) {
|
||||||
|
model.unfavoriteMoment(momentItem.id)
|
||||||
|
} else {
|
||||||
|
model.favoriteMoment(momentItem.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFollowClick = {
|
||||||
|
model.followAction(momentItem)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCommentModal && currentMoment != null) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { showCommentModal = false },
|
||||||
|
containerColor = appColor.background,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
CommentModalContent(
|
||||||
|
postId = currentMoment!!.id,
|
||||||
|
|
||||||
|
) {
|
||||||
|
// 评论回调
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SingleRecommendItemContent(
|
||||||
|
momentEntity: MomentEntity,
|
||||||
|
pagerState: RecommendPagerState,
|
||||||
|
page: Int,
|
||||||
|
onCommentClick: () -> Unit,
|
||||||
|
onLikeClick: () -> Unit,
|
||||||
|
onFavoriteClick: () -> Unit,
|
||||||
|
onFollowClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(AppColors.background)) {
|
||||||
|
// 主图片内容
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (momentEntity.images.isNotEmpty()) {
|
||||||
|
AsyncImage(
|
||||||
|
imageUrl = momentEntity.images[0].thumbnail,
|
||||||
|
contentDescription = "推荐内容",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 默认背景
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧操作按钮
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.BottomEnd
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(bottom = 72.dp, end = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// 用户头像
|
||||||
|
UserAvatar(momentEntity = momentEntity)
|
||||||
|
|
||||||
|
// 点赞按钮
|
||||||
|
RecommendActionButton(
|
||||||
|
icon = R.drawable.rider_pro_video_like,
|
||||||
|
text = formatCount(momentEntity.likeCount),
|
||||||
|
isActive = momentEntity.liked,
|
||||||
|
onClick = {
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
onLikeClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 评论按钮
|
||||||
|
RecommendActionButton(
|
||||||
|
icon = R.drawable.rider_pro_video_comment,
|
||||||
|
text = formatCount(momentEntity.commentCount),
|
||||||
|
onClick = {
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.COMMENT_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
onCommentClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 收藏按钮
|
||||||
|
RecommendActionButton(
|
||||||
|
icon = R.drawable.rider_pro_video_favor,
|
||||||
|
text = formatCount(momentEntity.favoriteCount),
|
||||||
|
isActive = momentEntity.isFavorite,
|
||||||
|
onClick = {
|
||||||
|
if (GuestLoginCheckOut.needLogin(GuestLoginCheckOutScene.LIKE_MOMENT)) {
|
||||||
|
navController.navigate(NavigationRoute.Login.route)
|
||||||
|
} else {
|
||||||
|
onFavoriteClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 分享按钮
|
||||||
|
RecommendActionButton(
|
||||||
|
icon = R.drawable.rider_pro_video_share,
|
||||||
|
text = "分享",
|
||||||
|
onClick = {
|
||||||
|
// TODO: 实现分享功能
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部信息
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.BottomStart
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
// 用户信息
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "@${momentEntity.nickname}",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.White,
|
||||||
|
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内容描述
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
text = momentEntity.momentTextContent,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = Color.White,
|
||||||
|
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserAvatar(momentEntity: MomentEntity) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp))
|
||||||
|
.clip(RoundedCornerShape(40.dp))
|
||||||
|
) {
|
||||||
|
if (momentEntity.avatar.isNotEmpty()) {
|
||||||
|
AsyncImage(
|
||||||
|
imageUrl = momentEntity.avatar,
|
||||||
|
contentDescription = "用户头像",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.default_avatar),
|
||||||
|
contentDescription = "默认头像",
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecommendActionButton(
|
||||||
|
icon: Int,
|
||||||
|
text: String,
|
||||||
|
isActive: Boolean = false,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val AppColors = LocalAppTheme.current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.clickable { onClick() },
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
painter = painterResource(id = icon),
|
||||||
|
contentDescription = "",
|
||||||
|
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(
|
||||||
|
if (isActive) Color.Red else Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.White,
|
||||||
|
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatCount(count: Int): String {
|
||||||
|
return when {
|
||||||
|
count >= 1000000 -> "${(count / 1000000.0).let { "%.1f".format(it) }}M"
|
||||||
|
count >= 1000 -> "${(count / 1000.0).let { "%.1f".format(it) }}k"
|
||||||
|
else -> count.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.Measurable
|
||||||
|
import androidx.compose.ui.layout.ParentDataModifier
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class RecommendPagerState(
|
||||||
|
currentPage: Int = 0,
|
||||||
|
minPage: Int = 0,
|
||||||
|
maxPage: Int = 0
|
||||||
|
) {
|
||||||
|
private var _minPage by mutableStateOf(minPage)
|
||||||
|
var minPage: Int
|
||||||
|
get() = _minPage
|
||||||
|
set(value) {
|
||||||
|
_minPage = value.coerceAtMost(_maxPage)
|
||||||
|
_currentPage = _currentPage.coerceIn(_minPage, _maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy())
|
||||||
|
var maxPage: Int
|
||||||
|
get() = _maxPage
|
||||||
|
set(value) {
|
||||||
|
_maxPage = value.coerceAtLeast(_minPage)
|
||||||
|
_currentPage = _currentPage.coerceIn(_minPage, maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage))
|
||||||
|
var currentPage: Int
|
||||||
|
get() = _currentPage
|
||||||
|
set(value) {
|
||||||
|
_currentPage = value.coerceIn(minPage, maxPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SelectionState { Selected, Undecided }
|
||||||
|
|
||||||
|
var selectionState by mutableStateOf(SelectionState.Selected)
|
||||||
|
|
||||||
|
suspend inline fun <R> selectPage(block: RecommendPagerState.() -> R): R = try {
|
||||||
|
selectionState = SelectionState.Undecided
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
selectPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectPage() {
|
||||||
|
currentPage -= currentPageOffset.roundToInt()
|
||||||
|
snapToOffset(0f)
|
||||||
|
selectionState = SelectionState.Selected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _currentPageOffset = Animatable(0f).apply {
|
||||||
|
updateBounds(-1f, 1f)
|
||||||
|
}
|
||||||
|
val currentPageOffset: Float
|
||||||
|
get() = _currentPageOffset.value
|
||||||
|
|
||||||
|
suspend fun snapToOffset(offset: Float) {
|
||||||
|
val max = if (currentPage == minPage) 0f else 1f
|
||||||
|
val min = if (currentPage == maxPage) 0f else -1f
|
||||||
|
_currentPageOffset.snapTo(offset.coerceIn(min, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fling(velocity: Float) {
|
||||||
|
if (velocity < 0 && currentPage == maxPage) return
|
||||||
|
if (velocity > 0 && currentPage == minPage) return
|
||||||
|
|
||||||
|
// 根据 fling 的方向滑动到下一页或上一页
|
||||||
|
_currentPageOffset.animateTo(velocity)
|
||||||
|
selectPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "RecommendPagerState{minPage=$minPage, maxPage=$maxPage, " +
|
||||||
|
"currentPage=$currentPage, currentPageOffset=$currentPageOffset}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
private data class PageData(val page: Int) : ParentDataModifier {
|
||||||
|
override fun Density.modifyParentData(parentData: Any?): Any? = this@PageData
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Measurable.page: Int
|
||||||
|
get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecommendPager(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: RecommendPagerState,
|
||||||
|
orientation: Orientation = Orientation.Horizontal,
|
||||||
|
offscreenLimit: Int = 2,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
|
||||||
|
onLoadMore: (() -> Unit)? = null,
|
||||||
|
content: @Composable RecommendPagerScope.() -> Unit
|
||||||
|
) {
|
||||||
|
var pageSize by remember { mutableStateOf(0) }
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 监听当前页面变化,当到达倒数第一个时触发加载更多
|
||||||
|
LaunchedEffect(state.currentPage, state.maxPage) {
|
||||||
|
if (state.currentPage >= state.maxPage - 1 && onLoadMore != null) {
|
||||||
|
onLoadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Layout(
|
||||||
|
content = {
|
||||||
|
// 根据 offscreenLimit 计算页面范围
|
||||||
|
val minPage = maxOf(state.currentPage - offscreenLimit, state.minPage)
|
||||||
|
val maxPage = minOf(state.currentPage + offscreenLimit, state.maxPage)
|
||||||
|
|
||||||
|
for (page in minPage..maxPage) {
|
||||||
|
val pageData = PageData(page)
|
||||||
|
val scope = RecommendPagerScope(state, page)
|
||||||
|
key(pageData) {
|
||||||
|
Column(
|
||||||
|
modifier = pageData
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
scope.content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier.draggable(
|
||||||
|
orientation = orientation,
|
||||||
|
onDragStarted = {
|
||||||
|
state.selectionState = RecommendPagerState.SelectionState.Undecided
|
||||||
|
},
|
||||||
|
onDragStopped = { velocity ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
// 根据速度判断是否滑动到下一页
|
||||||
|
val threshold = 1000f // 速度阈值,可调整
|
||||||
|
if (velocity > threshold) {
|
||||||
|
state.fling(1f) // 向下滑动
|
||||||
|
} else if (velocity < -threshold) {
|
||||||
|
state.fling(-1f) // 向上滑动
|
||||||
|
} else {
|
||||||
|
state.fling(0f) // 保持当前页
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
state = rememberDraggableState { dy ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
with(state) {
|
||||||
|
val pos = pageSize * currentPageOffset
|
||||||
|
val max = if (currentPage == minPage) 0 else pageSize
|
||||||
|
val min = if (currentPage == maxPage) 0 else -pageSize
|
||||||
|
|
||||||
|
// 直接将手指的位移应用到 currentPageOffset
|
||||||
|
val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat())
|
||||||
|
snapToOffset(newPos / pageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
) { measurables, constraints ->
|
||||||
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
val currentPage = state.currentPage
|
||||||
|
val offset = state.currentPageOffset
|
||||||
|
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||||
|
|
||||||
|
measurables.forEach { measurable ->
|
||||||
|
val placeable = measurable.measure(childConstraints)
|
||||||
|
val page = measurable.page
|
||||||
|
|
||||||
|
// 根据对齐参数计算 x 和 y 位置
|
||||||
|
val xPosition = when (horizontalAlignment) {
|
||||||
|
Alignment.Start -> 0
|
||||||
|
Alignment.CenterHorizontally -> (constraints.maxWidth - placeable.width) / 2
|
||||||
|
Alignment.End -> constraints.maxWidth - placeable.width
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val yPosition = when (verticalAlignment) {
|
||||||
|
Alignment.Top -> 0
|
||||||
|
Alignment.CenterVertically -> (constraints.maxHeight - placeable.height) / 2
|
||||||
|
Alignment.Bottom -> constraints.maxHeight - placeable.height
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage == page) { // 只在当前页面设置 pageSize,避免不必要的设置
|
||||||
|
pageSize = if (orientation == Orientation.Horizontal) {
|
||||||
|
placeable.width
|
||||||
|
} else {
|
||||||
|
placeable.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVisible = abs(page - (currentPage - offset)) <= 1
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
// 修正 y 的计算(垂直滑动)
|
||||||
|
val yOffset = if (orientation == Orientation.Vertical) {
|
||||||
|
((page - currentPage) * pageSize + offset * pageSize).roundToInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保内容不会溢出到容器顶部
|
||||||
|
val finalYPosition = (yPosition + yOffset).coerceAtLeast(0)
|
||||||
|
|
||||||
|
// 使用 placeRelative 进行放置
|
||||||
|
placeable.placeRelative(
|
||||||
|
x = xPosition + if (orientation == Orientation.Horizontal) ((page - (currentPage - offset)) * placeable.width).roundToInt() else 0,
|
||||||
|
y = finalYPosition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecommendPagerScope(
|
||||||
|
private val state: RecommendPagerState,
|
||||||
|
val page: Int
|
||||||
|
) {
|
||||||
|
val currentPage: Int
|
||||||
|
get() = state.currentPage
|
||||||
|
|
||||||
|
val currentPageOffset: Float
|
||||||
|
get() = state.currentPageOffset
|
||||||
|
|
||||||
|
val selectionState: RecommendPagerState.SelectionState
|
||||||
|
get() = state.selectionState
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.recommend
|
||||||
|
|
||||||
|
import com.aiosman.ravenow.entity.MomentLoaderExtraArgs
|
||||||
|
import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
|
||||||
|
object RecommendViewModel : BaseMomentModel() {
|
||||||
|
init {
|
||||||
|
EventBus.getDefault().register(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun extraArgs(): MomentLoaderExtraArgs {
|
||||||
|
return MomentLoaderExtraArgs(trend = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,7 @@
|
|||||||
<string name="index_following">Following</string>
|
<string name="index_following">Following</string>
|
||||||
<string name="index_hot">Hot</string>
|
<string name="index_hot">Hot</string>
|
||||||
<string name="index_news">News</string>
|
<string name="index_news">News</string>
|
||||||
|
<string name="index_recommend">Recommend</string>
|
||||||
<string name="main_home">Home</string>
|
<string name="main_home">Home</string>
|
||||||
<string name="main_ai">Agent</string>
|
<string name="main_ai">Agent</string>
|
||||||
<string name="main_message">Message</string>
|
<string name="main_message">Message</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user