You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用CameraX+Compose实现Telegram式相机打开过渡效果?

实现Telegram同款相机全屏过渡效果(Compose版本)

核心思路是将Camera容器从LazyGrid的item中剥离,放到父布局层级,通过尺寸、位置动画实现从网格项到全屏的过渡,同时避免Camera实例因Composable重组而销毁重建。

具体实现步骤

1. 独立的Camera容器Composable

这个容器负责承载TextureView和相机逻辑,通过动画控制自身的尺寸、位置和圆角,不会随LazyGrid的item销毁而重建:

@Composable
fun CameraContainer(
    isFullscreen: Boolean,
    gridItemScreenBounds: Rect,
    onTextureViewReady: (TextureView) -> Unit
) {
    val density = LocalDensity.current
    // 计算目标尺寸:全屏时用屏幕大小,非全屏时用网格项的尺寸
    val targetSize = if (isFullscreen) {
        val config = LocalConfiguration.current
        with(density) {
            Size(config.screenWidthDp.dp.toPx(), config.screenHeightDp.dp.toPx())
        }
    } else {
        Size(gridItemScreenBounds.width(), gridItemScreenBounds.height())
    }
    // 计算目标偏移:全屏时归位到左上角,非全屏时回到网格项的屏幕坐标
    val targetOffset = if (isFullscreen) {
        Offset(0f, 0f)
    } else {
        Offset(gridItemScreenBounds.left, gridItemScreenBounds.top)
    }

    // 尺寸和偏移的动画
    val animatedSize by animateSizeAsState(targetSize, animationSpec = tween(300, easing = FastOutSlowInEasing))
    val animatedOffset by animateOffsetAsState(targetOffset, animationSpec = tween(300, easing = FastOutSlowInEasing))
    val animatedCornerRadius by animateDpAsState(if (isFullscreen) 0.dp else 12.dp, animationSpec = tween(300))

    Box(
        modifier = Modifier
            .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
            .size(
                width = with(density) { animatedSize.width.toDp() },
                height = with(density) { animatedSize.height.toDp() }
            )
            .clip(RoundedCornerShape(animatedCornerRadius))
            .zIndex(1f) // 确保在LazyGrid上层
    ) {
        AndroidView(
            factory = { context ->
                TextureView(context).apply {
                    onTextureViewReady(this)
                }
            },
            modifier = Modifier.fillMaxSize()
        )
    }
}

2. 主布局整合LazyGrid与Camera容器

在主布局中,LazyGrid的相机项仅作为占位入口,点击后触发Camera容器的全屏动画:

@Composable
fun CameraGridScreen() {
    var isCameraFullscreen by remember { mutableStateOf(false) }
    var cameraGridItemBounds by remember { mutableStateOf(Rect.Zero) }
    var cameraTextureView by remember { mutableStateOf<TextureView?>(null) }
    var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
    val lifecycleOwner = LocalLifecycleOwner.current

    // 管理相机生命周期:仅当容器需要显示时初始化,关闭后释放
    LaunchedEffect(cameraTextureView, isCameraFullscreen) {
        cameraTextureView?.let { textureView ->
            val shouldInit = isCameraFullscreen || cameraGridItemBounds != Rect.Zero
            if (shouldInit) {
                val provider = ProcessCameraProvider.getInstance(textureView.context).get()
                cameraProvider = provider
                val preview = Preview.Builder().build().also {
                    it.setSurfaceProvider(textureView.surfaceProvider)
                }
                val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
                try {
                    provider.unbindAll()
                    provider.bindToLifecycle(lifecycleOwner, cameraSelector, preview)
                } catch (e: Exception) {
                    // 处理相机绑定异常
                }
            } else {
                cameraProvider?.unbindAll()
                cameraProvider = null
            }
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // 图片网格
        LazyVerticalGrid(columns = GridCells.Fixed(3)) {
            items(9) { index ->
                if (index == 4) { // 中间项作为相机入口
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(4.dp)
                            .onGloballyPositioned { coords ->
                                // 获取网格项在屏幕中的边界
                                cameraGridItemBounds = coords.boundsInWindow()
                            }
                            .clickable { isCameraFullscreen = true }
                    ) {
                        // 相机占位图标
                        Icon(
                            Icons.Filled.Camera,
                            contentDescription = "打开相机",
                            modifier = Modifier.align(Alignment.Center),
                            tint = Color.Gray
                        )
                    }
                } else {
                    // 其他网格项示例
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(4.dp)
                            .background(Color.LightGray)
                    )
                }
            }
        }

        // 相机容器:仅当需要显示时渲染
        if (isCameraFullscreen || cameraGridItemBounds != Rect.Zero) {
            CameraContainer(
                isFullscreen = isCameraFullscreen,
                gridItemScreenBounds = cameraGridItemBounds,
                onTextureViewReady = { cameraTextureView = it }
            )
        }

        // 全屏状态下的关闭按钮
        if (isCameraFullscreen) {
            IconButton(
                onClick = { isCameraFullscreen = false },
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .padding(16.dp)
                    .background(Color.Black.copy(alpha = 0.5f), CircleShape)
            ) {
                Icon(Icons.Filled.Close, contentDescription = "关闭相机", tint = Color.White)
            }
        }
    }
}

关键细节说明

  • 避免Camera重建:Camera容器脱离LazyGrid的item层级,不会因网格滚动或重组被销毁,确保Camera实例在过渡过程中持续存在。
  • 动画平滑过渡:通过animateSizeAsState、animateOffsetAsState实现尺寸和位置的连续动画,匹配Telegram的过渡曲线。
  • 生命周期管理:利用LaunchedEffect监听容器显示状态,仅在需要时初始化相机,关闭后及时释放资源。
  • 权限处理:在实际项目中,需确保在初始化相机前已获取CAMERA权限,可配合rememberLauncherForActivityResult实现权限申请逻辑。

内容的提问来源于stack exchange,提问作者Armen Sarkisyan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 10:58:16