androidkotlinandroid-jetpack-composeandroid-jetpack-compose-material3

Animate image in LazyVerticalGrid in bottom sheet to full screen image preview


I have a dialogue that has a button to open bottom sheet in that bottom sheet I have an image picker that first item of it is camera preview I want to make this preview full screen when I tap on it. this is my bottom sheet:

@OptIn(ExperimentalMaterial3Api::class)
@Composable

fun BottomSheetImagePicker(onDismiss: () -> Unit) {
val context = LocalContext.current
val imageUris = getAllImageUris(context)
val selected = remember { mutableStateOf(false) }

ModalBottomSheet(onDismiss) {

    LazyVerticalGrid(
        columns = GridCells.Fixed(3), // 3 columns
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
        modifier = Modifier.fillMaxSize()
    ) {
        item(span = { GridItemSpan(2) }) {
            CameraPreviewScreen(selected = selected.value) {
                selected.value = true
            }


        }
        item() {
            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                ImageItem(imageUris[0])
                ImageItem(imageUris[1])
            }
        }
        val otherImages = imageUris.drop(2)
        items(otherImages) { uri ->
            ImageItem(uri)


        }
    }
}

}

I tried to make CameraPreviewScreen bigger with changing its size and scale but it put camera under other items like this:

screen shot of my app simulator

now my question is how I can bring it on top of other things in first layer?

I want to make an image picker like telegram there


Solution

  • This is not that hard to implement with SharedElementTransition api except when you use ModalBottomSheet because it's a Dialog, and creates a new window layout and draws above anything on screen. If you try to have shared element transition it crashes with exception with

    E FATAL EXCEPTION: main java.lang.IllegalArgumentException: layouts are not part of the same hierarchy

    There are 2 ways to implement this with shared transitions, one without and another with ModalBottomSheet to create another Dialog and do shared element transitions in it with a workaround by getting bounds and resource of clicked Image and doing transition in new Dialog.

    Telegram also doesn't use a Dialog but a custom scrollable that does snap in specific positions and change TopAppbar.

    Using non-Dialog drawer component(Not ModalBottomSheet)

    enter image description here

    You can substitute custom Composable if you don't want to implement it yourself with BottomSheetScaffold, my initial choice was BottomDrawer but it crashes inside SharedTransitionLayout, when it's fixed you can use it instead of BottomSheetScaffold.

    @Preview
    @Composable
    private fun SharedElementsample() {
    
        SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
            val navController = rememberNavController()
            NavHost(
                navController = navController,
                startDestination = "home",
            ) {
                composable("home") {
                    BottomSheetImagePicker(
                        sharedTransitionScope = this@SharedTransitionLayout,
                        animatedContentScope = this@composable,
                        onClick = {
                            navController.navigate("details/$it")
                        },
                        onDismiss = {}
                    )
                }
    
                composable(
                    "details/{item}",
                    arguments = listOf(navArgument("item") { type = NavType.IntType })
                ) { backStackEntry ->
                    val item = backStackEntry.arguments?.getInt("item") ?: 0
                    Column(
                        modifier = Modifier.fillMaxSize().background(Color.Black),
                        verticalArrangement = Arrangement.Center
                    ) {
                        Image(
                            painter = painterResource(item),
                            modifier = Modifier
                                .sharedElement(
                                    state = rememberSharedContentState(key = item),
                                    animatedVisibilityScope = this@composable,
                                )
                                .fillMaxWidth(),
                            contentScale = ContentScale.Crop,
                            alignment = Alignment.CenterStart,
                            contentDescription = null
                        )
                    }
                }
            }
        }
    }
    
    @Composable
    private fun BottomSheetImagePicker(
        sharedTransitionScope: SharedTransitionScope,
        animatedContentScope: AnimatedContentScope,
        onDismiss: () -> Unit,
        onClick: (Int) -> Unit,
    ) {
        val imageUris = remember {
            listOf(
                R.drawable.landscape1,
                R.drawable.landscape2,
                R.drawable.landscape3,
                R.drawable.landscape4,
                R.drawable.landscape5,
                R.drawable.landscape6,
                R.drawable.landscape7,
                R.drawable.landscape8,
                R.drawable.landscape9,
                R.drawable.landscape10
            )
        }
    
        val scaffoldState = rememberBottomSheetScaffoldState(
            bottomSheetState = rememberStandardBottomSheetState(
                initialValue = SheetValue.Hidden,
                skipHiddenState = false
            )
        )
    
        BottomSheetScaffold(
            modifier = Modifier.fillMaxSize(),
            scaffoldState = scaffoldState,
            sheetPeekHeight = 400.dp,
            content = {
    
                val bottomState = scaffoldState.bottomSheetState
                val coroutineScope = rememberCoroutineScope()
    
                Column(
                    modifier = Modifier.fillMaxSize().padding(16.dp)
                ) {
    
                    Spacer(Modifier.weight(1f))
    
                    Button(
                        modifier = Modifier.fillMaxWidth(),
                        onClick = {
                            coroutineScope.launch {
                                bottomState.partialExpand()
                            }
                        }
                    ) {
                        Text("Expand")
                    }
                }
                if (bottomState.isVisible) {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .background(Color.Black.copy(alpha = .3f))
                    )
    
                }
            },
            sheetContent = {
                LazyVerticalGrid(
                    columns = GridCells.Fixed(3), // 3 columns
                    horizontalArrangement = Arrangement.spacedBy(8.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp),
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(horizontal = 16.dp)
                ) {
                    item(span = { GridItemSpan(2) }) {
                        Box(
                            modifier = Modifier
                                .size(200.dp)
                                .background(Color.Red, RoundedCornerShape(16.dp))
                        )
                    }
    
                    item {
    
                        Column(
                            verticalArrangement = Arrangement
                                .spacedBy(8.dp)
                        ) {
                            ImageItem(
                                sharedTransitionScope,
                                animatedContentScope,
                                imageUris[0],
                                onClick
                            )
                            ImageItem(
                                sharedTransitionScope,
                                animatedContentScope,
                                imageUris[1],
                                onClick
                            )
                        }
                    }
    
                    val otherImages = imageUris.drop(2)
                    items(otherImages) { uri ->
                        ImageItem(
                            sharedTransitionScope,
                            animatedContentScope,
                            uri,
                            onClick
                        )
                    }
                }
            }
        )
    }
    
    @Composable
    private fun ImageItem(
        sharedTransitionScope: SharedTransitionScope,
        animatedContentScope: AnimatedContentScope,
        @DrawableRes uri: Int,
        onClick: (Int) -> Unit,
    ) {
        with(sharedTransitionScope) {
            Image(
                modifier = Modifier.sharedElement(
                    state = rememberSharedContentState(key = uri),
                    animatedVisibilityScope = animatedContentScope,
                    boundsTransform = gridBoundsTransform
                ).clickable {
                    onClick(uri)
                },
                painter = painterResource(uri),
                contentScale = ContentScale.Crop,
                contentDescription = null
            )
        }
    }
    
    val gridBoundsTransform = BoundsTransform { initialBounds, targetBounds ->
        keyframes {
            durationMillis = 500
            initialBounds at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
            targetBounds at 500
        }
    }
    

    Using another Dialog to draw above ModalBottomSheet

    To way to implement this with ModalBottomSheet is to use another Dialog to draw above ModalBottomSheet.

    Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile, especially when offering a long list of action items, or when items require longer descriptions and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other app functionality when they appear, and remaining on screen until confirmed, dismissed, or a required action has been taken.

    With this approach you need to handle position and size of image as Rect you get from onGloballyPositioned and back press as well as in answer below.

    private sealed class AnimationScreen {
        data object List : AnimationScreen()
        data class Details(val item: Int, val rect: Rect) : AnimationScreen()
    }
    
    @Preview
    @Composable
    fun SharedElementsample2() {
    
        BottomSheetImagePicker()
    }
    
    @Composable
    fun BottomSheetImagePicker() {
    
        var state by remember {
            mutableStateOf<AnimationScreen>(AnimationScreen.List)
        }
    
        var openBottomSheet by rememberSaveable { mutableStateOf(false) }
        val bottomSheetState = rememberModalBottomSheetState()
    
        // App Content
        Column(
            modifier = Modifier.fillMaxSize().padding(16.dp)
        ) {
    
            Spacer(Modifier.weight(1f))
    
            Button(
                modifier = Modifier.fillMaxWidth(),
                onClick = {
                    openBottomSheet = openBottomSheet.not()
                }
            ) {
                Text("Expand")
            }
        }
    
        if (openBottomSheet) {
            ModalBottomSheet(
                sheetState = bottomSheetState,
                modifier = Modifier.fillMaxSize().systemBarsPadding(),
                onDismissRequest = {
                    openBottomSheet = false
                }
            ) {
                BottomSheetPickerContent { uri, rect ->
                    state = AnimationScreen.Details(uri, rect)
                }
            }
        }
    
        if (state is AnimationScreen.Details) {
            BasicAlertDialog(
                modifier = Modifier.fillMaxSize().background(Color.Black),
                onDismissRequest = {
                    state = AnimationScreen.List
                },
                properties = DialogProperties(usePlatformDefaultWidth = false)
            ) {
    
                var visible by remember {
                    mutableStateOf(false)
                }
    
                var expanded by remember {
                    mutableStateOf(false)
                }
    
                val dispatcher = LocalOnBackPressedDispatcherOwner.current
    
                BackHandler(visible) {
                    visible = false
                }
    
                SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    
                    LaunchedEffect(Unit) {
                        awaitFrame()
                        visible = true
                    }
    
                    AnimatedContent(
                        modifier = Modifier.fillMaxSize(),
                        targetState = visible,
                        label = "",
                    ) { visibleState ->
    
                        if (expanded.not()) {
                            expanded = visibleState && isTransitionActive.not()
                        }
    
                        val item = (state as? AnimationScreen.Details)?.item ?: R.drawable.landscape2
                        val rect = (state as? AnimationScreen.Details)?.rect ?: Rect.Zero
    
                        if (visibleState) {
                            Column(
                                modifier = Modifier.fillMaxSize(),
                                verticalArrangement = Arrangement.Center
                            ) {
                                Image(
                                    painter = painterResource(item),
                                    modifier = Modifier
                                        .sharedElement(
                                            state = rememberSharedContentState(key = item),
                                            animatedVisibilityScope = this@AnimatedContent,
                                            boundsTransform = gridBoundsTransform
                                        )
                                        .fillMaxWidth(),
                                    contentScale = ContentScale.Crop,
                                    contentDescription = null
                                )
                            }
                        } else {
    
                            LaunchedEffect(expanded, isTransitionActive) {
                                if (expanded && isTransitionActive.not()) {
                                    dispatcher?.onBackPressedDispatcher?.onBackPressed()
                                }
                            }
    
                            Box(modifier = Modifier.fillMaxSize()) {
                                val density = LocalDensity.current
                                val width: Dp
                                val height: Dp
    
                                with(density) {
                                    width = rect.width.toDp()
                                    height = rect.height.toDp()
                                }
                                Image(
                                    painter = painterResource(item),
                                    modifier = Modifier
                                        .offset {
                                            rect.topLeft.round()
                                        }
                                        .size(width, height)
                                        .sharedElement(
                                            state = rememberSharedContentState(key = item),
                                            animatedVisibilityScope = this@AnimatedContent,
                                            boundsTransform = gridBoundsTransform
                                        ),
                                    contentScale = ContentScale.Crop,
                                    contentDescription = null
                                )
                            }
                        }
                    }
                }
    
            }
        }
    
    }
    
    @Composable
    private fun BottomSheetPickerContent(
        onClick: (Int, Rect) -> Unit,
    ) {
    
        val imageUris = remember {
            listOf(
                R.drawable.landscape1,
                R.drawable.landscape2,
                R.drawable.landscape3,
                R.drawable.landscape4,
                R.drawable.landscape5,
                R.drawable.landscape6,
                R.drawable.landscape7,
                R.drawable.landscape8,
                R.drawable.landscape9,
                R.drawable.landscape10
            )
        }
    
        val imageMap = remember {
            mutableStateMapOf<Int, Rect>()
        }
    
        val density = LocalDensity.current
        val statusBarHeight = WindowInsets.statusBars.getTop(density)
    
        LazyVerticalGrid(
            columns = GridCells.Fixed(3), // 3 columns
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(horizontal = 16.dp)
        ) {
            item(span = { GridItemSpan(2) }) {
                Box(
                    modifier = Modifier
                        .size(200.dp)
                        .background(Color.Red, RoundedCornerShape(16.dp))
                )
            }
    
            item {
                Column(
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    ImageItem(
                        modifier = Modifier.onGloballyPositioned {
                            imageMap[imageUris[0]] = Rect(
                                offset = Offset(
                                    x = it.positionOnScreen().x,
                                    y = it.positionOnScreen().y - statusBarHeight
                                ),
                                size = it.size.toSize()
                            )
                        },
                        uri = imageUris[0],
                        onClick = {
                            onClick(imageUris[0], imageMap[imageUris[0]] ?: Rect.Zero)
                        }
                    )
                    ImageItem(
                        modifier = Modifier.onGloballyPositioned {
                            imageMap[imageUris[1]] = Rect(
                                offset = Offset(
                                    x = it.positionOnScreen().x,
                                    y = it.positionOnScreen().y - statusBarHeight
                                ),
                                size = it.size.toSize()
                            )
                        },
                        uri = imageUris[1],
                        onClick = {
                            onClick(imageUris[1], imageMap[imageUris[1]] ?: Rect.Zero)
    
                        }
                    )
                }
            }
    
            val otherImages = imageUris.drop(2)
            items(otherImages) { uri ->
                ImageItem(
                    modifier = Modifier.onGloballyPositioned {
                        imageMap[uri] = Rect(
                            offset = Offset(
                                x = it.positionOnScreen().x,
                                y = it.positionOnScreen().y - statusBarHeight
                            ),
                            size = it.size.toSize()
                        )
                    },
                    uri = uri,
                    onClick = {
                        onClick(uri, imageMap[uri] ?: Rect.Zero)
    
                    }
                )
            }
        }
    }
    
    @Composable
    private fun ImageItem(
        modifier: Modifier = Modifier,
        @DrawableRes uri: Int,
        onClick: (Int) -> Unit,
    ) {
    
        Image(
            modifier = modifier
                .clickable {
                    onClick(uri)
                },
            painter = painterResource(uri),
            contentScale = ContentScale.Crop,
            contentDescription = null
        )
    }