Computer >> 컴퓨터 >  >> 시스템 >> Android

Jetpack Compose로 앱 스타일링과 테마 지정하기: XML에서 Kotlin으로의 전환 가이드

이 글에서는 Jetpack Compose를 사용해 안드로이드 앱에 스타일과 테마를 적용하는 방법을 알아봅니다.

Compose는 기존의 XML 기반 View 시스템을 대체하는 새로운 UI 프레임워크입니다(현재 데스크톱과 웹 지원도 개발 중입니다). 이 글을 작성하는 시점에는 아직 베타 버전이지만, 스타일링 관련 기능은 정식 출시 전에 크게 바뀌지 않을 것으로 예상됩니다.

이 글에서 다룰 주제는 다음과 같습니다.

  • XML 방식에 대한 간단한 복습
  • XML 기반 색상, 테마, 타이포그래피(폰트) 시스템을 마이그레이션하는 방법
  • 단 몇 줄의 코드로 라이트/다크 테마를 설정하는 방법
  • Kotlin 기반 스타일 정보를 컴포저블(composable)에서 활용하는 방법
  • Text 컴포저블을 스타일링하는 구체적인 방법

본격적으로 시작하기 전에 컴포저블이 무엇인지 이해하고 있어야 합니다. 여기서 해당 개념을 따로 설명하지 않으니 참고 바랍니다.

짧은 영상으로 요약 내용을 확인할 수 있습니다.

XML 리소스로 안드로이드 앱을 스타일링하던 방식

늘 그렇듯이, 실전 내용으로 넘어가기 전에 배경과 역사에 대해 간단히 짚고 넘어가겠습니다. 관심이 없다면 바로 다음 섹션의 실습 부분으로 건너뛰셔도 좋습니다.

안드로이드 리소스 시스템

적어도 제 생각에 안드로이드 앱 리소스 시스템은 안드로이드 팀이 칭찬받아 마땅한 부분입니다. 하지만 모든 설계 결정이 그렇듯이, 어떤 상황에서는 장점인 기능이 다른 상황에서는 단점이 되기도 합니다.

구체적으로 플랫폼 개발자와 앱 개발자 모두에게 가장 큰 과제 중 하나는 제가 로컬화된 리소스(localized resources)라고 부르는 것을 만드는 일입니다. 즉, 다음과 같은 앱을 만드는 것이 쉽지 않다는 뜻입니다.

  • 다양한 언어와 문자 체계로 텍스트와 그래픽을 표시
  • 다양한 폼 팩터(화면 크기, 밀도 등)에 비례하는 모양과 느낌 제공

이것은 흔한 두 가지 예시일 뿐이며, 실제로는 더 많습니다. 리소스 시스템은 앱 개발자가 로컬화된 리소스를 제공하면 플랫폼이 컴파일 시점에 이를 선택할 수 있게 해줍니다. 덕분에 우리가 직접 보일러플레이트 코드를 작성할 필요가 없어집니다.

장점일까, 단점일까?

로컬화된 문자열 리소스에 필요한 보일러플레이트 코드를 직접 관리하고 싶지는 않지만, 그렇다고 XML 작성이 즐겁다는 뜻은 아닙니다.

사실 Kotlin이나 Swift처럼 현대적이고 우아한 언어보다 XML에서 하는 편이 나은 일은 거의 없습니다. 개인 취향을 떠나서도, XML 리소스가 항상 이상적이지 않은 더 근본적인 기술적 이유가 있습니다.

이것은 플랫폼 개발자/엔지니어에 대한 비판이 아니라는 점을 미리 말씀드립니다. 단순히 설계 결정에는 항상 장점과 비용이 함께 따른다는 관찰일 뿐입니다.

XML 기반 리소스를 JVM 기반 애플리케이션 코드에 통합하려면 필연적으로 번역 계층(컴파일)플랫폼 브리지(API)가 필요합니다. 이는 플랫폼 개발자와 앱 개발자 모두에게 어려움을 안겨줍니다.

제가 자주 겪었던 두 가지 문제는 다음과 같습니다.

  • 리소스를 제공하는 플랫폼 API와 강하게 결합되기를 원치 않는 곳에서 리소스에 접근하고 싶을 때
  • View의 모양을 바꾸는 것만으로도(즉, 리소스 스타일과 테마에 정의된 것을 오버라이드하는 것만으로도) 터무니없는 보일러플레이트 코드를 작성해야 할 때

모든 관련자에게 발생하는 근본 문제는 View 시스템과 안드로이드 리소스 시스템(이 둘 자체도 서로 강하게 결합되어 있습니다)에 대한 강한 결합(tight coupling)입니다.

플랫폼 개발자 입장에서는 거대하고 오래된 코드베이스 위에 새 기능을 쌓거나 우회해서 구현해야 합니다. 여기에 새 기능을 구버전 안드로이드 OS에서도 동작하도록 만들어야 한다는 조건까지 더해지면, 정말 고생스러운 일이 됩니다.

앱 개발자 입장에서 그 결과물은 대부분 수많은 보일러플레이트 코드, 그리고 직관적으로 한 줄이면 될 것 같은 일들을 처리하기 위한 임시방편(hacky workaround)들입니다. 게다가 이런 리소스에 접근하는 주요 API가 바로 Context인데, 이 클래스는 메모리에서 누수되기를 정말 원치 않는 대표적인 클래스입니다.

이제 Jetpack Compose가 등장합니다.

Jetpack Compose로 테마, 색상, 폰트 설정하기

기존 시스템에 대한 복습은 끝났으니, 이제 훨씬 깔끔하고 단순한 방식으로 안드로이드 앱에 스타일과 테마를 적용하는 방법을 살펴보겠습니다. 실용적으로 진행한다고 했지만 딱 한 가지만 이론적으로 언급하겠습니다.

우리는 이 작업을 Kotlin으로 수행합니다. 이는 매우 중요한 의미를 담고 있습니다. 우리와 플랫폼 개발자 모두 XML과 JVM 사이의 번역(컴파일) 및 API 브리지(안드로이드의 R 클래스와 Context)에 훨씬 덜 얽매이게 됩니다.

간단히 말해 보일러플레이트 코드가 크게 줄어들고, 런타임에 훨씬 많은 제어권을 갖게 된다는 뜻입니다.

실습 파트에서는 설명 순서대로 따라 하는 것을 권합니다. 저도 새 앱을 작성할 때 이 순서대로 코드를 작성합니다.

Color.xml 리소스를 Kotlin Compose로 대체하기

아직 앱의 색상 구성을 정하지 못했다면 공식 Material Design 사이트에서 제공하는 다양한 리소스를 활용해 보세요.

  • 색상 팔레트(color palettes)
  • 색상 도구(color tool)

라이트/다크 앱 테마를 지원할 계획이라면(곧 설명합니다), 흰색 텍스트와 잘 어울리는 색상 구성 하나와 검은색 텍스트와 잘 어울리는 색상 구성 하나를 함께 선택하세요.

Jetpack Compose로 앱 스타일링과 테마 지정하기: XML에서 Kotlin으로의 전환 가이드
라이트/다크 색상 구성 예시.

Color.kt 같은 이름의 파일을 만들고(파일명은 중요하지 않습니다) 불변 val 값으로 채워 넣습니다.

import androidx.compose.ui.graphics.Color

val primaryGreen = Color(0XFF00bc00)
val primaryCharcoal = Color(0xFF2b2b2b)
val accentAmber = Color(0xFFffe400)

val textColorLight = Color(0xDCFFFFFF)
val textColorDark = Color(0xFFf3f3f3)
val gridLineColorLight = Color.Black
//...

Color.Black처럼 미리 정의된 값을 사용하거나, 직접 ARGB 16진수 값을 입력할 수 있습니다.

ARGB 16진수가 무슨 뜻인지 어렵게 느껴질 수 있으니 "0XFF00bc00"의 의미를 해석해 드리겠습니다.

  • 첫 두 글자 0x는 컴파일러에게 16진수임을 알려주는 접두사입니다.
  • 다음 두 글자("FF" 또는 "DC")는 16진수로 표현된 투명도/불투명도, 즉 Alpha 값입니다.
  • 나머지 여섯 글자는 Red(빨강), Green(초록), Blue(파랑) 값을 나타냅니다.

폰트 추가 및 fontFamily 속성 대체하기

타이포그래피(폰트) 역시 관리가 아주 쉽습니다. 이런 부분에서 Kotlin의 기본 인자(default arguments)가 매우 유용합니다.

Type.kt 같은 이름의 파일을 만들고(역시 파일명은 중요하지 않습니다) Typography 클래스를 생성합니다.

val typography = Typography(
    body1 = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    ),

    button = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Bold,
        fontSize = 32.sp
    ),

    caption = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp
    )
)
//...

그리고 몇 개의 TextStyle 클래스도 만들어 줍니다.

//...
val mainTitle = TextStyle(
    fontFamily = FontFamily.Default,
    fontWeight = FontWeight.Light,
    fontSize = 48.sp,
    textAlign = TextAlign.Center
)

fun dropdownText(color: Color) = TextStyle(
    fontFamily = FontFamily.Default,
    fontWeight = FontWeight.Normal,
    fontSize = 32.sp,
    textAlign = TextAlign.Center,
    color = color
)
//...

공개 함수로 제공할지 값으로 제공할지는 개인 취향과 현재 요구사항에 따라 선택하면 됩니다(여기서 var 사용은 권하지 않습니다).

Jetpack Compose에서 앱 테마 만들기

컴포저블에서 테마를 사용하기 전에 마지막으로 설정할 것은 MaterialTheme @Composable입니다. 저는 라이트/다크 색상 팔레트와 함께 GraphSudokuTheme이라는 파일에 담아 사용합니다.

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color

private val LightColorPalette = lightColors(
    primary = primaryGreen,
    secondary = textColorLight,
    surface = lightGrey,
    primaryVariant = gridLineColorLight,
    onPrimary = accentAmber,
    onSurface = accentAmber
)

private val DarkColorPalette = darkColors(
    //main background color
    primary = primaryCharcoal,
    //used for text color
    secondary = textColorDark,
    //background of sudoku board
    surface = lightGreyAlpha,
    //grid lines of sudoku board
    primaryVariant = gridLineColorLight,
    onPrimary = accentAmber,

    onSurface = accentAmber

)

@Composable
fun GraphSudokuTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {

    MaterialTheme(
        colors = if (darkTheme) DarkColorPalette else LightColorPalette,
        typography = typography,
        shapes = shapes,
        content = content
    )
}

컴포저블이 무엇인지 이미 알고 있다면(미리 경고했던 부분입니다), 여기서 새로운 것은 darkTheme: Boolean = isSystemInDarkTheme()뿐입니다.

간단히 설명하면, isSystemInDarkTheme()은 호환되는 안드로이드 기기에 사용자가 라이트 테마와 다크 테마 중 무엇을 선호하는지 물어보는 호출입니다.

이 함수는 boolean 값을 반환하며, colors = if (darkTheme) DarkColorPalette else LightColorPalette 같은 삼항(조건부) 할당 표현식에서 활용할 수 있습니다.

정말 이게 전부입니다. 몇 분 만에 색상, 폰트, 두 가지 테마가 완성됩니다.

Compose에서 테마 사용하기

이제 이 테마를 앱에 적용할 차례입니다. 화면이 두 개뿐인 이 앱에서는 Activity를 컴포저블의 컨테이너로 활용했습니다.

class NewGameActivity : AppCompatActivity(), NewGameContainer {
	//...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
		//...

        setContent {
            GraphSudokuTheme {
                NewGameScreen(
                    onEventHandler = logic::onEvent,
                    viewModel
                )
            }
        }
		//...
    }

setContent {}를 호출하는 곳에서는 초보자라면 바로 그 안에 테마 컴포저블을 배치하는 것을 추천합니다. 그러면 스타일 정보가 중첩된 모든 컴포저블에 계단식(cascade)으로 상속됩니다.

완료입니다! 아주 조금 남았습니다.

스타일과 테마 오버라이드하기

가능하다면 필요한 색상을 라이트/다크 팔레트에 미리 포함시키는 것이 좋습니다. 그러면 MaterialTheme.colors.<Color>를 호출할 때 시스템이 적절한 팔레트를 선택하는 조건 로직을 알아서 처리해 줍니다.

@Composable
fun NewGameContent(
    onEventHandler: (NewGameEvent) -> Unit,
    viewModel: NewGameViewModel
) {
    Surface(
        Modifier
            .wrapContentHeight()
            .fillMaxWidth()
    ) {
        ConstraintLayout(Modifier.background(MaterialTheme.colors.primary)) { 
        	//...
        }
        //...
      }
}      
        

하지만 때로는 직접 조건 로직을 작성하는 편이 더 적합할 때가 있습니다. 다행히 Compose는 이런 설정을 속성 형태로 많이 제공합니다.

@Composable
fun DoneIcon(onEventHandler: (NewGameEvent) -> Unit) {
    Icon(
        imageVector = Icons.Filled.Done,
        tint = if (MaterialTheme.colors.isLight) textColorLight 
        else textColorDark,
        contentDescription = null,
        modifier = Modifier
            .clickable(
            //...
            )
    )
}

MaterialTheme.Colors.isLight는 현재 모드가 라이트인지 다크인지를 나타내는 boolean을 반환하므로, 이를 활용해 또 다른 삼항 할당 표현식을 작성할 수 있습니다.

Text 컴포저블 스타일 지정하기

style 인자에 텍스트 스타일(MaterialTheme에서 가져오거나 Type.kt 내의 스타일 중 하나)을 지정하기만 하면 됩니다.

Text(
    text = stat.toTime(),
    style = statsLabel.copy(
        color = if (isZero) Color.White
        else MaterialTheme.colors.onPrimary,
    fontWeight = FontWeight.Normal
    ),
    modifier = Modifier
        .wrapContentSize()
        .padding(end = 2.dp, bottom = 4.dp),
        textAlign = TextAlign.End
)

TextStyle에는 무언가를 덮어써야 할 때 바로 사용할 수 있는 copy 함수가 기본으로 준비되어 있습니다.

이것으로 끝입니다! 이제 Jetpack Compose를 사용해 앱에 스타일과 테마를 적용하는 방법을 알게 되셨습니다. 읽어주셔서 감사합니다 :)