Conditional navigation

When designing navigation for your app, you might want to navigate to one destination versus another based on conditional logic. For example, you might have some destinations that require the user to be logged in, or you might have different destinations in a game for when the player wins or loses.

User login

In this example, a user attempts to navigate to a profile screen that requires authentication. Because this action requires authentication, the user should be redirected to a login screen if they are not already authenticated.

The navigation graph for this example might look something like this:

Figure 1: A login flow is handled independently from the app's main navigation flow.

To authenticate, the app must navigate to the login_fragment, where the user can enter a username and password to authenticate. If accepted, the user is sent back to the profile_fragment screen. If not accepted, the user is informed that their credentials are invalid using a Snackbar.

Destinations in this app are represented using fragments that are hosted by a single activity.

Here's the navigation graph for this app:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/nav_graph"
        app:startDestination="@id/main_fragment">

    <fragment
            android:id="@+id/main_fragment"
            android:name="com.google.android.conditionalnav.MainFragment"
            android:label="fragment_main"
            tools:layout="@layout/fragment_main">

        <action
                android:id="@+id/navigate_to_profile_fragment"
                app:destination="@id/profile_fragment"/>
    </fragment>

    <fragment
            android:id="@+id/login_fragment"
            android:name="com.google.android.conditionalnav.LoginFragment"
            android:label="login_fragment"
            tools:layout="@layout/login_fragment"/>

    <fragment
            android:id="@+id/profile_fragment"
            android:name="com.google.android.conditionalnav.ProfileFragment"
            android:label="fragment_profile"
            tools:layout="@layout/fragment_profile"/>

</navigation>

MainFragment contains a button that the user can click if they want to view their profile. If the user wants to see the profile screen, they must first authenticate. This interaction is modeled using two separate fragments, but it depends on a shared state—whether the user is authenticated, and if so, the authenticated user name. Note that this state information is not the responsibility of either of these two fragments and is more appropriately held in a shared ViewModel, as shown in the following example:

Kotlin

class LoginViewModel : ViewModel() {
    enum class AuthenticationState {
        AUTHENTICATED,          // Initial state, the user needs to authenticate
        UNAUTHENTICATED,        // The user has authenticated successfully
        INVALID_AUTHENTICATION  // Authentication failed
    }

    val authenticationState = MutableLiveData<AuthenticationState>()
    val username = “”

    init {
        // In this example, the user is always unauthenticated when MainActivity is launched
        authenticationState.value = AuthenticationState.UNAUTHENTICATED
        username = ""
    }

    fun refuseAuthentication() {
        authenticationState.value = AuthenticationState.UNAUTHENTICATED
    }

    fun authenticate(username: String, password: String) {
        if (passwordIsValidForUsername(username, password)) {
            this.username = username
            authenticationState.value = AuthenticationState.AUTHENTICATED
        } else {
            authenticationState.value = AuthenticationState.INVALID_AUTHENTICATION
        }
    }

    private fun passwordIsValidForUsername(username: String, password: String): Boolean {
        ...
    }
}

Java

public class LoginViewModel extends ViewModel {

    public enum AuthenticationState {
        UNAUTHENTICATED,        // Initial state, the user needs to authenticate
        AUTHENTICATED,          // The user has authenticated successfully
        INVALID_AUTHENTICATION  // Authentication failed
    }

    final MutableLiveData<AuthenticationState> authenticationState =
            new MutableLiveData<>();
    final String username = ””;

    public LoginViewModel() {
        // In this example, the user is always unauthenticated when MainActivity is launched
        authenticationState.setValue(AuthenticationState.UNAUTHENTICATED);
        username = “”;
    }

    public void authenticate(String username, String password) {
        if (passwordIsValidForUsername(username, password)) {
            this.username = username;
            authenticationState.setValue(AuthenticationState.AUTHENTICATED);
        } else {
            authenticationState.setValue(AuthenticationState.INVALID_AUTHENTICATION);
        }
    }

    public void refuseAuthentication() {
       authenticationState.setValue(AuthenticationState.UNAUTHENTICATED);
    }

    private boolean passwordIsValidForUsername(String username, String password) {
        ...
    }
}

A ViewModel is scoped to a ViewModelStoreOwner. You can share data between the fragments by having a ViewModel scoped to the activity, which implements ViewModelStoreOwner. In the following example, requireActivity() resolves to MainActivity because MainActivity hosts ProfileFragment:

Kotlin

class LoginFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()
    ...
}

Java

public class LoginFragment extends Fragment {

    private LoginViewModel viewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = ViewModelProviders.of(requireActivity()).get(LoginViewModel.class);
        ...
    }
    ...
}

The user’s authentication state is represented as an enum class in LoginViewModel and exposed via LiveData, so in order to decide where to navigate, you should observe that state. Upon navigating to ProfileFragment, the app shows a welcome message if the user is authenticated. If not authenticated, then you navigate to LoginFragment, since the user needs to authenticate before seeing their profile. You need to define the deciding logic in your ViewModel, as shown in the following example:

Kotlin

class ProfileFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        welcomeTextView = view.findViewById(R.id.welcome_text_view)

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifecycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> showWelcomeMessage()
                UNAUTHENTICATED -> navController.navigate(R.id.login_fragment)
            }
        })
    }

    private fun showWelcomeMessage() {
        ...
    }
}
...

Java

public class ProfileFragment extends Fragment {

    private LoginViewModel viewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = ViewModelProviders.of(requireActivity()).get(LoginViewModel.class);

        welcomeTextView = view.findViewById(R.id.welcome_text_view);

        final NavController navController = Navigation.findNavController(view);
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        showWelcomeMessage();
                        break;
                    case UNAUTHENTICATED:
                        navController.navigate(R.id.loginFragment);
                        break;
                }
            }
        });
    }

    private void showWelcomeMessage() {
        ...
    }
    ...
}

If the user is not authenticated when they reach the ProfileFragment, they navigate to the LoginFragment. Once there, they are able to enter a username and password, which is then passed to the LoginViewModel.

If authentication is successful, then the ViewModel sets the authentication state to AUTHENTICATED. This causes the LoginFragment to be popped off of the back stack, taking the user back to the ProfileFragment. If authentication is unsuccessful due to invalid credentials, the state is set to INVALID_AUTHENTICATION, and the user is presented with a Snackbar in the LoginFragment. Finally, if they press the Back button, the state is set to UNAUTHENTICATED and the stack is popped back to the MainFragment.

Kotlin

class LoginFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    private lateinit var usernameEditText: EditText
    private lateinit var passwordEditText: EditText
    private lateinit var loginButton: Button
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        usernameEditText = view.findViewById(R.id.username_edit_text)
        passwordEditText = view.findViewById(R.id.password_edit_text)

        loginButton = view.findViewById(R.id.login_button)
        loginButton.setOnClickListener {
            viewModel.authenticate(usernameEditText.text.toString(),
                    passwordEditText.text.toString())
        }

        requireActivity().addOnBackPressedCallback(viewLifecycleOwner, OnBackPressedCallback {
            viewModel.refuseAuthentication()
            navController.popBackStack(R.id.main_fragment, false)
            true
        })

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifecycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> navController.popBackStack()
                INVALID_AUTHENTICATION ->
                    Snackbar.make(view,
                            R.string.invalid_credentials,
                            Snackbar.LENGTH_SHORT
                            ).show()
            }
        })
    }
}

Java

public class LoginFragment extends Fragment {

    private LoginViewModel viewModel;

    private EditText usernameEditText;
    private EditText passwordEditText;
    private Button loginButton;

    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = ViewModelProviders.of(requireActivity()).get(LoginViewModel.class);

        usernameEditText = view.findViewById(R.id.username_edit_text);
        passwordEditText = view.findViewById(R.id.password_edit_text);

        loginButton = view.findViewById(R.id.login_button);
        loginButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                viewModel.authenticate(usernameEditText.getText().toString(),
                        passwordEditText.getText().toString());
            }
        });

        requireActivity().addOnBackPressedCallback(getViewLifecycleOwner(), () -> {
           viewModel.refuseAuthentication();
           navController.popBackStack(R.id.main_fragment, false);
           return true;
        });

        final NavController navController = Navigation.findNavController(view);
        final View root = view;
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        navController.popBackStack();
                        break;
                    case INVALID_AUTHENTICATION:
                        Snackbar.make(root,
                                R.string.invalid_credentials,
                                Snackbar.LENGTH_SHORT
                                ).show();
                        break;
                }
            }
        });
    }
}

Note that all logic pertaining to authentication is held within LoginViewModel. This is important, as it is not the responsibility of either LoginFragment or ProfileFragment to determine how users are authenticated. Encapsulating your logic in a ViewModel makes it not only easier to share but also easier to test. If your navigation logic is complex, you should especially verify this logic through testing. See the Guide to app architecture for more information on structuring your app’s architecture around testable components.

When the user returns to the ProfileFragment, their authentication state is checked again. If they are now authenticated, the app displays a welcome message using the authenticated username, as shown in the following example:

Kotlin

class ProfileFragment : Fragment() {

    private val viewModel: LoginViewModel by activityViewModels()

    private lateinit var welcomeTextView: TextView

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        welcomeTextView = view.findViewById(R.id.welcome_text_view)

        val navController = findNavController()
        viewModel.authenticationState.observe(viewLifeycleOwner, Observer { authenticationState ->
            when (authenticationState) {
                AUTHENTICATED -> showWelcomeMessage()
                UNAUTHENTICATED -> navController.navigate(R.id.loginFragment)
        })
    }

    private fun showWelcomeMessage() {
        welcomeTextView.text = getString(R.string.welcome, viewModel.username)
    }

    ...
}

Java

public class ProfileFragment extends Fragment {

    private LoginViewModel viewModel;

    private TextView welcomeTextView;

    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        viewModel = ViewModelProviders.of(requireActivity()).get(LoginViewModel.class);

        welcomeTextView = view.findViewById(R.id.welcome_text_view);

        final NavController navController = Navigation.findNavController(view);
        viewModel.authenticationState.observe(getViewLifecycleOwner(),
                new Observer<LoginViewModel.AuthenticationState>() {
            @Override
            public void onChanged(LoginViewModel.AuthenticationState authenticationState) {
                switch (authenticationState) {
                    case AUTHENTICATED:
                        showWelcomeMessage();
                        break;
                    case UNAUTHENTICATED:
                        navController.navigate(R.id.loginFragment);
                        break;
                }
            }
        });
    }

    private void showWelcomeMessage() {
        welcomeTextView.setText(getString(R.string.welcome, viewModel.username));
    }
    ...
}

Not every navigation action is based on conditions, but this pattern can be quite useful for those that are. You determine how a user navigates through your app by defining the conditions by which they navigate and providing a shared source of truth in a ViewModel for communication between fragments.

First-time user experience

A first-time user experience (FTUE) is a specific flow that users see only when launching your app for the first time. Rather than make this flow part of your app's main navigation graph, you should keep this flow as a separate nested navigation graph.

Building onto the login example in the previous section, you might have a scenario where the user has a chance to register if they do not have a login, as shown with a REGISTER button in figure :

Figure 2: The login screen now contains a REGISTER button.

When the user clicks on the REGISTER button, they are taken to a sub-navigation flow specific to registration. After registering, the back stack is popped, and the user is taken directly into the profile screen.

The Navigation graph in the below example, has been updated to include a nested navigation graph. An action has also been added to the login_fragment and can trigger in response to tapping REGISTER:

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/nav_graph"
        app:startDestination="@id/main_fragment">

    ...

    <fragment
            android:id="@+id/login_fragment"
            android:name="com.google.android.conditionalnav.LoginFragment"
            android:label="login_fragment"
            tools:layout="@layout/fragment_login">

        <action android:id="@+id/action_login_fragment_to_register_fragment"
                app:destination="@id/registration_graph" />

    </fragment>


    <navigation android:id="@+id/registration_graph"
            app:startDestination="@id/enter_user_profile_fragment">

        <fragment android:id="@+id/enter_user_profile_fragment"
                android:name="com.google.android.conditionalnav.registration.EnterProfileDataFragment"
                android:label="Enter Profile Data"
                tools:layout="@layout/fragment_enter_profile_info">

            <action android:id="@+id/move_to_choose_user_password"
                    app:destination="@id/choose_user_password_fragment" />

        </fragment>

        <fragment android:id="@+id/choose_user_password_fragment"
                android:name="com.google.android.conditionalnav.registration.ChooseUserPasswordFragment"
                android:label="Choose User + Password"
                tools:layout="@layout/fragment_choose_user_password" />

    </navigation>
</navigation>

Represented visually in the Navigation editor, this nested graph appears as a smaller Nested Graph with the registration_graph id on top, as shown in figure 3:

Figure 3: The navigation graph now shows the nested registration_graph

Double-click on the Nested Graph in the editor to reveal the details of the registration graph. In figure 4, you can see a two-screen registration flow. The first screen gathers the user's full name and biographical information. The second screen captures their desired username and password. To go back to the main navigation graph, click ← Root in the Destinations pane.

Figure 4: The nested graph shows the registration flow.

The start destination of this nested navigation graph is the Enter Profile Data screen. Once the user enters profile data and clicks the NEXT button, the app navigates to the Create Login Credentials screen. Once they finish creating their username and password, they can click REGISTER + LOGIN to be taken directly into the profile screen.

As with the previous example, a ViewModel is used to share information between the registration fragments:

Kotlin

class RegistrationViewModel : ViewModel() {

    enum class RegistrationState {
        COLLECT_PROFILE_DATA,
        COLLECT_USER_PASSWORD,
        REGISTRATION_COMPLETED
    }

    val registrationState =
            MutableLiveData<RegistrationState>(RegistrationState.COLLECT_PROFILE_DATA)

    // Simulation of real-world scenario, where an auth token may be provided as
    // an alternate authentication mechanism instead of passing the password
    // around. This is set at the end of the registration process.
    var authToken = ""
        private set


    fun collectProfileData(name: String, bio: String) {
        // ... validate and store data

        // Change State to collecting username and password
        registrationState.value = RegistrationState.COLLECT_USER_PASSWORD
    }

    fun createAccountAndLogin(username: String, password: String) {
        // ... create account
        // ... authenticate
        this.authToken = // token

        // Change State to registration completed
        registrationState.value = RegistrationState.REGISTRATION_COMPLETED
    }

    fun userCancelledRegistration() : Boolean {
        // Clear existing registration data
        registrationState.value = RegistrationState.COLLECT_PROFILE_DATA
        authToken = ""
        return true
    }

}

Java

public class RegistrationViewModel extends ViewModel {

    enum RegistrationState {
        COLLECT_PROFILE_DATA,
        COLLECT_USER_PASSWORD,
        REGISTRATION_COMPLETED
    }

    private MutableLiveData<RegistrationState> registrationState =
            new MutableLiveData<>(RegistrationState.COLLECT_PROFILE_DATA);

    public MutableLiveData<RegistrationState> getRegistrationState() {
        return registrationState;
    }

    // Simulation of real-world scenario, where an auth token may be provided as
    // an alternate authentication mechanism instead of passing the password
    // around. This is set at the end of the registration process.
    private String authToken;

    public String getAuthToken() {
        return authToken;
    }

    public void collectProfileData(String name, String bio) {
        // ... validate and store data

        // Change State to collecting username and password
        registrationState.setValue( RegistrationState.COLLECT_USER_PASSWORD);
    }

    public void createAccountAndLogin(String username, String password) {
        // ... create account
        // ... authenticate
        this.authToken = // token

        // Change State to registration completed
        registrationState.setValue(RegistrationState.REGISTRATION_COMPLETED);
    }

    public boolean userCancelledRegistration() {
        // Clear existing registration data
        registrationState.setValue(RegistrationState.COLLECT_PROFILE_DATA);
        authToken = "";
        return true;
    }

}

The registration state of this ViewModel is observed from the fragments of each registration screen. The state drives moving to the next screen and is updated by RegistrationViewModel based on user interactions. Pressing back at any time cancels the registration process and pops the user back to the login screen:

Kotlin

class EnterProfileDataFragment : Fragment() {

    val registrationViewModel by activityViewModels<RegistrationViewModel>()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        val navController = findNavController()

        ...

        // When the next button is clicked, collect the current values from the
        // two edit texts and pass to the ViewModel to store.
        view.findViewById<Button>(R.id.next_button).setOnClickListener {
            val name = fullnameEditText.text.toString()
            val bio = bioEditText.text.toString()
            registrationViewModel.collectProfileData(name, bio)
        }

        // RegistrationViewModel will update the registrationState to
        // COLLECT_USER_PASSWORD when ready to move to the choose username and
        // password screen.
        registrationViewModel.registrationState.observe(
                viewLifecycleOwner, Observer { state ->
                    if (state == COLLECT_USER_PASSWORD) {
                        navController.navigate(R.id.move_to_choose_user_password)
                    }
                })

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it will be in the initial
        // state if the user comes back to register later.
        requireActivity().addOnBackPressedCallback(viewLifecycleOwner, OnBackPressedCallback {
            registrationViewModel.userCancelledRegistration()
            navController.popBackStack(R.id.login_fragment, false)
            true
        })
    }
}

class ChooseUserPasswordFragment : Fragment() {

    private val loginViewModel: LoginViewModel by activityViewModels()
    private val registrationViewModel: RegistrationViewModel by activityViewModels()

    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

        val navController = findNavController()

        ...

        // When the register button is clicked, collect the current values from
        // the two edit texts and pass to the ViewModel to complete registration.
        view.findViewById<Button>(R.id.register_button).setOnClickListener {
            registrationViewModel.createAccountAndLogin(
                    usernameEditText.text.toString(),
                    passwordEditText.text.toString()
            )
        }

        // RegistrationViewModel updates the registrationState to
        // REGISTRATION_COMPLETED when ready, and for this example, the username
        // is accessed as a read-only property from RegistrationViewModel and is
        // used to directly authenticate with loginViewModel.
        registrationViewModel.registrationState.observe(
                viewLifecycleOwner, Observer { state ->
                    if (state == REGISTRATION_COMPLETED) {

                        // Here we authenticate with the token provided by the ViewModel
                        // then pop back to the profie_fragment, where the user authentication
                        // status will be tested and should be authenticated.
                        val authToken = registrationViewModel.token
                        loginViewModel.authenticate(authToken)
                        navController.popBackStack(R.id.profile_fragment, false)
                    }
                }
        )

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it is in the initial state if
        // the user comes back to register later.
        requireActivity().addOnBackPressedCallback(viewLifecycleOwner, OnBackPressedCallback {
            registrationViewModel.userCancelledRegistration()
            navController.popBackStack(R.id.login_fragment, false)
            true
        })

    }
}

Java

public class EnterProfileDataFragment extends Fragment {

    private RegistrationViewModel registrationViewModel;
    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {

        registrationViewModel = ViewModelProviders
                .of(requireActivity())
                .get(RegistrationViewModel.class);

        final NavController navController = findNavController(view);
        ...

        // When the next button is clicked, collect the current values from the two edit texts
        // and pass to the ViewModel to store.
        view.findViewById(R.id.next_button).setOnClickListener(v -> {
            String name = fullnameEditText.getText().toString();
            String bio = bioEditText.getText().toString();
            registrationViewModel.collectProfileData(name, bio);
        });

        // RegistrationViewModel updates the registrationState to
        // COLLECT_USER_PASSWORD when ready to move to the choose username and
        // password screen.
        registrationViewModel.getRegistrationState().observe(getViewLifecycleOwner(), state -> {
            if (state == COLLECT_USER_PASSWORD) {
                navController.navigate(R.id.move_to_choose_user_password);
            }
        });

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it is in the initial state if
        // the user comes back to register later.
        requireActivity().addOnBackPressedCallback(getViewLifecycleOwner(), () -> {
            registrationViewModel.userCancelledRegistration();
            navController.popBackStack(R.id.login_fragment, false);
            return true;
        });
    }
}

class ChooseUserPasswordFragment extends Fragment {

    private LoginViewModel loginViewModel;
    private RegistrationViewModel registrationViewModel;
    ...

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {

        ViewModelProvider provider = ViewModelProviders.of(requireActivity());
        registrationViewModel = provider.get(RegistrationViewModel.class);
        loginViewModel = provider.get(LoginViewModel.class);

        final NavController navController = findNavController(view);

        ...

        // When the register button is clicked, collect the current values from
        // the two edit texts and pass to the ViewModel to complete registration.
        view.findViewById(R.id.register_button).setOnClickListener(v ->
                registrationViewModel.createAccountAndLogin(
                        usernameEditText.getText().toString(),
                        passwordEditText.getText().toString()
                )
        );

        // RegistrationViewModel updates the registrationState to
        // REGISTRATION_COMPLETED when ready, and for this example, the username
        // is accessed as a read-only property from RegistrationViewModel and is
        // used to directly authenticate with loginViewModel.
        registrationViewModel.getRegistrationState().observe(
                getViewLifecycleOwner(), state -> {

                    if (state == REGISTRATION_COMPLETED) {
                        // Here we authenticate with the token provided by the ViewModel
                        // then pop back to the profie_fragment, where the user authentication
                        // status will be tested and should be authenticated.
                        String authToken = registrationViewModel.getAuthToken();
                        loginViewModel.authenticate(authToken);
                        navController.popBackStack(R.id.profile_fragment, false);
                    }
                }
        );

        // If the user presses back, cancel the user registration and pop back
        // to the login fragment. Since this ViewModel is shared at the activity
        // scope, its state must be reset so that it will be in the initial
        // state if the user comes back to register later.
        requireActivity().addOnBackPressedCallback(getViewLifecycleOwner(), () -> {
            registrationViewModel.userCancelledRegistration();
            navController.popBackStack(R.id.login_fragment, false);
            return true;
        });
    }
}

Keeping this FTUE flow separated into its own graph makes it easy to change the sub-flow without affecting your main navigation flow. If you wanted to further encapsulate the nested FTUE graph, you could also store it in a separate navigation resource file and include it via an <include> element in your main navigation graph.