Sitemap

Jetpack Compose: Touch Target

터치 영역에 대해 살펴봅니다.️

--

정리 목적으로, 터치 영역을 살펴보는 글을 올립니다.
이 글에서 다루는 구성요소는 모두 Material3 입니다.

앱의 UI를 구현할 때는 디자인 가이드를 통해 디자이너와 소통합니다.
간단한 예시로, 좋아요 버튼을 구현한다고 가정해 봅시다.

Icon으로 ‘좋아요 이미지’를 가이드대로 표시할 수 있습니다.

Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
Press enter or click to view image in full size

이 아이콘에 클릭 기능을 넣어 버튼으로 만들어봅시다.

Modifier.clickable()

clickable로 클릭 기능을 적용할 수 있습니다.

  Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
+ modifier = Modifier.clickable(onClick = { /* do something */ }),
)
Press enter or click to view image in full size

그런데 디자인 가이드와는 일치하지만, 터치 영역은 작아 보입니다. 🤔

그 이유는 Icon은 크기를 지정하지 않으면 이미지 크기만큼만 영역이 잡히는 컴포넌트기 때문입니다. 그래서 ‘좋아요 이미지’ 크기가 24 × 24 dp 라면 Icon 크기도 24 × 24 dp 가 됩니다.

Press enter or click to view image in full size
Modifier.border(width = 1.dp, color = Color.Red)

Material Design에서는 최소 48 × 48 dp 만큼의 터치영역을 확보하는 것을 권장합니다. 그래서 크기를 적절히 조정해주는 것이 좋습니다.

Press enter or click to view image in full size
Material Design: Touch and pointer target sizes

그럼 Icon 크기를 조절해봅시다.

Modifier.size()

size로 영역을 넓힐 수 있습니다.

  Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
modifier = Modifier
.clickable(onClick = { /* do something */ })
+ .size(48.dp),
)
Press enter or click to view image in full size

터치영역은 충분히 넓어졌지만, ‘좋아요 이미지’ 도 확대되어 보입니다. ❌

Modifier.padding()

이럴 때 padding을 추가하여 아이콘 크기를 적절히 조절할 수 있습니다.

  Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
modifier = Modifier
.clickable(onClick = { /* do something */ })
.size(48.dp)
+ .padding(12.dp),
)
Press enter or click to view image in full size

이제 이미지가 가이드대로 보이면서, 터치 영역도 적절한 것 같습니다. ✅

이런 아이콘 형태의 버튼은 앱에서 자주 사용되니까, 매번 코드를 작성하는 것보다는 Component로 공통화하여 재사용하는 것이 좋겠죠?

Component 분리하기

Icon을 감싼 Composable 함수로 Component를 만들어봅시다.

@Composable
fun IconWithClick(
onClick: () -> Unit,
imageVector: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current,
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = modifier
.clickable(onClick = onClick)
.size(48.dp)
.padding(12.dp),
tint = tint,
)
}

재사용하기 쉬운 Component가 되었습니다(?)

IconWithClick(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
onClick = { /* do something */ },
)
Press enter or click to view image in full size

얼핏 보기엔 괜찮아 보이지만, 이렇게 만들면 몇 가지 아쉬운 점이 있습니다.

(1) 크기를 변경하기 어렵다.

터치 영역을 넓히거나 아이콘 크기를 변경하려면, sizepadding 값을 적절히 조정해야만 합니다. 값을 잘못 적용하면 이미지가 확대되어 보이구요.

  IconWithClick(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
onClick = { /* do something */ },
+ modifier = Modifier.size(72.dp),
)
Press enter or click to view image in full size

또는 축소되어 보이기도 합니다.

  IconWithClick(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
onClick = { /* do something */ },
+ modifier = Modifier.size(36.dp),
)
Press enter or click to view image in full size

(2) Composable을 여러 벌 작성해야 한다.

Icon은 다양한 포맷의 이미지를 표시할 수 있게, Composable 함수가 여러개 정의되어 있습니다. 이를 모두 사용하려면, Icon마다 매칭되는 Composable 함수를 정의해야 합니다.

@Composable
fun Icon(
imageVector: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) { ... }

@Composable
fun Icon(
bitmap: ImageBitmap,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) { ... }

@Composable
fun Icon(
painter: Painter,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current
) { ... }

...

더 나은 방법이 없을까요? 🤔

Component 구조 변경하기

Composable 함수 안에서 Icon을 직접 사용하지 않고, 조합하여 사용할 수 있게 만들어 봅시다. 코드로는 content slot을 노출하고, contentBox로 감싸서 size를 정의하면 됩니다.

@Composable
fun IconWrapper(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.size(48.dp)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
content()
}
}

content 부분에는 Icon을 전달합니다.

IconWrapper(
onClick = { /* do something */ },
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
Press enter or click to view image in full size

IconWrapper를 사용하는 부분에 size를 정의하여, 터치 영역을 조절할 수 있습니다.

  IconWrapper(
onClick = { /* do something */ },
+ modifier = Modifier.size(72.dp),
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
Press enter or click to view image in full size

필요한 경우에는 터치 영역을 줄일 수도 있습니다.

  IconWrapper(
onClick = { /* do something */ },
+ modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
Press enter or click to view image in full size

IconWithClick에서의 문제가 모두 해결되었습니다. ✅

✅ IconButton

조금 전 작성한 IconWrapper는 Material3의 IconButton 구조입니다.
앞선 코드를 변경해보면 동일하게 표시됩니다.

- IconWrapper(
+ IconButton(
onClick = { /* do something */ },
modifier = ...,
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
Press enter or click to view image in full size

정말 동일한 구조가 맞는지, IconButton의 내부코드를 살펴봅시다. 👀

다른 코드가 더 있긴 하지만, contentBox로 감싸고 size로 크기를 조정하는 부분을 확인할 수 있습니다.

Press enter or click to view image in full size

그런데 size에 들어가는 기본값을 계산해보면 40 × 40 dp 입니다. 🤯

이 부분은 Material Design 가이드를 보면 이해할 수 있습니다.
IconButton에는 3가지의 크기가 있습니다.

  • 아이콘 크기
  • 컨테이너 크기
  • 터치 영역 크기
Press enter or click to view image in full size
Material Design: Standard icon button measurements

IconButtonsize는 컨테이너 크기라서, 40 × 40 dp로 설정되어 있습니다. 그럼 터치 영역 크기인 48 × 48 dp 를 설정하는 부분도 살펴봅시다.

Modifier.minimumInteractiveComponentSize()

너비와 높이의 최소값을 설정하는데요. 기본은 48 dp입니다.
최소값은 LocalMinimumInteractiveComponentSize으로 변경할 수 있습니다.

Press enter or click to view image in full size
MinimumInteractiveModifierNode

자, 그러면 LocalMinimumInteractiveComponentSize 값을 줄이면 터치 영역이 줄어들겠죠?

+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 36.dp
+ ) {
IconButton(
onClick = { /* do something */ },
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
+ }

틀렸습니다. ❌

Press enter or click to view image in full size

실제로 테스트해보면, 영역 밖으로도 터치가 됩니다. 😲

ViewConfiguration#minimumTouchTargetSize

LocalMinimumInteractiveComponentSize 는 레이아웃 영역만을 제어하고 있어서, 터치 영역을 변경하려면 minimumTouchTargetSize 값을 조정해야 합니다.

Press enter or click to view image in full size
NodeCoordinator.kt

LocalMinimumInteractiveComponentSize 와 동일한 값으로 조정해봅시다.

  CompositionLocalProvider(
LocalMinimumInteractiveComponentSize provides 36.dp
+ LocalViewConfiguration provides CustomViewConfiguration(
+ minimumTouchTargetSize = DpSize(36.dp, 36.dp),
+ ),
) {
IconButton(
onClick = { /* do something */ },
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
)
}
}
Press enter or click to view image in full size

의도한 것처럼 36 × 36 dp 영역 안으로 터치 영역을 제한할 수 있습니다. ✅

지금까지 Icon, IconButton을 기반으로 Touch Target에 대한 내용을 다뤄봤구요. 이 글을 통해, 다음 내용을 알 수 있습니다.

  • 아이콘 모양의 버튼을 만들 때, IconButton + Icon 조합을 사용해야 하는 이유
  • IconButton 레이아웃 크기가 최소한 48 × 48 dp가 되는 이유
  • 48 × 48 dp 보다 레이아웃 크기가 줄어들더라도 실제 터치 영역은 48 × 48 dp 를 보장한다.

마지막에 터치 영역을 조정하는 부분은 충분한 설명없이 급하게 마무리한 느낌이 있는데요. 링크를 적어뒀으니, 궁금한 분들은 직접 찾아보는 것으로 남겨두겠습니다.

전체 코드는 fornewid/details-in-compose에서 볼 수 있습니다.

--

--