1 /*
<lambda>null2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16 package com.android.intentresolver.contentpreview.payloadtoggle.ui.composable
17
18 import androidx.compose.animation.Crossfade
19 import androidx.compose.foundation.ExperimentalFoundationApi
20 import androidx.compose.foundation.Image
21 import androidx.compose.foundation.background
22 import androidx.compose.foundation.border
23 import androidx.compose.foundation.layout.Arrangement
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.foundation.layout.Column
26 import androidx.compose.foundation.layout.PaddingValues
27 import androidx.compose.foundation.layout.Spacer
28 import androidx.compose.foundation.layout.aspectRatio
29 import androidx.compose.foundation.layout.fillMaxHeight
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.layout.fillMaxWidth
32 import androidx.compose.foundation.layout.height
33 import androidx.compose.foundation.layout.padding
34 import androidx.compose.foundation.layout.size
35 import androidx.compose.foundation.layout.width
36 import androidx.compose.foundation.lazy.LazyListState
37 import androidx.compose.foundation.lazy.LazyRow
38 import androidx.compose.foundation.lazy.itemsIndexed
39 import androidx.compose.foundation.selection.toggleable
40 import androidx.compose.foundation.shape.RoundedCornerShape
41 import androidx.compose.foundation.systemGestureExclusion
42 import androidx.compose.material3.AssistChip
43 import androidx.compose.material3.AssistChipDefaults
44 import androidx.compose.material3.LocalContentColor
45 import androidx.compose.material3.MaterialTheme
46 import androidx.compose.material3.Text
47 import androidx.compose.runtime.Composable
48 import androidx.compose.runtime.LaunchedEffect
49 import androidx.compose.runtime.derivedStateOf
50 import androidx.compose.runtime.getValue
51 import androidx.compose.runtime.mutableStateOf
52 import androidx.compose.runtime.remember
53 import androidx.compose.runtime.rememberCoroutineScope
54 import androidx.compose.runtime.setValue
55 import androidx.compose.ui.Modifier
56 import androidx.compose.ui.draw.clip
57 import androidx.compose.ui.graphics.ColorFilter
58 import androidx.compose.ui.graphics.asImageBitmap
59 import androidx.compose.ui.layout.ContentScale
60 import androidx.compose.ui.layout.MeasureScope
61 import androidx.compose.ui.layout.Placeable
62 import androidx.compose.ui.layout.layout
63 import androidx.compose.ui.res.dimensionResource
64 import androidx.compose.ui.res.stringResource
65 import androidx.compose.ui.semantics.contentDescription
66 import androidx.compose.ui.semantics.semantics
67 import androidx.compose.ui.unit.Dp
68 import androidx.compose.ui.unit.dp
69 import androidx.lifecycle.compose.collectAsStateWithLifecycle
70 import com.android.intentresolver.Flags.shareouselScrollOffscreenSelections
71 import com.android.intentresolver.Flags.unselectFinalItem
72 import com.android.intentresolver.R
73 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.ValueUpdate
74 import com.android.intentresolver.contentpreview.payloadtoggle.domain.model.getOrDefault
75 import com.android.intentresolver.contentpreview.payloadtoggle.shared.ContentType
76 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewModel
77 import com.android.intentresolver.contentpreview.payloadtoggle.shared.model.PreviewsModel
78 import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselPreviewViewModel
79 import com.android.intentresolver.contentpreview.payloadtoggle.ui.viewmodel.ShareouselViewModel
80 import kotlin.math.abs
81 import kotlin.math.min
82 import kotlin.math.roundToInt
83 import kotlinx.coroutines.flow.MutableStateFlow
84 import kotlinx.coroutines.launch
85
86 @Composable
87 fun Shareousel(viewModel: ShareouselViewModel) {
88 val keySet = viewModel.previews.collectAsStateWithLifecycle(null).value
89 if (keySet != null) {
90 Shareousel(viewModel, keySet)
91 } else {
92 Spacer(
93 Modifier.height(dimensionResource(R.dimen.chooser_preview_image_height_tall) + 64.dp)
94 .background(MaterialTheme.colorScheme.surfaceContainer)
95 )
96 }
97 }
98
99 @Composable
Shareouselnull100 private fun Shareousel(viewModel: ShareouselViewModel, keySet: PreviewsModel) {
101 Column(
102 modifier =
103 Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
104 .padding(vertical = 16.dp)
105 ) {
106 PreviewCarousel(keySet, viewModel)
107 ActionCarousel(viewModel)
108 }
109 }
110
111 @OptIn(ExperimentalFoundationApi::class)
112 @Composable
PreviewCarouselnull113 private fun PreviewCarousel(previews: PreviewsModel, viewModel: ShareouselViewModel) {
114 var measurements by remember { mutableStateOf(PreviewCarouselMeasurements.UNMEASURED) }
115 Box(
116 modifier =
117 Modifier.fillMaxWidth()
118 .height(dimensionResource(R.dimen.chooser_preview_image_height_tall))
119 .layout { measurable, constraints ->
120 val placeable = measurable.measure(constraints)
121 measurements =
122 if (placeable.height <= 0) {
123 PreviewCarouselMeasurements.UNMEASURED
124 } else {
125 PreviewCarouselMeasurements(placeable, measureScope = this)
126 }
127 layout(placeable.width, placeable.height) { placeable.place(0, 0) }
128 }
129 ) {
130 // Do not compose the list until we have measured values
131 if (measurements == PreviewCarouselMeasurements.UNMEASURED) return@Box
132
133 val prefetchStrategy = remember { ShareouselLazyListPrefetchStrategy() }
134 val carouselState = remember {
135 LazyListState(
136 prefetchStrategy = prefetchStrategy,
137 firstVisibleItemIndex = previews.startIdx,
138 firstVisibleItemScrollOffset =
139 measurements.scrollOffsetToCenter(
140 previewModel = previews.previewModels[previews.startIdx]
141 ),
142 )
143 }
144
145 LazyRow(
146 state = carouselState,
147 horizontalArrangement = Arrangement.spacedBy(4.dp),
148 contentPadding =
149 PaddingValues(
150 start = measurements.horizontalPaddingDp,
151 end = measurements.horizontalPaddingDp,
152 ),
153 modifier = Modifier.fillMaxSize().systemGestureExclusion(),
154 ) {
155 itemsIndexed(
156 items = previews.previewModels,
157 key = { _, model -> model.key.key to model.key.isFinal },
158 ) { index, model ->
159 val visibleItem by remember {
160 derivedStateOf {
161 carouselState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
162 }
163 }
164
165 // Index if this is the element in the center of the viewing area, otherwise null
166 val previewIndex by remember {
167 derivedStateOf {
168 visibleItem?.let {
169 val halfPreviewWidth = it.size / 2
170 val previewCenter = it.offset + halfPreviewWidth
171 val previewDistanceToViewportCenter =
172 abs(previewCenter - measurements.viewportCenterPx)
173 if (previewDistanceToViewportCenter <= halfPreviewWidth) {
174 index
175 } else {
176 null
177 }
178 }
179 }
180 }
181
182 val previewModel =
183 viewModel.preview(
184 /* key = */ model,
185 /* previewHeight = */ measurements.viewportHeightPx,
186 /* index = */ previewIndex,
187 /* scope = */ rememberCoroutineScope(),
188 )
189
190 if (shareouselScrollOffscreenSelections()) {
191 LaunchedEffect(index, model.uri) {
192 var current: Boolean? = null
193 previewModel.isSelected.collect { selected ->
194 when {
195 // First update will always be the current state, so we just want to
196 // record the state and do nothing else.
197 current == null -> current = selected
198
199 // We only want to act when the state changes
200 current != selected -> {
201 current = selected
202 with(carouselState.layoutInfo) {
203 visibleItemsInfo
204 .firstOrNull { it.index == index }
205 ?.let { item ->
206 when {
207 // Item is partially past start of viewport
208 item.offset < viewportStartOffset ->
209 measurements.scrollOffsetToStartEdge()
210 // Item is partially past end of viewport
211 (item.offset + item.size) > viewportEndOffset ->
212 measurements.scrollOffsetToEndEdge(model)
213 // Item is fully within viewport
214 else -> null
215 }?.let { scrollOffset ->
216 carouselState.animateScrollToItem(
217 index = index,
218 scrollOffset = scrollOffset,
219 )
220 }
221 }
222 }
223 }
224 }
225 }
226 }
227 }
228
229 ShareouselCard(
230 viewModel = previewModel,
231 aspectRatio = measurements.coerceAspectRatio(previewModel.aspectRatio),
232 )
233 }
234 }
235 }
236 }
237
238 @Composable
ShareouselCardnull239 private fun ShareouselCard(viewModel: ShareouselPreviewViewModel, aspectRatio: Float) {
240 val bitmapLoadState by viewModel.bitmapLoadState.collectAsStateWithLifecycle()
241 val selected by viewModel.isSelected.collectAsStateWithLifecycle(initialValue = false)
242 val borderColor = MaterialTheme.colorScheme.primary
243 val scope = rememberCoroutineScope()
244 val contentDescription =
245 when (viewModel.contentType) {
246 ContentType.Image -> stringResource(R.string.selectable_image)
247 ContentType.Video -> stringResource(R.string.selectable_video)
248 else -> stringResource(R.string.selectable_item)
249 }
250 Crossfade(
251 targetState = bitmapLoadState,
252 modifier =
253 Modifier.semantics { this.contentDescription = contentDescription }
254 .clip(RoundedCornerShape(size = 12.dp))
255 .toggleable(
256 value = selected,
257 onValueChange = { scope.launch { viewModel.setSelected(it) } },
258 ),
259 ) { state ->
260 if (state is ValueUpdate.Value) {
261 state.getOrDefault(null).let { bitmap ->
262 ShareouselCard(
263 image = {
264 bitmap?.let {
265 Image(
266 bitmap = bitmap.asImageBitmap(),
267 contentDescription = null,
268 contentScale = ContentScale.Crop,
269 modifier = Modifier.aspectRatio(aspectRatio),
270 )
271 } ?: PlaceholderBox(aspectRatio)
272 },
273 contentType = viewModel.contentType,
274 selected = selected,
275 modifier =
276 Modifier.thenIf(selected) {
277 Modifier.border(
278 width = 4.dp,
279 color = borderColor,
280 shape = RoundedCornerShape(size = 12.dp),
281 )
282 },
283 )
284 }
285 } else {
286 PlaceholderBox(aspectRatio)
287 }
288 }
289 }
290
291 @Composable
PlaceholderBoxnull292 private fun PlaceholderBox(aspectRatio: Float) {
293 Box(
294 modifier =
295 Modifier.fillMaxHeight()
296 .aspectRatio(aspectRatio)
297 .background(color = MaterialTheme.colorScheme.surfaceContainerHigh)
298 )
299 }
300
301 @Composable
ActionCarouselnull302 private fun ActionCarousel(viewModel: ShareouselViewModel) {
303 val actions by viewModel.actions.collectAsStateWithLifecycle(initialValue = emptyList())
304 if (actions.isNotEmpty()) {
305 Spacer(Modifier.height(16.dp))
306 val visibilityFlow =
307 if (unselectFinalItem()) {
308 viewModel.hasSelectedItems
309 } else {
310 MutableStateFlow(true)
311 }
312 val visibility by visibilityFlow.collectAsStateWithLifecycle(true)
313 val height = 32.dp
314 if (visibility) {
315 LazyRow(
316 horizontalArrangement = Arrangement.spacedBy(4.dp),
317 modifier = Modifier.height(height),
318 ) {
319 itemsIndexed(actions) { idx, actionViewModel ->
320 if (idx == 0) {
321 Spacer(
322 Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
323 )
324 }
325 ShareouselAction(
326 label = actionViewModel.label,
327 onClick = { actionViewModel.onClicked() },
328 ) {
329 actionViewModel.icon?.let {
330 Image(
331 icon = it,
332 modifier = Modifier.size(16.dp),
333 colorFilter = ColorFilter.tint(LocalContentColor.current),
334 )
335 }
336 }
337 if (idx == actions.size - 1) {
338 Spacer(
339 Modifier.width(dimensionResource(R.dimen.chooser_edge_margin_normal))
340 )
341 }
342 }
343 }
344 } else {
345 Spacer(modifier = Modifier.height(height))
346 }
347 }
348 }
349
350 @Composable
ShareouselActionnull351 private fun ShareouselAction(
352 label: String,
353 onClick: () -> Unit,
354 modifier: Modifier = Modifier,
355 leadingIcon: (@Composable () -> Unit)? = null,
356 ) {
357 AssistChip(
358 onClick = onClick,
359 label = { Text(label) },
360 leadingIcon = leadingIcon,
361 border = null,
362 shape = RoundedCornerShape(1000.dp), // pill shape.
363 colors =
364 AssistChipDefaults.assistChipColors(
365 containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
366 labelColor = MaterialTheme.colorScheme.onSurface,
367 leadingIconContentColor = MaterialTheme.colorScheme.onSurface,
368 ),
369 modifier = modifier,
370 )
371 }
372
thenIfnull373 inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier =
374 if (condition) this.then(factory()) else this
375
376 private data class PreviewCarouselMeasurements(
377 val viewportHeightPx: Int,
378 val viewportWidthPx: Int,
379 val viewportCenterPx: Int = viewportWidthPx / 2,
380 val maxAspectRatio: Float,
381 val horizontalPaddingPx: Int,
382 val horizontalPaddingDp: Dp,
383 ) {
384 constructor(
385 placeable: Placeable,
386 measureScope: MeasureScope,
387 horizontalPadding: Float = (placeable.width - (MIN_ASPECT_RATIO * placeable.height)) / 2,
388 ) : this(
389 viewportHeightPx = placeable.height,
390 viewportWidthPx = placeable.width,
391 maxAspectRatio =
392 with(measureScope) {
393 min(
394 (placeable.width - 32.dp.roundToPx()).toFloat() / placeable.height,
395 MAX_ASPECT_RATIO,
396 )
397 },
398 horizontalPaddingPx = horizontalPadding.roundToInt(),
399 horizontalPaddingDp = with(measureScope) { horizontalPadding.toDp() },
400 )
401
402 fun coerceAspectRatio(ratio: Float): Float = ratio.coerceIn(MIN_ASPECT_RATIO, maxAspectRatio)
403
404 fun scrollOffsetToCenter(previewModel: PreviewModel): Int =
405 horizontalPaddingPx + (aspectRatioToWidthPx(previewModel.aspectRatio) / 2) -
406 viewportCenterPx
407
408 fun scrollOffsetToStartEdge(): Int = horizontalPaddingPx
409
410 fun scrollOffsetToEndEdge(previewModel: PreviewModel): Int =
411 horizontalPaddingPx + aspectRatioToWidthPx(previewModel.aspectRatio) - viewportWidthPx
412
413 private fun aspectRatioToWidthPx(ratio: Float): Int =
414 (coerceAspectRatio(ratio) * viewportHeightPx).roundToInt()
415
416 companion object {
417 private const val MIN_ASPECT_RATIO = 0.4f
418 private const val MAX_ASPECT_RATIO = 2.5f
419
420 val UNMEASURED =
421 PreviewCarouselMeasurements(
422 viewportHeightPx = 0,
423 viewportWidthPx = 0,
424 maxAspectRatio = 0f,
425 horizontalPaddingPx = 0,
426 horizontalPaddingDp = 0.dp,
427 )
428 }
429 }
430