Skip to content

Commit 7929463

Browse files
authored
Merge pull request #75 from YAPP-Github/refactor/#62-migrate-composed
[refactor] #62 composed{} -> Modifier.Node() 마이그레이션
2 parents cb4f691 + 5281b97 commit 7929463

1 file changed

Lines changed: 209 additions & 50 deletions

File tree

  • core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier
Lines changed: 209 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,245 @@
11
package com.neki.android.core.designsystem.modifier
22

3+
import androidx.compose.foundation.IndicationNodeFactory
34
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.gestures.PressGestureScope
6+
import androidx.compose.foundation.gestures.detectTapGestures
47
import androidx.compose.foundation.interaction.MutableInteractionSource
8+
import androidx.compose.foundation.interaction.PressInteraction
59
import androidx.compose.material3.ripple
6-
import androidx.compose.runtime.remember
710
import 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
1020
import 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+
*/
2741
fun 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+
*/
5558
fun 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

89244
private 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

Comments
 (0)