diff --git a/app/src/main/java/com/aiosman/ravenow/AppState.kt b/app/src/main/java/com/aiosman/ravenow/AppState.kt index 86bb1e5..d9cc851 100644 --- a/app/src/main/java/com/aiosman/ravenow/AppState.kt +++ b/app/src/main/java/com/aiosman/ravenow/AppState.kt @@ -194,7 +194,7 @@ object AppState { // 重置动态列表页面 TimelineMomentViewModel.ResetModel() DynamicViewModel.ResetModel() - HotMomentViewModel.ResetModel() + HotMomentViewModel.resetModel() // 重置我的页面 MyProfileViewModel.ResetModel() diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt index 422a86a..b73d17d 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/Image.kt @@ -4,6 +4,8 @@ import android.content.Context import android.graphics.Bitmap import androidx.annotation.DrawableRes import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -79,7 +81,8 @@ fun CustomAsyncImage( errorRes: Int? = null, @DrawableRes defaultRes: Int? = null, - contentScale: ContentScale = ContentScale.Crop + contentScale: ContentScale = ContentScale.Crop, + showShimmer: Boolean = true ) { val localContext = LocalContext.current @@ -91,50 +94,111 @@ fun CustomAsyncImage( // 优先使用 defaultRes,然后是 placeholderRes val fallbackRes = defaultRes ?: placeholderRes if (fallbackRes != null) { - Image( - painter = androidx.compose.ui.res.painterResource(fallbackRes), - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale - ) + if (showShimmer) { + SimpleShimmer(modifier = modifier) { + Image( + painter = androidx.compose.ui.res.painterResource(fallbackRes), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + } else { + Image( + painter = androidx.compose.ui.res.painterResource(fallbackRes), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale + ) + } return } } // 处理 Bitmap 类型 if (imageUrl is Bitmap) { - Image( - bitmap = imageUrl.asImageBitmap(), - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale - ) + if (showShimmer) { + SimpleShimmer(modifier = modifier) { + Image( + bitmap = imageUrl.asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale + ) + } + } else { + Image( + bitmap = imageUrl.asImageBitmap(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale + ) + } return } // 处理字符串URL - AsyncImage( - model = ImageRequest.Builder(context ?: localContext) - .data(imageUrl) - .crossfade(200) - .memoryCachePolicy(coil.request.CachePolicy.ENABLED) - .diskCachePolicy(coil.request.CachePolicy.ENABLED) - .apply { - // 设置占位符图片 - if (placeholderRes != null) { - placeholder(placeholderRes) - } - // 设置错误时显示的图片 - if (errorRes != null) { - error(errorRes) + if (showShimmer) { + var isLoading by remember { mutableStateOf(true) } + + Box(modifier = modifier) { + AsyncImage( + model = ImageRequest.Builder(context ?: localContext) + .data(imageUrl) + .crossfade(200) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .apply { + // 设置占位符图片 + if (placeholderRes != null) { + placeholder(placeholderRes) + } + // 设置错误时显示的图片 + if (errorRes != null) { + error(errorRes) + } + } + .build(), + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + imageLoader = imageLoader, + onLoading = { isLoading = true }, + onSuccess = { isLoading = false }, + onError = { isLoading = false } + ) + + // 只在加载时显示shimmer + if (isLoading) { + SimpleShimmer(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) } } - .build(), - contentDescription = contentDescription, - modifier = modifier, - contentScale = contentScale, - imageLoader = imageLoader - ) + } + } else { + AsyncImage( + model = ImageRequest.Builder(context ?: localContext) + .data(imageUrl) + .crossfade(200) + .memoryCachePolicy(coil.request.CachePolicy.ENABLED) + .diskCachePolicy(coil.request.CachePolicy.ENABLED) + .apply { + // 设置占位符图片 + if (placeholderRes != null) { + placeholder(placeholderRes) + } + // 设置错误时显示的图片 + if (errorRes != null) { + error(errorRes) + } + } + .build(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + imageLoader = imageLoader + ) + } } /* diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt new file mode 100644 index 0000000..c2a9a2a --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/ShimmerEffect.kt @@ -0,0 +1,74 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 从左到右扫描的Shimmer加载效果 + */ +@Composable +fun ShimmerEffect( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val infiniteTransition = rememberInfiniteTransition() + val translateAnim by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1200, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim, y = translateAnim) + ) + + Box(modifier = modifier) { + content() + Box( + modifier = Modifier + .fillMaxSize() + .background(brush) + ) + } +} + +/** + * 带圆角的Shimmer占位符 + */ +@Composable +fun ShimmerPlaceholder( + modifier: Modifier = Modifier, + cornerRadius: Float = 8f +) { + Box( + modifier = modifier + .background( + color = Color.LightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(cornerRadius.dp) + ) + ) +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt b/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt new file mode 100644 index 0000000..29adade --- /dev/null +++ b/app/src/main/java/com/aiosman/ravenow/ui/composables/SimpleShimmer.kt @@ -0,0 +1,74 @@ +package com.aiosman.ravenow.ui.composables + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * 简单的从左到右扫描的Shimmer加载效果 + */ +@Composable +fun SimpleShimmer( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val infiniteTransition = rememberInfiniteTransition() + val translateAnim by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1200, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Restart + ) + ) + + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f), + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim, y = translateAnim) + ) + + Box(modifier = modifier) { + content() + Box( + modifier = Modifier + .fillMaxSize() + .background(brush) + ) + } +} + +/** + * 带圆角的Shimmer占位符 + */ +@Composable +fun SimpleShimmerPlaceholder( + modifier: Modifier = Modifier, + cornerRadius: Float = 8f +) { + Box( + modifier = modifier + .background( + color = Color.LightGray.copy(alpha = 0.3f), + shape = RoundedCornerShape(cornerRadius.dp) + ) + ) +} diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt index 9c35613..3004958 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/HotMomentViewModel.kt @@ -2,51 +2,86 @@ package com.aiosman.ravenow.ui.index.tabs.moment.tabs.hot import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.cachedIn -import com.aiosman.ravenow.AppState import com.aiosman.ravenow.data.MomentService import com.aiosman.ravenow.entity.MomentEntity -import com.aiosman.ravenow.entity.MomentLoaderExtraArgs -import com.aiosman.ravenow.entity.MomentPagingSource -import com.aiosman.ravenow.entity.MomentRemoteDataSource import com.aiosman.ravenow.entity.MomentServiceImpl -import com.aiosman.ravenow.ui.index.tabs.moment.BaseMomentModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import org.greenrobot.eventbus.EventBus object HotMomentViewModel : ViewModel() { private val momentService: MomentService = MomentServiceImpl() - private val _discoverMomentsFlow = - MutableStateFlow>(PagingData.empty()) - val discoverMomentsFlow = _discoverMomentsFlow.asStateFlow() - var firstLoad = true - fun refreshPager() { - if (!firstLoad) { - return - } - firstLoad = false + private val _discoverMoments = MutableStateFlow>(emptyList()) + val discoverMoments = _discoverMoments.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() + + private var currentPage = 1 + private var hasMoreData = true + private val pageSize = 20 + + fun loadMoments() { + if (_isLoading.value || !hasMoreData) return + viewModelScope.launch { - Pager( - config = PagingConfig(pageSize = 5, enablePlaceholders = false), - pagingSourceFactory = { - MomentPagingSource( - MomentRemoteDataSource(momentService), - trend = true - ) + _isLoading.value = true + try { + val response = momentService.getMoments( + pageNumber = currentPage, + trend = true + ) + + if (response.list.isNotEmpty()) { + if (currentPage == 1) { + _discoverMoments.value = response.list + } else { + _discoverMoments.value = _discoverMoments.value + response.list + } + currentPage++ + hasMoreData = response.list.size >= response.pageSize + } else { + hasMoreData = false } - ).flow.cachedIn(viewModelScope).collectLatest { - _discoverMomentsFlow.value = it + } catch (e: Exception) { + // 处理错误 + e.printStackTrace() + } finally { + _isLoading.value = false } } } - fun ResetModel(){ - firstLoad = true + + fun refreshMoments() { + viewModelScope.launch { + _isRefreshing.value = true + currentPage = 1 + hasMoreData = true + try { + val response = momentService.getMoments( + pageNumber = 1, + trend = true + ) + _discoverMoments.value = response.list + currentPage = 2 + hasMoreData = response.list.size >= response.pageSize + } catch (e: Exception) { + e.printStackTrace() + } finally { + _isRefreshing.value = false + } + } + } + + fun resetModel() { + currentPage = 1 + hasMoreData = true + _discoverMoments.value = emptyList() + _isLoading.value = false + _isRefreshing.value = false } } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt index 01118fb..9636404 100644 --- a/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt +++ b/app/src/main/java/com/aiosman/ravenow/ui/index/tabs/moment/tabs/hot/Moment.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi @@ -29,13 +31,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.collectLatest import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.paging.compose.collectAsLazyPagingItems +import androidx.compose.runtime.collectAsState import com.aiosman.ravenow.LocalAppTheme import com.aiosman.ravenow.LocalNavController import com.aiosman.ravenow.R @@ -57,12 +61,14 @@ fun HotMomentsList() { val navController = LocalNavController.current val navigationBarPaddings = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp + val isRefreshing by model.isRefreshing.collectAsState() + LaunchedEffect(Unit) { - model.refreshPager() + model.loadMoments() } - var refreshing by remember { mutableStateOf(false) } - val state = rememberPullRefreshState(refreshing, onRefresh = { - model.refreshPager() + + val state = rememberPullRefreshState(isRefreshing, onRefresh = { + model.refreshMoments() }) Column( @@ -84,7 +90,7 @@ fun HotMomentsList() { .padding(2.dp) ) { DiscoverView() - PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + PullRefreshIndicator(isRefreshing, state, Modifier.align(Alignment.TopCenter)) } } } @@ -93,17 +99,37 @@ fun HotMomentsList() { @Composable fun DiscoverView() { val model = HotMomentViewModel - var dataFlow = model.discoverMomentsFlow - var moments = dataFlow.collectAsLazyPagingItems() + val moments by model.discoverMoments.collectAsState() + val isLoading by model.isLoading.collectAsState() val context = LocalContext.current val navController = LocalNavController.current + val gridState = rememberLazyGridState() + + // 监听滚动到底部,自动加载更多 + LaunchedEffect(gridState, moments.size) { + snapshotFlow { + val layoutInfo = gridState.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (visibleItemsInfo.isNotEmpty() && moments.isNotEmpty()) { + val lastVisibleItemIndex = visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisibleItemIndex >= moments.size - 6 // 距离底部还有6个项目时开始加载 + } else { + false + } + }.collectLatest { shouldLoadMore -> + if (shouldLoadMore && !isLoading) { + model.loadMoments() + } + } + } + LazyVerticalGrid( columns = GridCells.Fixed(3), + state = gridState, modifier = Modifier.fillMaxSize().padding(bottom = 8.dp), // contentPadding = PaddingValues(8.dp) ) { - items(moments.itemCount) { idx -> - val momentItem = moments[idx] ?: return@items + items(moments) { momentItem -> Box( modifier = Modifier .fillMaxWidth() @@ -122,7 +148,8 @@ fun DiscoverView() { contentDescription = "", modifier = Modifier .fillMaxSize(), - context = context + context = context, + showShimmer = true ) if (momentItem.images.size > 1) { Box( diff --git a/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt b/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt index 7c6d7fa..5fb61c3 100644 --- a/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt +++ b/app/src/main/java/com/aiosman/ravenow/utils/ResourceCleanupManager.kt @@ -80,7 +80,7 @@ object ResourceCleanupManager { // 重置动态相关ViewModel TimelineMomentViewModel.ResetModel() DynamicViewModel.ResetModel() - HotMomentViewModel.ResetModel() + HotMomentViewModel.resetModel() // 重置个人资料相关ViewModel MyProfileViewModel.ResetModel() @@ -233,7 +233,7 @@ object ResourceCleanupManager { "moment" -> { TimelineMomentViewModel.ResetModel() DynamicViewModel.ResetModel() - HotMomentViewModel.ResetModel() + HotMomentViewModel.resetModel() } "profile" -> { MyProfileViewModel.ResetModel()