개발관련일지

android compose M3 theme, layout Reply 샘플앱 코드보기 본문

개발기록/안드로이드

android compose M3 theme, layout Reply 샘플앱 코드보기

BEECHANGBOT 2023. 7. 24. 02:33

안드로이드 컴포즈 학습하는 도중 material3 theme , 사이즈 변화에 대해서 어떻게 대응하나 학습하기 위해 Reply 샘플앱 대해서 전체적인 구조를 파악하기 위해서 학습

 

Jatpack Compsose는 M3를 사용할 수 있는 구현체를 제공하고 있으며 M3가 적용된 프로젝트를 생성하면 theme 디렉토리가 있고 기본적으로 Color, Theme , Type 세개의 파일이 생성 되어있다. color scheme과 typography를 정의하고 MateralTheme에 적용이 가능하다. 

 

Color Scheme에서 주요색상을 기준으로 톤을 변경하면서 M3의 컬러 유틸리티 색상파레트를 이용한다. 각 색상값들이 슬롯(이름)이 존재한다. 색상들을 미리 정의해놓고 theme를 사용한다. 

Material baseline colors light

위의 사진이 M3에서 베이스로 제시된 컬러스킴이다.

Primary : 메인색상 

Secondary : 보조색상이며 프라이머리보다 채도가 덜 강조

Teriary : 제 3자색상으로 강조에 사용 될 수 있다. 

컨테이너는 배경에 사용되는 색상이다. 

https://m3.material.io/theme-builder#/custom 에서 색상을 확인 할 수 있고 자신이 원하는 색상을 선택하고머테리얼3 테마 빌더에서 Export할 경우 그대로 Color에 코드를 삽입할 경우 네임만 맞추어서 사용가능하다.  생성 된 코드는 Color파일에 넣어준다.

Theme에서 다크모드와 라이트모드에 사용될 스킴을 정의한다. 

// Material 3 color schemes
// Theme.kt

private val replyDarkColorScheme = darkColorScheme(
    primary = replyDarkPrimary,
    onPrimary = replyDarkOnPrimary,
    primaryContainer = replyDarkPrimaryContainer,
    onPrimaryContainer = replyDarkOnPrimaryContainer,
    inversePrimary = replyDarkPrimaryInverse,
    secondary = replyDarkSecondary,
    onSecondary = replyDarkOnSecondary,
    secondaryContainer = replyDarkSecondaryContainer,
    onSecondaryContainer = replyDarkOnSecondaryContainer,
    tertiary = replyDarkTertiary,
    onTertiary = replyDarkOnTertiary,
    tertiaryContainer = replyDarkTertiaryContainer,
    onTertiaryContainer = replyDarkOnTertiaryContainer,
    error = replyDarkError,
    onError = replyDarkOnError,
    errorContainer = replyDarkErrorContainer,
    onErrorContainer = replyDarkOnErrorContainer,
    background = replyDarkBackground,
    onBackground = replyDarkOnBackground,
    surface = replyDarkSurface,
    onSurface = replyDarkOnSurface,
    inverseSurface = replyDarkInverseSurface,
    inverseOnSurface = replyDarkInverseOnSurface,
    surfaceVariant = replyDarkSurfaceVariant,
    onSurfaceVariant = replyDarkOnSurfaceVariant,
    outline = replyDarkOutline
)

private val replyLightColorScheme = lightColorScheme(
    primary = replyLightPrimary,
    onPrimary = replyLightOnPrimary,
    primaryContainer = replyLightPrimaryContainer,
    onPrimaryContainer = replyLightOnPrimaryContainer,
    inversePrimary = replyLightPrimaryInverse,
    secondary = replyLightSecondary,
    onSecondary = replyLightOnSecondary,
    secondaryContainer = replyLightSecondaryContainer,
    onSecondaryContainer = replyLightOnSecondaryContainer,
    tertiary = replyLightTertiary,
    onTertiary = replyLightOnTertiary,
    tertiaryContainer = replyLightTertiaryContainer,
    onTertiaryContainer = replyLightOnTertiaryContainer,
    error = replyLightError,
    onError = replyLightOnError,
    errorContainer = replyLightErrorContainer,
    onErrorContainer = replyLightOnErrorContainer,
    background = replyLightBackground,
    onBackground = replyLightOnBackground,
    surface = replyLightSurface,
    onSurface = replyLightOnSurface,
    inverseSurface = replyLightInverseSurface,
    inverseOnSurface = replyLightInverseOnSurface,
    surfaceVariant = replyLightSurfaceVariant,
    onSurfaceVariant = replyLightOnSurfaceVariant,
    outline = replyLightOutline
)

Typography는 텍스트 스타일에 대해서 나타내며 크기가 큰순서대로 display, headline, title, body, label으로 5가지의 타입을 가진다. 

Type 파일에서 기본적인 값들만 작성 되어있고 Typography클래스를 이용해서 본인이 원하는 사이즈로 커스텀해서 사용이 가능하다. 

// Material 3 typography
// type.kt

val replyTypography = Typography(
    headlineLarge = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp
    ),
    headlineMedium = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    headlineSmall = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    titleLarge = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    titleMedium = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    titleSmall = TextStyle(
        fontWeight = FontWeight.Bold,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    bodyLarge = TextStyle(
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    bodyMedium = TextStyle(
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.25.sp
    ),
    bodySmall = TextStyle(
        fontWeight = FontWeight.Bold,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.4.sp
    ),
    labelLarge = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    labelMedium = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontWeight = FontWeight.SemiBold,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

루트테마를 지정해주는 부분에서 다이나믹컬러여부, 다크모드 여부에 따라서 디바이스에 맞는 컬러스킴이 지정된다. 

@Composable
fun ReplyTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val replyColorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) //dynamic color scheme
        }
        darkTheme -> replyDarkColorScheme // dark scheme
        else -> replyLightColorScheme // light scheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = replyColorScheme.primary.toArgb()
            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme( // 설정된 theme들을 추가 
        colorScheme = replyColorScheme,
        typography = replyTypography,
        shapes = shapes,
        content = content
    )
}

 

 

 다음은 디바이스 사이즈에 따른 유아이 구성을 어떻게 하나 확인했다. 

WindowSizeClass을 이용해서 600dp하고 840dp 로 사이즈를 조건처리 하고 있으며 

WindowSizeClass.kt

M3에서 WindowSizeClass를 이용해서 반응형을 만들기 위해 하용하는 기준점을 가지고 있다. BreakPoint(dp)를 이용해서 아래의 코드와 같은 값을 가진다.  

Compact : 600dp 이하 , 핸드폰 세로 

Medium : 600 < x < 840 , 테블릿 세로 , 폴더블폰 세로(안접혓을떄)

Expanded : 840 이상 , 핸드폰 가로 , 테블릿 가로 , 폴더블폰 가로 , 데탑 

 

중요한건 가이드에서 각 사이즈에 따른 유저와 상호작용하기 위한 수단을 디바이스 사이즈마다 컴포넌트를 다르게 사용하는걸 권장한다. 

https://m3.material.io/foundations/layout/applying-layout/window-size-classes

각 사이즈에 따른 네비게이션 유저와의 상호작용 방식 , 다이얼로그 , 바텀시트or메뉴 등 구성이 다르다.

또한 컴팩트를 기준으로 많이 개발하니 컴팩트를 살펴 봤을 떄 주요 포인트는 바텀네비게이션 사용 , 좌우 마진이 16dp로 맞춰놔야한다. 

 

네비게이션에 관한 내용은 링크로 확인

 

그럼 Reply코드에서는 어떻게 처리하는지 코드로 확인 햇다. 

when (windowSize.widthSizeClass) {

    // Composable ReplyApp
    WindowWidthSizeClass.Compact -> {

        navigationType = ReplyNavigationType.BOTTOM_NAVIGATION

        contentType = ReplyContentType.SINGLE_PANE

    }

    WindowWidthSizeClass.Medium -> {

        navigationType = ReplyNavigationType.NAVIGATION_RAIL

        contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {

            ReplyContentType.DUAL_PANE

        } else {

            ReplyContentType.SINGLE_PANE

        }

    }

    WindowWidthSizeClass.Expanded -> {

        navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {

            ReplyNavigationType.NAVIGATION_RAIL

        } else {

            ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER

        }

        contentType = ReplyContentType.DUAL_PANE

    }

    else -> {

        navigationType = ReplyNavigationType.BOTTOM_NAVIGATION

        contentType = ReplyContentType.SINGLE_PANE

    }

}

 

content를 생성하는 과정에서 사이즈에 따른 네비게이션타입과 패널개수를 확인해 컨텐츠타입을 확인

 

타입이 확인 된 후에는 컴포저블을 보여주기 위한 작업을 진행 

ReplyApp(현재 디바이스 사이즈 확인) -> ReplyNavigationWrapper(사이즈에 맞는 네비게이션방식 설정 PermanentNavigationDrawer , ModalNavigationDrawer) -> ReplyAppContent ( rail or bottom navigaton 사용할지 지정 후 화면에 보여줄 content제작)

@Composable
private fun ReplyNavigationWrapper(
	...
) {
// 네비게이션 및 상태 선언 
    val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
    val scope = rememberCoroutineScope()

    val navController = rememberNavController()
    val navigationActions = remember(navController) {
        ReplyNavigationActions(navController)
    }
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val selectedDestination =
        navBackStackEntry?.destination?.route ?: ReplyRoute.INBOX
// 디바이스 사이즈에 맞는 네비게이션타입을 선언 후 안의 컨텐츠들은 ReplyAppContent로 생성
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
         PermanentNavigationDrawer(drawerContent = {
            PermanentNavigationDrawerContent(
              ...
            )
        }) {
            ReplyAppContent(
             ...
            )
        }
    } else {
        ModalNavigationDrawer(
            ...
        ) {
            ReplyAppContent(
               ...
            ) {
                scope.launch {
                    drawerState.open()
                }
            }
        }
    }
}

 

해당 내용을 보여줄 화면을 제작하는 컴포저블은 

패널이 1개 2개로 체크 한후 , 왼쪽은 1개의 패널일 때 보여줄 리스트 목록, 오른쪽 패널은 리스트에서 선택 했을 시 보여줄 상세페이지를 보여주어서 화면이동이 없이 한 화면에서 볼 수 있도록 해주었다. 

if (contentType == ReplyContentType.DUAL_PANE) {
        TwoPane(
            first = { // 왼쪽
                ReplyEmailList(
 					...
                )
            },
            second = { // 오른쪽
                ReplyEmailDetail(
                    email = replyHomeUIState.openedEmail ?: replyHomeUIState.emails.first(),
                    isFullScreen = false
                )
            },
            strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp),
            displayFeatures = displayFeatures
        )
    } else { //1패널일때 
    }

 

사실 이걸 찾아본 것도 M3와 Compose를 할줄 몰라서 시도할려고 보았던거지만 폴더블폰 대응을 어떻게 하나 보고 싶었다. 같은 세로로 접히는 경우 360dp였던거로 기억하는데 이부분에 대해선 패널수와 네비게이션타입을 이용해서 대응을 하고있엇다. 텍스트 사이즈를 줄인다는 등의 대응은 하지않고 타이포그래피에 작성된 그대로 유지한다.

 

 

참고

https://m3.material.io/foundations/layout/applying-layout/window-size-classes

https://m3.material.io/styles/typography/applying-type

https://m3.material.io/theme-builder#/custom

https://github.com/android/compose-samples/tree/main/Reply