Android Compose 应用进阶:Navigation 组件、共享元素与预测性返回的深度实践

Android Compose 应用进阶:Navigation 组件、共享元素与预测性返回的深度实践

Development of Shared element, Navigation, Predictive back component using Compose on Android

简述:

  1. 打开软件预测性返回选项后,使用Navigation组件组合页面间的跳转 即可实现页面的预测性返回
  2. 使用Navigation组件组合的页面,设置共享元素 即可实现共享元素间的预测性返回。

支持预测性返回手势

查看官方教程即可:
https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture

Navigation导航 结合 Shared element共享元素

查看官方教程学习:
https://developer.android.com/develop/ui/compose/navigation?hl=zh-cn

导航主干-伪代码:

// 1. 定义导航路由
sealed class PhotoScreenRouteForColumn(val route: String) {
    object Grid : PhotoScreenRouteForColumn("photo_grid") // 网格视图
    // 详情路由需要两个参数
    object Detail : PhotoScreenRouteForColumn("photo_detail/{outerIndex}/{innerIndex}") {
        fun createRoute(outerIndex: Int, innerIndex: Int) = "photo_detail/$outerIndex/$innerIndex"
    }
}
// 2.主屏幕 Composable
@Composable
fun RespondCollectScreenSimplified(
    collectId: Long,
    appNavController: NavController,
    // ... (其他状态初始化和数据获取逻辑)
) {
    // 创建内部导航控制器
    val photoNavController = rememberNavController()
    // 定义导航宿主
    NavHost(
        navController = photoNavController,
        startDestination = PhotoScreenRouteForColumn.Grid.route
    ) {
        // 路由 1: 网格视图
        composable(PhotoScreenRouteForColumn.Grid.route) {
            // 模拟点击照片后导航到详情
            val onPhotoClick = { outerIndex: Int, innerIndex: Int ->
                photoNavController.navigate(
                    PhotoScreenRouteForColumn.Detail.createRoute(outerIndex, innerIndex)
                )
            }
            // 假设这里会显示一个照片列表,点击后触发 onPhotoClick
            println("显示照片网格视图")
        }

        // 路由 2: 详情视图
        composable(
            route = PhotoScreenRouteForColumn.Detail.route,
            arguments = listOf(
                navArgument("outerIndex") { type = NavType.IntType },
                navArgument("innerIndex") { type = NavType.IntType }
            )
        ) { navBackStackEntry ->
           // 获取导航参数
            val outerIndexArg = navBackStackEntry.arguments?.getInt("outerIndex")
            val innerIndexArg = navBackStackEntry.arguments?.getInt("innerIndex")

            // 假设这里会根据 outerIndexArg 和 innerIndexArg 显示照片详情
            println("显示照片详情,outerIndex: $outerIndexArg, innerIndex: $innerIndexArg")

            // 返回操作
            val onBackClick = {
                photoNavController.popBackStack()
            }
            // 假设有一个按钮点击后 调用 onBackClick,即可实现返回
        }
    }
}

提示🌟
Navigation的路由之间的跳转是在路由包含的组件(composable(){xx}包含的内容)的跳转。也就是说如果某些compose组件 在路由外,跳转后这些组件还会继续显示。比如说:

  • 将composable{}包含Scaffold脚手架,跳转后会展示新的完整的页面,
  • 但是 将composable{}放在Scaffold脚手架的content里面,跳转后Scaffold的topBar等元素还是会在页面显示。

Shared element共享元素学习:

查看官方教程学习:
https://developer.android.com/develop/ui/compose/animation/shared-elements
主干 -kt 伪代码

//设置实验性标识
@file:OptIn(ExperimentalSharedTransitionApi::class)


// --- 统一动画定义(可有可无) ---
@OptIn(ExperimentalSharedTransitionApi::class)
val photoBoundsTransform = androidx.compose.animation.BoundsTransform { _, _ ->
    spring(stiffness = Spring.StiffnessMedium)
}

// --- Key 辅助函数 ---
private fun photoContainerKey(index: Int) = "photo_container_$index"

/**
 * 图片网格查看器 (共享元素页面1).
 */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun PhotoGridViewer(
    photos: List<File>,
    onPhotoClick: (Int) -> Unit,
    sharedScope: SharedTransitionScope, // 用于协调共享元素动画的 Scope
    animScope: AnimatedVisibilityScope // 来自 NavHost composable lambda 的 Scope
) {
    LazyVerticalGrid(
        columns = GridCells.Adaptive(minSize = 128.dp)
    ) {
        itemsIndexed(photos) { index, photo ->
            with(sharedScope) { // 在 SharedTransitionScope 中执行共享元素相关的操作
                Box(
                    modifier = Modifier
                        .aspectRatio(1f)
                        .sharedBounds( // 标记此 Box 的边界为共享的
                            rememberSharedContentState(key = photoContainerKey(index)), // 为共享内容创建一个稳定的 State,使用基于索引的 Key
                            animatedVisibilityScope = animScope, // 将此共享元素与导航动画的可见性 Scope 关联
                            boundsTransform = photoBoundsTransform // 应用预定义的边界变换动画
                        )
                        .clickable { onPhotoClick(index) }
                ) {
                    Box(
                        modifier = Modifier
                            .fillMaxSize()
                            .sharedBounds( // 标记此 Box 的边界为共享的 (代表图片元素)
                                rememberSharedContentState(key = "photo_element_${photo.path}"), // 为共享内容创建一个稳定的 State,使用图片路径作为 Key
                                animatedVisibilityScope = animScope, // 将此共享元素与导航动画的可见性 Scope 关联
                                boundsTransform = photoBoundsTransform // 应用预定义的边界变换动画
                            )
                    )
                }
            }
        }
    }
}

/**
 * 全屏图片查看器 (共享元素页面2).
 */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun FullScreenPhotoViewer(
    photos: List<File>,
    initialPhotoIndex: Int,
    onClose: () -> Unit,
    sharedScope: SharedTransitionScope, // 用于协调共享元素动画的 Scope
    animScope: AnimatedVisibilityScope // 来自 NavHost composable lambda 的 Scope
) {
    val pagerState = rememberPagerState(initialPage = initialPhotoIndex) { photos.size }

    with(sharedScope) { // 在 SharedTransitionScope 中执行共享元素相关的操作
        HorizontalPager(state = pagerState) { pageIndex ->
            val photo = photos[pageIndex]
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .sharedBounds( // 标记此 Box 的边界为共享的
                        rememberSharedContentState(key = photoContainerKey(pageIndex)), // 为共享内容创建一个稳定的 State,使用基于页面索引的 Key
                        animatedVisibilityScope = animScope, // 将此共享元素与导航动画的可见性 Scope 关联
                        boundsTransform = photoBoundsTransform // 应用预定义的边界变换动画
                    )
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .sharedBounds( // 标记此 Box 的边界为共享的 (代表图片元素)
                            rememberSharedContentState(key = "photo_element_${photo.path}"), // 为共享内容创建一个稳定的 State,使用图片路径作为 Key
                            animatedVisibilityScope = animScope, // 将此共享元素与导航动画的可见性 Scope 关联
                            boundsTransform = photoBoundsTransform // 应用预定义的边界变换动画
                        )
                )
            }
        }
    }
}

结合实战:

@file:OptIn(ExperimentalSharedTransitionApi::class)

import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.sharedBounds // 用于定义共享元素的边界动画
import androidx.compose.animation.rememberSharedContentState // 用于在导航之间保持共享元素的状态
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost // 用于定义导航图
import androidx.navigation.compose.composable // 用于定义导航图中的单个路由
import androidx.navigation.compose.rememberNavController // 用于创建和持有 NavController 实例

// 定义导航屏幕的路由
sealed class Screen(val route: String) {
    object Grid : Screen("grid") // 网格屏幕路由
    object Detail : Screen("detail/{itemId}") { // 详情屏幕路由,带有一个 itemId 参数
        fun createRoute(itemId: Int) = "detail/$itemId" // 用于创建带参数的详情路由
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun NavigationWithSharedElements() {
    val navController = rememberNavController() // 创建 NavController 实例,用于管理应用内的导航

    SharedTransitionScope { sharedScope -> // 包裹 NavHost 的 Composable,启用共享元素动画
        NavHost(navController = navController, startDestination = Screen.Grid.route) { // 定义导航图,设置起始屏幕为 Grid
            composable(Screen.Grid.route) { entry -> // 定义 Grid 屏幕的路由
                val animScope = this // 获取 AnimatedVisibilityScope,用于共享元素动画
                GridScreen(navController = navController, sharedScope = sharedScope, animScope = animScope)
            }
            composable(Screen.Detail.route) { entry -> // 定义 Detail 屏幕的路由,并接收参数
                val animScope = this // 获取 AnimatedVisibilityScope
                val itemId = entry.arguments?.getString("itemId")?.toIntOrNull() ?: -1 // 从导航参数中获取 itemId
                DetailScreen(itemId = itemId, navController = navController, sharedScope = sharedScope, animScope = animScope)
            }
        }
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun GridScreen(navController: NavHostController, sharedScope: SharedTransitionScope, animScope: AnimatedVisibilityScope) {
    // 模拟一些数据
    val items = remember { (1..5).toList() }

    androidx.compose.foundation.layout.Column {
        items.forEach { itemId ->
            Box(
                modifier = Modifier
                    .padding(16.dp)
                    .background(Color.LightGray)
                    .clickable {
                        navController.navigate(Screen.Detail.createRoute(itemId)) // 点击后导航到详情屏幕,并传递 itemId
                    }
            ) {
                with(sharedScope) { // 在 SharedTransitionScope 中定义共享元素
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .sharedBounds( // 标记此 Box 为共享元素,使用唯一的 key
                                rememberSharedContentState(key = "item_container_$itemId"),
                                animatedVisibilityScope = animScope // 将共享元素与导航动画的可见性关联
                            )
                            .background(Color.Gray)
                    ) {
                        androidx.compose.material3.Text(text = "Item $itemId", modifier = Modifier.padding(8.dp))
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DetailScreen(itemId: Int, navController: NavHostController, sharedScope: SharedTransitionScope, animScope: AnimatedVisibilityScope) {
    androidx.compose.foundation.layout.Column {
        androidx.compose.material3.Button(onClick = { navController.popBackStack() }) { // 点击按钮返回上一个屏幕
            androidx.compose.material3.Text("Go Back")
        }
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp)
                .background(Color.DarkGray)
        ) {
            with(sharedScope) { // 在 SharedTransitionScope 中定义共享元素
                Box(
                    modifier = Modifier
                        .size(100.dp)
                        .sharedBounds( // 标记此 Box 为共享元素,使用与 GridScreen 中相同的 key
                            rememberSharedContentState(key = "item_container_$itemId"),
                            animatedVisibilityScope = animScope // 将共享元素与导航动画的可见性关联
                        )
                        .background(Color.Black)
                        .align(Alignment.Center)
                ) {
                    androidx.compose.material3.Text(text = "Details for Item $itemId", color = Color.White, modifier = Modifier.padding(16.dp))
                }
            }
        }
    }
}
posted @ 2025-03-27 11:47  kingwzun  阅读(140)  评论(0)    收藏  举报