본문 바로가기
Android

Type-Safety Navigation (2)

by Jiwon_Loopy 2025. 3. 6.
반응형

앞서 공부한 Type-Safety 네비게이션을 진행중인 프로젝트에 적용해보는 과정을 기록해보았다.

 

 

1. common -> navigation 모듈 만들기

 

app 모듈과 feature 모듈에서 공통으로 접근할 수 있도록 common 모듈 안에 navigation 모듈을 하나 만들어주었다.

 

FeatureGraoph는 각 모듈에서 네비게이션을 관리할 수 있도록 연결해주는 인터페이스이며, NavigationDest는 앱 내에서의 네비게이션 경로들 (메인경로, 서브경로)을 모아놓은 파일이다.

 


FeatureGraph

package com.example.navigation

import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController

interface FeatureGraph {
    fun navGraph(
        navHostController: NavHostController,
        navGraphBuilder: NavGraphBuilder,
        provide: Any?
    )
}

 

각 네비게이션의 그래프와, 컨트롤러가 들어가며, provide는 따로 주입해야 될 값을 위해 임시로 넣어두었다. (전체 App의 상태를 관리해줄 appState에 필요한 것들..?)

 

NavigatinoDest.kt

package com.example.navigation

import com.example.data.model.MenuType
import com.example.data.model.PostType
import kotlinx.serialization.Serializable

@Serializable
sealed class NavigationDest {

    @Serializable
    data object MainRoute : NavigationDest()

    @Serializable
    data object LoginRoute : NavigationDest()

    @Serializable
    data object MyPageRoute : NavigationDest()

    @Serializable
    data object PlanetRoute : NavigationDest()

    @Serializable
    data object MyAccountRoute : NavigationDest()

    @Serializable
    data object EditPlanetPostRoute : NavigationDest()

    @Serializable
    data object MakePlanetNavigation : NavigationDest()

    @Serializable
    data class PlanetPostRoute(
        val cid : String = ""
    ) : NavigationDest()

    @Serializable
    data class PreviewRoute(
        val initialCardId: String = "",
        val initialPostType: PostType = PostType.NOT
    ) : NavigationDest()

    @Serializable
    data object RuleRoute : NavigationDest()

    @Serializable
    data class HoldPlanetRoute(
        val type : MenuType = MenuType.HOLD
    ): NavigationDest()

}

@Serializable
sealed class Dest {

    @Serializable
    data object MainRoute : Dest()

    @Serializable
    data object LoginRoute : Dest()

    @Serializable
    data object MyPageRoute : Dest()

    @Serializable
    data object PlanetRoute : Dest()

    @Serializable
    data object MyAccountRoute : Dest()

    @Serializable
    data object EditPlanetPostRoute : Dest()

    @Serializable
    data object MakePlanetNavigation : Dest()

    @Serializable
    data class PlanetPostRoute(
        val cid: String = ""
    ) : Dest()

    @Serializable
    data class PreviewRoute(
        val initialCardId: String = "",
        val initialPostType: PostType = PostType.NOT
    ) : Dest()

    @Serializable
    data object RuleRoute : Dest()

    @Serializable
    data class HoldPlanetRoute(
        val type : MenuType = MenuType.HOLD
    ) : Dest()
}

 

직렬화 된 메인 Navigation과 Sub Navigation들이 정의되어 있다. data class나 data object를 이용하여 선언해주었다.

각 화면 사이에서 값을 전달하는데 필요한 타입들을 넣어주었다.

NavigationDest와 Dest가 두 번 정의되어있는 이유는 각 Feature모듈 내에서의 새로운 그래프에 노드를 포함 시켜줄 수 있도록 (각 모듈마다 네비게이션 그래프가 다르게 정의) 해주려는 것 같다.

가장 강력한 기능 중 하나인 data class도 전달할 수 있는데 그 방법은 아직 사용해보진 않았다. 

 

 

2. 각 모듈 내에서 그래프 정의

 

구현체 정의

package com.example.mypage.navigation

import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import com.example.data.model.MenuType
import com.example.mypage.ui.HoldPlanetScreen
import com.example.navigation.Dest
import com.example.navigation.FeatureGraph
import com.example.navigation.NavigationDest

interface HoldPlanetFeature : FeatureGraph

class HoldPlanetFeatureImpl : HoldPlanetFeature {
    override fun navGraph(
        navHostController: NavHostController,
        navGraphBuilder: NavGraphBuilder,
        provide: Any?
    ) {
        navGraphBuilder.navigation<NavigationDest.HoldPlanetRoute>(startDestination = Dest.HoldPlanetRoute(
            type = MenuType.HOLD
        )) {
            composable<Dest.HoldPlanetRoute>(
                deepLinks = listOf(
                    navDeepLink {
                        uriPattern = "$uri/holdPlanet/{type}"
                    }
                )
            ) {
                HoldPlanetScreen(navHostController)
            }
        }
    }

}

 

FeatureGraph 인터페이스의 구현체를 각 모듈에서 작성한다.

navGraphBuilder를 통해 해당 모듈의 메인 네비게이션의 루트를 만들어주고, 서브 그래프를 composable을 통해 등록해준다.

 

Hilt를 통해 의존성 주입

package com.example.mypage.di

import com.example.mypage.navigation.HoldPlanetFeature
import com.example.mypage.navigation.HoldPlanetFeatureImpl
import com.example.mypage.navigation.MyPageFeature
import com.example.mypage.navigation.MyPageFeatureImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent


@InstallIn(SingletonComponent::class)
@Module
object MyPageModule {
    @Provides
    fun provideMyPageFeature(): MyPageFeature {
        return MyPageFeatureImpl()
    }

    @Provides
    fun provideHoldPlanetFeature(): HoldPlanetFeature {
        return HoldPlanetFeatureImpl()
    }
}

각 FeatureModule을 만든 뒤, Hilt를 통해 인터페이스를 연결해주는 작업을 해주었다.

 

 

3. mainApp에서 모든 그래프 연결

 

DefaultNavigation을 만들어 모든 네비게이션들을 연결해 줄 Data Class로 선언

package di

import com.example.login.LoginFeature
import com.example.myaccount.MyAccountFeature
import com.example.mypage.navigation.HoldPlanetFeature
import com.example.mypage.navigation.MyPageFeature
import com.example.planet.navigation.MakePlanetFeature
import com.example.planet.navigation.PlanetFeature
import com.example.planet.navigation.PlanetPostFeature
import com.example.planet.navigation.PreviewFeature
import com.example.rule.RuleFeature
import ui.MainAppState
import ui.MainFeature

data class DefaultNavigator(
    val logInFeature : LoginFeature,
    val mainFeature : MainFeature,
    val planetFeature : PlanetFeature,
    val previewFeature : PreviewFeature,
    val myPageFeature : MyPageFeature,
    val holdPlanetFeature: HoldPlanetFeature,
    val ruleFeature: RuleFeature,
    val myAccountFeature : MyAccountFeature,
    val makePlanetFeature : MakePlanetFeature,
    val planetPostFeature : PlanetPostFeature
)

mainApp에서 이동할 그래프들을 data class를 이용하여 연결해주었다.

 

 

package di

import com.example.login.LoginFeature
import com.example.myaccount.MyAccountFeature
import com.example.mypage.navigation.HoldPlanetFeature
import com.example.mypage.navigation.MyPageFeature
import com.example.planet.navigation.MakePlanetFeature
import com.example.planet.navigation.PlanetFeature
import com.example.planet.navigation.PlanetPostFeature
import com.example.planet.navigation.PreviewFeature
import com.example.rule.RuleFeature
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ui.MainFeature
import ui.MainFeatureImpl


@InstallIn(SingletonComponent::class)
@Module
object AppModule {
    @Provides
    fun provideDefaultNavigation(
        logInFeature: LoginFeature,
        mainFeature: MainFeature,
        planetFeature: PlanetFeature,
        previewFeature: PreviewFeature,
        myPageFeature: MyPageFeature,
        holdPlanetFeature: HoldPlanetFeature,
        ruleFeature: RuleFeature,
        myAccountFeature: MyAccountFeature,
        makePlanetFeature: MakePlanetFeature,
        planetPostFeature: PlanetPostFeature
    ): DefaultNavigator {
        return DefaultNavigator(
            logInFeature,
            mainFeature,
            planetFeature,
            previewFeature,
            myPageFeature,
            holdPlanetFeature,
            ruleFeature,
            myAccountFeature,
            makePlanetFeature,
            planetPostFeature
        )
    }

    @Provides
    fun provideMainFeature() : MainFeature {
        return MainFeatureImpl()
    }
}

그 후, 해당 데이터클래스를 각 모듈에서 구현한 구현체들과 연결해주었다.

 

navHost에서 그래프에  포함

package navigation

import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import com.example.navigation.NavigationDest
import ui.MainAppState


@Composable
fun AppNavHost(
    appState: MainAppState,
    modifier: Modifier = Modifier
) {
    val navController = appState.navController
    val defaultNavigator = appState.defaultNavigator
    val viewModel = appState.loginViewModel
    val isLoggedIn by viewModel.isLoggedIn.collectAsState()

    val destination = if (isLoggedIn) {
        NavigationDest.PlanetRoute
    } else {
        NavigationDest.LoginRoute
    }
 
    NavHost(
        navController = navController,
        startDestination = destination,
        modifier = modifier,
        enterTransition = {
            slideInHorizontally(
                initialOffsetX = { fullWidth -> fullWidth },
                animationSpec = tween(500)
            )
        },
        exitTransition = {
            slideOutHorizontally(
                targetOffsetX = { fullWidth -> -fullWidth },
                animationSpec = tween(500)
            )
        },
        popEnterTransition = {
            slideInHorizontally(
                initialOffsetX = { fullWidth -> -fullWidth },
                animationSpec = tween(500)
            )
        },
        popExitTransition = {
            slideOutHorizontally(
                targetOffsetX = { fullWidth -> fullWidth },
                animationSpec = tween(500)
            )
        }

    ) {

        defaultNavigator.logInFeature.navGraph(navController, this, appState.loginViewModel)

        defaultNavigator.mainFeature.navGraph(navController, this, appState)

        defaultNavigator.planetFeature.navGraph(navController, this, null)

        defaultNavigator.previewFeature.navGraph(navController, this, null)

        defaultNavigator.myPageFeature.navGraph(navController, this, null)

        defaultNavigator.holdPlanetFeature.navGraph(navController, this, null)

        defaultNavigator.ruleFeature.navGraph(navController, this, null)

        defaultNavigator.myAccountFeature.navGraph(navController, this, null)

        defaultNavigator.makePlanetFeature.navGraph(navController, this, null)

        defaultNavigator.planetPostFeature.navGraph(navController, this, null)
    }
}

 

defaultNavigator.해당피처모듈.navGraph 형식으로 navHost에서 포함시켜주면 앱 전체의 네비게이션 경로 설정이 끝난다.

 

필드 주입

package com.example.myplanning

import android.app.Activity
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.designsystem.theme.MyPlanningTheme
import com.example.login.LoginViewModel
import dagger.hilt.android.AndroidEntryPoint
import di.DefaultNavigator
import ui.MainApp
import ui.rememberMainAppState
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var defaultNavigator: DefaultNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val splashScreen = installSplashScreen()

        setContent {
            // ViewModel 가져오기
            val loginViewModel: LoginViewModel = hiltViewModel()
            // MainAppState 초기화
            val appState = rememberMainAppState(
                loginViewModel = loginViewModel,
                defaultNavigator = defaultNavigator
            )

            MyPlanningTheme {
                BackOnPressed(loginViewModel)
                MainApp(appState)
            }

        }
    }
}

@Composable
fun BackOnPressed(loginViewModel: LoginViewModel) {
    val context = LocalContext.current
    var backPressedTime = 0L

    BackHandler(enabled = true) {
        if (System.currentTimeMillis() - backPressedTime <= 1000L) {
            loginViewModel.logOut()
            (context as Activity).finish()
        } else {
            Toast.makeText(context, "한 번 더 누르면 앱이 종료됩니다.", Toast.LENGTH_SHORT).show()
        }
        backPressedTime = System.currentTimeMillis()
    }
}

마지막으로 필드 주입을 통해 defaultNavigation을 이용할 수 있다.

 

composable을 구현하는 부분에서 typeMap을 이용하여 사용자 정의 타입 전달도 가능하다고 하던데, 이 부분도 꼭 구현해봐야겠다.

 

typeMap 예시 - GPT 검색

import kotlinx.serialization.Serializable

@Serializable
data class Lecture(val id: Int, val title: String)

@Serializable
data class Student(val name: String, val age: Int)


object LectureType : NavType<Lecture>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Lecture? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): Lecture =
        Json.decodeFromString(value)

    override fun put(bundle: Bundle, key: String, value: Lecture) {
        bundle.putString(key, Json.encodeToString(value))
    }
}

object StudentListType : NavType<List<Student>>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): List<Student>? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): List<Student> =
        Json.decodeFromString(value)

    override fun put(bundle: Bundle, key: String, value: List<Student>) {
        bundle.putString(key, Json.encodeToString(value))
    }
}



object LectureType : NavType<Lecture>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Lecture? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): Lecture =
        Json.decodeFromString(value)

    override fun put(bundle: Bundle, key: String, value: Lecture) {
        bundle.putString(key, Json.encodeToString(value))
    }
}

object StudentListType : NavType<List<Student>>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): List<Student>? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): List<Student> =
        Json.decodeFromString(value)

    override fun put(bundle: Bundle, key: String, value: List<Student>) {
        bundle.putString(key, Json.encodeToString(value))
    }
}

 

 

출처


https://www.youtube.com/watch?v=WFTzMx1pHCg

 

 

728x90
반응형