
이전 글에서는 Jetpack Compose Navigation을 활용해 여러 화면을 연결하고, 화면 간 데이터 전달과 백스택 관리를 통해 완성도 있는 앱의 흐름을 구성해보았습니다.
이제 여러 화면이 있는 앱을 만들 수 있게 되었으니, 앱 전체에 일관된 디자인과 스타일을 적용하는 방법을 알아볼 차례입니다. 사용자에게 통일감 있고 세련된 경험을 제공하기 위해서는 색상, 글꼴, 모양 등의 디자인 요소를 체계적으로 관리해야 합니다.
이번 글에서는 Jetpack Compose의 Material Theme을 중심으로 앱의 디자인 시스템을 구축하고, 다크 모드 대응, 커스텀 색상 정의, 타이포그래피(글꼴) 설정 등을 실습해보겠습니다.
Material Theme은 Google의 Material Design 가이드라인을 Jetpack Compose 환경에 맞게 구현한 디자인 시스템입니다. 앱 전반에 걸쳐 일관된 색상, 글꼴, 모양을 적용할 수 있도록 도와주는 핵심 도구입니다.
Jetpack Compose는 Material Design 3를 기본으로 지원합니다. 이를 통해 색상, 글꼴, 모양, 컴포넌트 등 앱 전체에 걸친 디자인 시스템을 쉽게 구현할 수 있습니다.
Material Theme의 핵심 구성 요소
| 구성 요소 | 설명 | 주요 역할 |
|---|---|---|
| ColorScheme | 의미를 기반으로 색상 체계 정의 | primary, secondary, background, surface 등 |
| Typography | 글꼴과 텍스트 스타일 정의 | 제목, 본문, 캡션 등의 텍스트 크기와 스타일 |
| Shapes | UI 요소의 모양 정의 | 버튼, 카드, 입력 필드의 모서리 둥근 정도 |
@Composable
fun MyApp() {
MaterialTheme(
colorScheme = lightColorScheme(), // 색상 설정
typography = Typography, // 글꼴 설정
shapes = Shapes, // 모양 설정
) {
// 앱의 모든 컴포저블이 이 테마를 상속받음
MainContent()
}
}
MaterialTheme이 제공하는 장점
Material Design 3에서는 색상을 역할과 목적에 따라 체계적으로 분류합니다. 단순히 "빨간색", "파란색"이 아니라 "Primary", "Secondary", "Background" 같은 의미적 이름을 사용합니다.
@Composable
fun ColorSystemExample() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Primary: 앱의 주요 브랜드 색상
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Text(
text = "Primary Container",
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(16.dp)
)
}
// Secondary: 보조 색상
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Text(
text = "Secondary Container",
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(16.dp)
)
}
// Surface: 카드, 시트 등 표면 색상
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Text(
text = "Surface",
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp)
)
}
}
}
Material Theme의 색상은 색상 + 역할 조합으로 이름이 정해집니다:
| 색상 그룹 | 기본 색상 | 컨테이너 색상 | 텍스트 색상 |
|---|---|---|---|
| Primary | primary | primaryContainer | onPrimary, onPrimaryContainer |
| Secondary | secondary | secondaryContainer | onSecondary, onSecondaryContainer |
| Background | background | - | onBackground |
| Surface | surface | surfaceContainer | onSurface |
"on" 접두사의 의미:
onPrimary: Primary 색상 위에 올라가는 텍스트/아이콘 색상onBackground: Background 색상 위에 올라가는 콘텐츠 색상Material Theme을 직접 정의하면 앱의 브랜드 스타일을 반영할 수 있으며, 라이트/다크 테마 전환도 시스템 설정에 따라 자동으로 또는 사용자 설정에 따라 유연하게 지원할 수 있습니다.
먼저 ui/theme/Color.kt 파일에서 사용할 색상을 정의합니다.
// ui/theme/Color.kt
import androidx.compose.ui.graphics.Color
// 브랜드 기본 색상 정의
val BrandPrimary = Color(0xFF6750A4)
val BrandPrimaryLight = Color(0xFF9A82DB)
val BrandPrimaryDark = Color(0xFF381E72)
val BrandSecondary = Color(0xFF625B71)
val BrandSecondaryLight = Color(0xFF8B8499)
val BrandSecondaryDark = Color(0xFF3D3848)
// 시스템 색상
val BrandBackground = Color(0xFFFFFBFE)
val BrandSurface = Color(0xFFFFFBFE)
val BrandError = Color(0xFFBA1A1A)
// 다크 테마용 색상
val BrandBackgroundDark = Color(0xFF1C1B1F)
val BrandSurfaceDark = Color(0xFF1C1B1F)
val BrandErrorDark = Color(0xFFFFB4AB)
이제 위에서 정의한 색상을 기반으로 ColorScheme을 생성합니다.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
private val LightColorScheme = lightColorScheme(
primary = BrandPrimary,
onPrimary = Color.White,
primaryContainer = BrandPrimaryLight,
onPrimaryContainer = BrandPrimaryDark,
secondary = BrandSecondary,
onSecondary = Color.White,
secondaryContainer = BrandSecondaryLight,
onSecondaryContainer = BrandSecondaryDark,
background = BrandBackground,
onBackground = Color(0xFF1C1B1F),
surface = BrandSurface,
onSurface = Color(0xFF1C1B1F),
error = BrandError,
onError = Color.White
)
private val DarkColorScheme = darkColorScheme(
primary = BrandPrimaryLight,
onPrimary = BrandPrimaryDark,
primaryContainer = BrandPrimaryDark,
onPrimaryContainer = BrandPrimaryLight,
secondary = BrandSecondaryLight,
onSecondary = BrandSecondaryDark,
secondaryContainer = BrandSecondaryDark,
onSecondaryContainer = BrandSecondaryLight,
background = BrandBackgroundDark,
onBackground = Color(0xFFE6E1E5),
surface = BrandSurfaceDark,
onSurface = Color(0xFFE6E1E5),
error = BrandErrorDark,
onError = Color(0xFF690005)
)
테마를 실제 앱에 적용할 수 있도록 MyAppTheme 컴포저블을 정의합니다. 이 테마 함수(MyAppTheme)는 앱의 전체 UI에 색상, 글꼴, 모양 등 일관된 디자인 시스템을 적용하는 역할을 합니다.
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(), // 시스템 다크모드 자동 감지
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = Shapes,
content = content
)
}
MainActivity에서 MyAppTheme로 앱의 컴포저블 트리를 감싸면 전체 UI에 테마가 적용됩니다.
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
MyAppContent()
}
}
}
}
사용자가 직접 라이트/다크 모드를 전환할 수 있는 예제도 구현할 수 있습니다.
@file:OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DarkModeToggleExample() {
var isDarkMode by remember { mutableStateOf(false) }
MyAppTheme(darkTheme = isDarkMode) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("다크 모드 예제") },
actions = {
IconButton(onClick = { isDarkMode = !isDarkMode }) {
Icon(
imageVector = if (isDarkMode)
Icons.Filled.Star // 다크 모드 ON 상태
else
Icons.Filled.Settings, // 라이트 모드 ON 상태
contentDescription = "테마 전환"
)
}
}
)
},
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (isDarkMode) "🌙 다크 모드 활성화됨" else "☀️ 라이트 모드 활성화됨",
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { isDarkMode = !isDarkMode }) {
Text("테마 전환")
}
}
}
}
}
}
| 항목 | 설명 |
|---|---|
| 색상 정의 | 브랜드 색상 및 시스템 색상 직접 정의 |
| 라이트/다크 구성 | lightColorScheme, darkColorScheme로 두 가지 테마 설정 |
| 테마 적용 | MaterialTheme를 통해 전체 UI에 스타일 적용 |
| 수동 전환 | 사용자가 버튼으로 테마 전환 가능 |
타이포그래피는 앱의 모든 텍스트에 일관된 스타일을 제공합니다. Material Design 3에서는 다양한 텍스트 역할을 위한 미리 정의된 스타일을 제공합니다.
@Composable
fun TypographyExample() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Display Large",
style = MaterialTheme.typography.displayLarge
)
Text(
text = "Headline Large",
style = MaterialTheme.typography.headlineLarge
)
Text(
text = "Title Large",
style = MaterialTheme.typography.titleLarge
)
Text(
text = "Body Large - 일반적인 본문 텍스트에 사용됩니다.",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "Body Medium - 조금 더 작은 본문 텍스트입니다.",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Label Small - 버튼이나 작은 라벨에 사용",
style = MaterialTheme.typography.labelSmall
)
}
}
앱에 독특한 개성을 부여하려면 커스텀 폰트를 사용할 수 있습니다.
1단계: 폰트 파일 추가
app/src/main/res/font/ 폴더에 .ttf 또는 .otf 파일 추가noto_sans_kr_bold.ttf, noto_sans_kr_regular.ttf2단계: FontFamily 정의
// ui/theme/Type.kt
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
val NotoSansKr = FontFamily(
Font(R.font.noto_sans_kr_regular, FontWeight.Normal),
Font(R.font.noto_sans_kr_bold, FontWeight.Bold)
)
3단계: Typography 정의
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = NotoSansKr,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
headlineLarge = TextStyle(
fontFamily = NotoSansKr,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
bodyLarge = TextStyle(
fontFamily = NotoSansKr,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
// 필요한 다른 스타일들도 정의...
)
4단계: 테마에 적용
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography, // 커스텀 타이포그래피 적용
content = content
)
}
Shapes는 버튼, 카드, 입력 필드 등 UI 요소의 모서리 둥근 정도를 정의합니다.
@Composable
fun ShapesExample() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Small Shape (버튼 등에 사용)
Card(
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text("Small Shape (4dp)")
}
}
// Medium Shape (카드 등에 사용)
Card(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text("Medium Shape (8dp)")
}
}
// Large Shape (시트, 다이얼로그 등에 사용)
Card(
shape = MaterialTheme.shapes.large,
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
Text("Large Shape (16dp)")
}
}
}
}
// ui/theme/Shape.kt
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
extraSmall = RoundedCornerShape(2.dp), // 칩, 작은 요소 (거의 직각에 가까운 형태)
small = RoundedCornerShape(8.dp), // 버튼
medium = RoundedCornerShape(12.dp), // 카드, 다이얼로그
large = RoundedCornerShape(20.dp), // 큰 카드, 시트
extraLarge = RoundedCornerShape(28.dp) // 전체 화면 요소
)
// ✅ 좋은 예: 의미적 색상 사용
Text(
text = "에러 메시지",
color = MaterialTheme.colorScheme.error
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) { /* 내용 */ }
// ❌ 피해야 할 예: 하드코딩된 색상
Text(
text = "에러 메시지",
color = Color.Red // 다크 모드에서 문제 발생 가능
)
// 색상 대비 자동 보장
Text(
text = "콘텐츠",
color = MaterialTheme.colorScheme.onPrimary, // Primary 배경에 적절한 텍스트 색상
modifier = Modifier
.background(MaterialTheme.colorScheme.primary)
.padding(16.dp)
)
// 같은 종류의 요소는 같은 스타일 사용
@Composable
fun ConsistentButtons() {
Column {
Button(
onClick = { },
shape = MaterialTheme.shapes.small // 모든 버튼에 동일한 shape
) {
Text("버튼 1")
}
Button(
onClick = { },
shape = MaterialTheme.shapes.small // 일관성 유지
) {
Text("버튼 2")
}
}
}
이번 글에서는 Jetpack Compose의 MaterialTheme을 중심으로, 앱 전반에 통일된 디자인 시스템을 적용하는 방법을 단계별로 알아보았습니다.
Compose 앱에서 색상, 글꼴, 모양 등 다양한 스타일 요소를 직접 정의하고, 라이트/다크 모드 대응까지 커스터마이징하는 과정을 실습해보며, 앱의 개성과 일관성을 함께 갖춘 UI를 만드는 방법을 익혔습니다.
📌 이번 글에서 다룬 주요 내용
MaterialTheme의 구성 요소: ColorScheme, Typography, ShapesonPrimary, onSurface 등의 역할Shapes 커스터마이징Jetpack Compose의 테마 시스템을 잘 활용하면, 디자인과 개발의 경계를 허물고 사용자 경험과 유지보수성을 동시에 향상시킬 수 있습니다.
앱이 기능적으로 완성되었다고 해서, 사용자 경험이 곧바로 뛰어난 것은 아닙니다. 화면 전환이 너무 갑작스럽거나, 버튼을 눌러도 변화가 뚝 끊기는 느낌이라면 사용자는 쉽게 이탈하게 되죠.
다음 글에서는 Jetpack Compose에서 애니메이션을 활용하는 방법을 소개할 예정입니다. 단순히 예쁘게 만드는 것을 넘어서, 앱의 흐름을 부드럽게 연결하고, 사용자의 행동에 자연스럽게 반응하는 UI를 어떻게 구현할 수 있는지 함께 알아보겠습니다.
이런 요소들을 통해 앱은 훨씬 더 자연스럽고 몰입감 있게 다가갈 수 있습니다. 다음 글에서는 이러한 애니메이션을 실제 예제와 함께 체험해보며, 사용자 경험을 향상시키는 방법을 익혀보겠습니다. 기대해주세요! 🚀