본문으로 바로가기
반응형

Photo by unsplash

지난번 포스팅에 이어 화면을 구성하고 배치하기 위한 Layout에 대해서 자세히 알아봅니다.

여기서는 compose의 높은 레벨(Material components)에서 실제 화면에 구성요소를 배치하는 하위 레벨의 Layout까지에 대해서 다룹니다. 물론 Material Design을 베이스로 쓰지 않을 경우에 custom형태로 layout을 생성하는 것까지도 추후에 알아보도록 합니다.

이 글은 Android developer 공식 사이트에서 제공하는 문서를 기반으로 의역, 번역 하였습니다.[1]

Modifiers

앞서 기본에서도 많이 사용했지만 Modifier는 Compose의 동작이나, 형태, 접근성(Talk back == accessibility)에 대한 정의를 할 뿐만 아니라 clickable, scrollable, draggable, zoomable에 대한 속성도 정의할 수 있는 Kotlin의 object입니다. 또한 한번 정의한 Modifier를 재정의 할 수도 있고, 여러 개의 modifier를 chain으로 구성해서 사용할 수도 있습니다.

위와 같은 화면을 만들기 위해 아래와 같이 만들어 봅니다.

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ComposeTest1Theme { //프로젝트명으로 만들어진 기본 theme
        NameCard()
    }
}

@Composable
fun NameCard() {
    Column {
        Text("의적 홍길동", fontWeight = FontWeight.Bold)
        //LocalContentAlpha는 자식들의 투명도를 정의한다.
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

먼저 세로로 문구를 쌓기 위해서 Column을 사용합니다. 그리고 여기서 alpha값을 설정하기 위해 CompositionLocalProvider를 사용하는데, 이는 해당 블록 안에 있는 모든 하위 계층의 composables에 영향을 줍니다. [2]

보통 데이터를 전달하기 위해서는 composable 함수의 param으로 명시적으로 전달하는 형태로 함수를 구현합니다. 하지만 composable의 계층이 깊거나, 동일한 param을 여러 곳에서 사용해야 하는 경우 CompositionLocal을 통해서 암묵적으로 해당 값을 넘겨줄 수 있습니다.

유용하게 쓰일수 있는 부분이라 간단하게  CompositionLocalProvider를 위한 설명을 좀더 추가해 보면 아래와 같습니다. 

먼저 정적으로 참고 가능한 CompositionLocal 객체를 생성해야 하는데, 아래와 같은 방법으로 초기값을 function factory에  전달할 수 있습니다.

//ContentAlpha.kt

/**
 * CompositionLocal containing the preferred content alpha for a given position in the hierarchy.
 * This alpha is used for text and iconography ([Text] and [Icon]) to emphasize / de-emphasize
 * different parts of a component. See the Material guide on
 * [Text Legibility](https://material.io/design/color/text-legibility.html) for more information on
 * alpha levels used by text and iconography.
 *
 * See [ContentAlpha] for the default levels used by most Material components.
 *
 * [MaterialTheme] sets this to [ContentAlpha.high] by default, as this is the default alpha for
 * body text.
 *
 * @sample androidx.compose.material.samples.ContentAlphaSample
 */
val LocalContentAlpha = compositionLocalOf { 1f }

LocalContentAlpha값은 Material Theme level에서 사용하는 Text , Icon을 위한 alpha값 입니다. 기본값은 1f이므로 투명도가 없이 보여지나, 위 예제에서 처럼 CompositionLocalProvider를 통해서 만든 블럭에서는 지정된 값으로 변경되어 동작합니다.

좀더 자세한 내용을 알기위해 CompositionLocalProvider 함수를 따라가 보면, 아래와 같이 구성되어 있습니다. 인자 값을 vararg로 여러 개 받을 수 있으며, scope 안에 있는 composalbes(content) 모두를 내부적으로 안고 있는 형태로 구성되어 있습니다.

@Composable
@OptIn(InternalComposeApi::class)
fun CompositionLocalProvider(vararg values: ProvidedValue<*>, content: @Composable () -> Unit) {
    currentComposer.startProviders(values)
    content()
    currentComposer.endProviders()
}

sample code의 NameCard()에서 사용된 providesinfix function으로 CompositionLocal이 갖는 값을 설정합니다. 해당 값이 기본적으로 제공되지며, 하위 composable에서 해당 값을 확인하려면 current 함수를 이용할 수 있습니다.

@Composable
fun SampleText() {
    val alphaValue = LocalContentAlpha.current
    ...
}

실제 Compose의 샘플 코드들을 보면 ImageLoader를 설정하거나 IME animation을 제공하기 위해 windowInset, 또는 backkey 처리를 위해 CompositionLocalProvider를 사용하는 부분들을 종종 볼수 있습니다. 아래 코드는 JetChat 샘플에서 사용하는 코드의 일부 입니다.

// Create a ViewWindowInsetObserver using this view, and call start() to
// start listening now. The WindowInsets instance is returned, allowing us to
// provide it to AmbientWindowInsets in our content below.
val windowInsets = ViewWindowInsetObserver(this)
     // We use the `windowInsetsAnimationsEnabled` parameter to enable animated
     // insets support. This allows our `ConversationContent` to animate with the
     // on-screen keyboard (IME) as it enters/exits the screen.
     .start(windowInsetsAnimationsEnabled = true)

setContent {
   CompositionLocalProvider(
      LocalBackPressedDispatcher provides requireActivity().onBackPressedDispatcher,
      LocalWindowInsets provides windowInsets,
   ) {
       JetchatTheme {       
       ...

추가적으로 CompositionLocalProvider로 ViewModel을 넘기는 부분은 지양하도록 설명되어 있습니다. 이는 viewModel을 암묵적인 인수로 넘길 경우 특정 ViewModel과의 dependency로 인하여 composable function들이 재활용성이 떨어질 수 있기 때문입니다.

 

다시 원래의 코드로 돌아와서 왼쪽에 사진을 붙일 수 있는 공간을 제공합니다. 사진이 로딩되기 전에 보여줄 place holder는 Surface를 이용하여 크기와 배경색을 설정해 넣어줍니다.

@Composable
fun NameCard() {
    Row {
        Surface(modifier = Modifier.size(50.dp),
                shape = CircleShape,
                color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)) {
            // 이미지 로딩 처리 부분
        }

        Column {
            Text("의적 홍길동", fontWeight = FontWeight.Bold)
            //LocalContentAlpha는 자식들의 투명도를 정의한다.
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

먼저 Row를 만들어 문구 왼쪽에 이미지 로딩할 공간을 분할하여 만듭니다. 위와 같이 Surface로 공간을 만든 후에 surface 자체는 place holder로 사용합니다.

이제 배치는 끝났으니 이미지와 문구를 정렬해 봅니다.

먼저 문구의 중앙 정렬을 위해 Modifier의 align을 이용합니다. 추가적으로 이미지와 간격을 두기 위해 앞쪽에 padding도 추가하도록 합니다.

@Composable
fun NameCard() {
    Row {
       ...
        Column(
            modifier = Modifier.padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
           ...
        }
    }
}

예제에서 ColumnRow의 하위 계층이 입니다. 따라서 Row가 제공하는 weightalign을 사용할 수 있으며, 이는 xml에서 attribute의 역할과 유사합니다. 다만 modifier에서 사용되는 속성 값들은 compiler 수준에서 체크되기 때문에 지원하지 않는 속성을 사용 시 빌드 단계에서 에러를 검출할 수 있습니다. 예를 들어 Box composable에서 weight를 속성을 사용하려고 한다면 컴파일 오류가 발생합니다.

Modifier는 여러 param을 받아들이면서 화면을 유연하게 구성할 수 있도록 만들어 줍니다. 일반적인 경우 첫 번째 param으로 Modifier를 넘겨 받도록 하여 caller에서도 Modifier를 통해 하위 composable의 구성을 변경할 수 있도록 확장 가능한 형태로 만들어 주는 게 좋습니다. 물론 기본값으로 빈 Modifier지정해 두고 값을 넘겨주지 않더라도 문제 없는 optional param으로 만듭니다.

fun NameCard(modifier: Modifier = Modifier) {
    Row(modifier = modifier.xxx {
       ...

Modifier chain 순서

Modifer는 chain형태로 다양한 속성을 설정할 수 있습니다. 다만, chaining 순서에 따라 동작이 달라질 수 있으므로 어떻게 달라질 수 있는지 click listener의 위치를 변경하면서 확인해 보겠습니다.

@Composable
fun NameCard(modifier: Modifier = Modifier) {
    Row(
        modifier = Modifier
            .padding(16.dp)
            .clickable(onClick = {
                //donohting
            })
    ) {
        Surface(
           ...

위와 같이 16dp를 padding으로 띄운 이후에 click을 설정하면 click 범위는 16dp를 제외한 나머지 부분이 됩니다. 따라서 아래 그림처럼 클릭 공간이 잡힙니다.

클릭한 상태 (pressed)

아래와 같이 순서를 변경해야, padding 영역도 click 영역에 포함됩니다.

 Row(
        modifier = Modifier
            .clickable(onClick = {
                //donohting
            })
            .padding(16.dp)

    ) {
        Surface(
        ...

이제 좀 더 예쁘게 다듬기 위해서 클릭 영역 이외에 공간을 추가하고, 테두리를 곡면으로 처리한 후에 배경색도 바꿔보겠습니다.

NameCard(modifier: Modifier = Modifier) {
    Row(
        modifier = Modifier
            .padding(8.dp)
            .clip(RoundedCornerShape(4.dp))
            .background(MaterialTheme.colors.surface)
            .clickable(onClick = {
                //donohting
            })
            .padding(16.dp)
    ) {
        Surface(
        ...

클릭한 상태 (pressed)

클릭했을 경우 순서에 따라 위와 같이 적용됨을 확인할 수 있습니다.

modifier에서 제공하는 api와 api를 사용가능한 범위에 대한 부분은 Android developer문서에 따로 정리되어 있습니다.[3] ( https://developer.android.com/jetpack/compose/modifiers-list )따라서 해당 페이지를 방문하여 사용 가능 api와 어떤 api들이 제공되는지 확인 가능합니다.

Slot APIs

앞전에 프로젝트 생성 시에 추가되는 Theme.kt를 열어보면 기본적으로 compose는 상위단계에서 Material Components를 제공한다는 걸 알 수 있습니다. 물론 이렇게 기본적으로 제공되는 Material components들을 화면에 보여주기 위해서는 위에서 만들었던 것처럼 개발자가 component들을 화면에 잘 배치해야 합니다. 

예를 들어 버튼을 화면에 배치하려면 아래와 같이 간단하게 Material Button을 사용하면 됩니다. 버튼은 기본적으로 어떻게 배치되고 어떻게 보여줘야 할지에 대한 가이드라인이 미리 적용되어 있기 때문에 간단하게 API 형태로 호출해서 사용할 수 있습니다.

// 실제로는 compile 오류 코드 -> text 속성이 존재하지 않음.
Button(text="BUTTON")

text라는 속성이 존재하지는 않지만 위와 같이 Button의 이름을 넣을 수 있다고 가정해 보겠습니다. 만약 text라는 속성을 이외에 좀 더 원하는 형태로 customizing 하기 위해서 아래와 같은 속성들이 추가적으로 지원한다고 생각할 수 있습니다.

// 실제 complie 불가 코드
Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

XML의 사용에 익숙해져 있다면 속성명만 보더라도 어떤 역할을 하는지 단번에 알 수 있습니다. 하지만 위 속성들은 실제로 Button에서 제공하는  속성들이 아닙니다. 기존에 XML에서 다양한 속성을 지원했듯이 위와 같은 형태로 속성이 추가된다면 좀 더 간편하게 원하는 형태의 component를 구성할 수 있을 수 있지만, 지원하지 않는 속성에 대한 변경에는 한계가 발생합니다.

따라서 compose에서는 특정 역할을 하는 속성들이 각각 존재하는것이 아니라, slots라는 빈 공간을 제공하고 개발자가 그 안에 채워 넣을 수 있도록 만듭니다. 실제로 위 이미지는 아래 코드로 작성되었습니다.

@Composable
fun MakeButton() {
    Button(onClick = {}) {
        Row {
            Image(painter = painterResource(id = R.drawable.ic_launcher_foreground), contentDescription = "")
            Text(
                "BUTTON", modifier = Modifier
                    .padding(start = 4.dp)
                    .align(Alignment.CenterVertically)
            )
        }
    }
}

실제로 Button API를 타고 올라가 보면 아래와 같이 정의된 걸 볼 수 있습니다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
   ...
    content: @Composable RowScope.() -> Unit
) {
...

맨 마지막에 content라는 param으로 lambda를 받기 때문에 Button{...} 형태의 구문으로 실제 Button 안을 채워야 할 코드들을 넣을 수 있습니다. (이런 형태로 slot을 제공함)

경우에 따라 AppBar 같은 Compose는 여러 개의 Slots을 제공하기도 합니다.

출처: https://developer.android.com/codelabs/jetpack-compose-layouts/img/4365ce9b02ec2805.png

위 배치 형태는 아래와 같은 개념의 slot을 제공하면 이때 각 부분의 작성을 위한 코드는 아래와 같습니다.

출처: https://developer.android.com/codelabs/jetpack-compose-layouts/img/2decc9ec64c79a84.png

TopAppBar(
	title = {
    	Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
    	Icon(myNavIcon)
    }
}

따라서 composable을 구성시 Slots API pattern을 이용하여 좀 더 재사용성이 가능하고, 확장된 형태의 화면을 구성할 수 있습니다.

Scaffold

Compose에서는 Material component composable을 builtin으로 제공하고, 실제 UI 구성시 이를 사용할 수 있습니다. Material 디자인의 기본 화면 구성 요소를(Topbar, bottomBar, Floating button...) 구현하기 위해서 compose에서는 최상위 레벨에서 scaffold composable을 제공합니다.

scaffold는 Materail design의 기본 layout 구조를 만들기 위한 상위 레벨의 slots를 제공합니다. TopAppBar, BottomAppBar, FloatingActionButton, Drawer의 slot을 제공하고, 이 부분들을 채워 넣으면서 알맞은 위치에 components를 배치하고 동작하도록 할 수 있습니다.

먼저 Text를 화면에 표기하는 기본 화면 구성은 아래와 같습니다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApp {
                MainScreen()
            }
        }
    }
}

@Composable
fun MyApp(content: @Composable () -> Unit) {
    ComposeTest1Theme {
        content()
    }
}

@Composable
fun MainScreen() {
    Text("Hello it's main text")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyApp {
        MainScreen()
   }
}

여기에 Material design의 전형적인 구조를 갖도록 하기 위해 Scaffold composable을 이용하여 전체를 감싸도록 합니다. Scaffold의 param은 전부 optional이지만 content는 lambda 형태로 넣어야 합니다.

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    isFloatingActionButtonDocked: Boolean = false,
    drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean = true,
    drawerShape: Shape = MaterialTheme.shapes.large,
    drawerElevation: Dp = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color = MaterialTheme.colors.surface,
    drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color = DrawerDefaults.scrimColor,
    backgroundColor: Color = MaterialTheme.colors.background,
    contentColor: Color = contentColorFor(backgroundColor),
    content: @Composable (PaddingValues) -> Unit
) {
...

Scaffold를 따라 올라가서 함수의 정의를 보면 위와 같습니다. 맨 마지막 param인 content로 전달받는 lambda는 기본 padding값을 전달 받는데@Composable (PaddingValues) -> Unit, 이 padding은 content의 root composable에 적용되어야만 item들이 적절하게 화면에서 배치될 수 있습니다.

text를 하나더 추가한 형태로 Scafflod를 적용해 보겠습니다.

@Composable
fun MainScreen() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text("Hello it's main text")
            Text("Welcome to Composable world.")
        }
    }
}

이제 이걸 재사용성과 테스트가 용이하도록 바꿔 함수로 하나 빼내도록 합니다. 물론 Modifiler도 param으로 전달받도록 하여, 세부적인 제약 사항을 외부로 빼냅니다. (이 역시 재사용성과 테스트가 쉽도록 하기 위함입니다.)

@Composable
fun MainScreen() {
    Scaffold { innerPadding ->
      BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text("Hello it's main text")
        Text("Welcome to Composable world.")
    }
}

TopAppBar

보통 화면 최상단에 정보와 검색, 이동이 가능하도록 하는 상단 bar (AppBar)를 위치시킵니다. Scaffold는 이 영역을 topBar라는 속성으로 전달 받으며 @Composable () -> Unit type을 가집니다. 제목을 h3의 크기로 변경하여 배치하기 위해 아래와 같이 코드를 변경합니다.

@Composable
fun MainScreen() {
    Scaffold(topBar =  {
        Text("Layout Sample",
            style = MaterialTheme.typography.h3,
            maxLines = 1
        )
    }) { innerPadding ->
      BodyContent(Modifier.padding(innerPadding))
    }
}

하지만 Composable에서 이미 TopAppBar를 제공하고 있으니 이를 이용해서 topBar의 구성을 변경해 봅니다. TopAppBartitle, navigation icon, action을 위한 slots을 제공합니다.

@Composable
fun MainScreen() {
    Scaffold(topBar = {
        TopAppBar(title = { 
            Text("Layout Sample") 
        })
    }) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

보통 appBar에는 추가적인 동작들이 들어갑니다. 따라서 click 가능한 아이콘 버튼을 추가합니다. 이때 기본적으로 Composable에서 제공하는 아이콘을 사용하도록 합니다.

@Composable
fun MainScreen() {
    Scaffold(topBar = {
        TopAppBar(title = {
            Text("Layout Sample")
        },
        actions = {
            IconButton(onClick = { /* doNothing */ }) {
                Icon(Icons.Filled.Favorite, contentDescription = "")
            }
            IconButton(onClick = { /* doNothing */ }) {
                Icon(Icons.Filled.ArrowForward, contentDescription = "")
            }
        })
    }) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

actions의 scope은 기본적으로 Row 이기 때문에 item을 추가할때마다 가로로 배치됩니다. 위에서는 두 개의 IconButton composable을 이용하여 내부에 Icon composable로 이미지를 표시했습니다.

기본적인 Materal icons 이외에 fullset을 사용하려면 gradle 파일에 아래와 같은 dependency를 추가하면 됩니다.

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

해당 아이콘의 실제 이미지는 하기 링크에 방문하여 확인 가능하며, 클릭시 호출을 위한 icon 이름을 알 수 있습니다. [4]

https://fonts.google.com/icons

modifier의 위치

위 대부분의 코드들에서 그랬듯이 새로운 composable 함수를 생성할때 마다 modifier를 param으로 배치하도록 하여 재사용성을 높일 수 있습니다. 만약에 BodyContent에 추가적인 padding이 필요하다면 직접 BodyContent에 추가할 수도 있고, 외부에서 modifier에 추가하여 전달할 수도 있습니다.

만약 해당 padding이 항상 필요한 composable 이라면 내부에서 추가하고, 다른 곳에서 padding만 다르게 하여 사용하는 경우가 있다면 외부에서 선언하도록 해야 합니다. 즉, 어느 한쪽이 정답이라기 보단 composable의 타입과, 사용성에 따라서 결정하도록 구성 합니다.

#Case 1
@Composable
fun MainScreen() {
    Scaffold(topBar = {
    ...
        BodyContent(Modifier.padding(innerPadding).padding(5.dp))
    }
}

#Case 2
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(5.dp)) {
        Text("Hello it's main text")
        Text("Welcome to Composable world.")
    }
}

Modifier는 계속해서 chaining 가능하다고 앞선 포스팅에서 언급했습니다. 다만 더이상 chaining 할 수 없는 mehod 라면 .then()을 이용해서 추가할 수 있습니다. 실제로 Modifier내에 padding도 내부적으로는 .then()을 사용하고 있습니다.

fun Modifier.padding(paddingValues: PaddingValues) =
    this.then(
        PaddingValuesModifier(
           ...
    )

여기서 샘플로 구현한 topBar 이외에도 Scaffold의 param에서 유추할수 있듯이 drawerbottom영역도 해당 param에 composable을 넘겨줌으로써 적절한 위치 배치시켜 Material design의 기본 요소로 구성된 화면을 만들 수 있습니다.

 

References

[1] https://developer.android.com/codelabs/jetpack-compose-layouts?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-layouts#2

[2] https://developer.android.com/reference/kotlin/androidx/compose/runtime/CompositionLocal

[3] https://developer.android.com/jetpack/compose/modifiers-list

[4] https://fonts.google.com/icons

반응형