코틀린 & Java/컴포즈 Compose

컴포즈(compose) 앱 스타일링

코딩하는후운 2024. 1. 29. 11:15
반응형

컴포즈 앱 스타일링

1 색상, 모양, 텍스트 스타일의 정의

대부분의 컴포즈 UI는 android.compose.material 패키지에 내장된 컴포저블 함수를 사용할 것이다. 브랜드나 회사들은 그들의 정체성을 반영하는 색상, 모양, 텍스트 컬러를 정의하곤 한다.

그렇기 때문에 기본적으로 제공되는 머터리얼 컴포저블 함수의 모양을 수정할 필요가 있다.

머터리얼 테마의 메인 진입점은 MeterialTheme()이다. 이 컴포저블은 커스텀 색상, 모양, 텍스트 스타일을 매개변수로 전달받는다. 값을 설정하지 않으면 그에 상응하는 기본값이 사용된다. 

@Composable
fun ComposeUnitConverterTheme(
    darkTheme: Boolean = isSystemInDarkTheme(), //isSystemInDarkTheme() 컴포저블은 현재 기기에서 다크테마를 사용하는지를 감지한다.
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }
    MaterialTheme(
        colors = colors,
        content = content
    )
}

isSystemInDarkTheme() 컴포저블은 현재 기기에서 다크테마를 사용하는지를 감지한다.
앱에서는 이러한 환경 설정에 맞는 색상을 사용해야 하는데 예제에서는 dark와 light 두 가지 팔레트를 사용한다. 

private val DarkColorPalette = darkColors(
    primary = AndroidGreenDark,
    primaryVariant = AndroidGreenDark,
    secondary = OrangeDark,
    secondaryVariant = OrangeDark
)

private val LightColorPalette = lightColors(
    primary = AndroidGreen,
    primaryVariant = AndroidGreenDark,
    secondary = Orange,
    secondaryVariant = OrangeDark
)

lightColors()는 android.compose.material 패키지에 있는 최상위 함수다. 이 함수는 머터리얼 색상 명세를 완벽히 지원하는 색상 정의를 제공한다.

위에 LightColorPalette는 primary와 primaryVariant, secondary, secondaryVariant에 대한 기본 값을 재정의하고 이외 다른 값들은 변경하지 않은채로 유지하는 코드이다.

또한 다음은 예제에서 AndroidGreen 을 정의한 코드이다. 

val AndroidGreen = Color(0xFF3DDC84)
val AndroidGreenDark = Color(0xFF20B261)
val Orange = Color(0xFFFFA500)
val OrangeDark = Color(0xFFCC8400)

 

compose에서는 Color를 Color 객체를 생성해두고 사용한다.
하지만 기존 안드로이드처럼 resource에 colors.xml을 정의하고 사용하기도 하는데 이후 '리소스 기반의 테마 사용 부분'에서 다시 살펴본다.

MaterialTheme()에서는 색상뿐만 아니라 모양도 대체할 수 있다. 이를 통해 대형, 중형, 소형 구성요소의 도형을 정의할 수 있다.

  1. Small - 버튼, 스낵 바, 툴팁 등
  2. Medium - 카드, 다이얼로그, 메뉴 등
  3. Large - 시트, 드로우어 등

대체할 모양을 MaterialTheme()에 전달하려면 Shapes를 인스턴스화 하고 변경하고자 하는 카테고리에 제공해야한다. 

위와 같이 모서리가 잘린 버튼을 만들려면 MaterialTheme()을 호출할 때 다음 코드를 추가한다.

@Composable
fun ComposeUnitConverterTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }
    MaterialTheme(
        colors = colors,
        shapes = Shapes(small = CutCornerShape(8.dp)),
        content = content
    )
}

또한 머터리얼 컴포저블 함수에서 사용하는 텍스트 스타일을 변경하려면 Typography 인스턴스를 MaterialTheme()에 전달해야한다. Typohraphy는 h1, subtitle1, body1, button, caption과 같은 여러가지 매개변수를 전달받는다.
이를 전달하지 않으면 기본값이 사용된다.

@Composable
fun ComposeUnitConverterTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }
    MaterialTheme(
        colors = colors,
        typography = Typography(button = TextStyle(fontSize = 24.sp)),
        shapes = Shapes(small = CutCornerShape(8.dp)),
        content = content
    )
}

위 코드를 추가하면 테마를 사용하는 모든 버튼 텍스트의 픽셀값이 24만큼 증가하게 된다.

 

그렇다면 이 테마를 어떻게 설정할 수 있을까? 

class ComposeUnitConverterActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val factory = ViewModelFactory(Repository(applicationContext))
        setContent {
            ComposeUnitConverter(factory)
        }
    }
}

ComposeUnitConverter()가 setContent{} 내부에서 호출되기 때문에 이 함수가 앱의 컴포저블 UI의 루트가 된다. 

@Composable
fun ComposeUnitConverter(factory: ViewModelFactory) {
    ...
    ComposeUnitConverterTheme {
        Scaffold( ...

ComposeUnitConverter() 는 즉시 content 매개변수로 나머지 UI를 전달받는 ComposeUnitConverterTheme{} 에 위임한다.  Scaffold는 실제 컴포즈 UI를 위한 골격이 된다.

 

앱에서 일부는 다른 스타일을 적용하고자 한다면 부모 테마를 재정의해 테마를 중첩할 수 있다.

@Composable
@Preview
fun MaterialThemeDemo() {
    MaterialTheme( //부모 테마 
        typography = Typography(
            h1 = TextStyle(color = Color.Red)
        )
    ) {
        Row {
            Text(
                text = "Hello",
                style = MaterialTheme.typography.h1
            )
            Spacer(modifier = Modifier.width(2.dp))
            MaterialTheme(   //테마 재정의
                typography = Typography(
                    h1 = TextStyle(color = Color.Blue)
                )
            ) {
                Text(
                    text = "Compose",
                    style = MaterialTheme.typography.h1
                )
            }
        }
    }
}

위 코드에서 기본 테마는 h1으로 스타일을 지정한 모든 텍스트는 빨간색으로 나타나도록 설정한다. 두번째 Text()에서는 h1 스타일을 파란색으로 나타낸는 중첩된 테마를 사용한다. 따라서 여기서는 부모 테마를 재정의하게 된다.

(앱은 일관된 형태를 갖춰야 하기 때문에, 중첩 테마는 주의해서 사용해야 한다!) 

 

2 리소스 기반의 테마 사용

기존에는 앱에 스타일이나 테마를 적용하는 기능은 리소스 파일에 기반을 뒀다.
짧게 개념적으로 스타일과 테마 간의 차이점을 알아보자

  1. 스타일 : 단일 뷰의 외형( 폰트 색상, 폰트 크기 또는 배경색)을 명시하는 속성의 모음이다. 결과적으로 컴포저블 함수에서 스타일은 아무런 문제가 되지 않는다.
  2. 테마 : 이 역시 속성의 모음이지만 테마는 앱 전체, 액티비티 또는 뷰 계층 구조에 적용된다.

스타일과 테마는 res/values 폴더에 있는 XML파일에 정의된다. 테마는 <application /> 나 <activity /> 태그의 android:theme 속성을 사용해 메니페스트 파일에 있는 애플리케이션이나 액티비티에 적용된다.

만약 테마를 적용하지 않는다면 아래와 같이 추가적인 타이틀 바를 갖는 모습으로 보일 것이다.

위 그림에서 상태바가 어두운 회색임을 확인 할 수 있다.

Theme.AppCompat.DayNight 를 사용하면 상태 바는 colorPrimaryDark 테마 속성으로부터 배경 색상을 전달 받는다.
아무런 값을 명시하지 않으면 기본값을 사용한다.
그러므로 상태바 색상을 재정의하려면 res/values 에 themes.xml이라는 이름의 파일을 추가 해야한다.

<item name="colorPrimaryDark">@color/android_green_dark</item>

위 테마를 메니페스트 파일에서 적용하면 colorPrimaryDark는 android_green_dark 값을 가져온다.
#FF20B261  값을 직접 전달할 수 있지만 res/values 내부에 colors.xml인 파일에 색상을 지정하는 것이 좋다.

#FF20B261
#FFCC8400

추가적으로 res/values-night 에 themes.xml 에 다른 값을 지정해두면 다크 테마에는 다른 색상을 노출하여 테마 별 색상을 가질 수 있다.

val AndroidGreen = Color(0xFF3DDC84)

컴포즈에서는 다음과 같이 색상 리터럴을 전달하지만, colorResource() 컴포저블 함수를 사용해서 리소스에서 값을 얻어올 수 있다. 

 

colorResource()를 사용해 색상을 추가하는 동작은 다음과 같다.

@Composable
fun ComposeUnitConverterTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette.copy(secondary = colorResource(id = R.color.orange_dark))
    }
    MaterialTheme(
        colors = colors,
        content = content
    )
}

여기서는 copy()를 사용해 LightColorPalette의 수정된 버전을 생성한 다음 MaterialTheme에 전달했다.
모든 색상을 colors.xml에 저장하면 테마 컴포저블 안에서 팔레트를 완벽히 생성할 수 있다.
여러 컴포넌트에서 리소스 기반의 테마를 사용한다면 앱에서는 단일 colors.xml을 제공해 일관된 색상을 사용하도록 보장해줘야 한다.

Q? : 그렇다면.. 리터럴을 전달하는게 나은가 colorResource를 이용하는게 나은것인가...?? 🤔


툴바와 메뉴 통합

3 화면 구조화를 위해 Scaffold() 사용 

Scaffold()는 앱 프레임이나 스켈레톤처럼 동작하는 컴포저블 함수이다. 

@Composable
fun ComposeUnitConverter(factory: ViewModelFactory) {
    val navController = rememberNavController()
    val menuItems = listOf("Item #1", "Item #2")
    val scaffoldState = rememberScaffoldState()
    val snackbarCoroutineScope = rememberCoroutineScope()
    ComposeUnitConverterTheme {
        Scaffold(scaffoldState = scaffoldState,
            topBar = {
                ComposeUnitConverterTopBar(menuItems) { s ->
                    snackbarCoroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(s)
                    }
                }
            },
            bottomBar = {
                ComposeUnitConverterBottomBar(navController)
            }
        ) {
            ComposeUnitConverterNavHost(
                navController = navController,
                factory = factory,
                modifier = Modifier.padding(it)
            )
        }
    }
}

위 코드는 ComposeInitConverter UI의 루트 지점이다. 여기서 테마를 설정한 다음 scaffold에 위임한다.

Scaffold()에서는 기본적인 머터리얼 디자인의 시각적 레이아웃 구조를 구현한다. 여기에 TopAppBar()또는 BottomNavigation()과 같은 다른 머터리얼 컴포저블을 추가할 수 있다. 

부모 영역 또는 공간에 또 다른 컴포저블 함수를 추가함으로써 컴보저블 함수를 변경할 수 있는데 이러한 기능을 슬롯 API 라 부른다. 이미 설정을 완료한 자식을 전달하는 것은 여러 환경설정 매개변수를 노출하는 것보다 더 많은 유연성을 제공한다.

Scaffold()는 어떠한 자식을 끼워넣느냐에 따라 다른 상태를 기억해야 할 수도 있다. scaffoldState를 전달할 수도 있는데, 이는 rememberScaffoldState()로 생성된다.

예제에서는 스낵바를 노출하고자 ScaffoldState를 사용한다. 위 코드에서 showSnackbar()는 suspend function이기 때문에 remeberCoroutineScope()를 사용해 코루틴 스코프를 생성하고 기억해야만 하며, launch{} 함수를 호출해야한다. 

 

4 상단 앱 바 생성

화면 상단에 위치하는 앱 바는 TopAppBar()를 사용해 구현한다. 이곳에 내비게이션 아이콘, 타이틀, 액션 목록을 설정할 수 있다.

@Composable
fun ComposeUnitConverterTopBar(menuItems: List, onClick: (String) -> Unit) {
    var menuOpened by remember { mutableStateOf(false) }
    TopAppBar(title = {
        Text(text = stringResource(id = R.string.app_name))
    },
        actions = {
            Box {
                IconButton(onClick = {
                    menuOpened = true
                }) {
                    Icon(Icons.Default.MoreVert, "")
                }
                DropdownMenu(expanded = menuOpened,
                    onDismissRequest = {
                        menuOpened = false
                    }) {
                    menuItems.forEachIndexed { index, s ->
                        if (index > 0) Divider()
                        DropdownMenuItem(onClick = {
                            menuOpened = false
                            onClick(s)
                        }) {
                            Text(s)
                        }
                    }
                }
            }
        }
    )
}

TopAppBar() 에는 옵션 메뉴에 관한 구체적인 API가 없다. 대신 메뉴는 액션처럼 처리된다. 액션은 일반적으로 IconButton() 컴포저블이다.

액션은 앱 바의 맨 끝에 표시된다. IconButton()은 Onclick 콜백과 생략할 수 있는 enabled 매개변수를 전달받는다. 이는 사용자 입력 이벤트를 받을지 말지에 대한 여부를 제어한다. (여기서는 메뉴가 닫힌다)

Icon() 컴포저블은 벡터 이미지를 노출한다. 데이터는 리소스에서 가져올수 잇지만 가능하면 미리 정의된 그래픽을 사용한다. (Icons.Default.MoreVert) 

머터리얼 디자인의 드롭다운 메뉴는 버튼 같은 다른 요소와 상호작용하는 경우에 나타나게 된다.

예제에서는 DropdownMenu()를 IconButton()과 함께 Box()안에 위치시킨다. expanded 매개변수는 메뉴를 노출하거나 안 보이게 만들어준다.

콘텐츠는 DropdownMenuItem() 컴포저블로 이뤄져야 한다. onClick은 해당하는 메뉴 아이템이 선택됐을 때 호출된다. 

 


네비게이션 추가

5 화면 정의

Scaffold()는 bottomBar 매개변수를 사용해 화면 하단 슬롯에 콘텐츠를 추가할 수 있다. 머터리얼 디자인 하단 내비게이션 바는 정의된 목적지 즉, 화면간 이동을 가능하게 한다.

package eu.thomaskuenneth.composebook.composeunitconverter.screens

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import eu.thomaskuenneth.composebook.composeunitconverter.R

sealed class ComposeUnitConverterScreen(
    val route: String,
    @StringRes val label: Int,
    @DrawableRes val icon: Int
) {
    companion object {
        val screens = listOf(
            Temperature,
            Distances
        )

        const val route_temperature = "temperature"
        const val route_distances = "distances"
    }

    private object Temperature : ComposeUnitConverterScreen(
        route_temperature,
        R.string.temperature,
        R.drawable.baseline_thermostat_24
    )

    private object Distances : ComposeUnitConverterScreen(
        route_distances,
        R.string.distances,
        R.drawable.baseline_square_foot_24
    )
}

ComposeUnitConverter는 Temperature와 Distances 이렇게 두 개의 화면으로 이뤄진다. route는 화면을 식별하게 하는 고유한 값이다. 

@Composable
fun ComposeUnitConverterBottomBar(navController: NavHostController) {
    BottomNavigation {
        val navBackStackEntry by navController.currentBackStackEntryAsState()
        val currentDestination = navBackStackEntry?.destination
        ComposeUnitConverterScreen.screens.forEach { screen -> // 아이템들은 간단한 루프를 사용해 추가 할 수 있다. 
            BottomNavigationItem(
                selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
                onClick = {
                    navController.navigate(screen.route) {
                        launchSingleTop = true
                    }
                },
                label = {
                    Text(text = stringResource(id = screen.label))
                },
                icon = {
                    Icon(
                        painter = painterResource(id = screen.icon),
                        contentDescription = stringResource(id = screen.label)
                    )
                },
                alwaysShowLabel = false
            )
        }
    }
}

BottomNavigation()의 컨텐츠는 BottomNavigationItem() 아이템들로 이뤄진다. 각 아이템은 목적지를 나타낸다.

위의 코드 ComposeUnitConverterScreen 인스턴스의 label 과 icon 프로퍼티는 BottomNavigationItem() 호출 시에 사용된다. alwaysShowLabel은 아이템이 선택 됐을 때 레이블의 노출 여부를 제어한다.

BottomNavigationItem()을 클릭하면 onClick 콜백이 호출된다. 예제에 있는 구현체에서는 NavHostController 인스턴스에서 제공하는 navigate를 호출하면서 해당 ComposeUnitCenverterScreen 객체의 route를 전달한다. 

이렇게 화면을 정의하고, 화면과 바텀 네비게이션 아이템을 서로 매핑한 뒤 아이템을 클릭하면 앱은 주어진 경로로 이동한다.

 

6 NavHostController와 NavHost() 사용

NaviHostController 인스턴스는 navigate() 함수를 호출해 다른 화면으로 이동할 수 있게 해준다. ComposeUnitConverterBottomBar()로 전달하게 된다.

경로와 컴포저블 함수 간의 매핑은 NaviHost()를 통해 이뤄진다.

다음은 이 컴포저블 함수가 어떻게 호출되는지를 보여준다.

@Composable
fun ComposeUnitConverterNavHost(
    navController: NavHostController,
    factory: ViewModelProvider.Factory?,
    modifier: Modifier
) {
    NavHost(
        navController = navController,
        startDestination = ComposeUnitConverterScreen.route_temperature,
        modifier = modifier
    ) {
        composable(ComposeUnitConverterScreen.route_temperature) {
            TemperatureConverter(
                viewModel = viewModel(factory = factory)
            )
        }
        composable(ComposeUnitConverterScreen.route_distances) {
            DistancesConverter(
                viewModel = viewModel(factory = factory)
            )
        }
    }
}

NavHost()는 세 가지 매개변수를 전달받는다.

  1. NavHostController의 참조
  2. 시작 목적지의 경로
  3. 내비게이션 그래프를 구성하는 데 사용될 빌더

내비게이션 그래프는 젯팩 컴포즈 이전까지는 일반적으로 XML 파일로 정의했다.

composable()은 컴포저블 함수를 목적지로 추가한다. 경로뿐만 아니라 매개변수 목록과 딥 링크 목록을 전달할 수도 있다.

 

참조 : 젯팩 컴포즈로 개발하는 안드로이드 UI, 사내 스터디

반응형