[안드로이드스튜디오_문화][Jetpack Compose Navigation 총정리 + 예제링크 깃허브]

기말 지하기포·2023년 11월 20일
2
post-thumbnail

ComposeNavigation

-기존의 LegacyView에서는 Activity||Fragment + XML 파일을 활용해서 화면을 구성하고 리스너를 설정함으로서 사용자와 상호작용을 진행하였다. 이는 코드 작성시 불편함을 초래하였고 따라서 Compose라는 함수로 UI를 구성 할 수 있는 선언적 UI 프레임워크가 등장하게 되었다.

-따라서 이런 Compose의 기능을 간편하게 활용하는 ComposeNavigation에서 학습해보도록 하겠다.

-Compose Navigation은 이러한 Compose에 알맞게 화면전환을 가능하게 해준다.

공식문서 => https://developer.android.com/jetpack/compose/navigation?hl=ko 를 바탕으로 실습코드랑 ComposeNavigation에 대한 사용법 및 이론을 작성하였습니다.

Navigation 이론 + 기본 사용법 + 특이 메서드

-우선 compose navigation을 사용하기 위해서는 모듈 수준의 build.gradle에 종속성을 추가해줘야 한다.

// 아래와 같이 종속성을 추가해주면 된다.
implementation("androidx.navigation:navigation-compose:${nav_version}")

'compose navigation'의 구성요소는 총 3가지가 있다.

-navController를 선언하기 위해서는 rememberNavController()를 변수에 넣어주면 된다.

val navController = rememberNavController()

-rememberNavController()는 NavHostContoller 객체를 반환하게 되는데 이 객체가 실질적으로 화면 전환을 가능하게 한다. (뒤로가기 포함) 즉 , 뒤에 나올 composable간의 이동을 담당하며 이동시 Recomposition 역할도 함께 수행한다.

-rememberNavController()를 선언하는 위치는 NavHostController 객체를 참조할 모든 composable이 접근 할 수 있는 위치여야한다. 이는 상태 호이스팅의 원칙을 준수하기 위한것과 composable이 동일한 NavHostController 객체를 참조해야만 AnimatedContentScope에서 composable 간의 이동이 가능하기 때문이다.

-동일한 NavHostController를 참조하지 않으면 java.lang.IllegalArgumentException: Cannot navigate to NavDeepLinkRequest 라는 에러가 발생하는데 이는 navHostController가 NavHost에서 정의된 네비게이션 그래프를 관리하는데 , 하나의 navHostController로 네비게이션 그래프를 관리해야지 두개 이상의 navHostController로 관리하게 되면 그래프의 일관성이 깨져버린다.

-네비게이션 그래프는 아래의 navHost의 후행람다식에서 만들어진다. 즉 , navHost의 후행람다가 NavGraphBuilder이며 , 네비게이션 그래프는 목적지들의 집합을 의미한다.

-navHost란 , composable()의 목적화면이 모여있는 저장소이다. NavHost는 startDestination을 parameter로 가지고 있는 NavHost를 사용하면 된다. (더 편리해)

-NavHost는 NavHostController 객체와 NavGraph를 연결해주는 역할을 한다. NavHost를 선언 할 시 parameter에 넣어주는 NavHostController 객체는 NavGraphBuilder 내부에서 사용되는 것과 동일한 NavHostController 객체여야 한다.

-이는 NavHostController 객체가 NavHost의 후행 람다에서 생성되는 NavGraph와 연결이 되고 백스택 관리 및 composable()간의 이동을 담당하는 역할을 하기 때문에 NavHostController가 모든 정보를 알고 있어야 한다.

-navHost는 composable()들의 컨테이너 역할을 하며 , NavGraph에 정의된 다야안 목적지들 사이의 네비게이션을 관리한다.

-NavGraph에 정의된 네비게이션 구조를 실제로 화면에 표시하는 컴포넌트입니다.

-NavHost의 후행람다식인 NavGraphBuilder가 생성해준다.

-앱 내에서 사용자가 방문할 수 있는 모든 화면(목적지)과 그 화면들 사이를 이동할 수 있는 경로를 정의한 것이다. 즉 , 목적화면과 목적화면 사이 사이의 길들에 대한 정보가 들어가 있다.

-route : 목적화면을 나타내는 루트(식별자)
-arguments : 목적화면으로 전달할 데이터의 목록
-deepLinks : 딥링크 관련 설정
-transition : 목적화면 간 전환시 어떤 움직임을 보일것인지
-composable function : 목적화면을 구현한 composable function

기본 사용법

-navController.navigate("루트이름") : 현재 화면에서 "루트이름"을 "route"로 갖는 목적화면으로 이동한다.

-popUpTo + inclusive

navController.navigate("A") {
	// popUpTo( "B" ) : Stack에서 B요소를 찾아서 B루트와 A루트 '사이'의 것들을 없애 버린다.
	// 따라서 뒤로가기 누르면 바로 "해당 요소"로 돌아간다.
    
	popUpTo("B") {
		inclusive = true
	}
	// popUpTo의 후행 람다식에 inclusive를 true로 설정하면 , 
    // B요소도 Stack에서 제거된다. (기본값 == false)
	// 따라서 뒤로가기 누르면 절대로 B요소로 돌아 갈 수 없다.
    // 다시 가고 싶으면 navigate("해당 요소")를 사용해야 한다.
}

-launchSingleTop

navController.navigate("A") {
	// A가 스택의 최상단에 위치할 때 A를 스택에 더 쌓을 것인가 안 쌓을 것인가를 결정한다.
	launchSingleTop = true
    //true : 안 쌓아 , false(기본값) : 쌓아
}

github : package com.company.jetpackcomposenavigation

Nested Navigation

-중첩된 네비게이션이라는 뜻이다. 이는 하나의 네비게이션 그래프 내부에 다른 네비게이션 그래프를 포함시키는 것을 말한다. 이는 계층적인 구조를 나타낸다. 예를들면 메인화면에 BottomNav를 활용하여 A B C 화면으로 이동이 가능하다고 가정했을 때 , A화면에서 여기저기 돌아다니다가 다시 B화면으로 이동해서 여기저기 돌아다닌 후 다시 A화면으로 돌아갔을 때 A화면이 다시 등장해버린 다면? 기존에 내가 A Tab을 눌러서 들어갔던 화면에서 이동했던 화면까지 다시 들어가야하는 번거로움이 존재한다. 따라서 이를 해결하는 방법으로서 등장한 것이 nested Navigation이라는 개념이다.

-즉, navigation graph 내부에 여러 navigation graph를 넣는 계층적인 구조를 활용함으로서 , 하위 그래프는 상위 그래프에 독립적으로 활동함으로서 하나의 네비게이션 그래프의 로직이 다른 네비게이션 그래프에 영향을 주지도 받지도 않기 때문에 효율적으로 네비게이션 그래프를 모듈화 시켜서 관리 할 수 있게 된다. 즉, 각각의 중첩된 네비게이션 그래프가 독립적인 백스택을 갖게된다는 것이다. 그러나 이 문장은 주의해서 해석할 필요가 있다. 예를들어 A와 B라는 독립적인 네비게이션 그래프가 Root그래프의 하위 그래프로서 독립적인 관계를 맺고 있다고 가정했을 때 A의 백스택과 B의 백스택은 서로에게 영향을 끼치지 않지만 , A와 B의 백스택을 관리하는 navController는 동일한 navController이다. 이는 Root 그래프에서 사용되는 navController와 동일하기도 한다. 그러니까는 A와 B의 백스택이 서로 영향을 끼치지는 않지만 A와 B의 백스택을 관리하는 navController는 동일한 navController라는 것이다. 이러한 이유로 인해 nested graphs는 탭구조를 가진 UI에서 각 탭에 대한 별도의 네비게이션 흐름을 관리하는데 사용한다. 이렇게 사용을 한다면 네비게이션 자체를 모듈화 시켜 네비게이션의 흐름을 관리하기에 용이하다. 중요한 사실은 앱에 존재하는 모든 navigation graph를 관리하는 것은 단 하나의 navController라는 사실이다.

-아래코드들을 보면 중첩된 네비게이션을 적용하기 전 , 후 의 차이점에 대해서 알 수 있을 것이다.

// nestedGraphs 적용 전
@Composable
fun NestedNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = Screen.login.route) {
        composable(route = Screen.login.route) {
            login(navController)
        }
        composable(route = Screen.register.route) {
            Register(navController)
        }
        composable(route = Screen.forgotPassword.route) {
            ForgotPassword(navController)
        }
        composable(route = Screen.Home.route) {
            Home(navController)
        }
        composable(route = Screen.A.route) {
            A(navController)
        }
        composable(route = Screen.B.route) {
            B(navController)
        }
    }
}
// 위 코드는 nestedGraph 적용하기 전의 코드로서 composable()이 넘많아서 보기 불편하다.
// nestedGraphs 적용 후
@Composable
fun NestedNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = navRoots.first.route) {
        firstNavGraph(navController = navController)
        secondNavgraph(navController = navController)
    }
}
// 위 코드는 nestedGraph 적용한 후의 코드로서 보기에 완전 편하다.

-그럼 nestedGraph를 적용하기 위해서는 어떠한 절차를 거쳐야 할까?

-우선 적용전의 코드에서 1~3 composabel , 4~6 composable을 분리해서 아래와 같은 코드로 만들어준다. 아래 코드는 각각 NavGraphBuilder를 확장한 함수로서 route에는 자신을 식별하고자 하는 이름을 넣어주면 된다. 이 이름을 바탕으로 위의 적용 후의 코드의 startDestination에서 시작하고 싶은 목적화면을 넣어주면된다.

-즉 , 위의 코드에서 startDestination에는 처음으로 사용할 NavGraph를 선택하고 선택된 NavGraph의 route에는 처음으로 보여줄 목적 화면의 식별자를 넣어주면 되는 것이다.

fun NavGraphBuilder.firstNavGraph(navController: NavController) {
    navigation(startDestination = Screen.login.route , route = navRoots.first.route) {
        // navigation의 parameter의 startDestination : 시작 화면 , route : 중첩된 navigation 식별자
        composable(route = Screen.login.route) {
            login(navController)
        }
        composable(route = Screen.register.route) {
            Register(navController)
        }
        composable(route = Screen.forgotPassword.route) {
            ForgotPassword(navController)
        }
    }
}

//////////////
fun NavGraphBuilder.secondNavgraph(navController: NavController) {
    navigation(startDestination = Screen.login.route , route = navRoots.second.route) {
        // navigation의 parameter의 startDestination : 시작 화면 , route : 중첩된 navigation 식별자
        composable(route = Screen.Home.route) {
            Home(navController)
        }
        composable(route = Screen.A.route) {
            A(navController)
        }
        composable(route = Screen.B.route) {
            B(navController)
        }
    }
}

-이런 식으로 중첩된 네비게이션을 사용하면 네비게이션 그래프를 관리하기에 아주 편리하다.

Navigation + BottomBar

-BottomBar를 통해서도 Naigation을 구현 할 수 있다.

-BottomBar에 대한 설명은 생략하겠습니다.
-이런식으로 , BottomBar를 통해서 여러 탭으로 이동도 가능하고 특정 탭에서 다른 화면으로 전환 했을 때에는 BottomBar를 사라지게 할 수 도 있다. 아래의 코드를 같이보면서 설명하도록 하겠습니다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomNav() {
    val navController = rememberNavController()

	// 현재 route를 저장한다 : bottomNav를 보여줄 리스트 안에 현재 route가 들어가있는지 확인 할 때 사용할 것이야.
    val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
    // bottomNav를 보여줄 화면들을 리스트에 저장해준다.
    val bottomNavVisibleRoutes = listOf(Screen.HomeScreen.route, Screen.CallScreen.route, Screen.SettingsScreen.route)

	// BottomNav에 보일 BottomNavItem에 관한 설정을 해준다.
    val items = listOf(
        BottomNavItem(
            title = "Home",
            selectedIcon = Icons.Filled.Home,
            unselectedIcon = Icons.Outlined.Home,
            alarm = false
        ),
        BottomNavItem(
            title = "Call",
            selectedIcon = Icons.Filled.Call,
            unselectedIcon = Icons.Outlined.Call,
            alarm = false,
            badgeCount = 77
        ),
        BottomNavItem(
            title = "Settings",
            selectedIcon = Icons.Filled.Settings,
            unselectedIcon = Icons.Outlined.Settings,
            alarm = true
        )
    )
    // BottomNav에서 선택된 BottomNavItem의 인덱스 번호를 저장한다.
    var selectedItemIndex by rememberSaveable {
        mutableStateOf(0)
    }
    val scrollState = rememberScrollState()


    Scaffold(
        bottomBar = {
        	// 현재 루트가 BottomNav가 보이는 걸 허용한 리스트에 있을때만 bottombar가 보여.
            if (currentRoute in bottomNavVisibleRoutes) {
                NavigationBar {
                    items.forEachIndexed { index, bottomNavItem ->
                        NavigationBarItem(
                            selected = selectedItemIndex == index,
                            onClick = {
                                selectedItemIndex = index
                                navController.navigate(bottomNavItem.title) {
                                    launchSingleTop = true
                                }
                            },
                            label = {
                                Text(text = bottomNavItem.title)
                            },
                            alwaysShowLabel = false,
                            icon = {
                                BadgedBox(
                                    badge = {
                                        if(bottomNavItem.badgeCount != null) {
                                            Badge {
                                                Text(text = bottomNavItem.badgeCount.toString())
                                            }
                                        } else if(bottomNavItem.alarm) {
                                            Badge()
                                        }
                                    }
                                ) {
                                    Icon(
                                        imageVector = if (index == selectedItemIndex) {
                                            bottomNavItem.selectedIcon
                                        } else {
                                            bottomNavItem.unselectedIcon
                                        },
                                        contentDescription = bottomNavItem.title
                                    )
                                }
                            })
                    }
                }
            }

        }
    ) {
        it
        NavHost(navController = navController, startDestination = Screen.HomeScreen.route) {
            composable(route = Screen.HomeScreen.route) {
                HomeScreen(navController)
            }
            composable(route = Screen.CallScreen.route) {
                CallScreen()
            }
            composable(route = Screen.SettingsScreen.route) {
                SettingsScreen()
            }
            composable(route = Screen.botongScreen1.route) {
                BottongScreen1()
            }
            composable(route = Screen.botongScreen2.route) {
                BottongScreen2()
            }
        }


    }
}

-BottomBar에서 Navigation을 적용하는 point는 Scaffold의 후행람다에 NavHost 설정을 해준다는 것이다. 이것을 제외하고는 평범한 ComposeNavigation과 다르지 않다.

Navigation + NavigationDrawer

-viewModel을 이용해서 composable간의 데이터를 공유하는 방법은 총 두가지가 존재하는데 우선 여기서는 1번째 방법을 사용하였고 , 2번째 방법은 하단에 나와있음.
-아래코드는 viewModel을 적용해서 Home->bottong1->bottong2->setting->Home 으로 움직였을 때 Home을 눌렀을 때 bottong2 스크린으로 바로 이동 할 수 있도록 작성한 코드이다.

-데이터를 전달하는 것과 별개로 [Home , bottong1 , bottong2]를 중첩된 네비게이션으로 묶어서 활용하였다.

-viewModel에서 state를 저장하고 바꾸는 코드를 작성한 후에 중첩된 네비게이션의 시작점에 "var state : String = viewModel.state"를 넣어주고 , 해당 스크린에서 viewModel을 parameter로 받아서 state를 바꾸어 주면된다.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NavigationDrawer() {
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
    val scope = rememberCoroutineScope()
    var selectedItemIndex by rememberSaveable { mutableStateOf(0) }
    val navController = rememberNavController()

    DismissibleNavigationDrawer( // PermanentNavigationDrawer
        drawerContent = {
            ModalDrawerSheet { // PermanentDrawerSheet
                Spacer(modifier = Modifier.height(16.dp))
                items.forEachIndexed { index, navigationDrawerItem ->
                    NavigationDrawerItem(
                        label = {
                            Text(text = navigationDrawerItem.title)
                        },
                        selected = index == selectedItemIndex,
                        onClick = {
                            selectedItemIndex = index
                            scope.launch {
                                drawerState.close()
                            }
                            navController.navigate(navigationDrawerItem.title)
                        },
                        icon = {
                            Icon(
                                imageVector = if (index == selectedItemIndex) {
                                    navigationDrawerItem.selectedIcon
                                } else navigationDrawerItem.unselectedIcon,
                                contentDescription = navigationDrawerItem.title
                            )
                        },
                        badge = {
                            navigationDrawerItem.badgeCount?.let {
                                Text(text = navigationDrawerItem.badgeCount.toString())
                            }
                        },
                        modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
                    )
                }
            }
        },
        drawerState = drawerState
    ) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = {
                        Text(text = "NavDrawer Study")
                    },
                    navigationIcon = {
                        IconButton(onClick = {
                            scope.launch {
                                drawerState.open()
                            }
                        }) {
                            Icon(
                                imageVector = Icons.Default.Menu,
                                contentDescription = "Menu"
                            )
                        }
                    }
                )
            }
        ) {
            it
            val viewModel = MainViewModel()
            NavHost(navController = navController, startDestination = "maingroup") {
                navigation(startDestination = Screen.HomeScreen.route , route = "maingroup") {
                    composable(Screen.HomeScreen.route) {
                        var state : String = viewModel.state

                        Log.d("ddd" , viewModel.state)

                        if(state == "Home") {
                            HomeScreen(navController)
                        }
                        if(state == "Bottong2") {
                            Bottong2(navController)
                        }

                    }
                    composable(Screen.BottongScreen1.route) {
                        Bottong1(navController , viewModel)
                    }
                    composable(Screen.BottongScreen2.route) {
                        Bottong2(navController)
                    }
                }

                composable(Screen.MessageScreen.route) {
                    MessageScreen()
                }
                composable(Screen.SettingScreen.route) {
                    SettingScreen()
                }

            }

        }
    }
}

DeepLink

-딥링크는 앱의 알림 또는 특정 링크를 클릭하였을 때, 해당 앱의 특정화면으로 이동할 수 있게 해주는 것이다.

-해당 앱에 딥링크를 설정하기 위해서는 AndroidManifest.xml과 NavHost의 후행람다식(navGraphBuilder)에 딥링크 관련 설정을 진행해야 한다.

-딥링크 관련 설정 1 : AndroidManifest.xml에 아래와 같은 코드를 넣어줘야 한다.

// autoVerify를 true로 설정하면 해당 앱에서 특정 링크를 자동으로 처리하는 것을 허용 한다.
// 또한, 사용자가 특정 URL을 클릭했을 때 해당 URL이 앱과 연결되어 있는지 확인하고, 연결되어 있다면 자동으로 
// 앱을 열어 URL에 해당하는 내용을 보여준다.
<intent-filter android:autoVerify="true">
    
    // action 태그는 인텐트가 수행하려는 작업에 대해서 정의한다.
    <action android:name="android.intent.action.VIEW" />
    // VIEW는 데이터를 사용자에게 보여주는 작업만 한다.
    
    // category 태그는 인텐트의 유형을 알려준다.
    <category android:name="android.intent.category.DEFAULT" />
    // 암시적 인텐트를 사용할 때 설정해줘야 한다.
    <category android:name="android.intent.category.BROWSABLE" />
	// 해당 Activity가 URL링크를 클릭하거나 알림을 클릭하거나 다른 앱에서 시작 하는 것을 가능하게 해준다.
    
    // 인텐트 필터의 data 요소는 앱이 처리할 수 있는 데이터의 유형을 정의한다.
    <data android:scheme="https" />
	// scheme 속성은 앱과 연결시킬 URL의 스키마(URL 주소의 맨 앞)를 지정한다.
    <data android:host="geonnuyasha.com" />
    // host 속성은 앱과 연결시킬 URL의 스키마를 제외한 주소를 나타낸다.
    
</intent-filter>

-딥링크 관련 설정 2 : compose의 내부의 parameter에 아래와 같은 코드를 넣어준다.

deepLinks = listOf( // 딥링크 관련 설정을 리스트에서 여러개 설정 할 수 있다.
	navDeepLink {
    uriPattern = "https://geonnuyasha.com/{id}"
    // 앱이 uriPattern에 정의된 링크를 처리할 수 있게 해준다. 즉, 이 패턴과 일치하는 웹 주소를 클릭할 때 
    // 해당 주소에 해당하는 콘텐츠를 앱 내에서 직접 열 수 있게 해준다. 여기서 해당 주소에 해당하는 콘텐츠는
    // composable의 후행 람다에 있는 composable function으로 구성된 화면을 의미한다.
    // 즉, uriPattern이 composable()의 후행람다에 있는 화면의 링크라는 것이다.
    action = Intent.ACTION_VIEW
    },
)

-위와 같이 DeepLink 관련 설정을 AndroidManifest.xml 과 composabled의 parameter에 설정을 해준다. 이때 중요한 것은 AndroidManifest.xml의 intent-filter를 넣어준 Activity에 navDeepLink 설정을 해줘야 한다. 단, App이 인식하는 절대 URL 주소는 composable의 navDeepLink에 넣어준 uriPattern이다.

-특정 링크를 클릭 했을 때 해당 앱으로 들어가는지 확인하고 싶으면
Tools -> AppLinksAssitant -> (3)Associate website를 클릭 -> uriPatteren에 저장한 링크를 넣어주고
-> Generate Digital Asset Links file을 클릭하면 나오는 json 파일을 자신의 웹사이트 뒤에 /.well-known/assetlinks.json을 넣어주면된다.

-그리고 (4)Test on Device or Emulator

-

-위의 내용은 링크 클릭시 해당 앱으로 이동하는 것이고 아래 내용은 앱에서 특정 버튼을 누르면 이동하고 싶은 앱으로 이동 할 수 있는 DeepLink에 관련한 내용이다.

-아래 코드의 주석에 설명이 작성되어 있음.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            VelogJetpackComposeDeepLink2Theme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                        Button(onClick = {
                            val intent = Intent(
                                Intent.ACTION_VIEW,
                                Uri.parse("https://geonnuyasha.com/777")
                                // 해당 URL 주소를 볼 수 있는 웹 브라우저 또는 앱을 열도록 하는 intent 생성
                            )
                            val pendingIntent = TaskStackBuilder.create(applicationContext).run {
                                // TaskStackBuilder.create()를 통해서 앱들의 스택을 관리하는 TaskStackBuilder 객체 생성
                                // TaskStackBuilder 객체는 안드로이드의 백스택(액티비티들이 쌓이는 스택구조)을 생성한다.
                                addNextIntentWithParentStack(intent)
                                // 해당 intent와 intent의 부모 컨텍스트에 대한 정보를 백스택에 넣어준다.
                                // 이렇게 하면, A앱을 사용하던 중에 B앱의 알림이 울려서 사용자가 B앱을 들어갔을 때
                                // 뒤로가기 버튼을 누르면 다시 A앱으로 돌아오게 한다.
                                getPendingIntent(
                                    0,
                                    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
                                )// 해당 intent를 실행 할 수 있는 PendingIntent 객체 생성
                            }
                            pendingIntent.send()// 위에서 만든 PendingIntent 실행
                        }
                        ) {
                            Text(text = "Trigger Deeplink")
                        }
                    }
                }
            }
        }
    }
}

-위의 코드는 B앱을 나타내고 맨 위의 코드는 A앱을 나타낸다. 아래 gif에서 앱B에서 앱A로 이동한 후 뒤로가기를 눌렀을 때 다시 B앱으로 돌아오는 영상이다.

-

Navigation으로 화면 전환시 데이터 전달 방법 1. NavigationArguments

-화면전환시 데이터를 전달한다는 것은 A화면에서 B화면으로 전달할 데이터가 있다는 것이다. 즉, 우리가 신경써줘야 하는것들은 [A화면에서 B화면으로 전달할 데이터] , [A화면에서 B화면으로 데이터 보내는 방법] , [B화면에서 A화면에서 보낸 데이터를 받는 방법] , [NavGraphBuilder에서 composable의 경로 설정해주기] 이 네가지를 신경써줘야 한다. 여기에는 여러가지 방법들이 있는데 우선 NavigationArguments를 사용해서 A->B 전환시 데이터를 전달하는 방법을 알아볼 것이다.

-[A->B로 전달할 데이터 체크] : 이 경우 데이터의 종류는 크게 두가지로 나뉜다. 사용자가 입력한 데이터이냐 코드 흐름상 필요한 데이터를 넘기는 것이냐 이 두가지로 분류되는데 , 사용자가 입력한 데이터를 넘겨줄 때 빈값을 넘겨주면 NavHostController 객체가 경로를 찾지 못해서 에러가 발생하고 , 코드 흐름상 필요한 데이터가 null 값이여도 마찬가지이다.

-따라서 경로상에 빈값이나 null 값이 들어가면 안 된다는 것을 주의해야 한다. 이 사실을 바탕으로 아래에서 빈값이나 null 값을 처리하는 방법을 통해 NavHostControlle에게 올바른 경로를 제시하는 것을 알아보겠다.

-[A->B로 데이터를 보내는 방법] : 기존에는 이동시에 아래 코드를 활용해서 "route 이름"에 대응하는 목적화면으로 이동하였다.

navController.navigate("route 이름")

-그렇다면 우리가 A->B에 보낼 데이터를 navController에 어떻게 전달 할 수 있을까? 정답은 / 다. 이는, window 운영체계에서 파일의 위치를 찾아갈때 적용하는 방식과 비슷하다고 볼 수 있다. 다만 다른 것은 window 운영체계에서는 / 가 계층적인 구조를 띄면서 파일의 위치를 적용했다면 , 우리가 compose navigation에서 사용하는 /는 계층적인 구조를 띄지않고 수형적인 구조를 띄게 된다.

-아래 코드처럼 전달하고자 하는 데이터인 a , b , c를 수평적인 구조처럼 route 이름에 더해주면 된다.

navController.navigate(route이름 + "/${a}/${b}/${c}")

-그런데 만약에 a , b , c 중 하나라도 빈값이거나 null 값이면 에러가 날텐데 어떻게 설정해줘야 할까? (프로젝트 진행할 때 빈값이거나 null 값을 넘겨서 에러가 나면 찾아서 잡아줘야 하지만 UI 구성을 먼저한채로 작업을 진행 할 수도있는 등의 여러 이유가 있을 수 도 있으니까 혹시 모르니까 학습해 두자.) 정답은 빈값체크 또는 null 값 체크를 아래와 같은 식으로 코드작성을 해주면 된다.

if(text1.isNotEmpty() && text2.isNotEmpty() && !text2.isNullOrEmpty()) {
	navController.navigate(Screen.DetailScreen.route + "/${text1}/${text2}/$text3")
}

-[B화면에서 A화면에서 보낸 데이터를 받는 방법] : 그냥 단순하게 함수의 parameter를 추가하는 식으로 아래와 같이 작성하면 된다.

fun DetailScreen(
	navController: NavController, 
    name1 : String?,
    name2 : String?, 
	name3 : String?) {
	// BlahBlah~
}

-[NavGraphBuilder에서 composable의 경로 설정해주기] : 기존의 composable()의 paratmeter에 route와 arguments와 AnimatedContentScope 이 세가지를 신경써줘야 한다.보내는 쪽에서 아래 코드로 받는 쪽의 화면으로 이동하는 코드를 작성했다면

navController.navigate(route이름 + "/${a}/${b}/${c}")

-composable()의 route : arguments에에서 navArgument()의 name parameter의 이름을 순서대로 작성해주면 된다. 이 루트가 데이터를 받는 화면의 루트이름이다.

-composable()의 arguments : list 자료형에 navArgument(argument name) {argument 관련 설정}을 함으로서 해당 화면으로 화면이 전환 될 때 받아야 할 데이터를 명시적으로 작성해준다.

-composable()의 AnimatedContentScope : 여기서 목적 화면을 그리는 composable function을 작성해주면 되는데 , 각각의 parameter에 원하는 navArgument("name")을 맞춰서 넣어주면 된다.

-아래는 composable() 작성의 예시사항이다.

-ViewModel을 공유하면서 Data를 전달하는 방법은 크게 두가지가 있다. 위에서 NavigationDrawer에서 사용했던 방법처럼 compsable의 후행람다에서 화면을 구성하는 composable Function의 parameter에 동일한 viewModel 객체를 넣어주는 방법이 첫번째 방법이고 두번째 방법은 navBackStackEntry를 확자한 확장함수를 사용해서 ViewModel을 구성하는 방법이 있다.

-먼저 첫번째 방법은 아래의 코드를 보면서 설명하도록 하겠다. 우선 MainScreen과 SubScreen이 존재한다. 중요한 점은 Main과 Sub가 공유하는 ViewModel객체가 동일하도록 명시적으로 아래와 같이 navGraphBuilder에서 선언해줘야 한다는 사실이다.

-이렇게 같은 viewModel 객체를 공유하지 않으면 각기 다른 ViewModel 객체를 Main과 Sub가 사용하기 때문에 viewModel의 값이 변경되어도 서로에게 아무런 영향을 끼치지 못한채 viewModel의 상태의 초기값으로 설정된 "기본값"이라는 값만 사용하게 된다.

-Main과 Sub가 parameter로 받는 viewModel이 동일한 ShareViewModel 객체이기 때문에 ViewModel의 word 상태값이 변경되면 서로에게 영향을 미칠 수 있다.

@Composable
fun Nav() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = Screens.Main.route) {
        val shareViewModel = ShareViewModel()
        
        composable(Screens.Main.route) {
            Main(navController , shareViewModel)
        }
        composable(Screens.Sub.route) {
            Sub(shareViewModel)
        }
    }
}
class ShareViewModel : ViewModel() {

    var word by mutableStateOf("기본값")
        private set

    fun changeWord(newWord : String) {
        word = newWord
    }

}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Main(navController: NavController , shareViewModel: ShareViewModel) {
    Column {
        var text by remember { mutableStateOf("기본값") }
        TextField(value = text, onValueChange = { text = it })

        Button(onClick = {
            shareViewModel.changeWord(text)
            navController.navigate(Screens.Sub.route)
        }) {
            Text("Sub 이동")
        }
    }
}

@Composable
fun Sub(shareViewModel : ShareViewModel) {

    Text(text = shareViewModel.word)
}

-viewModel을 공유하는 두번째 방법인 navBackStackEntry 객체를 이용해서 확장함수를 만들어서 viewModel을 구성하는 방법을 알아보겠다.

-아래코드는 navBackStackEntry를 활용한 확장함수이다. 중첩된 네비게이션 그래프에서 유용하며 , 상위 네비게이션 그래프의 viewModel을 하위 네비게이션 그래프와 공유 할 수 있다.

@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.sharedViewModel(
    navController: NavHostController,
): T { // 


    val navGraphRoute = destination.parent?.route ?: return viewModel()
    // 현재 화면(composable)의 상위 네비게이션 route 값을 반환하고 없으면 기본 viewModel을 반환한다.
    
    val parentEntry = remember(this) { // this는 NavBackStackEntry를 의미한다.
        navController.getBackStackEntry(navGraphRoute)
    }// 현재 composable의 상위 composable의 route 값의 NavBackStackEntry를 상태값으로 반환+저장한다.
    
    return viewModel(parentEntry)
}

-위코드에서 viewModel의 parameter로 parentEntry를 넣으면 상위 composable의 ViewModel이 반환되는 이유는 아래코드에서 viewModelStoreOwner 매개변수와 관련이 있는데, viewModelStoreOwner는 현재의 composable을 포함하는 액티비티의 생명주기가 어디에 있는지 알려준다. 이 parameter에 상위의 composable의 navBackStackEntry를 매개변수로 제공하면 이것을 viewModelStoreOwner로 사용하기때문에 결국 상위 composable의 navBackStackEntry랑 연결된 VieWModelStore에서 viewModel을 가져와서 반환하게 된다. 따라서 상위 composable의 viewModel을 하위 composable에서도 사용 할 수 있는 것이다.

@Suppress("MissingJvmstatic")
@Composable
public inline fun <reified VM : ViewModel> viewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    },
    key: String? = null,
    factory: ViewModelProvider.Factory? = null,
    extras: CreationExtras = if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
        viewModelStoreOwner.defaultViewModelCreationExtras
    } else {
        CreationExtras.Empty
    }
): VM = viewModel(VM::class.java, viewModelStoreOwner, key, factory, extras)

-그럼 위에서 반환된 상위 composable의 viewModel을 하위 composable에서 고냥 사용해버리면 된다.
@Composable
fun SharedViewModelStudy() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "Main"
    ) {
        navigation(// 중첩된 네비게이션을 만들어서 vieWModel 공유시작
            startDestination = "First",
            route = "Main"
        ) {
            composable("First") { entry ->
            // Main을 route로 가지는 
                val viewModel = entry.sharedViewModel<SharedViewModel>(navController)
                val state = viewModel.sharedState

                FirstScreen(navController , state , viewModel)
            }
            composable("Second") { entry ->
                val viewModel = entry.sharedViewModel<SharedViewModel>(navController)
                val state = viewModel.sharedState

                SecondScreen(navController , state , viewModel)
            }
        }
    }
}

-위 코드에서는 상위 composable이 존재하지 않고 평등한 composable이기 때문에 그냥 viewModel()을 반환하는데 확장함수를 정의할 때 viewModel[SharedViewModel]로 정의하였기 때문이다. viewModel() 함수는 현재 composable의 ViewModelStoreOwner 범위에서 요청된 타입의 ViewModel인스턴스를 찾기 때문에 확장함수에서 요청한 SharedViewModel 타입의 ViewModel을 반환하기 때문에 SharedViewModel을 사용 할 수 있는 것이다.

-

Navigation으로 화면 전환시 데이터 전달 방법 3. StatefulDependency

-hiltViewModel에 "실제 viewModel의 역할을 하는 클래스"를 싱글턴 패턴으로 의존성을 주입해서, 화면간에 같은 viewModel을 사용하면 된다.

-싱글턴으로 생성되었기 때문에 앱의 어느 장소에서 접근을 하더라고 같은 객체에 접근하게 된다.(객체가 1개 밖에 없으니까:SingleTon Pattern이기 때문에) 즉, 모든 composable이 같은 vieWModle을 사용하기 때문에 데이터를 공유하는 것이 가능하다.

-아래 코드는 Screen1ViewModel과 실제 viewModel 역하을 하는 class인 GlobalCounter에 대한 설명이다.

@HiltViewModel
class Screen1ViewModel @Inject constructor(
	// @Inject를 사용하였기 때문에 Hilt가 Screen1ViewModel 객체를 생성 할 때 
    // 아래의 GlobalCounter 객체를 자동으로 주입해준다. : 따라서 Screen1ViewModel 객체에서 
    // GlobalCounter를 사용 할 수 있다.
    private val counter: GlobalCounter
): ViewModel() {

    val count = counter.count

    fun inc() {
        counter.inc()
    }
}

@Singleton
// Singleton으로 설정하였기 때문에 애플리케이션에서 단일 인스턴스로만 존재한다.
// 이로서 앱의 모든 장소에서 같은 GlobalCounter 인스턴스에 접근 할 수 있다.
class GlobalCounter @Inject constructor() {

    private val _count = MutableStateFlow(0)
    val count = _count.asStateFlow()

    fun inc() {
        _count.value++
    }
}

-위처럼 설정을 해주었으면, composable의 후행람다에서 viewModel을 가져와서 viewModel의 변수 및 함수를 사용 할 수 있다.

composable("screen1") {
	val viewModel = hiltViewModel<Screen1ViewModel>()
    val count by viewModel.count.collectAsStateWithLifecycle()
    Screen1(
        count = count,
        onNavigateToScreen2 = {
        	viewModel.inc()
            navController.navigate("screen2")
        }
    )
}

Navigation으로 화면 전환시 데이터 전달 방법 4. CompositionLocal

-compositionLocal을 사용하면 로컬값을 그냥 가져와서 사용하면 된다.

Navigation으로 화면 전환시 데이터 전달 방법 5. 로컬 저장소 활용

-Room , Realm 등의 로컬 저장소에서 값을 꺼내와서 composable에서 할용하는 방법도 있다.

profile
포기하지 말기

0개의 댓글