From 02c6c6bd7d820a45b3d13104a41ef19673881a71 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Wed, 5 Jun 2024 19:54:53 +0000 Subject: Fade in shareousel items as they load Switched to a StateFlow with the intiial cached value when it exists so that we don't fade in when we immediately have the bitmap. Bug: 344961968 Test: atest ShareoulselViewModelTest Test: Manual test with ShareTest and slow fade times. Flag: android.service.chooser.chooser_payload_toggling Change-Id: I14c9c82343e7e8a9330695121eebe17c65f1dcb9 --- .../CachingImagePreviewImageLoader.kt | 5 ++ .../intentresolver/contentpreview/ImageLoader.kt | 3 + .../ui/composable/ShareouselCardComposable.kt | 33 ++++----- .../ui/composable/ShareouselComposable.kt | 85 ++++++++++++---------- .../ui/viewmodel/ShareouselPreviewViewModel.kt | 3 +- .../ui/viewmodel/ShareouselViewModel.kt | 20 +++-- 6 files changed, 85 insertions(+), 64 deletions(-) (limited to 'java/src') diff --git a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt index ce064cdf..2e2aa938 100644 --- a/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/CachingImagePreviewImageLoader.kt @@ -28,6 +28,7 @@ import javax.inject.Qualifier import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -104,6 +105,10 @@ constructor( // [CancellationExceptions]s so that they don't cancel the calling coroutine/scope. runCatching { cache[uri].await() }.getOrNull() + @OptIn(ExperimentalCoroutinesApi::class) + override fun getCachedBitmap(uri: Uri): Bitmap? = + kotlin.runCatching { cache[uri].getCompleted() }.getOrNull() + companion object { private const val TAG = "CachingImgPrevLoader" } diff --git a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt index 629651a3..81913a8e 100644 --- a/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt +++ b/java/src/com/android/intentresolver/contentpreview/ImageLoader.kt @@ -35,6 +35,9 @@ interface ImageLoader : suspend (Uri) -> Bitmap?, suspend (Uri, Boolean) -> Bitm /** Prepopulate the image loader cache. */ fun prePopulate(uris: List) + /** Returns a bitmap for the given URI if it's already cached, otherwise null */ + fun getCachedBitmap(uri: Uri): Bitmap? = null + /** Load preview image; caching is allowed. */ override suspend fun invoke(uri: Uri) = invoke(uri, true) diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt index 71d16da9..197d6858 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselCardComposable.kt @@ -40,28 +40,25 @@ fun ShareouselCard( image: @Composable () -> Unit, contentType: ContentType, selected: Boolean, - loadComplete: Boolean, modifier: Modifier = Modifier, ) { Box(modifier) { image() - if (loadComplete) { - val topButtonPadding = 12.dp - Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { - SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) - when (contentType) { - ContentType.Video -> - TypeIcon( - R.drawable.ic_play_circle_filled_24px, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Other -> - TypeIcon( - R.drawable.chooser_file_generic, - modifier = Modifier.align(Alignment.TopEnd) - ) - ContentType.Image -> Unit // No additional icon needed. - } + val topButtonPadding = 12.dp + Box(modifier = Modifier.padding(topButtonPadding).matchParentSize()) { + SelectionIcon(selected, modifier = Modifier.align(Alignment.TopStart)) + when (contentType) { + ContentType.Video -> + TypeIcon( + R.drawable.ic_play_circle_filled_24px, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Other -> + TypeIcon( + R.drawable.chooser_file_generic, + modifier = Modifier.align(Alignment.TopEnd) + ) + ContentType.Image -> Unit // No additional icon needed. } } } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt index a40a9c50..c63055d2 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/composable/ShareouselComposable.kt @@ -15,6 +15,7 @@ */ package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -126,15 +127,14 @@ private fun PreviewCarousel( } } - ShareouselCard(viewModel.preview(model, previewIndex)) + ShareouselCard(viewModel.preview(model, previewIndex, rememberCoroutineScope())) } } } @Composable private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { - val bitmapLoadState by - viewModel.bitmapLoadState.collectAsStateWithLifecycle(initialValue = ValueUpdate.Absent) + val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle() val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false) val borderColor = MaterialTheme.colorScheme.primary val scope = rememberCoroutineScope() @@ -144,47 +144,56 @@ private fun ShareouselCard(viewModel: ShareouselPreviewViewModel) { ContentType.Video -> stringResource(R.string.selectable_video) else -> stringResource(R.string.selectable_item) } - // Image load is complete (but may have failed) - val loadComplete = bitmapLoadState is ValueUpdate.Value - ShareouselCard( - image = { - // TODO: max ratio is actually equal to the viewport ratio - val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) - bitmapLoadState.getOrDefault(null)?.let { bitmap -> - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.aspectRatio(aspectRatio), - ) - } - ?: run { - // TODO: look at ScrollableImagePreviewView.setLoading() - Box( - modifier = - Modifier.fillMaxHeight() - .aspectRatio(aspectRatio) - .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) - ) - } - }, - contentType = viewModel.contentType, - loadComplete = loadComplete, - selected = selected, + Crossfade( + targetState = bitmapLoadState, modifier = - Modifier.thenIf(selected && loadComplete) { - Modifier.border( - width = 4.dp, - color = borderColor, - shape = RoundedCornerShape(size = 12.dp), - ) - } - .semantics { this.contentDescription = contentDescription } + Modifier.semantics { this.contentDescription = contentDescription } .clip(RoundedCornerShape(size = 12.dp)) .toggleable( value = selected, onValueChange = { scope.launch { viewModel.setSelected(it) } }, ) + ) { state -> + // TODO: max ratio is actually equal to the viewport ratio + val aspectRatio = viewModel.aspectRatio.coerceIn(MIN_ASPECT_RATIO, MAX_ASPECT_RATIO) + if (state is ValueUpdate.Value) { + state.getOrDefault(null).let { bitmap -> + ShareouselCard( + image = { + bitmap?.let { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.aspectRatio(aspectRatio), + ) + } ?: PlaceholderBox(aspectRatio) + }, + contentType = viewModel.contentType, + selected = selected, + modifier = + Modifier.thenIf(selected) { + Modifier.border( + width = 4.dp, + color = borderColor, + shape = RoundedCornerShape(size = 12.dp), + ) + } + ) + } + } else { + PlaceholderBox(aspectRatio) + } + } +} + +@Composable +private fun PlaceholderBox(aspectRatio: Float) { + Box( + modifier = + Modifier.fillMaxHeight() + .aspectRatio(aspectRatio) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh) ) } diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt index 1acdcf7a..de435290 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselPreviewViewModel.kt @@ -20,11 +20,12 @@ import android.graphics.Bitmap import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow /** An individual preview within Shareousel. */ data class ShareouselPreviewViewModel( /** Image to be shared. */ - val bitmapLoadState: Flow>, + val bitmapLoadState: StateFlow>, /** Type of data to be shared. */ val contentType: ContentType, /** Whether this preview has been selected by the user. */ diff --git a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt index 19acb318..d0b89860 100644 --- a/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt +++ b/java/src/com/android/intentresolver/contentpreview/payloadtoggle/ui/viewmodel/ShareouselViewModel.kt @@ -56,7 +56,8 @@ data class ShareouselViewModel( /** List of action chips presented underneath Shareousel. */ val actions: Flow>, /** Creates a [ShareouselPreviewViewModel] for a [PreviewModel] present in [previews]. */ - val preview: (key: PreviewModel, index: Int?) -> ShareouselPreviewViewModel, + val preview: + (key: PreviewModel, index: Int?, scope: CoroutineScope) -> ShareouselPreviewViewModel, ) @Module @@ -113,7 +114,7 @@ interface ShareouselViewModelModule { } } }, - preview = { key, index -> + preview = { key, index, previewScope -> keySet.value?.maybeLoad(index) val previewInteractor = interactor.preview(key) val contentType = @@ -122,14 +123,19 @@ interface ShareouselViewModelModule { mimeTypeClassifier.isVideoType(key.mimeType) -> ContentType.Video else -> ContentType.Other } + val initialBitmapValue = + key.previewUri?.let { + imageLoader.getCachedBitmap(it)?.let { ValueUpdate.Value(it) } + } ?: ValueUpdate.Absent ShareouselPreviewViewModel( bitmapLoadState = flow { - emit( - key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } - ?: ValueUpdate.Absent - ) - }, + emit( + key.previewUri?.let { ValueUpdate.Value(imageLoader(it)) } + ?: ValueUpdate.Absent + ) + } + .stateIn(previewScope, SharingStarted.Eagerly, initialBitmapValue), contentType = contentType, isSelected = previewInteractor.isSelected, setSelected = previewInteractor::setSelected, -- cgit v1.2.3-59-g8ed1b