조건부 탐색

앱 탐색을 설계할 때 조건부 로직에 따라 이동하는 대상을 다르게 할 수 있습니다. 예를 들어 사용자가 로그인해야 하는 대상의 딥 링크를 따라가거나, 게임에서 플레이어의 승패에 따라 이동하는 대상이 여러 개인 경우입니다.

사용자 로그인

이 예에서는 사용자가 인증이 필요한 프로필 화면으로 이동하려고 합니다. 인증이 필요한 작업이므로 아직 인증되지 않은 사용자는 로그인 화면으로 리디렉션해야 합니다.

이 예의 탐색 그래프는 다음과 같을 수 있습니다.

로그인 흐름은 앱의 기본 탐색 흐름과 별도로 처리됩니다.
그림 1. 로그인 흐름은 앱의 기본 탐색 흐름과 별개로 처리됩니다.

인증하려면 앱에서 login_fragment로 이동해야 합니다. 여기서 사용자는 사용자 이름과 비밀번호를 입력하여 인증을 받을 수 있습니다. 승인되면 사용자를 profile_fragment 화면으로 다시 보냅니다. 승인되지 않으면 Snackbar를 사용해 사용자에게 사용자 인증 정보가 잘못되었다고 알립니다. 사용자가 로그인하지 않고 프로필 화면으로 다시 이동하면 사용자를 main_fragment 화면으로 보냅니다.

이 앱의 탐색 그래프는 다음과 같습니다.

<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에는 사용자가 클릭하여 프로필을 볼 수 있는 버튼이 포함되어 있습니다. 사용자가 프로필 화면을 보려면 먼저 인증을 받아야 합니다. 이 상호작용은 개별 프래그먼트 두 개를 사용하여 모델링되지만 공유 사용자 상태에 따라 달라집니다. 이 상태 정보는 두 프래그먼트 중 하나의 소관이 아니며 공유 UserViewModel에 더 적절하게 보관됩니다. 이 ViewModelViewModelStoreOwner를 구현하는 활동으로 범위를 지정하여 프래그먼트 간에 공유됩니다. 다음 예에서는 MainActivity에서 ProfileFragment를 호스팅하므로 requireActivity()MainActivity로 확인됩니다.

Kotlin

class ProfileFragment : Fragment() {
    private val userViewModel: UserViewModel by activityViewModels()
    ...
}

Java

public class ProfileFragment extends Fragment {
    private UserViewModel userViewModel;
    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        userViewModel = new ViewModelProvider(requireActivity()).get(UserViewModel.class);
        ...
    }
    ...
}

UserViewModel의 사용자 데이터는 LiveData를 통해 노출되므로 이동할 위치를 결정하려면 이 데이터를 관찰해야 합니다. ProfileFragment로 이동하면 앱은 사용자 데이터가 있는 경우 환영 메시지를 표시합니다. 사용자 데이터가 null이면 사용자가 프로필을 보기 전에 인증되어야 하므로 LoginFragment로 이동합니다. 다음 예와 같이 ProfileFragment에서 결정 로직을 정의합니다.

Kotlin

class ProfileFragment : Fragment() {
    private val userViewModel: UserViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val navController = findNavController()
        userViewModel.user.observe(viewLifecycleOwner, Observer { user ->
            if (user != null) {
                showWelcomeMessage()
            } else {
                navController.navigate(R.id.login_fragment)
            }
        })
    }

    private fun showWelcomeMessage() {
        ...
    }
}

Java

public class ProfileFragment extends Fragment {
    private UserViewModel userViewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        userViewModel = new ViewModelProvider(requireActivity()).get(UserViewModel.class);
        final NavController navController = Navigation.findNavController(view);
        userViewModel.user.observe(getViewLifecycleOwner(), (Observer<User>) user -> {
            if (user != null) {
                showWelcomeMessage();
            } else {
                navController.navigate(R.id.login_fragment);
            }
        });
    }

    private void showWelcomeMessage() {
        ...
    }
}

사용자 데이터가 ProfileFragment에 도달할 때 null이면 LoginFragment로 리디렉션됩니다.

NavController.getPreviousBackStackEntry()를 사용하여 이전 대상의 NavBackStackEntry를 검색할 수 있으며 이는 NavController 관련 대상 상태를 캡슐화합니다. LoginFragment는 이전 NavBackStackEntrySavedStateHandle을 사용하여 사용자가 성공적으로 로그인했는지 나타내는 초깃값을 설정합니다. 이는 사용자가 즉시 시스템 뒤로 버튼을 누르면 반환할 상태입니다. SavedStateHandle을 사용하여 이 상태를 설정하면 프로세스가 종료될 때까지 상태가 계속 유지됩니다.

Kotlin

class LoginFragment : Fragment() {
    companion object {
        const val LOGIN_SUCCESSFUL: String = "LOGIN_SUCCESSFUL"
    }

    private val userViewModel: UserViewModel by activityViewModels()
    private lateinit var savedStateHandle: SavedStateHandle

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        savedStateHandle = findNavController().previousBackStackEntry!!.savedStateHandle
        savedStateHandle.set(LOGIN_SUCCESSFUL, false)
    }
}

Java

public class LoginFragment extends Fragment {
    public static String LOGIN_SUCCESSFUL = "LOGIN_SUCCESSFUL"

    private UserViewModel userViewModel;
    private SavedStateHandle savedStateHandle;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        userViewModel = new ViewModelProvider(requireActivity()).get(UserViewModel.class);

        savedStateHandle = Navigation.findNavController(view)
                .getPreviousBackStackEntry()
                .getSavedStateHandle();
        savedStateHandle.set(LOGIN_SUCCESSFUL, false);
    }
}

사용자가 사용자 이름과 비밀번호를 입력하면 인증을 위해 UserViewModel로 전달됩니다. 인증에 성공하면 UserViewModel에서 사용자 데이터를 저장합니다. 그러면 LoginFragmentSavedStateHandleLOGIN_SUCCESSFUL 값을 업데이트하고 백 스택에서 사라집니다.

Kotlin

class LoginFragment : Fragment() {
    companion object {
        const val LOGIN_SUCCESSFUL: String = "LOGIN_SUCCESSFUL"
    }

    private val userViewModel: UserViewModel by activityViewModels()
    private lateinit var savedStateHandle: SavedStateHandle

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        savedStateHandle = findNavController().previousBackStackEntry!!.savedStateHandle
        savedStateHandle.set(LOGIN_SUCCESSFUL, false)

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

        loginButton.setOnClickListener {
            val username = usernameEditText.text.toString()
            val password = passwordEditText.text.toString()
            login(username, password)
        }
    }

    fun login(username: String, password: String) {
        userViewModel.login(username, password).observe(viewLifecycleOwner, Observer { result ->
            if (result.success) {
                savedStateHandle.set(LOGIN_SUCCESSFUL, true)
                findNavController().popBackStack()
            } else {
                showErrorMessage()
            }
        })
    }

    fun showErrorMessage() {
        // Display a snackbar error message
    }
}

Java

public class LoginFragment extends Fragment {
    public static String LOGIN_SUCCESSFUL = "LOGIN_SUCCESSFUL"

    private UserViewModel userViewModel;
    private SavedStateHandle savedStateHandle;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        userViewModel = new ViewModelProvider(requireActivity()).get(UserViewModel.class);

        savedStateHandle = Navigation.findNavController(view)
                .getPreviousBackStackEntry()
                .getSavedStateHandle();
        savedStateHandle.set(LOGIN_SUCCESSFUL, false);

        EditText usernameEditText = view.findViewById(R.id.username_edit_text);
        EditText passwordEditText = view.findViewById(R.id.password_edit_text);
        Button loginButton = view.findViewById(R.id.login_button);

        loginButton.setOnClickListener(v -> {
            String username = usernameEditText.getText().toString();
            String password = passwordEditText.getText().toString();
            login(username, password);
        });
    }

    private void login(String username, String password) {
        userViewModel.login(username, password).observe(viewLifecycleOwner, (Observer<LoginResult>) result -> {
            if (result.success) {
                savedStateHandle.set(LOGIN_SUCCESSFUL, true);
                NavHostFragment.findNavController(this).popBackStack();
            } else {
                showErrorMessage();
            }
        });
    }

    private void showErrorMessage() {
        // Display a snackbar error message
    }
}

인증과 관련된 모든 로직은 UserViewModel 내에 보관됩니다. 사용자가 인증되는 방법을 결정하는 것은 LoginFragment 또는 ProfileFragment의 책임이 아니므로 이는 중요합니다. 로직을 ViewModel로 캡슐화하면 공유하기 쉬울 뿐 아니라 테스트도 쉬워집니다. 탐색 로직이 복잡하면 특별히 테스트를 통해 이 로직을 확인해야 합니다. 테스트 가능한 구성요소를 중심으로 앱 아키텍처를 구성하는 방법을 자세히 알아보려면 앱 아키텍처 가이드를 참고하세요.

다시 ProfileFragment에서 SavedStateHandle에 저장된 LOGIN_SUCCESSFUL 값을 onCreate() 메서드로 관찰할 수 있습니다. 사용자가 ProfileFragment로 돌아오면 LOGIN_SUCCESSFUL 값을 확인합니다. 값이 false이면 사용자는 MainFragment로 다시 리디렉션될 수 있습니다.

Kotlin

class ProfileFragment : Fragment() {
    ...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val navController = findNavController()

        val currentBackStackEntry = navController.currentBackStackEntry!!
        val savedStateHandle = currentBackStackEntry.savedStateHandle
        savedStateHandle.getLiveData<Boolean>(LoginFragment.LOGIN_SUCCESSFUL)
                .observe(currentBackStackEntry, Observer { success ->
                    if (!success) {
                        val startDestination = navController.graph.startDestination
                        val navOptions = NavOptions.Builder()
                                .setPopUpTo(startDestination, true)
                                .build()
                        navController.navigate(startDestination, null, navOptions)
                    }
                })
    }

    ...
}

Java

public class ProfileFragment extends Fragment {
    ...

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        NavController navController = NavHostFragment.findNavController(this);

        NavBackStackEntry navBackStackEntry = navController.getCurrentBackStackEntry();
        SavedStateHandle savedStateHandle = navBackStackEntry.getSavedStateHandle();
        savedStateHandle.getLiveData(LoginFragment.LOGIN_SUCCESSFUL)
                .observe(navBackStackEntry, (Observer<Boolean>) success -> {
                    if (!success) {
                        int startDestination = navController.getGraph().getStartDestination();
                        NavOptions navOptions = new NavOptions.Builder()
                                .setPopUpTo(startDestination, true)
                                .build();
                        navController.navigate(startDestination, null, navOptions);
                    }
                });
    }

    ...
}

사용자가 성공적으로 로그인하면 ProfileFragment에서 환영 메시지를 표시합니다.

여기서 결과를 확인하는 데 사용된 기법을 통해 두 가지 사례를 구별할 수 있습니다.

  • 사용자가 로그인되어 있지 않고 로그인 요청을 받아야 하는 초기 사례입니다.
  • 사용자가 false의 결과로 로그인하지 않기로 선택했으므로 로그인되어 있지 않습니다.

이러한 사용 사례를 구별하면 사용자에게 반복적인 로그인 요청을 하지 않을 수 있습니다. 실패 사례를 처리하는 비즈니스 로직은 개발자가 결정해야 하며 이전 코드 예의 사례와 같이 여기에는 사용자가 로그인해야 하는 이유를 설명하는 오버레이 표시, 전체 활동 완료 또는 사용자를 로그인하지 않아도 되는 대상으로 리디렉션하는 것이 포함될 수 있습니다.