11package com.neki.android.core.designsystem.modifier
22
3+ import androidx.compose.foundation.IndicationNodeFactory
34import androidx.compose.foundation.clickable
5+ import androidx.compose.foundation.gestures.PressGestureScope
6+ import androidx.compose.foundation.gestures.detectTapGestures
47import androidx.compose.foundation.interaction.MutableInteractionSource
8+ import androidx.compose.foundation.interaction.PressInteraction
59import androidx.compose.material3.ripple
6- import androidx.compose.runtime.remember
710import androidx.compose.ui.Modifier
8- import androidx.compose.ui.composed
9- import androidx.compose.ui.platform.debugInspectorInfo
11+ import androidx.compose.ui.geometry.Offset
12+ import androidx.compose.ui.input.pointer.PointerEvent
13+ import androidx.compose.ui.input.pointer.PointerEventPass
14+ import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
15+ import androidx.compose.ui.node.DelegatableNode
16+ import androidx.compose.ui.node.DelegatingNode
17+ import androidx.compose.ui.node.ModifierNodeElement
18+ import androidx.compose.ui.node.PointerInputModifierNode
19+ import androidx.compose.ui.node.SemanticsModifierNode
1020import androidx.compose.ui.semantics.Role
21+ import androidx.compose.ui.semantics.SemanticsPropertyReceiver
22+ import androidx.compose.ui.semantics.disabled
23+ import androidx.compose.ui.semantics.onClick
24+ import androidx.compose.ui.semantics.role
25+ import androidx.compose.ui.unit.IntSize
26+ import kotlinx.coroutines.coroutineScope
27+ import kotlinx.coroutines.launch
1128
1229/* *
1330 * 클릭의 리플 효과를 없애주는 [Modifier]
14- * */
15- inline fun Modifier.noRippleClickable (crossinline onClick : () -> Unit ): Modifier = composed {
16- clickable(
17- indication = null ,
18- interactionSource = remember { MutableInteractionSource () },
19- ) {
20- onClick()
21- }
22- }
31+ */
32+ fun Modifier.noRippleClickable (onClick : () -> Unit ): Modifier = this .clickable(
33+ indication = null ,
34+ interactionSource = null ,
35+ onClick = onClick,
36+ )
2337
2438/* *
25- * 클릭의 리플 효과를 없애고 500L 내에 중복 클릭을를 막아주는 [Modifier]
26- * * /
39+ * 클릭의 리플 효과를 없애고 500ms 내에 중복 클릭을 막아주는 [Modifier]
40+ */
2741fun Modifier.noRippleClickableSingle (
2842 enabled : Boolean = true,
2943 onClickLabel : String? = null,
3044 role : Role ? = null,
3145 onClick : () -> Unit ,
32- ) = composed(
33- inspectorInfo = debugInspectorInfo {
34- name = " noRippleClickableSingle"
35- properties[" enabled" ] = enabled
36- properties[" onClickLabel" ] = onClickLabel
37- properties[" role" ] = role
38- properties[" onClick" ] = onClick
39- },
40- ) {
41- val multipleEventsCutter = remember { MultipleEventsCutter .get() }
42- Modifier .clickable(
46+ ): Modifier = this .then(
47+ ClickableSingleElement (
4348 enabled = enabled,
4449 onClickLabel = onClickLabel,
45- onClick = { multipleEventsCutter.processEvent { onClick() } },
4650 role = role,
47- indication = null ,
48- interactionSource = remember { MutableInteractionSource () },
49- )
50- }
51+ onClick = onClick,
52+ ),
53+ )
5154
5255/* *
53- * 500L 내의 중복 클릭을 막아주는 [Modifier]
54- * * /
56+ * 500ms 내의 중복 클릭을 막아주는 [Modifier]
57+ */
5558fun Modifier.clickableSingle (
5659 enabled : Boolean = true,
5760 onClickLabel : String? = null,
5861 role : Role ? = null,
62+ interactionSource : MutableInteractionSource ? = null,
5963 onClick : () -> Unit ,
60- ) = composed(
61- inspectorInfo = debugInspectorInfo {
62- name = " clickableSingle"
63- properties[" enabled" ] = enabled
64- properties[" onClickLabel" ] = onClickLabel
65- properties[" role" ] = role
66- properties[" onClick" ] = onClick
67- },
68- ) {
69- val multipleEventsCutter = remember { MultipleEventsCutter .get() }
70- Modifier .clickable(
64+ ): Modifier = this .then(
65+ ClickableSingleElement (
66+ enabled = enabled,
67+ onClickLabel = onClickLabel,
68+ role = role,
69+ onClick = onClick,
70+ indicationNodeFactory = ripple(),
71+ interactionSource = interactionSource,
72+ ),
73+ )
74+
75+ private data class ClickableSingleElement (
76+ private val enabled : Boolean ,
77+ private val onClickLabel : String? ,
78+ private val role : Role ? ,
79+ private val indicationNodeFactory : IndicationNodeFactory ? = null ,
80+ private val interactionSource : MutableInteractionSource ? = null ,
81+ private val onClick : () -> Unit ,
82+ ) : ModifierNodeElement<ClickableSingleNode>() {
83+
84+ override fun create (): ClickableSingleNode = ClickableSingleNode (
7185 enabled = enabled,
7286 onClickLabel = onClickLabel,
73- onClick = { multipleEventsCutter.processEvent { onClick() } },
7487 role = role,
75- indication = ripple(),
76- interactionSource = remember { MutableInteractionSource () },
88+ onClick = onClick,
89+ indicationNodeFactory = indicationNodeFactory,
90+ interactionSource = interactionSource,
7791 )
92+
93+ override fun update (node : ClickableSingleNode ) = node.update(
94+ enabled = enabled,
95+ onClickLabel = onClickLabel,
96+ role = role,
97+ onClick = onClick,
98+ indicationNodeFactory = indicationNodeFactory,
99+ interactionSource = interactionSource,
100+ )
101+ }
102+
103+ private class ClickableSingleNode (
104+ private var enabled : Boolean ,
105+ private var onClickLabel : String? ,
106+ private var role : Role ? ,
107+ private var indicationNodeFactory : IndicationNodeFactory ? ,
108+ private var interactionSource : MutableInteractionSource ? ,
109+ private var onClick : () -> Unit ,
110+ ) : DelegatingNode(), PointerInputModifierNode, SemanticsModifierNode {
111+
112+ private val multipleEventsCutter = MultipleEventsCutter .get()
113+ private var internalInteractionSource: MutableInteractionSource ? = null
114+ private var indicationNode: DelegatableNode ? = null
115+ private var currentPressInteraction: PressInteraction .Press ? = null
116+
117+ private val activeInteractionSource: MutableInteractionSource ?
118+ get() = interactionSource ? : internalInteractionSource
119+
120+ override val shouldAutoInvalidate: Boolean = false
121+
122+ private val pointerInputNode = delegate(
123+ SuspendingPointerInputModifierNode {
124+ detectTapGestures(
125+ onPress = { offset -> if (enabled) handlePressInteraction(offset) },
126+ onTap = { if (enabled) processClick() },
127+ )
128+ },
129+ )
130+
131+ override fun onAttach () {
132+ initializeIndicationIfNeeded()
133+ }
134+
135+ private fun initializeIndicationIfNeeded () {
136+ if (indicationNode != null ) return
137+ indicationNodeFactory?.let { factory ->
138+ if (interactionSource == null && internalInteractionSource == null ) {
139+ internalInteractionSource = MutableInteractionSource ()
140+ }
141+ val source = activeInteractionSource ? : return @let
142+ val node = factory.create(source)
143+ delegate(node)
144+ indicationNode = node
145+ }
146+ }
147+
148+ private suspend fun PressGestureScope.handlePressInteraction (offset : Offset ) {
149+ initializeIndicationIfNeeded()
150+ activeInteractionSource?.let { source ->
151+ coroutineScope {
152+ val press = PressInteraction .Press (offset)
153+ currentPressInteraction = press
154+ launch { source.emit(press) }
155+
156+ val success = tryAwaitRelease()
157+ currentPressInteraction = null
158+ val endInteraction = if (success) {
159+ PressInteraction .Release (press)
160+ } else {
161+ PressInteraction .Cancel (press)
162+ }
163+ launch { source.emit(endInteraction) }
164+ }
165+ }
166+ }
167+
168+ private fun processClick () {
169+ multipleEventsCutter.processEvent { onClick() }
170+ }
171+
172+ override fun onPointerEvent (
173+ pointerEvent : PointerEvent ,
174+ pass : PointerEventPass ,
175+ bounds : IntSize ,
176+ ) {
177+ pointerInputNode.onPointerEvent(pointerEvent, pass, bounds)
178+ }
179+
180+ override fun onCancelPointerInput () {
181+ pointerInputNode.onCancelPointerInput()
182+ }
183+
184+ override fun SemanticsPropertyReceiver.applySemantics () {
185+ this @ClickableSingleNode.role?.let { this .role = it }
186+ onClick(
187+ label = onClickLabel,
188+ action = { processClick(); true },
189+ )
190+ if (! enabled) { disabled() }
191+ }
192+
193+ fun update (
194+ enabled : Boolean ,
195+ onClickLabel : String? ,
196+ role : Role ? ,
197+ indicationNodeFactory : IndicationNodeFactory ? ,
198+ interactionSource : MutableInteractionSource ? ,
199+ onClick : () -> Unit ,
200+ ) {
201+ val interactionSourceChanged = this .interactionSource != interactionSource
202+ val indicationChanged = this .indicationNodeFactory != indicationNodeFactory
203+
204+ this .enabled = enabled
205+ this .onClickLabel = onClickLabel
206+ this .role = role
207+ this .onClick = onClick
208+
209+ if (interactionSourceChanged) {
210+ this .interactionSource = interactionSource
211+ }
212+
213+ if (indicationChanged || interactionSourceChanged) {
214+ indicationNode?.let { undelegate(it) }
215+ indicationNode = null
216+ if (interactionSource == null ) {
217+ internalInteractionSource = null
218+ }
219+ this .indicationNodeFactory = indicationNodeFactory
220+ initializeIndicationIfNeeded()
221+ }
222+ }
223+
224+ override fun onDetach () {
225+ currentPressInteraction?.let { press ->
226+ activeInteractionSource?.tryEmit(PressInteraction .Cancel (press))
227+ }
228+ currentPressInteraction = null
229+ }
78230}
79231
80- internal interface MultipleEventsCutter {
232+ /* *
233+ * 중복 클릭 방지를 위한 인터페이스
234+ * Button, IconButton 등 Composable 컴포넌트에서 사용
235+ */
236+ interface MultipleEventsCutter {
81237 fun processEvent (event : () -> Unit )
82238
83239 companion object
84240}
85241
86- internal fun MultipleEventsCutter.Companion.get (): MultipleEventsCutter =
87- MultipleEventsCutterImpl ()
242+ fun MultipleEventsCutter.Companion.get (): MultipleEventsCutter = MultipleEventsCutterImpl ()
88243
89244private class MultipleEventsCutterImpl : MultipleEventsCutter {
90245 private val now: Long
@@ -93,9 +248,13 @@ private class MultipleEventsCutterImpl : MultipleEventsCutter {
93248 private var lastEventTimeMs: Long = 0
94249
95250 override fun processEvent (event : () -> Unit ) {
96- if (now - lastEventTimeMs >= 500L ) {
251+ if (now - lastEventTimeMs >= DEBOUNCE_TIME_MS ) {
97252 lastEventTimeMs = now
98253 event.invoke()
99254 }
100255 }
256+
257+ companion object {
258+ private const val DEBOUNCE_TIME_MS = 500L
259+ }
101260}
0 commit comments