From b952af4aeee7b0a8e7da4137c2b66bc4a4c9bbf4 Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Sat, 31 Jan 2026 18:10:07 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[refactor]=20#62=20Clickable=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=99=95=EC=9E=A5=ED=95=A8=EC=88=98=20composed{}?= =?UTF-8?q?=20->=20Modifier.Node()=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/Clickable.kt | 224 +++++++++++++----- 1 file changed, 165 insertions(+), 59 deletions(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index 01fbb9bcf..7e60d8ad7 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -1,90 +1,192 @@ package com.neki.android.core.designsystem.modifier +import androidx.compose.foundation.IndicationNodeFactory import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.PressGestureScope +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.material3.ripple -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.platform.debugInspectorInfo -import androidx.compose.ui.semantics.Role +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch /** * 클릭의 리플 효과를 없애주는 [Modifier] - * */ -inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { - clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { - onClick() - } -} + */ +fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = this.clickable( + indication = null, + interactionSource = null, + onClick = onClick, +) /** - * 클릭의 리플 효과를 없애고 500L 내에 중복 클릭을를 막아주는 [Modifier] - * */ + * 클릭의 리플 효과를 없애고 500ms 내에 중복 클릭을 막아주는 [Modifier] + */ fun Modifier.noRippleClickableSingle( enabled: Boolean = true, - onClickLabel: String? = null, - role: Role? = null, onClick: () -> Unit, -) = composed( - inspectorInfo = debugInspectorInfo { - name = "noRippleClickableSingle" - properties["enabled"] = enabled - properties["onClickLabel"] = onClickLabel - properties["role"] = role - properties["onClick"] = onClick - }, -) { - val multipleEventsCutter = remember { MultipleEventsCutter.get() } - Modifier.clickable( +): Modifier = this.then( + ClickableSingleElement( enabled = enabled, - onClickLabel = onClickLabel, - onClick = { multipleEventsCutter.processEvent { onClick() } }, - role = role, - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) -} + onClick = onClick, + indicationNodeFactory = null, + ), +) /** - * 500L 내의 중복 클릭을 막아주는 [Modifier] - * */ + * 500ms 내의 중복 클릭을 막아주는 [Modifier] + */ fun Modifier.clickableSingle( enabled: Boolean = true, - onClickLabel: String? = null, - role: Role? = null, onClick: () -> Unit, -) = composed( - inspectorInfo = debugInspectorInfo { - name = "clickableSingle" - properties["enabled"] = enabled - properties["onClickLabel"] = onClickLabel - properties["role"] = role - properties["onClick"] = onClick - }, -) { - val multipleEventsCutter = remember { MultipleEventsCutter.get() } - Modifier.clickable( +): Modifier = this.then( + ClickableSingleElement( enabled = enabled, - onClickLabel = onClickLabel, - onClick = { multipleEventsCutter.processEvent { onClick() } }, - role = role, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + indicationNodeFactory = ripple(), + ), +) + +private data class ClickableSingleElement( + private val enabled: Boolean, + private val onClick: () -> Unit, + private val indicationNodeFactory: IndicationNodeFactory?, +) : ModifierNodeElement() { + + override fun create(): ClickableSingleNode = ClickableSingleNode( + enabled = enabled, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, ) + + override fun update(node: ClickableSingleNode) { + node.update( + enabled = enabled, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + ) + } } -internal interface MultipleEventsCutter { +private class ClickableSingleNode( + private var enabled: Boolean, + private var onClick: () -> Unit, + private var indicationNodeFactory: IndicationNodeFactory?, +) : DelegatingNode(), PointerInputModifierNode { + + private var lastClickTime = 0L + private var interactionSource: MutableInteractionSource? = null + private var indicationNode: DelegatableNode? = null + + override val shouldAutoInvalidate: Boolean = false + + private val pointerInputNode = delegate( + SuspendingPointerInputModifierNode { + detectTapGestures( + onPress = { offset -> if (enabled) handlePressInteraction(offset) }, + onTap = { if (enabled) processClick() }, + ) + }, + ) + + override fun onAttach() { + initializeIndicationIfNeeded() + } + + private fun initializeIndicationIfNeeded() { + if (indicationNode != null) return + indicationNodeFactory?.let { factory -> + if (interactionSource == null) { + interactionSource = MutableInteractionSource() + } + val node = factory.create(interactionSource!!) + delegate(node) + indicationNode = node + } + } + + private suspend fun PressGestureScope.handlePressInteraction(offset: Offset) { + initializeIndicationIfNeeded() + interactionSource?.let { source -> + coroutineScope { + val press = PressInteraction.Press(offset) + launch { source.emit(press) } + + val success = tryAwaitRelease() + val endInteraction = if (success) { + PressInteraction.Release(press) + } else { + PressInteraction.Cancel(press) + } + launch { source.emit(endInteraction) } + } + } + } + + private fun processClick() { + val now = System.currentTimeMillis() + if (now - lastClickTime >= DEBOUNCE_TIME_MS) { + lastClickTime = now + onClick() + } + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize, + ) { + pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) + } + + override fun onCancelPointerInput() { + pointerInputNode.onCancelPointerInput() + } + + fun update( + enabled: Boolean, + onClick: () -> Unit, + indicationNodeFactory: IndicationNodeFactory?, + ) { + val indicationChanged = this.indicationNodeFactory != indicationNodeFactory + this.enabled = enabled + this.onClick = onClick + + if (indicationChanged) { + indicationNode?.let { undelegate(it) } + indicationNode = null + interactionSource = null + this.indicationNodeFactory = indicationNodeFactory + initializeIndicationIfNeeded() + } + } + + companion object { + private const val DEBOUNCE_TIME_MS = 500L + } +} + +/** + * 중복 클릭 방지를 위한 인터페이스 + * Button, IconButton 등 Composable 컴포넌트에서 사용 + */ +interface MultipleEventsCutter { fun processEvent(event: () -> Unit) companion object } -internal fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter = - MultipleEventsCutterImpl() +fun MultipleEventsCutter.Companion.get(): MultipleEventsCutter = MultipleEventsCutterImpl() private class MultipleEventsCutterImpl : MultipleEventsCutter { private val now: Long @@ -93,9 +195,13 @@ private class MultipleEventsCutterImpl : MultipleEventsCutter { private var lastEventTimeMs: Long = 0 override fun processEvent(event: () -> Unit) { - if (now - lastEventTimeMs >= 500L) { + if (now - lastEventTimeMs >= DEBOUNCE_TIME_MS) { lastEventTimeMs = now event.invoke() } } + + companion object { + private const val DEBOUNCE_TIME_MS = 500L + } } From 13b8afbaab5c156826ac27be4d5f28cff6db70a5 Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Sat, 31 Jan 2026 18:20:32 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[refactor]=20#62=20composed{},=20Node()?= =?UTF-8?q?=EC=9D=98=20create(),=20update()=20=EB=8F=99=EC=9E=91=EC=9D=84?= =?UTF-8?q?=20=EB=B3=BC=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/Clickable.kt | 69 +++++++++++++++++-- .../feature/mypage/impl/main/MyPageScreen.kt | 51 ++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index 7e60d8ad7..df4d88ac5 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -1,5 +1,6 @@ package com.neki.android.core.designsystem.modifier +import android.util.Log import androidx.compose.foundation.IndicationNodeFactory import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.PressGestureScope @@ -7,7 +8,9 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.material3.ripple +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass @@ -16,10 +19,32 @@ import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +private const val TAG = "ClickableRecomposition" + +// TODO: 테스트 후 삭제 - 객체 생성 카운터 +object ClickableTestCounter { + var composedBlockExecutionCount = 0 + var nodeCreateCount = 0 + var nodeUpdateCount = 0 + + fun reset() { + composedBlockExecutionCount = 0 + nodeCreateCount = 0 + nodeUpdateCount = 0 + } + + fun logStatus() { + Log.d(TAG, "📊 [composed] 블록 실행: $composedBlockExecutionCount 회") + Log.d(TAG, "📊 [Node] CREATE: $nodeCreateCount 회, UPDATE: $nodeUpdateCount 회") + } +} + /** * 클릭의 리플 효과를 없애주는 [Modifier] */ @@ -43,6 +68,34 @@ fun Modifier.noRippleClickableSingle( ), ) +// TODO: 테스트 후 삭제 - composed 버전 +fun Modifier.clickableSingleComposed( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit, +) = composed( + inspectorInfo = debugInspectorInfo { + name = "clickableSingle" + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["role"] = role + properties["onClick"] = onClick + }, +) { + ClickableTestCounter.composedBlockExecutionCount++ + val modifier = Modifier.clickable( + enabled = enabled, + onClickLabel = onClickLabel, + onClick = { onClick() }, + role = role, + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + ) + Log.d(TAG, "🔴 composed: 블록 실행 #${ClickableTestCounter.composedBlockExecutionCount}, 객체ID = ${System.identityHashCode(modifier)}") + modifier +} + /** * 500ms 내의 중복 클릭을 막아주는 [Modifier] */ @@ -63,13 +116,19 @@ private data class ClickableSingleElement( private val indicationNodeFactory: IndicationNodeFactory?, ) : ModifierNodeElement() { - override fun create(): ClickableSingleNode = ClickableSingleNode( - enabled = enabled, - onClick = onClick, - indicationNodeFactory = indicationNodeFactory, - ) + override fun create(): ClickableSingleNode { + ClickableTestCounter.nodeCreateCount++ + Log.d(TAG, "✅ Node: CREATE #${ClickableTestCounter.nodeCreateCount} - Element 객체ID = ${System.identityHashCode(this)}") + return ClickableSingleNode( + enabled = enabled, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + ) + } override fun update(node: ClickableSingleNode) { + ClickableTestCounter.nodeUpdateCount++ + Log.d(TAG, "🔄 Node: UPDATE #${ClickableTestCounter.nodeUpdateCount} - Element 객체ID = ${System.identityHashCode(this)}, Node 객체ID = ${System.identityHashCode(node)}") node.update( enabled = enabled, onClick = onClick, diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt index 47fe0e580..954dad2f7 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt @@ -6,11 +6,19 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.neki.android.core.designsystem.modifier.ClickableTestCounter +import com.neki.android.core.designsystem.modifier.clickableSingle +import com.neki.android.core.designsystem.modifier.clickableSingleComposed import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -55,10 +63,53 @@ fun MyPageScreen( uiState: MyPageState, onIntent: (MyPageIntent) -> Unit, ) { + // TODO: 테스트 후 삭제 - recomposition 테스트용 + var testCount by remember { mutableIntStateOf(0) } + Column( modifier = Modifier .fillMaxSize(), ) { + // TODO: 테스트 후 삭제 - Node 방식 테스트 + Box( + modifier = Modifier + .clickableSingle { testCount++ } + .padding(16.dp) + .background(NekiTheme.colorScheme.gray100), + ) { + Text("Node 방식 클릭: $testCount") + } + + // TODO: 테스트 후 삭제 - composed 방식 테스트 + Box( + modifier = Modifier + .clickableSingleComposed { testCount++ } + .padding(16.dp) + .background(NekiTheme.colorScheme.gray50), + ) { + Text("Composed 방식 클릭: $testCount") + } + + // TODO: 테스트 후 삭제 - 카운터 로그 출력 + Box( + modifier = Modifier + .clickableSingle { ClickableTestCounter.logStatus() } + .padding(16.dp) + .background(NekiTheme.colorScheme.primary50), + ) { + Text("카운터 로그 출력 (Logcat 확인)") + } + + // TODO: 테스트 후 삭제 - 카운터 리셋 + Box( + modifier = Modifier + .clickableSingle { ClickableTestCounter.reset() } + .padding(16.dp) + .background(NekiTheme.colorScheme.gray200), + ) { + Text("카운터 리셋") + } + MainTopBar( onClickIcon = { onIntent(MyPageIntent.ClickNotificationIcon) }, ) From 604e3236e1e37005afa7d6fa51aa56804de792de Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Sat, 31 Jan 2026 21:30:32 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[fix]=20#62=20ClickableSingleNode=20?= =?UTF-8?q?=EB=82=B4=EC=97=90=EC=84=9C=20MultipleEventsCutter=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/designsystem/modifier/Clickable.kt | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index df4d88ac5..8425bd3b9 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -143,7 +143,7 @@ private class ClickableSingleNode( private var indicationNodeFactory: IndicationNodeFactory?, ) : DelegatingNode(), PointerInputModifierNode { - private var lastClickTime = 0L + private val multipleEventsCutter = MultipleEventsCutter.get() private var interactionSource: MutableInteractionSource? = null private var indicationNode: DelegatableNode? = null @@ -193,11 +193,7 @@ private class ClickableSingleNode( } private fun processClick() { - val now = System.currentTimeMillis() - if (now - lastClickTime >= DEBOUNCE_TIME_MS) { - lastClickTime = now - onClick() - } + multipleEventsCutter.processEvent { onClick() } } override fun onPointerEvent( @@ -229,10 +225,6 @@ private class ClickableSingleNode( initializeIndicationIfNeeded() } } - - companion object { - private const val DEBOUNCE_TIME_MS = 500L - } } /** From 602d477f68d801b89b0265587d63d660ae8821c8 Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Sat, 31 Jan 2026 22:10:25 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[chore]=20#62=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EB=A0=A4=ED=95=B4=20onClickLabel,=20role?= =?UTF-8?q?=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/Clickable.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index 8425bd3b9..c13832d30 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -19,8 +19,12 @@ import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.SemanticsModifierNode import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -59,10 +63,14 @@ fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier = this.clickable( */ fun Modifier.noRippleClickableSingle( enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, onClick: () -> Unit, ): Modifier = this.then( ClickableSingleElement( enabled = enabled, + onClickLabel = onClickLabel, + role = role, onClick = onClick, indicationNodeFactory = null, ), @@ -101,10 +109,14 @@ fun Modifier.clickableSingleComposed( */ fun Modifier.clickableSingle( enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, onClick: () -> Unit, ): Modifier = this.then( ClickableSingleElement( enabled = enabled, + onClickLabel = onClickLabel, + role = role, onClick = onClick, indicationNodeFactory = ripple(), ), @@ -112,6 +124,8 @@ fun Modifier.clickableSingle( private data class ClickableSingleElement( private val enabled: Boolean, + private val onClickLabel: String?, + private val role: Role?, private val onClick: () -> Unit, private val indicationNodeFactory: IndicationNodeFactory?, ) : ModifierNodeElement() { @@ -121,6 +135,8 @@ private data class ClickableSingleElement( Log.d(TAG, "✅ Node: CREATE #${ClickableTestCounter.nodeCreateCount} - Element 객체ID = ${System.identityHashCode(this)}") return ClickableSingleNode( enabled = enabled, + onClickLabel = onClickLabel, + role = role, onClick = onClick, indicationNodeFactory = indicationNodeFactory, ) @@ -131,6 +147,8 @@ private data class ClickableSingleElement( Log.d(TAG, "🔄 Node: UPDATE #${ClickableTestCounter.nodeUpdateCount} - Element 객체ID = ${System.identityHashCode(this)}, Node 객체ID = ${System.identityHashCode(node)}") node.update( enabled = enabled, + onClickLabel = onClickLabel, + role = role, onClick = onClick, indicationNodeFactory = indicationNodeFactory, ) @@ -139,9 +157,11 @@ private data class ClickableSingleElement( private class ClickableSingleNode( private var enabled: Boolean, + private var onClickLabel: String?, + private var role: Role?, private var onClick: () -> Unit, private var indicationNodeFactory: IndicationNodeFactory?, -) : DelegatingNode(), PointerInputModifierNode { +) : DelegatingNode(), PointerInputModifierNode, SemanticsModifierNode { private val multipleEventsCutter = MultipleEventsCutter.get() private var interactionSource: MutableInteractionSource? = null @@ -208,13 +228,22 @@ private class ClickableSingleNode( pointerInputNode.onCancelPointerInput() } + override fun SemanticsPropertyReceiver.applySemantics() { + this@ClickableSingleNode.role?.let { this.role = it } + onClick(label = onClickLabel, action = { processClick(); true }) + } + fun update( enabled: Boolean, + onClickLabel: String?, + role: Role?, onClick: () -> Unit, indicationNodeFactory: IndicationNodeFactory?, ) { val indicationChanged = this.indicationNodeFactory != indicationNodeFactory this.enabled = enabled + this.onClickLabel = onClickLabel + this.role = role this.onClick = onClick if (indicationChanged) { From 98d48812e74eb791c7696976996c638455c226ba Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Wed, 4 Feb 2026 20:35:37 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[chore]=20#75=20applySemantics()=20?= =?UTF-8?q?=EB=82=B4=20disabled()=20=EA=B5=AC=EB=AC=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../neki/android/core/designsystem/modifier/Clickable.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index c13832d30..04925d87a 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.node.SemanticsModifierNode import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.role import androidx.compose.ui.unit.IntSize @@ -230,7 +231,11 @@ private class ClickableSingleNode( override fun SemanticsPropertyReceiver.applySemantics() { this@ClickableSingleNode.role?.let { this.role = it } - onClick(label = onClickLabel, action = { processClick(); true }) + onClick( + label = onClickLabel, + action = { processClick(); true } + ) + if (!enabled) { disabled() } } fun update( From be6d597758b92d9dde937b0acce63f899b213f01 Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Wed, 4 Feb 2026 21:42:49 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[fix]=20#75=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=A4=91=20=EC=9D=B4=ED=83=88=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20=ED=95=B4=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84=EC=83=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/Clickable.kt | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index 04925d87a..6e4fc5e0f 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -73,7 +73,6 @@ fun Modifier.noRippleClickableSingle( onClickLabel = onClickLabel, role = role, onClick = onClick, - indicationNodeFactory = null, ), ) @@ -112,6 +111,7 @@ fun Modifier.clickableSingle( enabled: Boolean = true, onClickLabel: String? = null, role: Role? = null, + interactionSource: MutableInteractionSource? = null, onClick: () -> Unit, ): Modifier = this.then( ClickableSingleElement( @@ -120,6 +120,7 @@ fun Modifier.clickableSingle( role = role, onClick = onClick, indicationNodeFactory = ripple(), + interactionSource = interactionSource, ), ) @@ -127,8 +128,9 @@ private data class ClickableSingleElement( private val enabled: Boolean, private val onClickLabel: String?, private val role: Role?, + private val indicationNodeFactory: IndicationNodeFactory? = null, + private val interactionSource: MutableInteractionSource? = null, private val onClick: () -> Unit, - private val indicationNodeFactory: IndicationNodeFactory?, ) : ModifierNodeElement() { override fun create(): ClickableSingleNode { @@ -140,6 +142,7 @@ private data class ClickableSingleElement( role = role, onClick = onClick, indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, ) } @@ -152,6 +155,7 @@ private data class ClickableSingleElement( role = role, onClick = onClick, indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, ) } } @@ -160,13 +164,18 @@ private class ClickableSingleNode( private var enabled: Boolean, private var onClickLabel: String?, private var role: Role?, - private var onClick: () -> Unit, private var indicationNodeFactory: IndicationNodeFactory?, + private var interactionSource: MutableInteractionSource?, + private var onClick: () -> Unit, ) : DelegatingNode(), PointerInputModifierNode, SemanticsModifierNode { private val multipleEventsCutter = MultipleEventsCutter.get() - private var interactionSource: MutableInteractionSource? = null + private var internalInteractionSource: MutableInteractionSource? = null private var indicationNode: DelegatableNode? = null + private var currentPressInteraction: PressInteraction.Press? = null + + private val activeInteractionSource: MutableInteractionSource? + get() = interactionSource ?: internalInteractionSource override val shouldAutoInvalidate: Boolean = false @@ -186,10 +195,11 @@ private class ClickableSingleNode( private fun initializeIndicationIfNeeded() { if (indicationNode != null) return indicationNodeFactory?.let { factory -> - if (interactionSource == null) { - interactionSource = MutableInteractionSource() + if (interactionSource == null && internalInteractionSource == null) { + internalInteractionSource = MutableInteractionSource() } - val node = factory.create(interactionSource!!) + val source = activeInteractionSource ?: return@let + val node = factory.create(source) delegate(node) indicationNode = node } @@ -197,12 +207,14 @@ private class ClickableSingleNode( private suspend fun PressGestureScope.handlePressInteraction(offset: Offset) { initializeIndicationIfNeeded() - interactionSource?.let { source -> + activeInteractionSource?.let { source -> coroutineScope { val press = PressInteraction.Press(offset) + currentPressInteraction = press launch { source.emit(press) } val success = tryAwaitRelease() + currentPressInteraction = null val endInteraction = if (success) { PressInteraction.Release(press) } else { @@ -242,23 +254,39 @@ private class ClickableSingleNode( enabled: Boolean, onClickLabel: String?, role: Role?, - onClick: () -> Unit, indicationNodeFactory: IndicationNodeFactory?, + interactionSource: MutableInteractionSource?, + onClick: () -> Unit, ) { + val interactionSourceChanged = this.interactionSource != interactionSource val indicationChanged = this.indicationNodeFactory != indicationNodeFactory + this.enabled = enabled this.onClickLabel = onClickLabel this.role = role this.onClick = onClick - if (indicationChanged) { + if (interactionSourceChanged) { + this.interactionSource = interactionSource + } + + if (indicationChanged || interactionSourceChanged) { indicationNode?.let { undelegate(it) } indicationNode = null - interactionSource = null + if (interactionSource == null) { + internalInteractionSource = null + } this.indicationNodeFactory = indicationNodeFactory initializeIndicationIfNeeded() } } + + override fun onDetach() { + currentPressInteraction?.let { press -> + activeInteractionSource?.tryEmit(PressInteraction.Cancel(press)) + } + currentPressInteraction = null + } } /** From 718e48f4afd8c3ff8489d64b191d844229c9df41 Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Wed, 4 Feb 2026 21:56:21 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[chore]=20#75=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/Clickable.kt | 92 ++++--------------- .../feature/mypage/impl/main/MyPageScreen.kt | 51 ---------- 2 files changed, 16 insertions(+), 127 deletions(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index 6e4fc5e0f..a38644bff 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -1,6 +1,5 @@ package com.neki.android.core.designsystem.modifier -import android.util.Log import androidx.compose.foundation.IndicationNodeFactory import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.PressGestureScope @@ -8,9 +7,7 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.material3.ripple -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass @@ -20,7 +17,6 @@ import androidx.compose.ui.node.DelegatingNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.PointerInputModifierNode import androidx.compose.ui.node.SemanticsModifierNode -import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.disabled @@ -30,26 +26,6 @@ import androidx.compose.ui.unit.IntSize import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -private const val TAG = "ClickableRecomposition" - -// TODO: 테스트 후 삭제 - 객체 생성 카운터 -object ClickableTestCounter { - var composedBlockExecutionCount = 0 - var nodeCreateCount = 0 - var nodeUpdateCount = 0 - - fun reset() { - composedBlockExecutionCount = 0 - nodeCreateCount = 0 - nodeUpdateCount = 0 - } - - fun logStatus() { - Log.d(TAG, "📊 [composed] 블록 실행: $composedBlockExecutionCount 회") - Log.d(TAG, "📊 [Node] CREATE: $nodeCreateCount 회, UPDATE: $nodeUpdateCount 회") - } -} - /** * 클릭의 리플 효과를 없애주는 [Modifier] */ @@ -76,34 +52,6 @@ fun Modifier.noRippleClickableSingle( ), ) -// TODO: 테스트 후 삭제 - composed 버전 -fun Modifier.clickableSingleComposed( - enabled: Boolean = true, - onClickLabel: String? = null, - role: Role? = null, - onClick: () -> Unit, -) = composed( - inspectorInfo = debugInspectorInfo { - name = "clickableSingle" - properties["enabled"] = enabled - properties["onClickLabel"] = onClickLabel - properties["role"] = role - properties["onClick"] = onClick - }, -) { - ClickableTestCounter.composedBlockExecutionCount++ - val modifier = Modifier.clickable( - enabled = enabled, - onClickLabel = onClickLabel, - onClick = { onClick() }, - role = role, - indication = ripple(), - interactionSource = remember { MutableInteractionSource() }, - ) - Log.d(TAG, "🔴 composed: 블록 실행 #${ClickableTestCounter.composedBlockExecutionCount}, 객체ID = ${System.identityHashCode(modifier)}") - modifier -} - /** * 500ms 내의 중복 클릭을 막아주는 [Modifier] */ @@ -133,31 +81,23 @@ private data class ClickableSingleElement( private val onClick: () -> Unit, ) : ModifierNodeElement() { - override fun create(): ClickableSingleNode { - ClickableTestCounter.nodeCreateCount++ - Log.d(TAG, "✅ Node: CREATE #${ClickableTestCounter.nodeCreateCount} - Element 객체ID = ${System.identityHashCode(this)}") - return ClickableSingleNode( - enabled = enabled, - onClickLabel = onClickLabel, - role = role, - onClick = onClick, - indicationNodeFactory = indicationNodeFactory, - interactionSource = interactionSource, - ) - } + override fun create(): ClickableSingleNode = ClickableSingleNode( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, + ) - override fun update(node: ClickableSingleNode) { - ClickableTestCounter.nodeUpdateCount++ - Log.d(TAG, "🔄 Node: UPDATE #${ClickableTestCounter.nodeUpdateCount} - Element 객체ID = ${System.identityHashCode(this)}, Node 객체ID = ${System.identityHashCode(node)}") - node.update( - enabled = enabled, - onClickLabel = onClickLabel, - role = role, - onClick = onClick, - indicationNodeFactory = indicationNodeFactory, - interactionSource = interactionSource, - ) - } + override fun update(node: ClickableSingleNode) = node.update( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onClick = onClick, + indicationNodeFactory = indicationNodeFactory, + interactionSource = interactionSource, + ) } private class ClickableSingleNode( diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt index 954dad2f7..47fe0e580 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageScreen.kt @@ -6,19 +6,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.neki.android.core.designsystem.modifier.ClickableTestCounter -import com.neki.android.core.designsystem.modifier.clickableSingle -import com.neki.android.core.designsystem.modifier.clickableSingleComposed import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.ui.theme.NekiTheme @@ -63,53 +55,10 @@ fun MyPageScreen( uiState: MyPageState, onIntent: (MyPageIntent) -> Unit, ) { - // TODO: 테스트 후 삭제 - recomposition 테스트용 - var testCount by remember { mutableIntStateOf(0) } - Column( modifier = Modifier .fillMaxSize(), ) { - // TODO: 테스트 후 삭제 - Node 방식 테스트 - Box( - modifier = Modifier - .clickableSingle { testCount++ } - .padding(16.dp) - .background(NekiTheme.colorScheme.gray100), - ) { - Text("Node 방식 클릭: $testCount") - } - - // TODO: 테스트 후 삭제 - composed 방식 테스트 - Box( - modifier = Modifier - .clickableSingleComposed { testCount++ } - .padding(16.dp) - .background(NekiTheme.colorScheme.gray50), - ) { - Text("Composed 방식 클릭: $testCount") - } - - // TODO: 테스트 후 삭제 - 카운터 로그 출력 - Box( - modifier = Modifier - .clickableSingle { ClickableTestCounter.logStatus() } - .padding(16.dp) - .background(NekiTheme.colorScheme.primary50), - ) { - Text("카운터 로그 출력 (Logcat 확인)") - } - - // TODO: 테스트 후 삭제 - 카운터 리셋 - Box( - modifier = Modifier - .clickableSingle { ClickableTestCounter.reset() } - .padding(16.dp) - .background(NekiTheme.colorScheme.gray200), - ) { - Text("카운터 리셋") - } - MainTopBar( onClickIcon = { onIntent(MyPageIntent.ClickNotificationIcon) }, ) From 5281b97dfb4d66dcd39ee2188b07cac66853207f Mon Sep 17 00:00:00 2001 From: Ojongseok Date: Wed, 4 Feb 2026 21:57:16 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[build]=20#75=20detekt=20=EB=A3=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/neki/android/core/designsystem/modifier/Clickable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt index a38644bff..84a38b7ea 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Clickable.kt @@ -185,7 +185,7 @@ private class ClickableSingleNode( this@ClickableSingleNode.role?.let { this.role = it } onClick( label = onClickLabel, - action = { processClick(); true } + action = { processClick(); true }, ) if (!enabled) { disabled() } }