图片添加加载效果

- 为AsyncImage添加了Shimmer加载效果
- 优化了热门动态的加载逻辑
- 统一了ViewModel的重置方法名
This commit is contained in:
2025-09-03 14:59:47 +08:00
parent 79547de2db
commit 824be5fad8
7 changed files with 352 additions and 78 deletions

View File

@@ -194,7 +194,7 @@ object AppState {
// 重置动态列表页面
TimelineMomentViewModel.ResetModel()
DynamicViewModel.ResetModel()
HotMomentViewModel.ResetModel()
HotMomentViewModel.resetModel()
// 重置我的页面
MyProfileViewModel.ResetModel()

View File

@@ -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
)
}
}
/*

View File

@@ -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)
)
)
}

View File

@@ -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)
)
)
}

View File

@@ -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<MomentEntity>>(PagingData.empty())
val discoverMomentsFlow = _discoverMomentsFlow.asStateFlow()
var firstLoad = true
fun refreshPager() {
if (!firstLoad) {
return
}
firstLoad = false
private val _discoverMoments = MutableStateFlow<List<MomentEntity>>(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
}
}

View File

@@ -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(

View File

@@ -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()