본문 바로가기

Android/Trouble Shoot

[Jetpack Compose] MinimumInteractiveComponentSize

Unsplash, Mick Haupt.

동기

디자인 파트와의 협업을 진행하다 보면, 툴팁 표시 등의 기능을 위한 UI 가 다소 작게 구성되어 있는 경우가 왕왕 있습니다. 이러한 경우, Jetpack Compose 는 자동으로 터치 영역을 확장하여 UX 를 개선하도록 구현되어 있습니다.

 

어떠한 방식으로 이러한 개선이 이루어지는지, 별도로 활용할 수 있는지, 이를 해제하고 싶다면 어떻게 하면 좋을지 등을 학습하고, 정리하고 싶어 포스트를 작성합니다.


개요

머티리얼 디자인 시스템의 규칙을 따라, 위젯은 최소 48dp 의 터치 영역을 확보하는 편이 좋은데요. 

Android View System 에는 이에 대한 자체 처리가 안 되어 있어, 마켓에 앱을 올리면 이에 대한 경고를 주기도 합니다.

 

Jetpack Compose 에는 이를 간편히 만족시킬 수 있도록 구현된 메서드가 있습니다. 

@OptIn(ExperimentalMaterial3Api::class)
fun Modifier.minimumInteractiveComponentSize(): Modifier = composed(
    inspectorInfo = debugInspectorInfo {
        name = "minimumInteractiveComponentSize"
        // TODO: b/214589635 - surface this information through the layout inspector in a better way
        //  - for now just add some information to help developers debug what this size represents.
        properties["README"] = "Reserves at least 48.dp in size to disambiguate touch " +
            "interactions if the element would measure smaller"
    }
) {
    if (LocalMinimumInteractiveComponentEnforcement.current) {
        MinimumInteractiveComponentSizeModifier(minimumInteractiveComponentSize)
    } else {
        Modifier
    }
}

 

작은 아이콘을 사용한 디자인이나 작은 사이즈의 텍스트를 사용하는 UI 가 많기에 꽤 효과를 볼 수 있습니다. 이와 같이 별도로 메서드가 존재하지만, 사실 호출할 일은 잘 없습니다. 이유는 간단한데, 보통 클릭 이벤트를 구현할 때에 사용되는 Modifier.clickable {} 메서드와 Modifier.pointerInput() 메서드 내부에 이에 대한 구현이 이미 적용되어 있기 때문입니다.

 

Modifier.clickable {} 의 경우, SemanticsNode 에 주석으로 작성되어 있습니다.

    /**
     * The rectangle of the touchable area.
     *
     * If this is a clickable region, this is the rectangle that accepts touch input. This can
     * be larger than [size] when the layout is less than
     * [ViewConfiguration.minimumTouchTargetSize]
     */
    val touchBoundsInRoot: Rect
        get() {
            val entity = if (unmergedConfig.isMergingSemanticsOfDescendants) {
                (layoutNode.outerMergingSemantics ?: outerSemanticsNode)
            } else {
                outerSemanticsNode
            }
            return entity.node.touchBoundsInRoot(unmergedConfig.useMinimumTouchTarget)
        }

 

Modifier 에 적용된 크기가 ViewConfiguration 의 minimumTouchTargetSize 보다 작은 경우 재설정 될 수 있다고 이해할 수 있겠습니다. 이러한 프로세스는 clickable {} 메서드를 통해 기존 Modifier 의 노드와 결합되는 ClickableSemanticsNode 에 의해 진행됩니다.

 

pointerInput() 메서드에도 관련된 코드가 작성되어 있습니다.

override val extendedTouchPadding: Size
    get() {
        val minimumTouchTargetSize = viewConfiguration.minimumTouchTargetSize.toSize()
        val size = size
        val horizontal = max(0f, minimumTouchTargetSize.width - size.width) / 2f
        val vertical = max(0f, minimumTouchTargetSize.height - size.height) / 2f
        return Size(horizontal, vertical)
    }

 

clickable {} 메서드와 동일하게, viewConfiguration.minimumTouchTargetSize 를 그대로 활용하고 있습니다.

즉, clickable {} 메서드와 pointerInput() 메서드를 사용하는 경우, 위 서술한 메서드를 별도로 호출하지 않아도 자동으로 적용됩니다.


콜 사이트에 따라 다르게 적용된다

 

이미 적용되어 있음에도 불구하고 직접 호출할 상황이 있을 수는 있겠는데요. 바로, 자체 패딩을 통한 영역 확보가 필요한 경우입니다.

 

메서드 콜 사이트에 따라 UI 에 반영되는 것에 약간 씩의 차이가 있습니다. 확인 하시고, 필요하다면 콜 사이트를 적절히 선택해서 사용하시면 좋겠습니다.


제거하기

이를 적용하고 싶지 않은 상황들이 있을 수도 있는데요. 오밀조밀 구현된 UI 의 경우, 이러한 구현이 오히려 방해가 될 수 있습니다. 제 경우, 회사에서 진행하고 있는 영상 플레이어 앱의 Seekbar Thumb 의 사이즈가 의도적으로 작게 구현되었는데, 이러한 설정이 적용되어 Thumb 과 매우 인접한 Seekbar 영역을 터치할 때에 불편함이 있었습니다.

 

학습한 바, ViewConfiguration minimumTouchTargetSize 값을 조정하면 될 것이라 생각했고, 이는 CompositionLocalProvider 를 통해 설정할 수 있었습니다. CompositionLocalProvider 에 대한 정보는 다음 포스트에서 확인하실 수 있습니다.

 

[Jetpack Compose] CompositionLocal 은 어떻게 동작하는가?

동기 Jetpack Compose 를 꽤 오랜 시간 사용해 왔지만, 여전히 모르는 부분이 많습니다. 그중 가장 가려웠던 부분은 CompositionLocal 인데, 언제 어떻게 사용해야 하는지에 대해서는 알고 있으나, 어떠한

blothhundr.tistory.com

 

LocalViewConfiguration 은 현재 적용된 ViewConfiguration 을 제공하고 있는데요. 이는 데이터 클래스가 아니기 때문에, copy() 와 같은 메서드를 사용할 수 없습니다. 그래서 내부 값을 수정하기 위한 클래스를 따로 정의하고, 이를 LocalCompositionProvider 에 전달하여 해결하는 방식을 사용해야 합니다.

class CustomViewConfiguration(
    private val defaultViewConfiguration: ViewConfiguration,
) : ViewConfiguration {
    override val doubleTapMinTimeMillis: Long
        get() = defaultViewConfiguration.doubleTapMinTimeMillis
    override val doubleTapTimeoutMillis: Long
        get() = defaultViewConfiguration.doubleTapTimeoutMillis
    override val longPressTimeoutMillis: Long
        get() = defaultViewConfiguration.longPressTimeoutMillis
    override val touchSlop: Float
        get() = defaultViewConfiguration.touchSlop
    override val minimumTouchTargetSize: DpSize
        get() = DpSize(0.dp, 0.dp)
}

 

minimumTouchTargetSize 말고도 다른 값들 역시 조정할 수 있으니, 필요 시 조정하시면 되겠습니다. 굉장히 매력적인 설정들이네요.

 

기본 값을 전달해주어야 하기 때문에, 프로퍼티로 기본 ViewConfigration 을 전달할 수 있도록 구현하면 됩니다. 이후에는 내부 코드 블럭에 원하는 UI 코드를 작성하면 됩니다.

val defaultViewConfiguration = LocalViewConfiguration.current
val customViewConfiguration = remember { CustomViewConfiguration(defaultViewConfiguration) }
CompositionLocalProvider(LocalViewConfiguration.provides(customViewConfiguration)) {
    Text(
        modifier = Modifier.clickable { },
        text = "안녕하세요"
    )
}

 

코드를 보면 아시겠지만, 반대로 늘릴 수도 있습니다.

 


Jetpack Compose 는 이제 메인 스트림으로 자리 잡았고, 수많은 개선을 거쳐 현재에 이르렀습니다. 다양한 지원이 존재하고, 또 이에 대해서 꽤 세밀한 부분까지 조정할 수 있구나 싶어 새삼 놀랍네요.