동작

Compose는 사용자 상호작용에서 생성된 동작을 감지하는 데 도움이 되는 다양한 API를 제공합니다. 이 API는 광범위하게 사용됩니다.

  • 그중 일부는 상위 수준이며 가장 일반적으로 사용되는 동작을 처리하도록 설계되었습니다. 예를 들어 clickable 수정자를 사용하면 클릭을 쉽게 감지할 수 있으며 접근성 기능도 이용할 수 있고 탭할 경우 시각적 표시기도 표시됩니다(예: 물결).

  • 또한 PointerInputScope.detectTapGestures 또는 PointerInputScope.detectDragGestures와 같이 하위 수준에서 더 유연하게 작동하지만 추가 기능이 포함되지 않은 덜 일반적으로 사용되는 동작 감지기도 있습니다.

탭하기 및 누르기

clickable 수정자를 사용하면 수정자가 적용되는 요소에서 앱이 클릭을 감지할 수 있습니다.

@Composable
fun ClickableSample() {
    val count = remember { mutableStateOf(0) }
    // content that you want to make clickable
    Text(
        text = count.value.toString(),
        modifier = Modifier.clickable { count.value += 1 }
    )
}

탭에 응답하는 UI 요소의 예

유연성이 더 필요한 경우 pointerInput 수정자를 통해 탭 동작 감지기를 제공할 수 있습니다.

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onPress = { /* Called when the gesture starts */ },
        onDoubleTap = { /* Called on Double Tap */ },
        onLongPress = { /* Called on Long Press */ },
        onTap = { /* Called on Tap */ }
    )
}

스크롤

스크롤 수정자

verticalScrollhorizontalScroll 수정자는 콘텐츠의 경계가 최대 크기 제약 조건보다 클 때 사용자가 요소를 스크롤할 수 있는 가장 간단한 방법을 제공합니다. verticalScrollhorizontalScroll 수정자를 사용하면 콘텐츠를 변환하거나 오프셋할 필요가 없습니다.

@Composable
fun ScrollBoxes() {
    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .verticalScroll(rememberScrollState())
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

스크롤 동작에 응답하는 간단한 세로 목록

ScrollState를 사용하면 스크롤 위치를 변경하거나 현재 상태를 가져올 수 있습니다. 기본 매개변수를 사용하여 만들려면 rememberScrollState()를 사용하세요.

@Composable
private fun ScrollBoxesSmooth() {

    // Smoothly scroll 100px on first composition
    val state = rememberScrollState()
    LaunchedEffect(Unit) { state.animateScrollTo(100) }

    Column(
        modifier = Modifier
            .background(Color.LightGray)
            .size(100.dp)
            .padding(horizontal = 8.dp)
            .verticalScroll(state)
    ) {
        repeat(10) {
            Text("Item $it", modifier = Modifier.padding(2.dp))
        }
    }
}

스크롤 가능한 수정자

scrollable 수정자는 스크롤 수정자와는 다릅니다. 즉, scrollable은 스크롤 동작을 감지하지만 콘텐츠를 오프셋하지 않습니다. 이 수정자가 올바르게 작동하려면 ScrollableState가 필요합니다. ScrollableState를 구성할 때는 각 스크롤 단계에서 픽셀 단위 델타를 사용하여 (동작 입력, 부드러운 스크롤 또는 플링으로) 호출할 consumeScrollDelta 함수를 제공해야 합니다. 이 함수는 scrollable 수정자가 있는 중첩 요소가 있는 경우 이벤트가 올바르게 전파되도록 하기 위해 사용된 스크롤 거리를 반환해야 합니다.

다음 스니펫은 동작을 감지하고 오프셋의 숫자 값을 표시하지만 아무 요소도 오프셋하지 않습니다.

@Composable
fun ScrollableSample() {
    // actual composable state
    var offset by remember { mutableStateOf(0f) }
    Box(
        Modifier
            .size(150.dp)
            .scrollable(
                orientation = Orientation.Vertical,
                // Scrollable state: describes how to consume
                // scrolling delta and update offset
                state = rememberScrollableState { delta ->
                    offset += delta
                    delta
                }
            )
            .background(Color.LightGray),
        contentAlignment = Alignment.Center
    ) {
        Text(offset.toString())
    }
}

손가락 누르기를 감지하고 손가락 위치의 숫자 값을 표시하는 UI 요소

중첩 스크롤

Compose는 여러 요소가 단일 스크롤 동작에 반응하는 중첩 스크롤을 지원합니다. 중첩 스크롤의 일반적인 예는 다른 목록 안에 있는 목록이며 더 복잡한 경우는 접기 방식 툴바입니다.

자동 중첩 스크롤

단순한 중첩 스크롤의 경우 개발자가 아무 조치를 취하지 않아도 됩니다. 스크롤 작업을 시작하는 동작은 하위 요소에서 상위 요소로 자동 전파됩니다. 따라서 하위 요소가 더 이상 스크롤할 수 없는 경우 상위 요소에 의해 동작이 처리됩니다.

자동 중첩 스크롤은 verticalScroll, horizontalScroll, scrollable, Lazy API 및 TextField 등 Compose의 일부 구성요소 및 수정자에 의해 즉시 지원 및 제공됩니다. 즉, 사용자가 중첩된 구성요소의 내부 하위 요소를 스크롤하면 이전 수정자가 중첩된 스크롤을 지원하는 상위 요소에 스크롤 델타를 전파합니다.

다음 예에서는 verticalScroll 수정자가 적용된 컨테이너 내부에 있는 verticalScroll 수정자가 적용된 요소를 보여줍니다.

val gradient = Brush.verticalGradient(0f to Color.Gray, 1000f to Color.White)
Box(
    modifier = Modifier
        .background(Color.LightGray)
        .verticalScroll(rememberScrollState())
        .padding(32.dp)
) {
    Column {
        repeat(6) {
            Box(
                modifier = Modifier
                    .height(128.dp)
                    .verticalScroll(rememberScrollState())
            ) {
                Text(
                    "Scroll here",
                    modifier = Modifier
                        .border(12.dp, Color.DarkGray)
                        .background(brush = gradient)
                        .padding(24.dp)
                        .height(150.dp)
                )
            }
        }
    }
}

내부 요소 안팎의 동작에 반응하는 두 개의 중첩된 세로 스크롤 UI 요소

nestedScroll 수정자 사용

여러 요소 간에 조정된 고급 스크롤을 만들어야 하는 경우 nestedScroll 수정자를 사용하면 중첩된 스크롤 계층 구조를 정의하여 더 유연하게 만들 수 있습니다. 이전 섹션에서 언급했듯이 일부 구성요소에는 중첩 스크롤 지원이 내장되어 있습니다. 그러나 Box 또는 Column과 같이 자동으로 스크롤되지 않는 컴포저블의 경우 스크롤 델타가 중첩된 스크롤 시스템에서 전파되지 않고 델타가 NestedScrollConnection 또는 상위 구성요소에 도달하지 않습니다. 이 문제를 해결하려면 nestedScroll을 사용하여 맞춤 구성요소 등 다른 구성요소에 이러한 지원을 부여할 수 있습니다.

중첩 스크롤 상호 운용성(실험용)

스크롤 가능한 컴포저블에 스크롤 가능한 View 요소를 중첩하려고 시도하거나 그 반대를 시도하는 경우 문제가 발생할 수 있습니다. 하위 요소를 스크롤하여 시작 또는 끝 경계에 도달한 후 상위 요소가 스크롤을 넘겨받을 것으로 예상하는 시점에 가장 눈에 띄는 문제가 발생합니다. 이 예상 동작은 발생하지 않거나 예상대로 작동하지 않을 수 있습니다.

이 문제는 스크롤 가능한 컴포저블에 내장된 예상 동작의 결과입니다. 스크롤 가능한 컴포저블에는 'nested-scroll-by-default' 규칙이 있습니다. 이 규칙은 모든 스크롤 가능한 컨테이너는 NestedScrollConnection을 통해 상위 요소로서, 그리고 동시에 NestedScrollDispatcher를 통해 하위 요소로서 중첩된 스크롤 체인에 참여해야 한다는 것입니다. 하위 요소는 하위 요소가 경계에 있을 때 상위 요소를 위해 중첩된 스크롤을 실행합니다. 일례로 이 규칙은 Compose Pager와 Compose LazyRow가 함께 작동할 수 있도록 지원합니다. 그러나 ViewPager2 또는 RecyclerView를 사용하여 상호 운용성 스크롤이 실행되는 경우 이 둘은 NestedScrollingParent3을 구현하지 않으므로 하위 요소에서 상위 요소로의 연속 스크롤이 가능하지 않습니다.

스크롤 가능한 View 요소와 스크롤 가능한 컴포저블 간에 양쪽 방향으로 중첩된 중첩 스크롤 상호 운용성 API를 사용하도록 설정하려면 다음과 같은 시나리오에서 중첩 스크롤 상호 운용성 API를 사용하여 이러한 문제를 완화할 수 있습니다.

하위 ComposeView를 포함하는 협력 상위 뷰

협력 상위 View란 이미 NestedScrollingParent3를 구현하고 있기 때문에 중첩된 협력 하위 컴포저블에서 스크롤 델타를 수신할 수 있는 뷰입니다. 이때 ComposeView가 하위 요소로 기능하므로 (간접적으로) NestedScrollingChild3를 구현해야 합니다. 협력 상위 요소의 한 가지 예로 androidx.coordinatorlayout.widget.CoordinatorLayout을 들 수 있습니다.

스크롤 가능한 View 상위 컨테이너와 중첩된 스크롤 가능 하위 컴포저블 간에 중첩 스크롤 상호 운용성이 필요한 경우 rememberNestedScrollInteropConnection()을 사용할 수 있습니다.

rememberNestedScrollInteropConnection()NestedScrollingParent3를 구현하는 View 상위 요소와 Compose 하위 요소 간의 중첩 스크롤 상호 운용성을 지원하는 NestedScrollConnection을 허용하고 기억합니다. 이는 nestedScroll 수정자와 함께 사용해야 합니다. 중첩 스크롤이 Compose 쪽에서 기본적으로 사용하도록 설정되므로 View 쪽에서 중첩 스크롤을 사용하도록 설정하고 Views와 컴포저블 간에 필요한 연결 로직을 추가하는 용도로 이 연결을 사용할 수 있습니다.

자주 사용되는 사용 사례로 다음 예에서와 같이 CoordinatorLayout, CollapsingToolbarLayout, 하위 컴포저블을 사용하는 경우를 들 수 있습니다.

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:fitsSystemWindows="true">

            <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

             <!--...-->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

활동 또는 프래그먼트에서 하위 컴포저블과 필수 NestedScrollConnection을 설정해야 합니다.

@ExperimentalComposeUiApi
open class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                // Add the nested scroll connection to your top level @Composable element
                // using the nestedScroll modifier.
                LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
                    items(20) { item ->
                        Box(
                            modifier = Modifier
                                .padding(16.dp)
                                .height(56.dp)
                                .fillMaxWidth()
                                .background(Color.Gray),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(item.toString())
                        }
                    }
                }
            }
        }
    }
}

하위 AndroidView를 포함하는 상위 컴포저블

이 시나리오에서는 하위 AndroidView를 포함하는 상위 컴포저블이 있는 경우 Compose 쪽에서 중첩 스크롤 상호 운용성 API가 구현되는 사례를 다룹니다. AndroidView는 Compose 스크롤 상위 요소의 하위 요소로 기능하는 NestedScrollDispatcherView 스크롤 하위 요소의 상위 요소로 기능하는 NestedScrollingParent3를 구현합니다. 그러면 Compose 상위 요소가 스크롤 가능한 중첩 하위 View로부터 중첩 스크롤 델타를 수신할 수 있습니다.

다음 예에서는 Compose 접기 툴바와 함께 이 시나리오에서 중첩 스크롤 상호 운용성을 달성하는 방법을 보여줍니다.

@Composable
private fun NestedScrollInteropComposeParentWithAndroidChildExample() {
    val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
    val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }

    // Sets up the nested scroll connection between the Box composable parent
    // and the child AndroidView containing the RecyclerView
    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                // Updates the toolbar offset based on the scroll to enable
                // collapsible behaviour
                val delta = available.y
                val newOffset = toolbarOffsetHeightPx.value + delta
                toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
                return Offset.Zero
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .nestedScroll(nestedScrollConnection)
    ) {
        TopAppBar(
            modifier = Modifier
                .height(ToolbarHeight)
                .offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) }
        )

        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(R.layout.view_in_compose_nested_scroll_interop, null).apply {
                        with(findViewById<RecyclerView>(R.id.main_list)) {
                            layoutManager = LinearLayoutManager(context, VERTICAL, false)
                            adapter = NestedScrollInteropAdapter()
                        }
                    }.also {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(it, true)
                    }
            },
            // ...
        )
    }
}

private class NestedScrollInteropAdapter :
    Adapter<NestedScrollInteropAdapter.NestedScrollInteropViewHolder>() {
    val items = (1..10).map { it.toString() }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): NestedScrollInteropViewHolder {
        return NestedScrollInteropViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(R.layout.list_item, parent, false)
        )
    }

    override fun onBindViewHolder(holder: NestedScrollInteropViewHolder, position: Int) {
        // ...
    }

    class NestedScrollInteropViewHolder(view: View) : ViewHolder(view) {
        fun bind(item: String) {
            // ...
        }
    }
    // ...
}

다음 예에서는 scrollable 수정자를 사용하여 API를 사용하는 방법을 보여줍니다.

@Composable
fun ViewInComposeNestedScrollInteropExample() {
    Box(
        Modifier
            .fillMaxSize()
            .scrollable(rememberScrollableState {
                // View component deltas should be reflected in Compose
                // components that participate in nested scrolling
                it
            }, Orientation.Vertical)
    ) {
        AndroidView(
            { context ->
                LayoutInflater.from(context)
                    .inflate(android.R.layout.list_item, null)
                    .apply {
                        // Nested scrolling interop is enabled when
                        // nested scroll is enabled for the root View
                        ViewCompat.setNestedScrollingEnabled(this, true)
                    }
            }
        )
    }
}

마지막으로, 다음 예에서는 BottomSheetDialogFragment와 함께 중첩 스크롤 상호 운용성 API를 사용하여 성공적인 드래그 및 닫기 동작을 달성하는 방법을 보여줍니다.

@OptIn(ExperimentalComposeUiApi::class)
class BottomSheetFragment : BottomSheetDialogFragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        val rootView: View = inflater.inflate(R.layout.fragment_bottom_sheet, container, false)

        rootView.findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                LazyColumn(
                    Modifier
                        .nestedScroll(nestedScrollInterop)
                        .fillMaxSize()
                ) {
                    item {
                        Text(text = "Bottom sheet title")
                    }
                    items(10) {
                        Text(
                            text = "List item number $it",
                            modifier = Modifier.fillMaxWidth()
                        )
                    }
                }
            }
            return rootView
        }
    }
}

rememberNestedScrollInteropConnection()은 사용자가 이 연결을 적용한 요소에 NestedScrollConnection을 설치합니다. NestedScrollConnection는 Compose 수준에서 View 수준으로 델타를 전송하는 작업을 담당합니다. 이에 따라 요소가 중첩 스크롤에 참여할 수 있게 되나 요소의 스크롤을 자동으로 사용하도록 설정하지 않습니다. Box 또는 Column과 같이 자동으로 스크롤되지 않는 컴포저블의 경우 스크롤 델타가 중첩된 스크롤 시스템에서 전파되지 않고 델타가 rememberNestedScrollInteropConnection()에 의해 제공된 NestedScrollConnection에 도달하지 않으므로 상위 View 구성요소에 도달하지 않습니다. 이 문제를 해결하려면 스크롤 가능한 수정자 또한 이러한 유형의 중첩된 컴포저블로 설정해야 합니다. 자세한 내용은 중첩 스크롤에 관한 위의 섹션을 참고하세요.

하위 ComposeView를 포함하는 비협력 상위 뷰

비협력 뷰란 View 쪽에서 필요한 NestedScrolling 인터페이스를 구현하지 않는 뷰를 가리킵니다. 즉, 이 Views와의 중첩 스크롤 상호 운용성은 추가 설정 없이는 작동하지 않습니다. 비협력 ViewsRecyclerViewViewPager2입니다.

드래그

draggable 수정자는 동작을 한 방향으로 드래그하는 상위 수준 진입점이며 드래그 거리를 픽셀 단위로 보고합니다.

이 수정자는 동작만 감지한다는 점에서 scrollable과 유사합니다. 예를 들어 offset 수정자를 통해 요소를 이동하여 상태를 유지하고 화면에 표시해야 합니다.

var offsetX by remember { mutableStateOf(0f) }
Text(
    modifier = Modifier
        .offset { IntOffset(offsetX.roundToInt(), 0) }
        .draggable(
            orientation = Orientation.Horizontal,
            state = rememberDraggableState { delta ->
                offsetX += delta
            }
        ),
    text = "Drag me!"
)

전체 드래그 동작을 제어해야 하는 경우 대신 pointerInput 수정자를 통해 드래그 동작 감지기를 사용해 보세요.

Box(modifier = Modifier.fillMaxSize()) {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
        Modifier
            .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
            .background(Color.Blue)
            .size(50.dp)
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consumeAllChanges()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    )
}

손가락 누르기로 드래그 중인 UI 요소

스와이프

swipeable 수정자를 사용하여, 손을 떼면 한 방향으로 정의된 두 개 이상의 앵커 포인트를 향해 애니메이션 처리되는 요소를 드래그할 수 있습니다. 일반적인 용도는 '스와이프하여 닫기' 패턴을 구현하는 것입니다.

이 수정자는 요소를 이동하지 않으며 동작만 감지합니다. 예를 들어 offset 수정자를 통해 요소를 이동하여 상태를 유지하고 화면에 표시해야 합니다.

스와이프 가능 상태는 swipeable 수정자에 필요하며 rememberSwipeableState()를 사용하여 만들고 저장할 수 있습니다. 이 상태는 또한 프로그래매틱 방식으로 앵커에 애니메이션 처리하는 데 유용한 메서드(snapTo, animateTo, performFling, performDrag 참고) 및 드래그 진행 상태를 확인할 수 있는 속성을 제공합니다.

스와이프 동작은 FixedThreshold(Dp)FractionalThreshold(Float)와 같은 다양한 기준점 유형을 갖도록 구성할 수 있으며 앵커 포인트 시작-끝 조합마다 다를 수 있습니다.

더 유연하게 작동하도록 경계를 지나 스와이프할 때 resistance를 구성할 수 있으며 또한 위치 thresholds에 도달하지 않은 경우에도 스와이프를 다음 상태로 애니메이션 처리하는 velocityThreshold도 구성할 수 있습니다.

@Composable
fun SwipeableSample() {
    val width = 96.dp
    val squareSize = 48.dp

    val swipeableState = rememberSwipeableState(0)
    val sizePx = with(LocalDensity.current) { squareSize.toPx() }
    val anchors = mapOf(0f to 0, sizePx to 1) // Maps anchor points (in px) to states

    Box(
        modifier = Modifier
            .width(width)
            .swipeable(
                state = swipeableState,
                anchors = anchors,
                thresholds = { _, _ -> FractionalThreshold(0.3f) },
                orientation = Orientation.Horizontal
            )
            .background(Color.LightGray)
    ) {
        Box(
            Modifier
                .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) }
                .size(squareSize)
                .background(Color.DarkGray)
        )
    }
}

스와이프 동작에 응답하는 UI 요소

멀티터치: 화면 이동, 확대/축소, 회전

화면 이동, 확대/축소, 회전에 사용되는 멀티터치 동작을 감지하려면 transformable 수정자를 사용하세요. 이 수정자는 자체적으로 요소를 변환하지 않으며 동작만 감지합니다.

@Composable
fun TransformableSample() {
    // set up all transformation states
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val state = rememberTransformableState { zoomChange, offsetChange, rotationChange ->
        scale *= zoomChange
        rotation += rotationChange
        offset += offsetChange
    }
    Box(
        Modifier
            // apply other transformations like rotation and zoom
            // on the pizza slice emoji
            .graphicsLayer(
                scaleX = scale,
                scaleY = scale,
                rotationZ = rotation,
                translationX = offset.x,
                translationY = offset.y
            )
            // add transformable to listen to multitouch transformation events
            // after offset
            .transformable(state = state)
            .background(Color.Blue)
            .fillMaxSize()
    )
}

멀티터치 동작(화면 이동, 확대/축소, 회전)에 응답하는 UI 요소

확대/축소, 화면 이동, 회전을 다른 동작과 결합해야 하는 경우 PointerInputScope.detectTransformGestures 감지기를 사용할 수 있습니다.