Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Compose 함수 나누는 조건은?

TaeHwan
July 27, 2024

Compose 함수 나누는 조건은?

GDG 송도 발표자료

TaeHwan

July 27, 2024
Tweet

More Decks by TaeHwan

Other Decks in Programming

Transcript

  1. • Preview 가능한 형태? • 무조건 많이 나누면 효과적일까? 함수

    분리 조건? Split fun Jetpack Compose Compose 함수는 어떤 식으로 분리해야 유용할까요? 무작정 함수를 나누는 것은 큰 의미가 없을 수 있지만 결국 UI를 표현하기 위함이니 Preview 가능한 형태로 분리하는 것이 가장 좋은 선택입니다.
  2. • Preview 가능한 형태? • 무조건 많이 나누면 효과적일까? 함수

    분리 조건? Split fun Jetpack Compose • Preview를 통한 빠른 UI 확인 • Screen ◦ Components를 합쳐서 Screen을 구성하도록 • Components ◦ Screen에 해당하는 Components 구성 ◦ 디자인 시스템에서의 Components 구성 Preview를 통해 UI 확인이 가능하도록 크게 3가지로 분리하여 사용합니다. Screen 단위는 가장 기본적인 단위이며, Navigation 사용 시 각각을 Screen 단위로 나누게 됩니다. 다음으로 Components. Components는 크게 2가지로 나눌 수 있는데, Screen 단위에 맞는 Components(ex 리스트 형태의 화면이나 그 안에서의 화면들), 디자인 시스템에서의 Components로 보통 나누고 있습니다.
  3. 두 개의 Navigation 구성 * Main Screen * WebView Screen

    오늘의 예제에서는 MainScreen과 WebView 용 Screen 두개로 나눴습니다. 이를 감싸는 Navigation 용 Screen 역시 제공합니다.
  4. @SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MainScreen( list: List<NavigationSample>, navController: NavHostController

    = rememberNavController(), ) { var listItem by remember { mutableStateOf(ListItem(emptyList())) } val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination Scaffold( topBar = { TopAppBar( title = { Text(text = currentDestination?.route ?: "") }, ) }, bottomBar = { NavigationBar { list.forEach { screen -> NavigationBarItem( label = { Text( text = screen.title, ) }, selected = currentDestination?.hierarchy?.any { it.route == screen.trigger.name } == true, icon = { Icon( painter = painterResource(id = screen.icon), contentDescription = screen.title ) }, onClick = { navController.navigate(screen.trigger.name) { // Pop up to the start destination of the graph to // avoid building up a large stack of destinations // on the back stack as users select items popUpTo(navController.graph.findStartDestination().id) { saveState = true } // Avoid multiple copies of the same destination when // reselecting the same item launchSingleTop = true // Restore state when reselecting a previously selected item restoreState = true } } ) More 311 line 이 샘플 코드는 GitHub에 풀 코드가 올라가있습니다. 하나의 MainScreen 이란 이름에 311 줄의 라인을 그냥 작성했습니다. 이 코드엔 Scaffold부터 Navigation 등 모든 코드가 하나로 포함되어 있습니다.
  5. • 하나의 Composable ◦ 331 Line • Preview 어려움 ◦

    다양한 데이터 구조를 잡기가 힘듦 ◦ ViewModel 사용 시 Preview 어려움 • 코드 분리를 하지 않아 수정이 어려움 이 코드의 단점 tip 당연한 이야기지만 코드가 길어지니 유지 보수가 어려워집니다. 그리고 Preview 역시 어렵습니다. 특히나 우리가 많이 활용하는 외부 주입 객체가 있는 경우에는 Preview가 불가능할 수 있습니다.
  6. • 목적 ◦ 코드양을 줄여 Components 단위 Preview 가능 •

    분리 방법은? ◦ 단순히 작은 단위로 나눈다 ◦ 클린 코드 형태로 분리 ▪ 함수는 하나의 역할과 맡는 이름을 가지도록 한다 ◦ Stateful, Stateless를 이해한 분리 분리 조건? tip 이러한 코드의 분리 조건은 어떻게 가져가는 것이 좋을까요? 단순히 작은 단위로 나누는 것이 좋을지, 클린 코드 형태로 분리하는 것이 좋을지, 아니면 선언형 UI에서 주로 이야기하는 Stateful, Stateless를 이해한 분리를 하는 것이 좋을지
  7. Scaffold( topBar = { // Top bar 관련 }, bottomBar

    = { // bottom Bar 관련 }, modifier = Modifier .fillMaxSize() ) { Box( modifier = Modifier .padding(it) ) { NavHost( ) { composable(route = NavigationSample.Trigger.HOME.name) { // Home 관련 } composable(route = NavigationSample.Trigger.WEB.name) { // WebView 관련 } } } } 먼저 어디서부터 어떠한 코드를 분리하는 것이 좋을지를 순서대로 이야기해 보겠습니다. 모든 코드에서 큰 부분에 대해서 역할을 분리하였습니다.
  8. Scaffold( topBar = { // Top bar 관련 }, bottomBar

    = { // bottom Bar 관련 }, modifier = Modifier .fillMaxSize() ) { Box( modifier = Modifier .padding(it) ) { NavHost( ) { composable(route = NavigationSample.Trigger.HOME.name) { // Home 관련 } composable(route = NavigationSample.Trigger.WEB.name) { // WebView 관련 } } } } 후보 1 몇 개의 대상을 나누어보았는데 먼저 후보 1. 상단의 TopBar에 해당하는 영역입니다. 저는 이 부분은 코드에서 특별히 분리하지 않고 있습니다. 길어질 부분이 특별히 없기도 하고, 분리했을 때의 이점이 크게 없는 부분입니다.
  9. Scaffold( topBar = { // Top bar 관련 }, bottomBar

    = { // bottom Bar 관련 }, modifier = Modifier .fillMaxSize() ) { Box( modifier = Modifier .padding(it) ) { NavHost( ) { composable(route = NavigationSample.Trigger.HOME.name) { // Home 관련 } composable(route = NavigationSample.Trigger.WEB.name) { // WebView 관련 } } } } 후보 2 후보 2번은 BottomBar의 부분입니다. 아무리 중복 코드를 제거한다 해도 길어질 수 있습니다. 기능적인 빠른 확인도 가능한 부분이라 분리해서 관리해도 좋은 예가 됩니다. 오늘의 발표에선 분리하지 않습니다.
  10. Scaffold( topBar = { // Top bar 관련 }, bottomBar

    = { // bottom Bar 관련 }, modifier = Modifier .fillMaxSize() ) { Box( modifier = Modifier .padding(it) ) { NavHost( ) { composable(route = NavigationSample.Trigger.HOME.name) { // Home 관련 } composable(route = NavigationSample.Trigger.WEB.name) { // WebView 관련 } } } } 후보 3 후보 3 마지막 후보 3번 Screen은 매우 길어지게 됩니다. 지금은 2개의 Screen뿐이지만 하나씩 늘어나면 코드 양도 함께 늘어납니다. 그러니 자연스럽게 2개의 Screen을 분리하는 것이 가장 적합한 예입니다.
  11. • 후보 1번은 매우 짧은 코드라 분리하지 않음 • 후보

    2번 ◦ BottomBarComponents • 후보 3 ◦ HomeScreen ◦ WebScreen 크게 3개로 분리할 수 있다 Screen 이 발표에서는 후보 3번의 코드를 분리하는 과정을 순차적으로 정리해 보겠습니다.
  12. @Composable internal fun HomeScreen() { var listItem by remember {

    mutableStateOf(ListItem(emptyList())) } Column { LazyColumn { items(listItem.items) { item -> Surface( shape = MaterialTheme.shapes.small, ) { if (item.editMode) { Column() { } } else { Column() { } } } } } Button( onClick = {}, modifier = Modifier .padding(20.dp) ) { Text(text = "New") } } } @Preview(showBackground = true) @Composable private fun PreviewHomeScreen() { HomeScreenOne() } 코드 생략 일부 코드는 생략하고 중요한 코드만 정리하였습니다. 1차적으로 기존 MainScreen의 전체 코드 중에서 Navigation에 적용한 Composable Home 부분을 그대로 복사해서 이동시켰습니다.
  13. @Composable internal fun HomeScreen() { var listItem by remember {

    mutableStateOf(ListItem(emptyList())) } Column { LazyColumn { items(listItem.items) { item -> Surface( shape = MaterialTheme.shapes.small, ) { if (item.editMode) { Column() { } } else { Column() { } } } } } } } @Preview(showBackground = true) @Composable private fun PreviewHomeScreen() { HomeScreen() } 이 코드에 대한 Preview를 적용했습니다. 그런데 한 가지 문제가 있습니다. Preview는 그려졌지만 실제로 의미 없는 Preview가 되었습니다. 이유는 3번째 줄의 remember{} 부분 때문입니다.
  14. @Composable internal fun HomeScreen() { var listItem by remember {

    mutableStateOf(ListItem(emptyList())) } Column { LazyColumn { items(listItem.items) { item -> Surface( shape = MaterialTheme.shapes.small, ) { if (item.editMode) { Column() { } } else { Column() { } } } } } Button( onClick = {}, modifier = Modifier .padding(20.dp) ) { Text(text = "New") } } } @Preview(showBackground = true) @Composable private fun PreviewHomeScreen() { HomeScreenOne() } 지금 Preview는 의미가 없다
  15. Stateful versus stateless Proprietary & Confidential Stateful, stateless를 통해 코드를

    분리할 수 있는데, 이에 대한 개념을 익혀봅니다.
  16. remember를 사용해 객체를 저장하는 Composable 포함 ex) ViewModel에서 State 처리

    외부에서 값을 변경하거나 이벤트로 전달할 수 있는 형태 Stateful Stateless Stateful versus stateless State 구글 문서상 Stateful은 remember를 사용해 객체를 저장하는 Composable을 포함하는 부분입니다. 일반적으론 ViewModel의 state를 collect() 하는 부분을 말합니다. stateless는 외부에서 값을 변경하거나 이벤트로 전달할 수 있는 형태 즉 외부에서 주입하는 값에 따라 화면이 달라지는 형태를 뜻합니다. * 이 문서 참고 : https://developer.android.com/develop/ui/compose/state
  17. @Composable internal fun HomeScreen() { val listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen(listItem) } @Composable internal fun HomeScreen(listItem: ListItem) { LazyColumn { items(listItem.items) { item -> Text( text = item.text, style = MaterialTheme.typography.bodyMedium, ) } } } 두 개의 함수가 있습니다. 위 HomeScreen은 remember{}를 포함하고 있고, 아래 HomeScreen은 listItem을 외부에서 주입받고 있습니다.
  18. @Composable internal fun HomeScreen() { val listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen(listItem) } @Composable internal fun HomeScreen(listItem: ListItem) { LazyColumn { items(listItem.items) { item -> Text( text = item.text, style = MaterialTheme.typography.bodyMedium, ) } } } Stateful 위 HomeScreen을 Stateful이라고 칭하고
  19. @Composable internal fun HomeScreen() { val listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen(listItem) } @Composable internal fun HomeScreen(listItem: ListItem) { LazyColumn { items(listItem.items) { item -> Text( text = item.text, style = MaterialTheme.typography.bodyMedium, ) } } } Stateless 아래 HomeScreen을 Stateless라 칭하게 됩니다.
  20. @Composable internal fun HomeScreen() { val listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen(listItem) } @Composable internal fun HomeScreen(listItem: ListItem) { LazyColumn { items(listItem.items) { item -> Text( text = item.text, style = MaterialTheme.typography.bodyMedium, ) } } } HomeScreen (Stateful) HomeScreen (Stateless) state 도식화하면 오른쪽과 같습니다. 위 HomeScreen에서 viewModel 또는 remember를 통해 값을 정의할 수 있고, 이를 아래 HomeScreen에 주입하게 됩니다. 아래 HomeScreen은 이제 외부에서 정의해 주는 값을 표현하게 됩니다.
  21. @Composable internal fun HomeScreen() { val listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen(listItem) } @Composable internal fun HomeScreen(listItem: ListItem) { LazyColumn { items(listItem.items) { item -> Text( text = item.text, style = MaterialTheme.typography.bodyMedium, ) } } } HomeScreen (Stateful) HomeScreen (Stateless) state event 아래 HomeScreen에서는 event를 다시 Stateful 함수에 전달해 줄 수 있습니다.
  22. 상태를 갖는 Composable 구성하기에 테스트하기 어렵다 외부에서 값을 변경하거나 이벤트로

    전달할 수 있는 형태로 테스트가 유연 • 단일 정보 소스로 관리 • 캡슐화 • 공유 가능 • 가로채기 가능 • 분리 Stateful Stateless Stateful versus stateless State Stateful 은 상태를 갖는 Composable 함수를 구성하기에 테스트가 어렵습니다. Stateless는 외부에서 값을 변경하거나, 이벤트를 통해 전달할 수 있는 형태로 테스트(Preview)가 유연해지게 되는데, 아래와 같이 5가지 장점이 생깁니다. • 단일 정보 소스: 상태를 복제하는 대신 옮겼기 때문에 정보 소스가 하나만 있습니다. 버그 방지에 도움이 됩니다. • 캡슐화됨: 스테이트풀(Stateful) 컴포저블 만 상태를 수정할 수 있습니다. 철저히 내부적 속성입니다. • 공유 가능함: 호이스팅한 상태를 여러 컴포저블과 공유할 수 있습니다. 다른 컴포저블에서 name을 읽으려는 경우 호이스팅을 통해 그렇게 할 수 있습니다. • 가로채기 가능함: 스테이트리스(Stateless) 컴포저블의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다. • 분리됨: 스테이트리스(Stateless) 컴포저블의 상태가 저장될 수 있습니다. 액세스할 수 있습니다 예를 들어 이제 name를 ViewModel로 이동할 수 있습니다.
  23. Stateful, Stateless 방식을 잘 활용하면 유연한 개발 가능 State 결국

    Stateful과 Stateless를 적절하게 차용하면 Composable 함수 분리를 쉽게 할 수 있게 됩니다.
  24. @Composable internal fun HomeScreen() { var listItem by remember {

    mutableStateOf(ListItem(emptyList())) } HomeScreen( listItem = listItem, onEvent = { listItem = it } ) } @Composable private fun HomeScreen( listItem: ListItem, onEvent: (listItem: ListItem) -> Unit, ) { Column { LazyColumn { items(listItem.items) { item -> Surface( shape = MaterialTheme.shapes.small, ) { if (item.editMode) { Column() { } } else { Column() { } } } } } } } @Composable internal fun HomeScreen() { var listItem by remember { mutableStateOf(ListItem(emptyList())) } Column { LazyColumn { items(listItem.items) { item -> Surface( shape = MaterialTheme.shapes.small, ) { if (item.editMode) { Column() { } } else { Column() { } } } } } } } 결국 왼쪽의 HomeScreen에서 remember 부분을 Stateful로 잡아주면 이전과 다르게 Preview가 가능한 형태로 변경됩니다.
  25. @Preview( showBackground = true, ) @Composable private fun PreviewHomeScreen() {

    var listItem by remember { mutableStateOf( ListItem( listOf( ListItem.Item( index = 0, text = "message", editMode = false, ), ListItem.Item( index = 1, text = "", editMode = true, ), ) ) ) } HomeScreen( listItem = listItem, onEvent = { listItem = it } ) } Preview를 통해 listItem을 주입하게 되고, event도 받게 되니 이전과 다르게 Preview를 잘 활용할 수 있도록 변경되었습니다.
  26. @Preview( showBackground = true, ) @Composable private fun PreviewHomeScreen() {

    var listItem by remember { mutableStateOf( ListItem( listOf( ListItem.Item( index = 0, text = "message", editMode = false, ), ListItem.Item( index = 1, text = "", editMode = true, ), ) ) ) } HomeScreen( listItem = listItem, onEvent = { listItem = it } ) } 첫 번째 아이템에 대한 Preview
  27. @Preview( showBackground = true, ) @Composable private fun PreviewHomeScreen() {

    var listItem by remember { mutableStateOf( ListItem( listOf( ListItem.Item( index = 0, text = "message", editMode = false, ), ListItem.Item( index = 1, text = "", editMode = true, ), ) ) ) } HomeScreen( listItem = listItem, onEvent = { listItem = it } ) } 두 번째 아이템에 대한 Preview
  28. • Preview 가능 • 마우스/기기를 통한 테스트 가능 • 화면

    사이즈별로 즉시 확인 가능(Preview 옵션) 장점 Stateless Tablet Foldable Phone 이러한 Stateless의 장점은 Preview, 마우스/기기를 통한 테스트가 가능해지고, 화면 사이즈별 즉시 확인 역시 가능해집니다.
  29. 값을 입력하고, 삭제 입력한 값을 확인 Edit mode View mode

    Components Components 아직은 긴 코드를 가지고 있는데 다음으로 Components도 분리해 봅시다. 제가 작성한 코드에는 크게 EditMode와 ViewMode 두 개가 분리되어 있습니다.
  30. @Composable internal fun HomeItemEdit( item: ListItem.Item, onEditModeOff: (changeItem: ListItem.Item) ->

    Unit, onCancel: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp) ) { var changeItem by remember(item) { mutableStateOf(item) } TextField( value = changeItem.text, onValueChange = { new -> changeItem = changeItem.copy(text = new) }, modifier = Modifier.fillMaxWidth() ) Row { Button( onClick = { onEditModeOff(changeItem) }, modifier = Modifier.weight(1f) ) { Text(text = "Save") } Button( onClick = { onCancel() }, modifier = Modifier.weight(1f).padding(start = 10.dp) ) { Text(text = "X") } } } } EditMode는 값을 입력할 수 있는 Composable 함수로 작성하였고,
  31. @Composable internal fun HomeItemEdit( item: ListItem.Item, onEditModeOff: (changeItem: ListItem.Item) ->

    Unit, onCancel: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp) ) { var changeItem by remember(item) { mutableStateOf(item) } TextField( value = changeItem.text, onValueChange = { new -> changeItem = changeItem.copy(text = new) }, modifier = Modifier.fillMaxWidth() ) Row { Button( onClick = { onEditModeOff(changeItem) }, modifier = Modifier.weight(1f) ) { Text(text = "Save") } Button( onClick = { onCancel() }, modifier = Modifier.weight(1f).padding(start = 10.dp) ) { Text(text = "X") } } } } Stateless 작성 Stateless로 작성하였습니다.
  32. TextField의 입력값의 상태는 내부에서 기억하도록 작성했습니다. (ViewModel로 작성한다면 거기서 바로

    처리하거나 하겠지만) Save button의 이벤트가 발생할 경우 외부로 값이 전달됩니다. @Composable internal fun HomeItemEdit( item: ListItem.Item, onEditModeOff: (changeItem: ListItem.Item) -> Unit, onCancel: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp) ) { var changeItem by remember(item) { mutableStateOf(item) } TextField( value = changeItem.text, onValueChange = { new -> changeItem = changeItem.copy(text = new) }, modifier = Modifier.fillMaxWidth() ) Row { Button( onClick = { onEditModeOff(changeItem) }, modifier = Modifier.weight(1f) ) { Text(text = "Save") } Button( onClick = { onCancel() }, modifier = Modifier.weight(1f).padding(start = 10.dp) ) { Text(text = "X") } } } } TextField 정보 갱신을 위한 처리
  33. @Composable internal fun HomeItemEdit( item: ListItem.Item, onEditModeOff: (changeItem: ListItem.Item) ->

    Unit, onCancel: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp) ) { var changeItem by remember(item) { mutableStateOf(item) } TextField( value = changeItem.text, onValueChange = { new -> changeItem = changeItem.copy(text = new) }, modifier = Modifier.fillMaxWidth() ) Row { Button( onClick = { onEditModeOff(changeItem) }, modifier = Modifier.weight(1f) ) { Text(text = "Save") } Button( onClick = { onCancel() }, modifier = Modifier.weight(1f).padding(start = 10.dp) ) { Text(text = "X") } } } } 저장하거나, 취소하거나 저장하고 취소하는 버튼을 2개 두었습니다. 이 코드의 이벤트는 모두 외부로 전달합니다.
  34. @Preview(showBackground = true) @Composable private fun PreviewHomeItemEdit() { var item

    by remember { mutableStateOf(ListItem.Item.NEW) } HomeItemEdit( item = item, onEditModeOff = { changeItem -> item = changeItem.copy( editMode = false, ) }, onCancel = { item = ListItem.Item.NEW }, ) } 이에 대한 Preview 역시 가능합니다. 바로 테스트도 가능하게 됩니다.
  35. @Composable internal fun HomeItemView( item: ListItem.Item, onRemove: () -> Unit,

    onEditMode: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth().background(color = Color.Gray.copy(0.3f)) ) { Row { Text( text = item.text, modifier = Modifier.weight(1f).padding(horizontal = 16.dp).padding(top = 16.dp) ) IconButton( onClick = { onRemove() }, ) { Icon(painter = painterResource(id = R.drawable.baseline_close_24), contentDescription = "remove") } } Button( onClick = { onEditMode() }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 10.dp, bottom = 16.dp) ) { Text(text = "edit") } } } 이번엔 ViewMode입니다.
  36. @Preview(showBackground = true) @Composable private fun PreviewHomeItemView() { var item

    by remember { mutableStateOf( ListItem.Item.NEW.copy(text = "message~!!!!\naaaa") ) } HomeItemView( item = item, onRemove = { // Do nothing }, onEditMode = { item = item.copy( editMode = true, ) } ) } ViewMode 역시 외부 주입 형태로 작성하였기에 Preview에서 빠른 미리 보기가 가능해졌습니다.
  37. /** * Button의 재사용이 불가, Save 전용 * 함수를 나눈다는

    장점은 있지만 필요할까? */ @Composable private fun SaveButton( changeItem: ListItem.Item, modifier: Modifier, onEditModeOff: (changeItem: ListItem.Item) -> Unit, ) { Button( onClick = { onEditModeOff(changeItem) }, modifier = modifier ) { SaveText() } } /** * Text 하나 뿐인데 굳이...? */ @Composable private fun SaveText() { Text(text = "Save") } 이미 Components인 Text, Button을 Screen에서 활용할 건데 Title, SubText, Content … 등으로 과도한 적용? Don’t Example 1 첫 번째 예제 Composable 함수를 어디까지 나누는 것이 좋을까요? 무작정 많이 나누는 것은 답은 아닙니다.
  38. /** * Button의 재사용이 불가, Save 전용 * 함수를 나눈다는

    장점은 있지만 필요할까? */ @Composable private fun SaveButton( changeItem: ListItem.Item, modifier: Modifier, onEditModeOff: (changeItem: ListItem.Item) -> Unit, ) { Button( onClick = { onEditModeOff(changeItem) }, modifier = modifier ) { SaveText() } } /** * Text 하나 뿐인데 굳이...? */ @Composable private fun SaveText() { Text(text = "Save") } 이미 Components인 Text, Button을 Screen에서 활용할 건데 Title, SubText, Content … 등으로 과도한 적용? Don’t Example 1 첫 번째 예제 여기서처럼 SaveText()까지 Composable로 나누는 것이 적합할까요? 개인적인 명확한 다음 의미 없다. StringResource도 있고, 재사용 가능한 형태로 코드를 작성해야 수정도 쉬운데 나중에 Save 대신 다른 이름이 온다면? 그럼 그때 또 수정하고 대응하려고 함수명까지 교체해야 할까요? 이 부분은 디자인을 눈으로 보고 합쳐져있는 UI에 대해 확인이 가능해야 하는데 이미 그 범위를 벗어난 형태로 보입니다.
  39. /** * Button을 재사용할 수 있도록 수정 */ @Composable private

    fun HomeButton( onClick: () -> Unit, modifier: Modifier = Modifier, text: String, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } /** * 사용하는 경우 */ Row { HomeButton( onClick = { }, text = "Save", modifier = Modifier .weight(1f) ) HomeButton( onClick = { }, text = "x", modifier = Modifier .weight(1f) .padding(start = 10.dp) ) } Button을 공용 Components로 만들어 사용하는 방법 Suggestion Example 1 첫 번째 예제 그래서 정말 필요한 형태로 바꾼다면 Button을 Text 주입으로도 동작할 수 있도록 코드를 수정하는 방법입니다. 이렇게 하면 새로운 버튼 추가도 빠르게 할 수 있고, Text 관련 정보가 변경되어도, HomeButton 부분만 수정하면 됩니다.
  40. @Composable private fun HomeButton( onClick: () -> Unit = {},

    modifier: Modifier = Modifier, text: String, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } @Preview(showBackground = true) @Composable private fun PreviewHomeButton() { Row { HomeButton( onClick = {}, modifier = Modifier .weight(1f), text = "Save", ) HomeButton( onClick = {}, modifier = Modifier .weight(1f) .padding(start = 10.dp), text = "x", ) } } Modifier 기준으로 최상 또는 최하단에 둔다? 사용할 때도 중간에? Maybe Example 2 두 번째 예제 Modifier 기준을 최상 또는 최하단에 두는 경우입니다. 어디에 두는 것이 적합할까요?
  41. @Composable private fun HomeButton( onClick: () -> Unit = {},

    modifier: Modifier = Modifier, text: String, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } @Preview(showBackground = true) @Composable private fun PreviewHomeButton() { Row { HomeButton( onClick = {}, modifier = Modifier .weight(1f), text = "Save", ) HomeButton( onClick = {}, modifier = Modifier .weight(1f) .padding(start = 10.dp), text = "x", ) } } Modifier 기준으로 최상 또는 최하단에 둔다? 사용할 때도 중간에? Maybe Example 2 코드 가이드 위치 X 두 번째 예제 오른쪽 코드만 먼저 보면 코드 가이드 위치가 틀렸습니다. 린트 오류가 발생할 거예요.
  42. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } @Preview(showBackground = true) @Composable private fun PreviewHomeButton() { Row { HomeButton( onClick = {}, text = "Save", modifier = Modifier .weight(1f) ) HomeButton( onClick = {}, modifier = Modifier .weight(1f) .padding(start = 10.dp), text = "x", ) } } Components의 Modifier 위치는 Optional Parameter 중 가장 첫 번째 위치로 Suggestion Example 2 두 번째 예제 구글 문서상 지금과 같이 필수 값은 modifier 위에, 옵션 값은 modifier 아래에 두는 것입니다. 어떻게 보면 기준을 잡고 위/아래로 분리한 것입니다.
  43. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } @Preview(showBackground = true) @Composable private fun PreviewHomeButton() { Row { HomeButton( onClick = {}, text = "Save", modifier = Modifier .weight(1f) ) HomeButton( onClick = {}, modifier = Modifier .weight(1f) .padding(start = 10.dp), text = "x", ) } } Components의 Modifier 위치는 Optional Parameter 중 가장 첫 번째 위치로 Suggestion Example 2 코드 가이드 위치
  44. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } @Preview(showBackground = true) @Composable private fun PreviewHomeButton() { Row { HomeButton( onClick = {}, text = "Save", modifier = Modifier .weight(1f) ) HomeButton( onClick = {}, modifier = Modifier .weight(1f) .padding(start = 10.dp), text = "x", ) } } Components의 Modifier 위치는 Optional Parameter 중 가장 첫 번째 위치로 개인적으로 사용할 때도 Modifier는 맨 마지막에 둔다. • Modifier가 길어지기 때문에 아래로 내림 Suggestion Example 2 코드 가이드 위치 개인적인 위치 두 번째 예제 그럼 사용할 때는? 구글 가이드는 따로 없는 것 같고, 순서대로 표기하는 방식을 사용하고 있습니다. 개인적으론 Modifier가 길어지는 것이 싫어서 항상 최하단에 위치시키고 있습니다.
  45. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, ) { Text( text = text, modifier = modifier ) } } Modifier는 Container(최상위 Components)에 적용하지 않으면 의도하지 않은 UI로 노출 Maybe Example 3 세 번째 예제 Modifier는 어디에 적용하는 것이 좋을까요? Composable 함수 아무 곳에서 나 외부에서 주입한 Modifier를 적용하는 것이 맞을까요?
  46. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, ) { Text( text = text, modifier = modifier ) } } Modifier는 Container(최상위 Components)에 적용하지 않으면 의도하지 않은 UI로 노출 Maybe Example 3 의도하지 않은 UI 노출 유발 세 번째 예제 일단 임의로 Text 부분에 넣어보겠습니다. 그럼 외부에서 설정한 Modifier 정보는 button Container가 아닌 Text에 포함됩니다. 대부분의 사람들은 당연히 container에 적용될 거라고 기대할 것이지만 그렇지 않았습니다.
  47. @Composable private fun HomeButton( text: String, modifier: Modifier = Modifier,

    onClick: () -> Unit = {}, ) { Button( onClick = onClick, modifier = modifier ) { Text(text = text) } } Modifier는 Container(최상위 Components)에 적용 Suggestion Example 3 세 번째 예제 당연한 것이지만 최상위 container에 적용해야 합니다.
  48. • Text ◦ Text를 앱 내의 공통 Components로 작성할 경우

    TextStyle 등을 한 번에 적용하는 것이 가능 • Button ◦ 디자인 가이드에 따라 Button을 공통화하고, 디자인을 적용할 수 있다(사용의 편리) • TextField ◦ 디자인 가이드에 따라 TextField을 공통화 목적 Design Components 디자인 컴포넌트를 사용하는 이유는 명확합니다. 우리 서비스 자체에서 사용하는 디자인을 포함하여 컴포넌트 화 시키는 부분입니다. 내부에서 사용하는 디자인 시스템이 없더라도 디자인 컴포넌트는 만들어 두는 것이 좋습니다. Text를 예로 들면 내부에서만 사용하는 textStyle을 강제로 적용할 수도 있고, Button 색상 가이드에 맞게 적용하는 것 역시 가능합니다.
  49. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) { Button( onClick = onClick, colors = ButtonDefaults.buttonColors( containerColor = colors.containerColor().value, contentColor = colors.contentColor().value, ), modifier = modifier .defaultMinSize(minHeight = ExampleButtonDefaults.MinHeight) ) { Text( text = text, style = textStyle, color = colors.contentColor().value, modifier = Modifier .padding(contentPadding) ) } } 위에서 작성한 HomeButton을 ExampleButton으로 바꾸어 공통 버튼 형태로 구성하였습니다. 여기에서 알아야 할 부분들을 몇 가지 차례로 살펴보겠습니다.
  50. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) { Button( onClick = onClick, colors = ButtonDefaults.buttonColors( containerColor = colors.containerColor().value, contentColor = colors.contentColor().value, ), modifier = modifier .defaultMinSize(minHeight = ExampleButtonDefaults.MinHeight) ) { Text( text = text, style = textStyle, color = colors.contentColor().value, modifier = Modifier .padding(contentPadding) ) } } Defaults 정의 값을 초기화 코틀린의 함수에는 Defaults 정의가 가능한데, 컴포즈에서도 동일하게 사용합니다. 여기서 Defaults를 나눌 때는 크게 2가지가 있는데 누가 보아도 이해할 수 있는 true/false와 같은 기본값, 그리고 Defaults를 통해 명확한 이름을 제공하는 형태가 있습니다.
  51. object ExampleButtonDefaults { val MinHeight = 50.dp val ContentPadding =

    PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 12.dp) val defaultTextStyle: TextStyle @Composable get() = MaterialTheme.typography.bodyMedium @Composable fun filledButtonColors( containerColor: Color = MaterialTheme.colorScheme.secondary, contentColor: Color = MaterialTheme.colorScheme.onSecondary, ): ExampleButtonColors = ExampleButtonColors( containerColor = containerColor, contentColor = contentColor, ) } @Immutable data class ExampleButtonColors internal constructor( private val containerColor: Color, private val contentColor: Color, ) { @Composable internal fun containerColor(): State<Color> { return rememberUpdatedState(containerColor) } @Composable internal fun contentColor(): State<Color> { return rememberUpdatedState(contentColor) } } Defaults에는 값을 가지고 구분하기 힘들거나, colors와 같은 정보들을 포함합니다. MinHeight를 미리 지정하거나, shape 역시 미리 지정할 수 있습니다. Color 정보는 기존 머트리얼에 포함되어 있는 정보를 참고하여 구성하시면 도움 됩니다.
  52. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) • 필수값, 옵션값으로 나뉘고 • 옵션값은 inline과 defaults로 구분하여 사용 Parameters 종류 Parameters 파라미터 종류는 앞에서 짧게 적었는데 다시 적어보면 필수 값과 옵션 값이 있습니다. 옵션 값에는 inline과 defaults로 구분하여 사용합니다.
  53. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) • 필수값, 옵션값으로 나뉘고 • 옵션값은 inline과 defaults로 구분하여 사용 Parameters 종류 Parameters 필수로 입력 필수 값은 값이 비어있으니 외부에서 필수로 적용하는 값을 말하고
  54. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) • 필수값, 옵션값으로 나뉘고 • 옵션값은 inline과 defaults로 구분하여 사용 Parameters 종류 Parameters 옵션 inline 옵션 값 중에 inline은 누가 보아도 바로 이해할 수 있는 값을 나타냅니다.
  55. @Composable fun ExampleButton( text: String, onClick: () -> Unit, modifier:

    Modifier = Modifier, enabled: Boolean = true, contentPadding: PaddingValues = ExampleButtonDefaults.ContentPadding, textStyle: TextStyle = ExampleButtonDefaults.defaultTextStyle, colors: ExampleButtonColors = ExampleButtonDefaults.filledButtonColors(), ) • 필수값, 옵션값으로 나뉘고 • 옵션값은 inline과 defaults로 구분하여 사용 Parameters 종류 Parameters 옵션 Defaults 옵션 Defaults는 내부에서 정의한 값, 높이나 스타일이나 padding 등의 정보입니다.
  56. • true/false와 같은 직관적인 값에 활용 • 한눈에 예측 가능

    한 값을 제외한 부분을 Defaults로 정의 • 색상 정보, 버튼의 폰트 정보, Padding 등을 정의 ◦ 디자인 가이드상 크게 변하지 않는 부분을 정의 ◦ 상황에 따라 외부에 노출하여 수정 가능하도록 정의 inline Defaults Parameters 종류 Parameters defaults는 한눈에 예측 가능한 값을 제외한 부분을 정의합니다. 상황에 따라 외부에 노출하거나, 노출하지 않을 수 있습니다.(노출하지 않는다면 강제 적용이라고 보면 됩니다.)
  57. • 새로운 값의 적용 빈도가 높을 경우 활용 • 새로운

    값의 적용 빈도가 최소한 인 경우 활용 compositionLocal staticCompositonLocal 2개의 compositionLocal CompositionLocal CompositionLocal은 크게 2가지가 제공됩니다. compositionLocal은 새로운 값의 적용 빈도가 높을 경우 활용하는 것이고, staticCompositionLocal은 새로운 값의 적용 빈도가 최소한인 경우 활용할 수 있습니다. 두 개의 recomposition 범위가 명확히 다르기 때문에 테스트해 보시면 명확한 차이를 볼 수 있습니다.
  58. @Composable fun Button( content: @Composable RowScope.() -> Unit ) {

    Surface( onClick = onClick, modifier = modifier.semantics { role = Role.Button }, enabled = enabled, shape = shape, color = containerColor, contentColor = contentColor, shadowElevation = shadowElevation, border = border, interactionSource = interactionSource ) { ProvideContentColorTextStyle( contentColor = contentColor, textStyle = MaterialTheme.typography.labelLarge ) { Row( Modifier.defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight ) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content ) } } } 내부 Button 코드를 가지고 참고해 보겠습니다.
  59. @Composable fun Button( content: @Composable RowScope.() -> Unit ) {

    Surface( onClick = onClick, modifier = modifier.semantics { role = Role.Button }, enabled = enabled, shape = shape, color = containerColor, contentColor = contentColor, shadowElevation = shadowElevation, border = border, interactionSource = interactionSource ) { ProvideContentColorTextStyle( contentColor = contentColor, textStyle = MaterialTheme.typography.labelLarge ) { Row( Modifier.defaultMinSize( minWidth = ButtonDefaults.MinWidth, minHeight = ButtonDefaults.MinHeight ) .padding(contentPadding), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = content ) } } } Local 정보는 이 위치에 사용 내부 버튼에는 이와 같이 Local 처리하는 부분이 포함되어 있습니다. ProvideContentColorTextStyle이라는 함수를 사용하고 있습니다.
  60. @Composable internal fun ProvideContentColorTextStyle( contentColor: Color, textStyle: TextStyle, content: @Composable

    () -> Unit ) { val mergedStyle = LocalTextStyle.current.merge(textStyle) CompositionLocalProvider( LocalContentColor provides contentColor, LocalTextStyle provides mergedStyle, content = content ) } 이를 따라가보면 2개의 LocalContentColor, LocalTextStyle을 지정하고 있습니다. 이때 주의해야 할 부분이 있는데 꼭 merge 형태를 취해야 합니다. 단순히 값을 바꾸는 것이 아니라 기존 값과 사용자가 추가한 값 중 사용자가 추가한 값에 대한 우선순위가 높아야 합니다.
  61. @Composable internal fun ProvideContentColorTextStyle( contentColor: Color, textStyle: TextStyle, content: @Composable

    () -> Unit ) { val mergedStyle = LocalTextStyle.current.merge(textStyle) CompositionLocalProvider( LocalContentColor provides contentColor, LocalTextStyle provides mergedStyle, content = content ) } Content 범위 부터 적용할 ContentColor와 Style
  62. • 범위를 지정 할때는 외부 명시적인(Parameter)를 활용한 값과 머지 하여

    사용 ◦ val mergedStyle = LocalTextStyle.current.merge(textStyle) 사용시 주의 CompositionLocal 사용 시 주의해야 할 부분이 그 부분입니다. 구글에서는 compositionLocal 사용을 지양하라고 하지만 사용해야 할 경우가 생깁니다. 이때는 무조건 외부에서 주입하는 값의 우선순위가 높도록 지정해야 합니다. 꼭 필요한 정보가 아니면 사용하지 않는 것도 하나의 예입니다.
  63. Navigation + WebView Navigation과 WebView를 함께 사용하는 경우가 있을 수

    있는데 이때 어떻게 처리하는 것이 좋을지를 다루는 부분입니다. 이 부분이 바로 앞에서 학습한 compositionLocal과 이어지게 됩니다.
  64. CompositionLocal CompositionLocal을 활용 전 CompositionLocal을 활용 후 앞에서 학습한 CompositionLocal을

    활용해 본 예입니다. 여기서는 staticCompositonLocal을 사용합니다. 이유는 잦은 변화가 없고, 더 유용한 형태를 필요로 하기 때문입니다.
  65. internal object LocalWebOwner { private val LocalComposition = staticCompositionLocalOf<WebView?> {

    null } val current: WebView? @Composable get() = LocalComposition.current infix fun provides(registerOwner: WebView?): ProvidedValue<WebView?> = LocalComposition provides registerOwner } staticCompositionLocal을 사용하여 LocalWebOwner 클래스를 구현하였습니다. WebView가 null인 경우에도 넘어갈 수 있도록 구성하였습니다.
  66. val context = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) {

    val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination Scaffold() { Box( modifier = Modifier .padding(it) ) { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } 그리고 MainScreen 부분에서 최상단 부분을 감싸줍니다. 새로운 WebView를 객체로 초기화합니다.
  67. val context = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) {

    val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination Scaffold() { Box( modifier = Modifier .padding(it) ) { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } Local 정보는 이 위치에 사용
  68. val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination val context

    = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) { Scaffold() { Box { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } NavController 코드 위치 중요 Don’t Navigation Compose에서는 CompositionLocal 적용하는 위치가 매우 중요합니다! 지금의 코드에는 문제가 있습니다.
  69. val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination val context

    = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) { Scaffold() { Box { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } NavController 코드 위치 중요 Don’t Navigation 이 위치가 중요 바로 navController에서 값을 불러오는 currentBackStackEntry 부분입니다. 이 위치가 지금 오른쪽과 같이 작성되어 있다면 왼쪽처럼 화면에 흰 바탕이 보였다가 웹 로드가 다시 될 것입니다. 이유는 리컴포지션 시작 부분이 CompositionLocal 위에서 시작되어 CompositionLocal 도 다시 부르게 된다는 점입니다. static으로 작성되어 있을 경우 영향의 범위가 하위 모든 컴포넌트에 영향을 미치게 됩니다.
  70. val context = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) {

    val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination Scaffold { Box { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } 이런 경우 CompositionLocalProvider 정의 안에 navController 사용 부분을 포함해야 함 Use Navigation 리컴포지션이 민감하니 이런 부분을 주의하여 지금과 같이 꼭! 위치를 조정해야 합니다.
  71. val context = LocalContext.current CompositionLocalProvider( LocalWebOwner provides WebView(context) ) {

    val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination Scaffold { Box { NavHost( navController = navController, startDestination = NavigationSample.Trigger.HOME.name, ) { composable(route = NavigationSample.Trigger.HOME.name) { HomeScreenThree() } composable(route = NavigationSample.Trigger.WEB.name) { WebScreen() } } } } } 이런 경우 CompositionLocalProvider 정의 안에 navController 사용 부분을 포함해야 함 Use Navigation 여기로 이동 안으로 이동하면 웹뷰 객체는 살아있고, 웹만 리로드 함을 알 수 있습니다.
  72. • Compose 가이드인 Stateful, Stateless를 적절하게 이용하여 코드를 분리 ◦

    과도한 분리는 오히려 유지 보수성을 떨어지도록 한다 • 개인적으로는 ◦ Screen ◦ Screen에 해당하는 Components ▪ 해당 Screen에서 분리하기 위함 ◦ Design Components ▪ 공통으로 관리하고 대부분 디자인 시스템으로 관리 정리하면 Conclusion