[Background]
개발을 하던 중 디자인을 따르기 위해서 작은 버튼을 만들었는데 분명 나는 버튼 밖에 있는 공간을 클릭하고 있는데 버튼이 클릭되는 이상한(?) 현상을 발견해서 왜 이런 일이 일어나는지 한참을 조사해 봤습니다.
찾아본 결과 Android 내부에서 버튼, 스위치, 체크 박스 같이 클릭이 되는 Component의 클릭 사이즈를 일부로 키워서 개발자가 아무리 작게 만들어도 사용자가 쉽게 클릭할 수 있도록 제한을 걸어두고 있었다.
설정된 값은 48.dp x 48.dp로 생각보다 클릭이 가능한 범위가 매우 크다고 느껴서 이 제한을 풀 수 있는 방법을 찾아보게 되었다.
[실제 코드]
한번 버튼을 권장 사이즈보다 작은 20.dp로 만들어보고 실제로 어느 정도가 클릭 범위인지 확인해 보겠습니다.
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Button(
modifier = Modifier
.size(20.dp)
.background(Color.LightGray),
onClick = {}
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "")
}
Canvas(modifier = Modifier.size(48.dp)) {
drawRect(color = Color.Red, style = Stroke(width = 1f))
}
}
저 회색 부분이 실제로 클릭되는 부분으로 Canvas로 48.dp x 48.dp 사각형을 그려본 결과 딱 맞는 걸 보실 수가 있습니다.
내부 코드를 확인해 보니깐 Button을 Surface을 통해서 구현하는데, Surface 내부에 minimumInteractiveComponentSize이라는 Modifier Extension이 있습니다.
fun Surface(
onClick: () -> Unit,
...
) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalAbsoluteTonalElevation provides absoluteElevation
) {
Box
modifier = modifier
.minimumInteractiveComponentSize()
....
) {
content()
}
}
}
내부를 보니깐 LocalMinimumInteractiveComponentEnforcement이라는 compositionLocal이 있고 마침 minimumInteractiveComponentSize이 DpSize(48.dp, 48.dp)로 이거라고 생각하고 저 값을 false로 바꿔봤습니다.
fun Modifier.minimumInteractiveComponentSize(): Modifier = composed(
....
) {
if (LocalMinimumInteractiveComponentEnforcement.current) {
MinimumInteractiveComponentSizeModifier(minimumInteractiveComponentSize)
} else {
Modifier
}
}
MaterialTheme {
CompositionLocalProvider(
LocalMinimumInteractiveComponentEnforcement provides false
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Button(
modifier = Modifier
.size(20.dp)
.background(Color.LightGray),
onClick = {}
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "")
}
Canvas(modifier = Modifier.size(48.dp)) {
drawRect(color = Color.Red, style = Stroke(width = 1f))
}
}
}
}
회색 부분이 줄어들면서 클릭 범위를 줄였다고 생각했지만 여전히 저 빨간색 테두리 안이면 클릭이 됐습니다.
이는 오로지 layout만 영향을 주는 것으로 일종의 Padding이라고 생각하시면 됩니다. 클릭 범위 내에 다른 Component와 겹쳐서 클릭이 안 되는 상황을 가정한 것 같습니다.
실제로 클릭 범위를 관여하고 있는 녀석은 LocalViewConfiguration으로 내부 코드를 보면 이와 같습니다.
interface ViewConfiguration {
...
/**
* The minimum touch target size. If layout has reduced the pointer input bounds below this,
* the touch target will be expanded evenly around the layout to ensure that it is at least
* this big.
*/
val minimumTouchTargetSize: DpSize
get() = DpSize(48.dp, 48.dp)
}
val이기 때문에 저 값을 바꿀 수는 없어서 적용이 되는 부분만 찾는다면 직접 구현해서 클릭 범위 제한을 벗어날 수 있다고 생각했습니다.
이 minimumTouchTargeSize가 사용되는 부분까지 천천히 들어가 보자면 가장 먼저 Modifier.clickable부터 시작이 됩니다. (불필요한 부분은 ….으로 대체했습니다)
fun Modifier.clickable(
....
) = inspectable(
....
) {
Modifier
....
.then(ClickableElement(interactionSource, enabled, onClickLabel, role, onClick))
}
여기서 ClickableElement가 ClickableNode을 만들어준다,
private class ClickableElement(
....
) : ModifierNodeElement<ClickableNode>() {
override fun create() = ClickableNode(
interactionSource,
enabled,
onClickLabel,
role,
onClick
)
....
}
Node란 Compose가 화면을 그리기 위해 Component 한 개의 정보를 담아두는 일종의 Building block이다. 이 Node들이 Component Tree을 만들어서 화면을 구성한다.
private class ClickableNode(
....
) : AbstractClickableNode(interactionSource, enabled, onClickLabel, role, onClick) {
override val clickableSemanticsNode = delegate(
ClickableSemanticsNode(
enabled = enabled,
role = role,
onClickLabel = onClickLabel,
onClick = onClick,
onLongClick = null,
onLongClickLabel = null
)
)
}
그리고 Clickable Node는 ClickableSemanticNode을 만들어주는데
private class ClickableSemanticsNode(
....
) : SemanticsModifierNode, Modifier.Node
얘가 SemanticsModifierNode라는 interface을 상속받고
interface SemanticsModifierNode : DelegatableNode {
....
/**
* Add semantics key/value pairs to the layout node, for use in testing, accessibility, etc.
*
* The [SemanticsPropertyReceiver] provides "key = value"-style setters for any
* [SemanticsPropertyKey]. Additionally, chaining multiple semantics modifiers is
* also a supported style.
*
* The resulting semantics produce two [SemanticsNode] trees:
*
* The "unmerged tree" rooted at [SemanticsOwner.unmergedRootSemanticsNode] has one
* [SemanticsNode] per layout node which has any [SemanticsModifierNode] on it. This
* [SemanticsNode] contains all the properties set in all the [SemanticsModifierNode]s on
* that node.
*
* The "merged tree" rooted at [SemanticsOwner.rootSemanticsNode] has equal-or-fewer nodes: it
* simplifies the structure based on [shouldMergeDescendantSemantics] and
* [shouldClearDescendantSemantics]. For most purposes (especially accessibility, or the
* testing of accessibility), the merged semantics tree should be used.
*/
fun SemanticsPropertyReceiver.applySemantics()
}
internal fun Modifier.Node.touchBoundsInRoot(useMinimumTouchTarget: Boolean): Rect {
if (!node.isAttached) {
return Rect.Zero
}
if (!useMinimumTouchTarget) {
return requireCoordinator(Nodes.Semantics).boundsInRoot()
}
return requireCoordinator(Nodes.Semantics).touchBoundsInRoot()
}
여기서 applySemantics는 SemanticNode을 등록해 주는 tree에 추가해 주는 역할을 하고
touchBoundsInRoot라는 함수가 SemanticNode에 클릭 범위를 정해줍니다.
val minimumTouchTargetSize: Size
get() = with(layerDensity) { layoutNode.viewConfiguration.minimumTouchTargetSize.toSize() }
/**
* Returns the bounds of this [NodeCoordinator], including the minimum touch target.
*/
fun touchBoundsInRoot(): Rect {
if (!isAttached) {
return Rect.Zero
}
val root = findRootCoordinates()
val bounds = rectCache
val padding = calculateMinimumTouchTargetPadding(minimumTouchTargetSize)
bounds.left = -padding.width
bounds.top = -padding.height
bounds.right = measuredWidth + padding.width
bounds.bottom = measuredHeight + padding.height
var coordinator: NodeCoordinator = this
while (coordinator !== root) {
coordinator.rectInParent(
bounds,
clipBounds = false,
clipToMinimumTouchTargetSize = true
)
if (bounds.isEmpty) {
return Rect.Zero
}
coordinator = coordinator.wrappedBy!!
}
return bounds.toRect()
}
그러면 마지막으로 touchBoundsInRoot가 viewConfiguration으로부터 minimumTouchTargetSize을 받아 SemanticNode의 bound를 설정하는 것을 볼 수가 있습니다.
[WorkAround]
이 클릭 범위를 줄이는 방법은 없지만 돌아서 가는 방법은 있습니다.
수많은 방법이 있지만 제가 봤을 때 가장 간단한 건 Scale을 이용하는 것입니다.
MaterialTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Button(
shape = CircleShape,
modifier = Modifier
.scale(0.5f)
.background(Color.LightGray),
onClick = {
}
) {
Icon(imageVector = Icons.Filled.Add, contentDescription = "")
}
Canvas(modifier = Modifier.size(48.dp)) {
drawRect(color = Color.Red, style = Stroke(width = 1f))
}
}
}
확인해보시면 빨간색 태두리 안을 클릭해도 반응을 없는 것을 볼 수 있습니다.
[마무리]
LocalMinimumInteractiveComponentEnforcement 이름 때문에 얘를 false로 바뀌면 클릭 범위에도 영향을 주는 줄 알았는데 차이가 없어서 한동안 헤매었네요…
제가 직접 코드를 까보니 글에 쓴 것처럼 나왔지만 놓치는 게 있을 수도 있어서 참고용도로 써주세요.
'android > compose' 카테고리의 다른 글
(Android/Compose) Paging 라이브러리 없이 Paging 적용해보기 (1) | 2023.12.08 |
---|---|
(android/compose) Server driven UI + Rich Text로 배포 없이 간편하게 UI를 변화시키다 (1) | 2023.11.30 |