사용자 상호작용 처리

사용자 인터페이스 구성요소는 사용자 상호작용에 반응하는 방식으로 기기 사용자에게 피드백을 제공합니다. 모든 구성요소에는 상호작용에 반응하는 고유한 방식이 있으므로 상호작용으로 무슨 일이 벌어지고 있는지 사용자가 파악할 수 있습니다. 예를 들어, 사용자가 기기의 터치스크린에 있는 버튼을 터치하면 강조표시 색상이 추가되는 등의 방식으로 버튼이 변경될 수 있습니다. 이 변경을 통해 사용자는 자신이 버튼을 터치한 것을 인식할 수 있습니다. 사용자가 그렇게 하기를 원하지 않는 경우 버튼을 클릭한 다음에 버튼에서 손가락을 드래그하는 것이 버튼을 놓으면 버튼이 활성화됩니다.

그림 1. 버튼을 누르면 물결 효과 없이 항상 사용 설정된 상태로 표시됩니다.
그림 2. 버튼에 사용 설정된 상태가 적절하게 반영되는 물결 효과 누르기

Compose 동작 문서에서는 Compose 구성요소가 포인터 이동 및 클릭과 같은 하위 수준 포인터 이벤트를 처리하는 방법을 다룹니다. Compose는 즉시 이러한 하위 수준 이벤트를 상위 수준 상호작용으로 추상화합니다. 예를 들어, 일련의 포인터 이벤트가 버튼 누르기 및 해제에까지 추가될 수 있습니다. 더 높은 수준의 추상화를 이해하면 UI가 사용자에게 반응하는 방식을 맞춤설정하는 데 도움이 됩니다. 예를 들어, 사용자가 구성요소와 상호작용할 때 구성요소의 외양이 변경되는 방식을 맞춤설정할 수 있고 또는 이러한 사용자 작업의 로그를 유지관리할 수도 있습니다. 이 문서에서는 표준 UI 요소를 수정하거나 직접 디자인하는 데 필요한 정보를 제공합니다.

<ph type="x-smartling-placeholder">

Interactions

대부분의 경우 Compose 구성요소가 사용자 상호작용을 해석하는 방법을 알 필요는 없습니다. 예를 들어, ButtonModifier.clickable을 통해 사용자가 버튼을 클릭했는지 파악합니다. 앱에 일반 버튼을 추가하는 경우 버튼의 onClick 코드를 정의할 수 있고 Modifier.clickable은 적절한 시기에 이 코드를 실행합니다. 즉, 사용자가 화면을 탭했는지 또는 키보드로 버튼을 선택했는지 알 필요가 없습니다. Modifier.clickable은 사용자가 클릭했는지 파악하고 onClick 코드를 실행하여 응답합니다.

그러나 사용자의 동작에 맞게 UI 구성요소의 응답을 맞춤설정하려면 내부에서 무슨 일이 일어나는지 더 자세히 알 필요가 있습니다. 이 섹션에서는 이와 관련된 정보를 일부 제공합니다.

사용자가 UI 구성요소와 상호작용하면 시스템은 다양한 Interaction 이벤트를 생성하여 동작을 표현합니다. 예를 들어 사용자가 버튼을 터치하면 버튼은 PressInteraction.Press를 생성합니다. 사용자가 버튼 내에서 손가락을 떼면 클릭이 완료되었음을 버튼이 알 수 있도록 PressInteraction.Release가 생성됩니다. 반면, 사용자가 버튼 밖으로 손가락을 드래그한 다음 손가락을 떼면 버튼은 PressInteraction.Cancel을 생성하여 버튼을 누른 것이 취소되었고 완료되지 않았음을 나타냅니다.

이러한 상호작용은 독단적이지 않습니다. 즉, 이러한 하위 수준 상호작용 이벤트는 사용자 작업 또는 작업 시퀀스의 의미를 해석하려고 하지 않습니다. 또한, 어떤 사용자 작업이 더 우선순위가 높은지 해석하지 않습니다.

일반적으로 이러한 상호작용은 시작과 끝이 있는 쌍으로 구성됩니다. 두 번째 상호작용에는 첫 번째 상호작용에 관한 참조가 포함됩니다. 예를 들어, 사용자가 버튼을 터치한 후 손가락을 떼면 터치 동작으로 PressInteraction.Press 상호작용이 생성되고 해제 동작으로 PressInteraction.Release 상호작용이 생성됩니다. Release는 앞선 PressInteraction.Press를 인식할 수 있도록 press 속성을 포함합니다.

InteractionSource를 관찰하여 특정 구성요소의 상호작용을 확인할 수 있습니다. InteractionSourceKotlin 흐름을 기반으로 빌드되므로 다른 흐름에서와 같은 방식으로 상호작용을 수집할 수 있습니다. 이러한 설계 결정에 관한 자세한 내용은 Illuminating Interactions 블로그 게시물을 참고하세요.

상호작용 상태

상호작용을 직접 추적하여 구성요소의 내장 기능을 확장할 수도 있습니다. 예를 들어 버튼을 눌렀을 때 버튼 색상이 변경되도록 할 수 있습니다. 상호작용을 추적하는 가장 간단한 방법은 적절한 상호작용 상태를 관찰하는 것입니다. InteractionSource는 다양한 상호작용의 상황을 상태로 나타내는 여러 메서드를 제공합니다. 예를 들어, 특정 버튼이 눌렸는지 확인하려면 버튼의 InteractionSource.collectIsPressedAsState() 메서드를 호출하면 됩니다.

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Compose는 collectIsPressedAsState() 외에 collectIsFocusedAsState(), collectIsDraggedAsState(), collectIsHoveredAsState()를 제공합니다. 이러한 메서드는 실제로 하위 수준의 InteractionSource API를 기반으로 빌드된 편의 메서드입니다. 어떤 경우에는 이러한 하위 수준 함수를 직접 사용하는 것이 좋습니다.

예를 들어, 버튼을 누르고 있는지 그리고 드래그하고 있는지 알아야 하는 상황을 가정해 보겠습니다. collectIsPressedAsState()collectIsDraggedAsState()를 모두 사용하는 경우 Compose가 많은 중복 작업을 실행하고 모든 상호작용을 올바른 순서로 가져온다는 보장이 없습니다. 이 같은 경우에는 InteractionSource를 직접 사용하는 것이 좋습니다. 사이트의 상호작용 추적에 대해 자세히 알아보려면 InteractionSource를 사용하여 작업하려면 InteractionSource 작업을 참조하세요.

다음 섹션에서는 다음과 같은 상호작용을 사용하고 내보내는 방법을 설명합니다. 각각 InteractionSourceMutableInteractionSource입니다.

Interaction 소비 및 내보내기

InteractionSourceInteractions의 읽기 전용 스트림을 나타냅니다. InteractionInteractionSource에 내보낼 수 있음 내보내기 Interaction를 사용하려면 다음에서 확장되는 MutableInteractionSource를 사용해야 합니다. InteractionSource입니다.

수정자와 구성요소는 Interactions를 소비하거나 내보내거나 내보낼 수 있습니다. 다음 섹션에서는 두 API에서 상호작용을 사용하고 내보내는 방법을 설명합니다. 살펴보겠습니다

수정자 사용 예

포커스가 맞춰진 상태의 테두리를 그리는 수정자의 경우 InteractionSource를 수락할 수 있는 Interactions:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

함수 서명에서 이 수정자가 소비자임을 명확하게 알 수 있습니다. Interaction를 소비할 수 있지만 내보낼 수는 없습니다.

수정자 생성 예

Modifier.hoverable와 같은 마우스 오버 이벤트를 처리하는 수정자의 경우 Interactions를 내보내고 MutableInteractionSource를 매개변수를 사용하세요.

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

이 수정자는 생산자이며, 제공된 MutableInteractionSource: 마우스를 가져가면 HoverInteractions를 내보냅니다. 없습니다.

데이터를 소비하고 생성하는 구성요소를

Material Button와 같은 상위 수준 구성요소는 생산자이자 있습니다. 입력 및 포커스 이벤트를 처리하고 모양도 변경합니다. 물결 효과를 표시하거나 애니메이션을 적용하는 등 고도. 따라서 MutableInteractionSource를 다음과 같이 직접 노출합니다. 매개변수를 사용하여 저장된 자체 인스턴스를 제공할 수 있습니다.

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

이를 통해 MutableInteractionSource를 가져와 구성 요소에서 구성요소에서 생성된 Interaction입니다. 이를 사용하여 모양이나 UI의 다른 모양 등을 정의할 수 있습니다

자체적인 상위 수준의 대화형 구성요소를 빌드하는 경우 이러한 방식으로 MutableInteractionSource를 매개변수로 노출해야 합니다. 외 이렇게 하면 상태 호이스팅 권장사항을 따르는 데 도움이 되므로 같은 방식으로 구성요소의 시각적 상태를 제어할 수 있습니다. 읽기 및 제어가 가능합니다.

Compose는 계층화된 아키텍처 방식을 따릅니다. 따라서 상위 수준의 머티리얼 구성요소는 기본 빌딩 블록 위에 빌드됩니다. 물결 효과와 기타 동작을 제어하는 데 필요한 Interaction를 생성하는 할 수 있습니다. 기반 라이브러리는 상위 수준 상호작용 수정자를 제공합니다. Modifier.hoverable, Modifier.focusable, Modifier.draggable:

마우스 오버 이벤트에 응답하는 구성요소를 빌드하려면 Modifier.hoverable를 사용하고 MutableInteractionSource를 매개변수로 전달합니다. 구성요소에 마우스를 가져갈 때마다 HoverInteraction를 내보내며 다음을 사용할 수 있습니다. 구성요소가 표시되는 방식을 변경할 수 있습니다.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

이 구성요소도 포커스 가능하게 만들려면 Modifier.focusable를 추가하고 매개변수와 동일한 MutableInteractionSource입니다. 이제 HoverInteraction.Enter/ExitFocusInteraction.Focus/Unfocus를 내보냅니다. 동일한 MutableInteractionSource 한 곳에서 두 유형의 상호작용이 모두 어떻게 표시되는지 확인하세요.

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable은(는) 훨씬 더 높습니다. hoverablefocusable보다 수준 추상화를 사용합니다. 암시적으로 마우스 오버가 가능하고 클릭 가능한 구성요소는 포커스 가능합니다 Modifier.clickable를 사용하여 하위 요소를 결합하지 않고도 마우스 오버, 포커스, 누름 상호작용을 처리함 사용할 수 있습니다 요소도 클릭 가능하도록 만들려면 다음 작업을 수행합니다. hoverablefocusableclickable로 바꿉니다.

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

InteractionSource 사용

구성요소와의 상호작용에 관한 하위 수준의 정보가 필요한 경우 구성요소의 InteractionSource에 맞게 표준 흐름 API를 사용하면 됩니다. 예를 들어, InteractionSource의 누르기 및 드래그 상호작용 목록을 유지하려고 한다고 가정해 보겠습니다. 이 코드는 버튼을 누를 때 새 누르기 작업을 목록에 추가하여 작업의 절반을 실행합니다.

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

하지만 새 상호작용을 추가하는 것 외에 종료된 상호작용도 삭제해야 합니다 (예: 사용자가 구성요소에서 손가락을 떼는 경우). 종료 상호작용에는 항상 연결된 시작 상호작용의 참조가 포함되어 있으므로 상호작용을 삭제하기는 쉽습니다. 이 코드는 종료된 상호작용을 삭제하는 방법을 보여줍니다.

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

이제 구성요소를 누르고 있는지 또는 드래그하고 있는지 알려면 interactions가 비어 있는지만 확인하면 됩니다.

val isPressedOrDragged = interactions.isNotEmpty()

최근 상호작용이 무엇인지 알고 싶다면 항목을 찾습니다. 예를 들어 다음과 같은 방식으로 Compose 물결 효과를 구현할 수 있습니다. 가장 최근의 상호작용에 사용할 적절한 상태 오버레이를 파악합니다.

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

모든 Interaction가 동일한 구조를 따르기 때문에 다양한 유형의 사용자 상호작용으로 작업할 때의 코드 차이 전체 패턴은 동일합니다.

이 섹션의 이전 예는 Flow를 나타냅니다. State를 사용한 상호작용 이렇게 하면 업데이트된 값을 쉽게 관찰할 수 있습니다. 상태 값을 읽으면 자동으로 리컴포지션이 발생하기 때문입니다. 하지만 일괄 처리되는 프리프레임입니다. 즉, 상태가 변경되고 동일한 프레임 내에서 다시 변경되면 상태를 관찰하는 구성 요소가 확인할 수 있습니다

상호작용이 정기적으로 시작하고 끝날 수 있으므로 이는 상호작용에 중요합니다. 표시할 수 있습니다. 예를 들어 이전 예와 Button를 함께 사용하면 다음과 같습니다.

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

같은 프레임 내에서 누르기가 시작되고 끝나면 텍스트가 "눌렀습니다!". 대부분의 경우 이것은 문제가 아니며, 그렇게 짧게 하면 깜박이게 되고 크게 상회한다고 판단되는 경우에만 사용됩니다 일부 경우에는 물결 효과나 최소 시간 이상 동안 효과를 표시하고 싶을 수 있습니다. 버튼을 누르지 않으면 즉시 정지하는 대신 받는사람 이렇게 하면 컬렉션 내부에서 애니메이션을 직접 시작하고 상태에 쓰는 대신 람다를 사용합니다. 이 패턴의 예는 테두리가 애니메이션으로 표시된 고급 Indication 빌드 섹션

예: 맞춤 상호작용 처리가 포함된 빌드 구성요소

다음은 수정된 버튼의 예로, 입력에 맞춤 응답을 사용하는 구성요소의 빌드 방법을 보여줍니다. 이 경우 누르기에 관한 응답으로 모양을 변경하는 버튼을 만든다고 가정해 보겠습니다.

클릭 시 식료품 카트 아이콘을 동적으로 추가하는 버튼의 애니메이션
그림 3. 클릭하면 아이콘을 동적으로 추가하는 버튼

이렇게 하려면 Button을 기반으로 맞춤 컴포저블을 빌드하고 추가 icon 매개변수를 사용하여 아이콘을 그립니다(이 경우에는 장바구니임). collectIsPressedAsState()를 호출하여 사용자가 버튼 위에 마우스 오버 중인지 추적합니다. 마우스가 버튼 위에 있다면 아이콘을 추가합니다. 코드는 다음과 같습니다.

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

새로운 컴포저블을 사용하는 모습은 다음과 같습니다.

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

이 새로운 PressIconButton은 기존 머티리얼 Button을 기반으로 빌드되었으므로 모든 일반적인 방식으로 사용자 상호작용에 반응합니다. 사용자가 버튼을 누르면 일반 머티리얼 Button과 마찬가지로 불투명도가 약간 달라집니다.

Indication로 재사용 가능한 맞춤 효과 만들기 및 적용

이전 섹션에서는 응답에서 구성요소의 일부를 변경하는 방법을 알아봤습니다. 다른 Interaction에 전달합니다(예: 눌렀을 때 아이콘 표시). 동일한 에 제공하는 매개변수의 값을 변경하는 데 구성요소 내에 표시되는 콘텐츠를 변경할 수 있지만 이는 구성 요소별로만 적용됩니다. 종종 애플리케이션 또는 설계 시스템은 상태 저장 시각 효과를 위한 일반 시스템이 있습니다. 모든 구성요소에 일관된 방식으로 적용됩니다.

이런 종류의 디자인 시스템을 빌드하는 경우에는 하나의 구성요소를 맞춤설정하여 이러한 맞춤설정을 다른 구성요소에 재사용하는 것은 이유는 다음과 같습니다.

  • 설계 시스템의 모든 구성요소에는 동일한 상용구가 필요함
  • 이 효과를 새로 빌드된 구성요소와 커스텀 모델에 적용하는 것을 잊어버리기 십상입니다. 클릭 가능한 구성요소
  • 맞춤 효과를 다른 효과와 결합하기 어려울 수 있습니다.

이러한 문제를 방지하고 시스템 전반에서 커스텀 구성요소를 쉽게 확장하려면 Indication를 사용하면 됩니다. Indication는 여러 영역에 적용할 수 있는 재사용 가능한 시각 효과를 나타냅니다. 애플리케이션 또는 설계 시스템의 구성요소를 살펴보겠습니다 Indication이(가) 2개로 나뉩니다. 파트:

  • IndicationNodeFactory: 다음과 같이 Modifier.Node 인스턴스를 생성하는 팩토리입니다. 구성요소의 시각적 효과를 렌더링할 수 있습니다. 다음과 같은 간단한 구현의 경우 이는 싱글톤 (객체)이 될 수 있으며 애플리케이션을 실행할 수 있습니다

    이러한 인스턴스는 스테이트풀(Stateful) 또는 스테이트리스(Stateless)일 수 있습니다. API마다 생성되므로 CompositionLocal에서 값을 가져와 다른 구성요소와 마찬가지로 특정 구성 요소 내에서 표시되거나 Modifier.Node

  • Modifier.indication: 수식을 나타내는 Indication 구성요소를 사용합니다. Modifier.clickable 및 기타 상위 수준 상호작용 수정자 표시 매개변수를 직접 허용하여 Interaction를 사용할 수 있지만 Interaction의 시각적 효과도 그릴 수 있습니다. 내보내기. 따라서 간단한 경우에는Modifier.clickable Modifier.indication 필요.

효과를 Indication로 대체

이 섹션에서는 적용된 수동 배율 효과를 대체하는 방법을 설명합니다. 여러 영역에서 재사용할 수 있는 표시가 있는 특정 버튼 구성할 수 있습니다.

다음 코드는 누를 때 아래로 축소되는 버튼을 만듭니다.

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

위 스니펫의 배율 효과를 Indication로 변환하려면 다음을 따르세요. 다음 단계를 따르세요.

  1. 배율 효과를 적용할 Modifier.Node를 만듭니다. 연결되면 노드가 이전과 유사하게 상호작용 소스를 관찰합니다. 예로 들 수 있습니다 여기서 유일한 차이점은 애니메이션을 직접 실행한다는 것입니다. 상태를 전환하지 않아도 됩니다

    노드에서 재정의할 수 있도록 DrawModifierNode를 구현해야 합니다. ContentDrawScope#draw(), 동일한 그림을 사용하여 축척 효과 렌더링 명령어를 사용해야 합니다.

    ContentDrawScope 수신기에서 사용할 수 있는 drawContent()를 호출하면 Indication가 적용되어야 하는 실제 구성요소이므로 이 함수를 호출해야 합니다. 먼저 Indication 구현은 특정 시점에 항상 drawContent()를 호출합니다. 그렇지 않으면 Indication를 적용하는 구성요소가 그려지지 않습니다.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. IndicationNodeFactory를 만듭니다. 이 시스템의 유일한 책임은 새 노드 인스턴스를 만듭니다. 매개변수로 표시를 구성하는 경우 팩토리는 객체가 될 수 있습니다.

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable는 내부적으로 Modifier.indication를 사용하므로 클릭 가능한 요소를 ScaleIndication 사용하면 clickable에 매개변수로 Indication를 추가합니다.

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    또한 커스텀 API를 사용하여 높은 수준의 재사용 가능한 구성요소를 쉽게 빌드할 수 있습니다. Indication: 버튼은 다음과 같이 표시됩니다.

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

그러면 다음과 같이 버튼을 사용할 수 있습니다.

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

누르면 작아지는 식료품 카트 아이콘이 있는 버튼의 애니메이션
그림 4. 맞춤 Indication로 빌드된 버튼
를 통해 개인정보처리방침을 정의할 수 있습니다.

애니메이션 테두리가 있는 고급 Indication 빌드

Indication는 확장, 축소와 같은 변환 효과로만 국한되지 않습니다. 구성요소를 사용합니다. IndicationNodeFactoryModifier.Node를 반환하므로 다음을 그릴 수 있습니다. 다른 그리기 API에서와 마찬가지로 콘텐츠 위 또는 아래에 모든 종류의 효과가 적용됩니다. 대상 예를 들어, 요소 주위에 애니메이션 테두리를 그릴 수 있고 구성요소 맨 위에 다음과 같이 표시됩니다.

누르면 화려한 무지개 효과가 있는 버튼
그림 5. Indication로 그린 애니메이션 테두리 효과

여기서 Indication 구현은 이전 예와 매우 유사합니다. 단지 몇 가지 매개변수를 가진 노드를 생성합니다. 애니메이션 테두리의 색상은 Indication가 사용되는 구성요소의 도형 및 테두리 Indication를 구현하려면 도형과 테두리 너비도 제공해야 합니다. 매개변수:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

Modifier.Node 구현 또한 개념적으로 동일합니다. 더 복잡합니다. 이전과 마찬가지로 InteractionSource를 관찰합니다. 연결되면 애니메이션을 실행하고 그리기를 위해 DrawModifierNode를 구현합니다. 다음과 같습니다.

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

여기서 가장 큰 차이점은 이제 animateToResting() 함수를 사용하여 애니메이션 애니메이션을 만들 수 있습니다. 누르기 애니메이션이 계속됩니다. 또한 처리 속도도 animateToPressed 시작 부분에서 빠르게 여러 번 누르기 — 기존 누르기 또는 휴면 애니메이션 중에 발생하는 경우 이전 애니메이션은 취소되고 누르기 애니메이션이 처음부터 시작됩니다. 여러 동시 효과 (예: 물결 효과의 경우 새 물결 효과 여러 개의 메아리를 기반으로 하는 경우)를 추적할 수 있습니다. 기존 애니메이션을 취소하고 새 애니메이션을 시작할 수 있습니다.