Android/Jetpack Compose

[Compose] Modifier Extension - 클릭 이벤트 효과 방지, 다중 클릭 이벤트 방지

Heeg's 2023. 10. 31. 22:14
728x90

필자가 Compose를 실무에서 사용하면서, 가장 자주 사용하는 두 가지 Click Event 유틸을 소개하고자 한다.

첫 번째로는 클릭 이벤트 효과를 없애주는 것,

두 번째로는 다중 클릭 이벤트를 방지해 주는 것이다.

 

이 두가지 유틸은 한번 작성해 두면 수정 없이 사용이 가능할 뿐 아니라, 항상 사용하기 때문에 Compose로 개발을 하는데 큰 도움이 되지 않을까 생각한다.

 


기본적으로 버튼과 같은 경우 클릭 이벤트 효과. RippleEffect가 존재하기 때문에 원하지 않아도 클릭되었다는 것을 명시적으로 보게 된다.

하지만, 실무에서 서비스를 만들다보면 클릭 이벤트 효과가 있는 것이 더 어색하고 이상한 케이스가 많기 때문에 클릭 이벤트에는 늘 해당 유틸을 적용시켰다.

따라서, Modifier에서 적용할 수 있도록 미리 유틸을 만들어두도록 한다.

 

우선, 일반적으로 Click Event를 넣어둔 Text Compose 함수를 보자.

Text(
    modifier = Modifier
        .fillMaxWidth()
        .height(52.dp)
        .clickable {
            ...
        },
    text = "....",
)

 

이처럼 modifier의 clickable을 사용하여 텍스트를 클릭했을 때 이벤트를 발생시킨다.

문제는 clickable 함수에 기본적으로 적용되어 있는 Ripple Effect가 발생하여 원하지 않는 클릭 이벤트 효과가 발생하게 된다.

따라서, clickable 함수에서 설정되는 이펙트를 변경해 주면 된다.

 

Text(
    modifier = Modifier
        .fillMaxWidth()
        .height(52.dp)
        .clickable(
            indication = null, // Ripple Effect 제거
            interactionSource = remember { MutableInteractionSource() }
        ) {
            ...
        },
    text = "...",
)

 

indication과 interactionSource는 동시에 존재해야 한다. 둘 중 하나만 선언하여 사용할 수는 없다.

위와 같이 인터렉션 소스를 설정해주고, indication 값을 null로 설정해 주면 ripple Effect가 사라지게 된다.

 

하지만, 매번 클릭 이벤트를 넣을 때 마다 위와 같이 선언해서 사용하는 것은 불편하다.

따라서 다음과 같이 modifier Extension을 활용하여 함수를 만들어준다.

 

fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = composed {
    clickable(
        onClick = onClick,
        indication = null, // Ripple 이벤트 제거
        interactionSource = remember { MutableInteractionSource() }
    )
}

 

클릭 이벤트에 설정했던 clickable 속성 값을 이처럼 미리 설정해 두고 사용하면 된다.

 

Text(
    modifier = Modifier
        .fillMaxWidth()
        .height(52.dp)
        .noRippleClickable {
            ...
        },
    text = "...",
)

 

실제 사용하는 부분은 위와 같이 호출하여 사용하면 된다.


다음으로는 중복 호출 방지이다.

어떤 클릭 이벤트가 발생했을 때, API를 호출한다던지, 데이터가 변경된다던지의 이벤트는 흔히 있는 이벤트이다.

하지만, 의도하지 않았지만 연속해서 이벤트가 호출되는 경우 예기치 못한 이슈들이 발생할 수 있다.

따라서, debounce를 사용하여 중복 클릭 이벤트를 방지할 수 있다.

 

interface SingleClickEventInterface {
    fun event(event: () -> Unit)
}

@OptIn(FlowPreview::class)
@Composable
fun <T> singleClickEvent(
    // Interface를 변수로 사용하여 지정된 함수를 Override하여 Compose 함수에 적용시킨다.
    content: @Composable (SingleClickEventInterface) -> T,
): T {
    val debounceState = remember {
        MutableSharedFlow<() -> Unit>(
            replay = 0, // 캐시의 크기.
            extraBufferCapacity = 1, // 버퍼 크기
            onBufferOverflow = BufferOverflow.DROP_OLDEST // 오래된 것부터 버린다.
        )
    }

    val result = content(
        object : SingleClickEventInterface {
            // 매개 변수로 받은 event를 실행시킨다. tryEmit을 통해 함수를 실행시킨다.
            // emit는 suspend 환경에서만 사용이 가능하고, tryEmit은 suspend 환경이 아닐 때 사용이 가능하다.
            override fun event(event: () -> Unit) {
                debounceState.tryEmit(event)
//                debounceState.emit(event)
            }
        }
    )

    LaunchedEffect(true) {
        debounceState
            // 0.3초의 시간동안 추가적인 입력이 없으면 가장 마지막의 이벤트를 발생시킨다.
            .debounce(300L)
            .collect { onClick ->
                onClick.invoke()
            }
    }

    return result
}

 

각 코드 라인은 주석으로 설명을 써두긴 하였다.

가장 아래부터 함수를 읽어보면,

1. debounce(300L)을 통해 0.3초에 한번 이벤트를 발생한다.

2. interface를 인자로 받는 compose 함수의 event를 override하여 이벤트를 실행시킨다.

3. 버퍼의 크기가 1이며, 가장 오래된 것 부터 버리는 옵션을 가지고 있는 sharedFlow를 생성한다.

 

순서에 따른 함수의 역할을 조합해보자.

sharedFlow에서 () -> Unit을 인자로 받는 이유는, 클릭 이벤트에 debounce 옵션을 주고 싶기 때문에 클릭 이벤트를 인자로 받을 수 있도록 위와 같이 선언해 준다.

다음으로 sharedFlow 인스턴스에 이벤트를 넣어주기 위해 미리 선언한 인터페이스를 사용하고, override 하여 함수를 재 정의 해준다.

즉, content로 들어오는 compose 함수에서는 interface에 선언된 함수를 사용할 수 있고, 그 함수는 sharedFlow 인스턴스에서 재 정의한 것으로 실행되게 된다.

 

마지막으로, debounce 연산자를 사용하여 이벤트를 처리하도록 한다.

 

위의 함수를 사용하면 debounce가 0.3초로 걸려있기 때문에, 마지막 입력 이후 0.3초 동안 다른 입력이 없어야 이벤트가 호출되게 된다.

 

그렇다면 위의 함수는 어떻게 사용해야 하는가?

 

singleClickEvent { singleEvent ->
    Button(onClick = {
        singleEvent.event {
            // Click Event
        }
    }) {
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .height(52.dp),
            text = "...",
        )
    }
}

 

singleClickEvent 함수를 호출할 때 매개변수는 Compose 함수이다.

즉, 매개 변수로 다양한 UI들이 들어갈 수 있다는 것이고 내부에 선언한 interface 구현체에 의하여 override 된 함수를 사용할 수 있게 된다.

함수의 내부에서는 LaunchedEffect를 통해 debounce를 걸어주었으니, singleEvent.event 안에 선언된 내용들은 0.3초에 한번 호출이 된다.

 

마지막으로,

이처럼 함수를 구현하였으니 다음으로는 위의 singleClickEvent 함수도 noRippleClickable 함수처럼 Modifier Extension으로 만들어 두자.

 

fun Modifier.singleClickable(
    onClick: () -> Unit,
) = composed {
    singleClickEvent { manager ->
        clickable(
            onClick = { manager.event { onClick() } },
        )
    }
}

 

noRippleClickable과 완전히 동일한 사용 방법이고, 내부 함수만 조금 바뀌었다.

위의 Button에서 사용한 것처럼, 매개 변수로 compose 함수를 받을 수 있다는 것은 Modifier Extension으로 활용이 가능하다는 것이다.

따라서, 위와 같이 만들어 준 다음에

 

Text(
    modifier = Modifier
        .fillMaxWidth()
        .height(52.dp)
        .singleClickable {
            ...
        },
    text = "...",
)

 

이처럼 간단하게 호출하여 사용이 가능하다.


필자가 가장 많이 사용하는 유틸 두 가지를 작성해 보았다.

그냥 구현해 두고 사용하던 것들이었는데, 글을 작성하기 위해 다시 확인해 보니 명확하게 알지 못하고 사용하고 있던 부분이 많았다.

역시 글을 작성하다 보면 놓치고 있던 부분들이 잘 보이고, 그것을 공부하면서 내가 알고 있던 것들에 대해서 더 자세히 알게 되는 것 같다.

 

앞으로도 쓸만한 유틸 함수가 있다면 구현을 해두고, 이처럼 정리하면서 명확히 이해하고자 노력해야겠다.

너무 당연하다시피 사용하고 있던 것들이기 때문에, 언젠가 까먹고 있다가 내가 쓴 글을 보고 도움을 받을 수 있을 것 같기도 하다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample

Contribute to HeeGyeong/ComposeSample development by creating an account on GitHub.

github.com

 

728x90