문제 상황
최근 업무 중 각 컴포넌트를 클릭했을 때 동작이 다르다는 이슈가 나왔었다.
각 동작을 확인해봤을 때
- 클릭과 동시에 Press 효과가 바로 적용됨
- 클릭 시에는 효과가 없고, Press 상태일 때 약간 뒤에야 효과가 들어옴
같은 라디오 버튼이 있는 컴포넌트였지만 동작이 다른 것을 확인할 수 있다.
두 동작을 분석한 결과, case1의 경우 Column, case2의 경우 LazyColumn이 각 컴포넌트를 감싸고 있었다.
// case1
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState
) {
itemsIndexed(radioOptions) { index, text ->
RadioTextComponent(
onClick = { onClick(index) }
) { isPressed ->
RadioAndText(
text = text,
selected = selected == index,
isPressed = isPressed
)
}
}
}
// case2
Column(
modifier = Modifier.fillMaxSize()
) {
radioOptions.forEachIndexed { index, text ->
RadioTextComponent(
onClick = { onClick(index) }
) { isPressed ->
RadioAndText(
text = text,
selected = selected == index,
isPressed = isPressed
)
}
}
}
같은 RadioTextComponent와 RadioAndText를 사용했음에도 Pres 효과가 다르게 들어오는 것을 확인할 수 있었다.
원인 분석
코드를 따라가보니 차이는 LazyColumn 내부의 LazyListState 때문이었다.
LazyColumn은 내부적으로 rememberLazyListState를 사용하고 있다.
@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
content: LazyListScope.() -> Unit
) {
LazyList(
modifier = modifier,
state = state,
contentPadding = contentPadding,
flingBehavior = flingBehavior,
horizontalAlignment = horizontalAlignment,
verticalArrangement = verticalArrangement,
isVertical = true,
reverseLayout = reverseLayout,
userScrollEnabled = userScrollEnabled,
content = content
)
}
여기서 파라미터로 있는 state를 봐야한다.
state: LazyListState = rememberLazyListState()
rememberLazyListState()를 열어보면 LazyListState를 반환하도록 되어있다.
@Composable
fun rememberLazyListState(
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Int = 0
): LazyListState {
return rememberSaveable(saver = LazyListState.Saver) {
LazyListState(
initialFirstVisibleItemIndex,
initialFirstVisibleItemScrollOffset
)
}
}
LazyListState의 구현을 확인해보면
@OptIn(ExperimentalFoundationApi::class)
@Stable
class LazyListState constructor(
firstVisibleItemIndex: Int = 0,
firstVisibleItemScrollOffset: Int = 0
) : ScrollableState {
...
val interactionSource: InteractionSource get() = internalInteractionSource
...
}
interactionSource를 멤버 변수로 갖고 있는 것을 확인할 수 있다.
즉, LazyColumn은 내부적으로 스크롤 제스처 처리를 위한 interactionSource를 갖고 있으며 하위 InteractionSource보다 먼저 이벤트를 가져가 Press 지연이 발생한다.
해결 과정
이를 해결하기 위해서는 하위 컴포넌트가 갖고 있는 InteractionSource가 클릭 이벤트를 먼저 갖고 오도록 해야 하는데, 이거 하는데 거진 하루는 다 쓴 것 같다.
내가 사용한 방법은 pointerInput을 통해 하위 컴포넌트에서 터치 이벤트를 먼저 갖게 하는 것이었다.
이로 인해 RadioTextComponent에서 사용하던 collectIsPressedAsState가 아닌 직접적으로 pressed 값을 저장하도록 변경했다.
// 전
val isPressed by interactionSource.collectIsPressedAsState()
// 후
var isPressed by remember { mutableStateOf(false) }
pointerInput은 확장 함수를 통해 구현을 하여 라디오 박스, 체크 박스가 사용되는 부분에서 모두 사용할 수 있도록 하였다.
fun Modifier.nestedPointerInput(
enabled: Boolean = true,
setPressedState: (Boolean) -> Unit
): Modifier = composed {
var isPressed by remember { mutableStateOf(false) }
this.pointerInput(enabled) {
if (enabled) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
val changes = event.changes
val pressedChange = changes.firstOrNull { it.pressed }
val releasedChange = changes.firstOrNull { !it.pressed && it.previousPressed }
when {
// Press
event.type == PointerEventType.Press && pressedChange != null -> {
isPressed = true
setPressedState(true)
}
// Release
event.type == PointerEventType.Release && releasedChange != null -> {
isPressed = false
setPressedState(false)
}
// Move(스크롤 판단)
event.type == PointerEventType.Move && isPressed -> {
val isInside = changes.all {
it.position.x >= 0 && it.position.x <= size.width &&
it.position.y > size.height / 3 && it.position.y < (size.height - size.height / 3)
}
if (!isInside) {
isPressed = false
setPressedState(false)
}
}
}
// PointerEvent가 다른 곳에서 소비되었는지 확인
if (changes.any { it.isConsumed }) {
if (isPressed) {
isPressed = false
setPressedState(false)
}
}
}
}
} else {
if (isPressed) {
isPressed = false
setPressedState(false)
}
}
}
}
awaitPointerEventScope를 통해 press, release, move 이벤트를 감지하고, pressed 상태를 직접 관리하도록 했다.
특히 Move의 경우 천천히 스크롤하면 Press 효과가 유지되는 문제가 있어, 영역 계산(size.height / 3)으로 임시 보정을 넣었다.
이는 차후에 더 좋은 방법이 있는지 더 알아볼 필요가 있다.
적용
@Composable
fun RadioTextComponent(
onClick: () -> Unit,
content: @Composable (Boolean) -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
// val isPressed by interactionSource.collectIsPressedAsState()
var isPressed by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.clickable(
onClick = onClick,
indication = null,
interactionSource = interactionSource
)
.nestedPointerInput { pressed -> isPressed = pressed },
verticalAlignment = Alignment.CenterVertically
) {
content(isPressed)
}
}
이를 통해 하위 컴포넌트에서 우선적으로 클릭 이벤트에 대한 값을 받아올 수 있으며 Column에서의 동작처럼 클릭과 동시에 Press 효과를 갖는 것을 확인할 수 있었다.
결론
- 문제 원인: LazyColumn은 스크롤 처리를 위해 자체 interactionSource를 사용 → 하위 interactionSource보다 이벤트를 먼저 가져감
- 해결 방법: pointerInput을 이용해 Press 상태를 직접 관리
- 보완 필요: Move 이벤트 처리 부분은 임시 보정(height / 3)이라, 더 정확한 영역 판별 방법이 필요
- 전체 코드: Github 링크
'Android > Compose' 카테고리의 다른 글
Compose - AutoSizeText (0) | 2025.03.04 |
---|---|
[Compose] Compose Text (0) | 2023.07.18 |
[Compose] LazyColumn이란? (0) | 2023.07.11 |
[Compose] @Preview 분석 (0) | 2023.06.27 |
[Compose] Compose의 Side-Effect(3) (0) | 2023.05.15 |
댓글