본문 바로가기
Android/Jetpack Compose App

JETPACK COMPOSE: 영화 앱 만들기 - 1, Navigation Component, Scaffold, LazyColumn, Passing Data Between Screens

by 개발자J의일상 2022. 2. 18.
반응형

이번 앱을 통해서 배울 것들은 아래와 같습니다. 

 

  • Scaffold - a UI structure for our apps
  • Navigation Component - set of tools for navigation
  • Passing Data between Screens
  • LazyColumn - Showing a list of items on screen

 

위의 4가지를 이 영화 앱을 만들어 보면서 정리해보려고 합니다.

 

Scaffold에 대해 더 자세히 알고 싶은 분은 아래 포스팅을 참고하세요~

https://mypark.tistory.com/entry/JETPACK-COMPOSE-Scaffold-%EC%A0%95%EB%A6%AC

 

JETPACK COMPOSE Scaffold 정리

Jetpack Compose는 Android 프로그래밍의 미래가 될 수 있습니다! 대부분의 기능과 컴포저블(Composable)은 사용하기 쉽고 이해하기 쉽습니다. 또한 엄청난 수의 속성을 가지고 있습니다. 이번 시간에는 Sc

mypark.tistory.com

 

우선 Movie를 Scaffold를 통해 앱 맨 위에 UI를 만들어 주고 MainContent에 영화 리스트를 Text로 출력하는 앱을 만들어 봅시다.

 

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp {
                MainContent()
            }
        }
    }
}

@Composable
fun MyApp(content: @Composable () -> Unit) {
    MovieappTheme {
        val scope = rememberCoroutineScope()
        val scaffoldState = rememberScaffoldState()
        // A surface container using the 'background' color from the theme
        Scaffold( topBar = {
            TopAppBar(backgroundColor = Color.Gray,
                    elevation = 5.dp,
                title = { Text(text = "Moives") },
                navigationIcon = {
                    Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back")
                },
                actions = {
                    Icon(imageVector = Icons.Default.Favorite, contentDescription = "Favorite")
                    Icon( imageVector = Icons.Default.Search, contentDescription = "Search")
                }
            )
        }) {
            content()
        }
    }
}
@Composable
fun MainContent(movieList: List<String> = listOf (
    "Avatar",
    "300",
    "Harry Potter",
    "Life")) {
    Column(modifier = Modifier.padding(12.dp)){
        LazyColumn {
            items(items = movieList) {
                Text(text = it)
            }
        }
    }
}

 

여기에서 ScaffoldtopBarTopAppBar를 매개변수로 넣어서 여러 가지 설정들은 바꿔줍니다.

설정에 관한 것들은 아까 위의 포스팅을 참고해주시기 바랍니다.

 

MainContent는 topBar를 제외한 아래 부분으로 영화의 List가 출력될 곳입니다.

현재 4개의 영화 "Avatar", "300", "Harry Potter", "Life"를 Text로 출력하게 만들었고 LazyColumn을 통해 반복적으로 Text를 생성하였습니다. 

 

LazyColumn안에 items를 주목해주세요 현재 String을 it을 통해 받아올 수 있습니다.

 

지금은 항목이 4개 밖에 없지만 많은 수의 항목이나 길이를 알 수 없는 목록을 표시해야 하는 경우 Column과 같은 레이아웃을 사용하면 모든 항목이 표시 가능 여부와 관계없이 구성되고 배치되므로 성능 문제가 발생할 수 있습니다.

Compose는 구성요소의 표시 영역에 표시되는 항목만 구성하여 배치하는 구성요소 집합을 제공합니다. 이러한 구성요소에는 LazyColumn 및 LazyRow가 포함됩니다.

이름에서 알 수 있듯이 LazyColumn과 LazyRow의 차이점은 항목을 배치하고 스크롤하는 방향입니다. LazyColumn은 세로로 스크롤되는 목록을 생성하고 LazyRow는 가로로 스크롤되는 목록을 생성합니다.

 

https://developer.android.com/jetpack/compose/lists

 

성능 향상을 위해 LazyColumn을 사용하는 것에 주목해주시기 바랍니다.

 

 

이제 MainContent를 좀 더 꾸며봅시다!

MovieRow라는 Composable function을 만들고 안에 Card를 만들고 Card안에 Row 형태로 SurfaceText를 구현합니다.

 

@Composable
fun MainContent(movieList: List<String> = listOf (
    "Avatar",
    "300",
    "Harry Potter",
    "Life")) {
    Column(modifier = Modifier.padding(12.dp)){
        LazyColumn {
            items(items = movieList) {
                MovieRow(movie = it)
            }
        }
    }
}

@Composable
fun MovieRow(movie: String) {
    Card(modifier = Modifier
        .padding(4.dp)
        .fillMaxWidth()
        .height(120.dp),
        shape = RoundedCornerShape(corner = CornerSize(14.dp)),
        elevation = 5.dp) {
        Row(verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.Start) {
            Surface(modifier = Modifier
                .padding(12.dp)
                .size(100.dp),
                    shape = RectangleShape,
                    elevation = 4.dp) {
                Icon(imageVector = Icons.Default.AccountBox,
                    contentDescription = "Movie Image")
            }
            Text(text = movie)
        }
    }
}

 

AccountBox라는 Icon이 Surface에 생성되었고 그 옆에 Row 방향으로 Text가 출력되었습니다.

아까 LazyColumn에서 Text 대신에 MovieRow를 호출해주면 간단히 구현이 가능합니다!

 

 

이제 Navigation에 대해 살펴보려고 합니다.

우리가 하고자 하는 것은 영화를 클릭했을 때 Movie의 Detail을 보여주는 화면을 띄워주려고 합니다.

 

Android Jetpack Compose: The Comprehensive Bootcamp [2022] - Udemy

 

Jetpack Navigation Component는 Android에서 복잡한 navigation 케이스를 처리하기 위한 최신 도구 및 라이브러리 세트입니다.

 

Navigation Component는 3가지 파트로 구성되어 있습니다.

1. Navigation Graph

2. NavHost

3. NavController

 

Android Jetpack Compose: The Comprehensive Bootcamp [2022] - Udemy

1. Navigation Graph는 모든 탐색 관련 정보가 하나의 중심 위치에 모여있는 XML 리소스입니다. 

여기에는 대상이라고 부르는 앱 내의 모든 개별적 콘텐츠 영역과 사용자가 앱에서 갈 수 있는 모든 이용 가능한 경로가 포함됩니다.

2. NavHost는 Navigation Graph에 정의된 화면들을 보여주는 컨테이너 역할을 합니다. 대상 구성요소에는 프래그먼트 대상을 표시하는 기본 NavHost 구현인 NavHostFragement가 포함됩니다.

3. NavController는 우리가 만든 Navigation Graph에 맞게 NavHost 안이 구현되도록 도와주는 객체입니다. 앱 내에서 화면 이동을 할 때 NavHost에서 대상 콘텐츠의 전환에 대한 컨트롤러 역할을 합니다. NavHost에서 앱 탐색을 관리하는 객체입니다.

 

앱을 탐색하는 동안 Navigation Graph에서 특정 경로를 따라 이동할지, 특정 대상으로 직접 이동할지 NavController에게 전달합니다. 그러면 NavController가 NavHost에 적절한 대상을 표시합니다.

 

이제 Navigation Component를 구현해 보도록 하겠습니다.

navigation component를 사용하려면 build.gradle에 아래 implementation을 작성해야 import가 가능합니다.

 

 // Jetpack Compose Integration
  implementation("androidx.navigation:navigation-compose:2.5.0-alpha01")

 

아래처럼 navigation package를 만든 이후 코드를 작성합니다.

enum class를 생성하는데 enum class에 대해 모르시는 분은 아래 글을 참고해주세요~

 

https://mypark.tistory.com/entry/Kotlin-Enumerations-%EC%A0%95%EB%A6%AC

 

Kotlin Enumerations 정리

열거형(enumeration)은 이름들의 모음집입니다. Kotlin의 enum 클래스는 다음 이름을 관리하는 편리한 방법입니다: // Enumerations/Level.kt package enumerations import atomictest.eq enum class Level { Ove..

mypark.tistory.com

 

fromRoute 함수를 구현하는데 String을 받아서 substringBefore을 호출합니다.

substringBefore은 delimiter가 나오기 전의 string을 반환합니다.

fun main() {
  val s = "www.google.com/sign_in"
  println("${s.substringBefore("/")}")
}
/* Output:
www.google.com
*/

 

HomeScreen과 DetailsScreen의 각각의 이름과 같으면 해당하는 enum 값을 반환하고 null이면 그냥 HomeScreen을 이 세 가지 경우가 아닌 경우 Exception을 발생시킵니다.

 

이 코드는 다음 강의에서 사용될 예정입니다.

//MovieScreens.kt
package com.example.movieapp.navigation

import java.lang.IllegalArgumentException

//www.google.com/sign_in
enum class MovieScreens {
    HomeScreen,
    DetailsScreen;
    companion object {
        fun fromRoute(route: String?) : MovieScreens
            = when (route?.substringBefore("/")) {
                HomeScreen.name -> HomeScreen
                DetailsScreen.name -> DetailsScreen
                null -> HomeScreen
                else -> throw IllegalArgumentException("Route $route is not proper")
            }
    }
}

 

companion object를 통해 구현이 되어있는데 companion object에 대한 자세할 설명은 아래 글을 참고부탁드립니다.

 

https://mypark.tistory.com/entry/Kotlin-Companion-Objects-%EC%A0%95%EB%A6%AC

 

Kotlin Companion Objects 정리

멤버 함수는 클래스의 특정 인스턴스에서 작동합니다. 일부 기능은 객체에 "관련"되지 않으므로 해당 객체에 연결될 필요가 없습니다. 컴패니언 객체(companion object) 내부의 함수와 필드는 클래스

mypark.tistory.com

 

이제 navController와 NavHost를 구현해 봅시다.

 

MoiveNavigation이라는 함수를 생성하고 rememberNavController()로 navController를 생성합니다. 

 

NavHost안에 navController에 생성한 navController를 인자로 넣고 startDestination을 HomeScreen으로 설정합니다.

NavHost의 NavGraphBuilder부분의 람다 함수 안에 composable를 생성하고 MovieScreens.HomeScreen.name 일 때 HomeScreen을 호출합니다. 

package com.example.movieapp.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.movieapp.screens.home.HomeScreen

@Composable
fun MovieNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController,
        startDestination = MovieScreens.HomeScreen.name) {
        //NavGraph
        composable(MovieScreens.HomeScreen.name) {
            //here we pass where this should lead us to
            HomeScreen(navController)
        }
    }
}

 

HomeScreen은 우리가 생성했던 UI이고 여기에 이제 navController를 넘기게 됩니다.

일단은 아래와 같이 navController를 받는 형태로 HomeScreen과 MainContent 함수를 수정합니다.

@Composable
fun HomeScreen(navController: NavController) {
    Scaffold( topBar = {
        TopAppBar(backgroundColor = Color.Gray,
            elevation = 5.dp,
            title = { Text(text = "Moives") },
            navigationIcon = {
                Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back")
            },
            actions = {
                Icon(imageVector = Icons.Default.Favorite, contentDescription = "Favorite")
                Icon( imageVector = Icons.Default.Search, contentDescription = "Search")
            }
        )
    }) {
        MainContent(navController = navController)
    }
}

@Composable
fun MainContent(
    navController: NavController,
    movieList: List<String> = listOf (
    "Avatar",
    "300",
    "Harry Potter",
    "Life")) {
    Column(modifier = Modifier.padding(12.dp)){
        LazyColumn {
            items(items = movieList) {
                MovieRow(movie = it){ movie ->
                    Log.d("TAG", "MainContent: $it")
                }
            }
        }
    }
}

 

우리는 동일한 위와 같이 수정하여도 동일한 화면을 얻을 수 있습니다.

 

이제 클릭을 하면 다른 창으로 넘어가는 것을 구현해보겠습니다.

 

우선은 DetailsScreen을 만들고 Text만 출력하는 것을 구현해 보겠습니다.

package com.example.movieapp.screens.details

import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.navigation.NavController

@Composable
fun DetailsScreen(navController: NavController) {
    Text(text = "Details screen!")
}

 

이제 우리가 고쳐야 할 부분은 NavHost 부분입니다.

composable을 추가하고 MovieScreens.DetailsScreen.name에 대한 부분을 추가합니다.

여기서 방금 만든 DetailsScreen을 호출합니다.

@Composable
fun MovieNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController,
        startDestination = MovieScreens.HomeScreen.name) {
        //NavGraph
        composable(MovieScreens.HomeScreen.name) {
            //here we pass where this should lead us to
            HomeScreen(navController = navController)
        }
        composable(MovieScreens.DetailsScreen.name) {
            DetailsScreen(navController = navController)
        }
    }
}

 

마지막으로 MovieRow를 클릭하였을 때 동작하는 람다 함수를 구현합니다.

 

아까 받아온 navController를 가지고 navigate 함수를 호출합니다. 그리고 routeMovieScreens.DetailsScreen.name을 넣어줍니다.

그렇게 되면 아까 위에서 구현된 composable(MovieScreens.DetailsScreen.name)의 경우가 됨으로 DetailsScreen으로 화면이 전환되게 됩니다.

@Composable
fun MainContent(
    navController: NavController,
    movieList: List<String> = listOf (
    "Avatar",
    "300",
    "Harry Potter",
    "Life")) {
    Column(modifier = Modifier.padding(12.dp)){
        LazyColumn {
            items(items = movieList) {
                MovieRow(movie = it){ movie ->
                    navController.navigate(route = MovieScreens.DetailsScreen.name)
                    //Log.d("TAG", "MainContent: $it")
                }
            }
        }
    }
}

 

지금은 그냥 어떤 영화를 클릭하여도 Details Screen! Text만 호출이 되지만 실제로는 이제 영화가 클릭되면 그 영화에 대한 데이터가 전달되도록 하고 싶습니다. 

 

지금 현재는 영화의 이름밖에 없기 때문에 일단은 이름을 출력해보려고 합니다.

 

MovieScreens.DetailsScreen.name에 추가 문자를 넣어주고 arguments에 navArgument로 그 추가 문자를 받아서 DetailsScreen에 넣어주는 코드를 구현하였습니다.

arguments가 null일 수도 있기 때문에 backStackEntry.arugments?.getString("movie")로 ?.인 safe call을 호출합니다.

@Composable
fun MovieNavigation() {
    val navController = rememberNavController()
    NavHost(navController = navController,
        startDestination = MovieScreens.HomeScreen.name) {
        //NavGraph
        composable(MovieScreens.HomeScreen.name) {
            //here we pass where this should lead us to
            HomeScreen(navController = navController)
        }
        composable(MovieScreens.DetailsScreen.name+"/{movie}",
                arguments = listOf(navArgument(name = "movie") { type = NavType.StringType})) {
            backStackEntry ->
            DetailsScreen(navController = navController, backStackEntry.arguments?.getString("movie"))
        }
    }
}
@Composable
fun MainContent(
    navController: NavController,
    movieList: List<String> = listOf (
    "Avatar",
    "300",
    "Harry Potter",
    "Life")) {
    Column(modifier = Modifier.padding(12.dp)){
        LazyColumn {
            items(items = movieList) {
                MovieRow(movie = it){ movie ->
                    navController.navigate(route = MovieScreens.DetailsScreen.name+"/$movie")
                    //Log.d("TAG", "MainContent: $it")
                }
            }
        }
    }
}

 

DetailsScreen은 movieData, 즉 String을 받아서 그냥 출력해주는 코드입니다.

@Composable
fun DetailsScreen(navController: NavController, movieData: String?) {
    Surface(modifier = Modifier
        .fillMaxWidth()
        .fillMaxHeight())
    {
        Column(horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center) {
            Text(text = movieData.toString(), style = MaterialTheme.typography.h2)
        }
    }
}

 

간단하게 영화에 이름이 각각 해당하는 영화를 누를 때마다 출력되는 것을 확인할 수 있습니다.

 

이제 앱 아래 하단에 뒤로 가기처럼 기존 MainContent로 돌아가는 back 버튼을 생성해보려고 합니다. 위의 화면에서 MainContent로 돌아가는 코드를 구현해보려고 합니다.

 

Text 밑에 공백을 줄 Spacer와 Button을 만드는데 Button에 onClick에 위에서 받아온 navController를 이용하면 됩니다.

navController.popBackStack()을 호출하면 바로 그전의 화면으로 이동하게 됩니다.

@Composable
fun DetailsScreen(navController: NavController, movieData: String?) {
    Surface(modifier = Modifier
        .fillMaxWidth()
        .fillMaxHeight())
    {
        Column(horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center) {
            Text(text = movieData.toString(), style = MaterialTheme.typography.h2)
            Spacer(modifier = Modifier.height(20.dp))
            Button(onClick = {
                navController.popBackStack()
            }) {
                Text(text = "Go Back")
            }
        }
    }
}

 

아래와 같이 Go Back 버튼이 생성되고 클릭 시 이전 화면으로 돌아가는 것을 확인할 수 있습니다.

 

이것으로 이번 시간 Movie App 만들기는 마치겠습니다.

 

다음 시간에는 이 Movie App을 좀 더 개선하는 방법에 대해 배워보겠습니다.

 

감사합니다.

300x250

댓글