
Modifier는 Jetpack Compose의 핵심 요소로, 스타일과 동작을 적용하거나 Composable을 체이닝 방식으로 수정할 수 있게 해줍니다. Modifier는 패딩 설정, 크기 지정, 정렬, 클릭 동작, 배경 및 상호작용 등을 UI 요소에 유연하게 적용할 수 있는 방법을 제공합니다. Compose는 선언적 UI 패러다임을 따르기 때문에, Modifier는 UI 컴포넌트를 재사용 가능하고 유지보수하기 쉽게 만드는 데 중요한 역할을 합니다.
Modifier를 사용할 때 또 하나 중요한 점은 Modifier를 적용하는 순서입니다. Modifier는 순차적으로 체이닝되므로, 각 함수는 이전의 결과를 감싸고 확장하여 최종적인 구성 요소의 외형과 동작에 직접적인 영향을 줍니다.
이러한 계층적 구조 덕분에 각 Modifier는 제어 가능하고 예측 가능한 방식으로 적용됩니다. 이는 마치 트리를 순회하듯 단계별로 수정이 이루어져 최종 레이아웃과 상호작용 속성에 영향을 미칩니다.
예를 들어, clickable을 padding보다 먼저 적용하면 패딩 영역을 포함한 전체 영역이 클릭 가능해지고, 반대로 padding을 먼저 적용하면 클릭 가능한 영역이 내부 콘텐츠로 제한됩니다.
Modifier는 Jetpack Compose의 핵심이며, Composable 함수에 스타일을 쉽게 적용하고 구성할 수 있게 해줍니다. 그러나 잘못 사용하면 예기치 않은 동작이 발생할 수 있습니다. 다음은 Modifier를 올바르게 사용하기 위한 규칙입니다.
Modifier는 구성 요소의 계층 구조 중 가장 바깥쪽 Composable에 적용해야 합니다. 임의의 내부 계층에 적용하면 예기치 않은 동작을 유발하고 사용자에게 혼란을 줄 수 있습니다.
예시:
@Composable
fun RoundedButton(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Button(
modifier = modifier.clip(RoundedCornerShape(32.dp)),
onClick = onClick
) {
Text(
modifier = Modifier.padding(10.dp),
text = "Rounded"
)
}
}
이것이 올바른 방식입니다. 반대로 잘못된 예는 다음과 같습니다:
@Composable
fun RoundedButton(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Button(
modifier = Modifier.clip(RoundedCornerShape(32.dp)),
onClick = onClick
) {
Text(
modifier = modifier.padding(10.dp), // 잘못된 사용
text = "Rounded"
)
}
}
RoundedButton은 Button을 대표하므로 스타일링은 버튼 레벨에서 이루어져야 합니다.
여러 Modifier 파라미터를 제공하는 방식은 복잡성과 혼란을 초래할 수 있습니다.
@Composable
fun RoundedButton(
modifier: Modifier = Modifier,
textModifier: Modifier = Modifier,
onClick: () -> Unit
) {
Button(
modifier = modifier.clip(RoundedCornerShape(32.dp)),
onClick = onClick
) {
Text(
modifier = textModifier.padding(10.dp),
text = "Rounded"
)
}
}
더 나은 방식은 슬롯 기반 접근법을 사용하는 것입니다:
@Composable
fun RoundedButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable RowScope.() -> Unit
) {
Button(
modifier = modifier.clip(RoundedCornerShape(32.dp)),
onClick = onClick
) {
content()
}
}
이 방식은 API를 더 깔끔하게 유지하며 Compose의 유연성과 예측 가능성을 반영합니다.
Modifier 체인을 변수로 추출해 상위 범위로 이동시키는 것은 유익할 수 있습니다. 하지만 같은 Modifier 인스턴스를 여러 Composable에 재사용하면 예기치 않은 결과를 낳을 수 있습니다.
예:
@Composable
fun MyButtons(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Column(modifier = modifier) {
Button(
modifier = modifier,
onClick = onClick
) {
Text(
modifier = modifier.padding(10.dp),
text = "Rounded"
)
}
Button(
modifier = modifier,
onClick = onClick
) {
Text(
modifier = modifier.padding(10.dp),
text = "Not Rounded"
)
}
}
}
호출 시에 Modifier에 스타일을 추가하면 전체 계층 구조에 예상치 못한 영향이 발생할 수 있습니다:
MyButtons(
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(Color.Blue)
) {}
모든 자식에 동일한 modifier 인스턴스를 적용하면 위처럼 전역적으로 영향을 미칠 수 있습니다.
Jetpack Compose에서 Modifier를 효과적으로 사용하면 컴포넌트의 예측 가능성, 유지보수 용이성, 사용 편의성이 보장됩니다. Modifier를 올바른 레벨에 적용하고, API를 단순하게 유지하며, 의도하지 않은 동작을 방지하는 것은 깔끔하고 견고한 UI 컴포넌트를 구성하는 데 있어 핵심입니다.
Layout Composable은 자식 Composable의 측정 및 배치에 대한 완전한 제어 권한을 제공하는 저수준 API입니다. Column, Row, Box 같은 고수준 레이아웃과는 달리, Layout을 사용하면 특정한 요구 사항에 맞춰 커스텀 레이아웃을 구현할 수 있습니다.
Layout Composable은 자식 Composable들을 어떻게 측정하고 배치할지를 정의하는 함수를 제공합니다. 두 가지 주요 단계가 있습니다:
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val width = constraints.maxWidth
val height = placeables.sumOf { it.height }
layout(width, height) {
var yPosition = 0
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
}
}
}
}
measure() 함수는 부모로부터 전달된 제약 조건을 사용하여 각 자식 컴포저블의 크기를 측정합니다.layout() 함수는 최종적인 레이아웃의 width와 height를 정의합니다.placeRelative() 함수는 레이아웃 내에서 각 자식 컴포저블의 위치를 설정합니다.Layout 컴포저블은 Column, Row, Box와 같은 표준 레이아웃 컴포넌트로는 원하는 수준의 커스터마이징을 할 수 없을 때 유용합니다. 자식 컴포저블들의 측정 및 배치를 완전히 제어해야 할 필요가 있다면, Layout을 사용하여 고유한 레이아웃 논리를 정의할 수 있습니다.
다음과 같은 경우에 특히 유리합니다:
SubcomposeLayout은 레이아웃 내에서 동적으로 컴포지션을 수행할 수 있도록 해주는 저수준 API입니다. 이 API는 특정 UI 컴포넌트가 부모 레이아웃 패스와 독립적으로 자식 요소를 재컴포지션해야 할 때 사용됩니다.
이는 자식 콘텐츠의 크기가 비동기 데이터에 의존하거나 여러 번의 측정 과정이 필요한 경우에 특히 유용합니다.
Jetpack Compose에서 기본 레이아웃 시스템은 1회 측정 모델(one-pass measurement model)을 따릅니다. 각 컴포저블은 컴포지션 당 한 번만 측정됩니다. 그러나 일부 UI 구조는 자식 컴포저블을 여러 번 측정하거나, 제약 조건이 확인된 이후에 컴포지션을 수행해야 할 필요가 있습니다.
SubcomposeLayout은 이와 같은 유연성을 제공하며, 측정 단계 중 자식 요소들을 동적으로 컴포지션할 수 있게 합니다.
BoxWithConstraints)SubcomposeLayout은 매우 강력하지만, 성능 비용이 발생할 수 있으므로 주의해서 사용해야 합니다. 자식 요소를 여러 번 컴포지션하거나 측정하게 되면 불필요한 recomposition이 발생할 수 있으며, 이는 성능 저하로 이어질 수 있습니다.
따라서 표준 레이아웃으로는 해결이 어려운 상황, 예컨대 다단계 측정이 필요한 경우에만 사용하는 것이 바람직합니다.
또한 재컴포지션이 반드시 필요한 경우에만 발생하도록 제어하여 성능을 최적화해야 합니다.
페이징은 리스트에서 대용량 데이터를 효율적으로 처리하면서 부드러운 UI 성능을 유지하는 데 필수적입니다. 다양한 솔루션이 존재하며, Jetpack Paging Library⁷⁸도 그중 하나로 페이징 데이터를 관리하는 방법을 제공합니다. 그러나 사용자가 리스트의 끝에 도달했을 때 동적으로 데이터를 로드함으로써, 서드파티 라이브러리 없이도 무한 스크롤을 구현할 수 있습니다.
페이징을 구현하는 일반적인 전략은 사용자가 마지막 보이는 항목에 도달했을 때 데이터를 로드하는 것입니다. 이 작업은 아래 예시처럼 LazyListState를 사용하여 구현할 수 있습니다:
@Composable
fun PaginatedList(viewModel: ListViewModel) {
val listState = rememberLazyListState()
val items by viewModel.items.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val threshold = 2
val shouldLoadMore by remember {
derivedStateOf {
val totalItemsCount = listState.layoutInfo.totalItemsCount
val lastVisibleItemIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
(lastVisibleItemIndex + threshold >= totalItemsCount) && !isLoading
}
}
LaunchedEffect(listState) {
snapshotFlow { shouldLoadMore }
.distinctUntilChanged()
.filter { it }
.collect { viewModel.loadMoreItems() }
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(items) { item ->
Text(modifier = Modifier.padding(8.dp), text = "$item")
}
item {
if (viewModel.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
}
}
}
이 예시에서:
LazyListState는 스크롤 위치를 모니터링합니다.snapshotFlow는 마지막 보이는 항목의 인덱스를 추적합니다.threshold만큼 가까워지고 아직 로딩 중이 아니라면 loadMoreItems()가 트리거됩니다.페이징 로직을 관리하기 위해 ViewModel을 사용하여 데이터를 점진적으로 로드할 수 있습니다.
private class ListViewModel : ViewModel() {
private val _items = mutableStateListOf<Int>()
internal val items: StateFlow<List<Int>> = MutableStateFlow(_items)
private var isLoading = MutableStateFlow(false)
private set
private var currentPage = 0
private fun loadMoreItems() {
if (isLoading) return
isLoading.value = true
viewModelScope.launch {
delay(1000) // Simulate network request
val newItems = List(20) { (currentPage * 20) + it }
_items += newItems
currentPage++
isLoading.value = false
}
}
}
이 구현에서:
_items는 상태 리스트 데이터를 보유하며 페이지가 로드될 때마다 업데이트됩니다.loadMoreItems()는 호출 시 새 데이터를 가져옵니다.isLoading은 한 페이지가 이미 로드 중일 때 중복 요청을 방지합니다.페이징은 LazyListState를 사용하여 추가 데이터를 로드할 시점을 감지함으로써 효율적으로 구현할 수 있습니다. 이 접근 방식은 새로운 항목이 필요한 경우에만 데이터를 가져오며, 불필요한 recomposition을 줄이고 성능을 향상시킵니다. Lazy 로딩을 활용하면 대용량 데이터를 부드럽게 표시하면서도 리소스를 효율적으로 관리할 수 있습니다