Jetpack Compose 애니메이션

1. 소개

ea1442f28b3c3b39.png

최종 업데이트: 2021년 1월 25일

이 Codelab에서는 Jetpack Compose에서 애니메이션 API를 사용하는 방법을 알아봅니다.

Jetpack Compose는 UI 개발을 간소화하도록 설계된 최신 UI 도구 키트입니다. Jetpack Compose를 처음 사용하는 경우 이 Codelab 전에 시도해 볼 수 있는 여러 Codelab이 있습니다.

학습할 내용

  • 여러 기본 애니메이션 API 사용 방법
  • API 사용 시기

기본 요건

필요한 항목

2. 설정

Codelab 코드를 다운로드합니다. 다음과 같이 저장소를 클론할 수 있습니다.

$ git clone git@github.com:googlecodelabs/android-compose-codelabs.git

또는 ZIP 파일을 다운로드할 수 있습니다.

Android 스튜디오에서 AnimationCodelab 프로젝트를 가져옵니다.

7a7c10526864d5c2.png

프로젝트에는 여러 모듈이 있습니다.

  • start는 Codelab의 시작 상태입니다.
  • finished는 이 Codelab을 완료한 후의 최종 앱 상태입니다.

start

선택되어 있는지 확인합니다.

39b7acb33706a9b.png

다음 장에서 몇 가지 애니메이션 시나리오에 대한 작업을 시작합니다. 이 Codelab에서 작업하는 모든 코드 스니펫은 // TODO 주석으로 표시됩니다. 한 가지 깔끔한 방법은 Android 스튜디오에서 TODO 도구 창을 열고 해당 장에 대한 각 TODO 주석으로 이동하는 것입니다.

C4a2180b956cad9f.png

3. 간단한 값 변경 애니메이션 처리

Compose에서 가장 간단한 애니메이션 API로 시작하겠습니다.

start 구성을 실행하고 상단의 '홈' 버튼을 클릭하여 탭을 전환해 보세요. 실제로 탭 콘텐츠가 전환되지는 않지만 콘텐츠의 배경 색상이 변경되는 것을 볼 수 있습니다.

TODO 도구 창에서 TODO 1을 클릭하여 구현 방법을 확인합니다. Home 컴포저블에 있습니다.

val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300

여기에서 tabPageState 객체에서 지원하는 Int입니다. 값에 따라 배경 색상은 보라색과 녹색 간에 전환됩니다. 이 값 변경사항을 애니메이션으로 보여 주려고 합니다.

이처럼 간단한 값 변경을 애니메이션하기 위해 animate*AsState API를 사용할 수 있습니다. 이 경우 animate*AsState 컴포저블의 상응하는 변형(animateColorAsState)으로 변경 값을 래핑하는 것만으로 애니메이션 값을 만들 수 있습니다. 반환된 값은 State<T> 객체이므로 by 선언과 함께 로컬 위임된 속성을 사용하여 일반 변수로 처리할 수 있습니다.

val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

앱을 다시 실행하고 탭을 전환해 보세요. 이제 색상 변경이 애니메이션으로 표시됩니다.

6946feb47acc2cc6.gif

4. 가시성 애니메이션

앱의 콘텐츠를 스크롤하면 스크롤 방향에 따라 플로팅 작업 버튼이 펼쳐집니다.

TODO 2-1을 찾아 어떻게 작동하는지 알아보세요. HomeFloatingActionButton 컴포저블에 있습니다. "EDIT"라는 텍스트는 if 문을 사용하여 표시하거나 숨깁니다.

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

이 공개 상태 변경을 애니메이션 처리하는 것은 간단히 ifAnimatedVisibility 컴포저블로 대체하기만 하면 됩니다.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

앱을 실행하고 이제 FAB가 확장 및 축소되는 방식을 확인합니다.

37a613b87156bfbe.gif

AnimatedVisibility는 지정된 Boolean 값이 변경될 때마다 애니메이션을 실행합니다. 기본적으로 AnimatedVisibility은 페이드인 및 확장 방식으로 요소를 표시하고 페이드아웃 및 축소 방식으로 요소를 숨깁니다. 이 동작은 FAB를 사용하여 이 예시에서 잘 작동하지만 동작을 맞춤설정할 수도 있습니다.

FAB를 클릭하면 '편집 기능이 지원되지 않습니다'라는 메시지가 표시됩니다. 또한 AnimatedVisibility를 사용하여 모양과 사라짐을 애니메이션으로 보여줍니다. 이 상단에서 요소를 슬라이드하여 상단으로 슬라이드하도록 애니메이션을 맞춤설정하는 방법을 살펴보겠습니다.

11d77a9c6af0309c.png

TODO 2-2를 찾고 EditMessage 컴포저블의 코드를 확인합니다.

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

애니메이션을 맞춤설정하려면 enterexit 매개변수를 AnimatedVisibility 컴포저블에 추가하세요.

enter 매개변수는 EnterTransition의 인스턴스여야 합니다. 이 예에서는 slideInVertically 함수를 사용하여 EnterTransition를 만들 수 있습니다. 이 함수를 사용하면 initialOffsetYanimationSpec 매개변수를 기준으로 추가로 맞춤설정할 수 있습니다. initialOffsetY은 초기 위치를 반환하는 람다여야 합니다. 람다는 요소의 높이인 요소의 높이를 수신하므로 단순히 음수 값을 반환할 수 있습니다. slideInVertically를 사용하는 경우 슬라이드 후의 타겟 오프셋은 항상 0(픽셀)입니다. initialOffsetY은 람다 함수를 통해 절댓값 또는 요소의 전체 높이 비율로 지정할 수 있습니다.

animationSpecEnterTransition, ExitTransition 등 많은 애니메이션 API의 공통 매개변수입니다. 다양한 AnimationSpec 유형 중 하나를 전달하여 시간 경과에 따른 애니메이션 값을 어떻게 변경할지 지정할 수 있습니다. 이 예에서 let은 간단한 기간 기반 AnimationSpec를 사용합니다. tween 함수를 사용하여 만들 수 있습니다. 지속 시간은 150밀리초이며 이징은 LinearOutSlowInEasing입니다.

마찬가지로 exit 매개변수에 slideOutVertically 함수를 사용할 수 있습니다. slideOutVertically는 초기 오프셋이 0이라고 가정하므로 targetOffsetY만 지정하면 됩니다. animationSpec 매개변수에 동일한 tween 함수를 사용하되 길이는 250밀리초이며 FastOutLinearInEasing의 이징을 사용합니다.

결과 코드는 아래와 같습니다.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

앱을 실행하고 FAB를 다시 클릭합니다. 이제 메시지가 상단에서 안팎으로 슬라이드됩니다.

76895615b43b9263.gif

5. 콘텐츠 크기 변경 애니메이션 처리

앱에는 콘텐츠의 여러 주제가 표시됩니다. 그중 하나를 클릭하면 해당 주제의 본문 텍스트가 열리고 표시됩니다. 본문이 표시되거나 숨겨지면 텍스트가 포함된 카드가 펼쳐집니다.

TopicRow 컴포저블에서 TODO 3 코드를 확인하세요.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

여기서 Column 컴포저블은 콘텐츠가 변경되면 크기가 변경됩니다. animateContentSize 수정자를 추가하여 크기 변경에 애니메이션을 적용할 수 있습니다.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

앱을 실행하고 주제 중 하나를 클릭합니다. 애니메이션으로 확장 및 축소되는 것을 볼 수 있습니다.

C0ad7381779fcb09.gif

6. 다중 값 애니메이션

이제 몇 가지 기본 애니메이션 API에 익숙해졌으니 더 복잡한 애니메이션을 만들 수 있는 Transition API를 살펴보겠습니다. 이 예에서는 탭 표시기를 맞춤설정합니다. 현재 선택된 탭에 표시되는 직사각형입니다.

HomeTabIndicator 컴포저블에서 TODO 4를 찾고 탭 표시기가 구현되는 방식을 확인합니다.

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800

여기서 indicatorLeft는 탭 행에 있는 표시기 왼쪽 가장자리의 가로 위치입니다. indicatorRight는 표시기 오른쪽 가장자리의 가로 위치입니다. 색상도 보라색과 녹색 간에 변경됩니다.

이러한 여러 값을 동시에 애니메이션 처리하기 위해 Transition를 사용할 수 있습니다. updateTransition 함수를 사용하여 Transition를 만들 수 있습니다. 현재 targetState 매개변수로 선택된 탭의 색인을 전달합니다.

각 애니메이션 값을 Transitionanimate* 확장 함수로 선언할 수 있습니다. 이 예에서는 animateDpanimateColor를 사용합니다. 람다 블록을 사용하며, 각 상태의 타겟 값을 지정할 수 있습니다. 타겟 값이 무엇일지 이미 알고 있으므로 아래와 같이 값을 래핑할 수 있습니다. by 선언을 사용하면 다시 로컬 위임 속성으로 만들 수 있습니다. animate* 함수가 State 객체를 반환하기 때문입니다.

val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

이제 앱을 실행하면 탭 스위치가 훨씬 더 흥미로운 것을 볼 수 있습니다. 탭을 클릭하면 tabPage 상태 값이 변경되면 transition와 연결된 모든 애니메이션 값이 타겟 상태에 지정된 값으로 애니메이션을 적용하기 시작합니다.

3262270d174e77bf.gif

또한 transitionSpec 매개변수를 지정하여 애니메이션 동작을 맞춤설정할 수도 있습니다. 예를 들어 대상에 더 가까운 가장자리가 다른 가장자리보다 빨리 움직이게 하여 표시기에 탄력적인 효과를 얻을 수 있습니다. transitionSpec 람다의 isTransitioningTo 중위 함수를 사용하여 상태 변경 방향을 결정할 수 있습니다.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

앱을 다시 실행하고 탭을 전환해 봅니다.

2ad4adbefce04ae2.gif

Android 스튜디오는 Compose 미리보기에서 전환 검사를 지원합니다. 애니메이션 미리보기를 사용하려면 미리보기에서 컴포저블의 오른쪽 상단에 있는 '대화형 모드 시작' 아이콘을 클릭하여 대화형 모드를 시작하세요. 아이콘을 찾을 수 없는 경우 여기의 안내에 따라 실험 설정에서 이 기능을 사용 설정해야 합니다. PreviewHomeTabBar 컴포저블의 아이콘을 클릭해 보세요. 그런 다음 대화형 모드의 오른쪽 상단에서 '애니메이션 검사 시작' 아이콘을 클릭합니다. 새 quos;Animations" 창이 열립니다.

'재생' 아이콘 버튼을 클릭하여 애니메이션을 실행할 수 있습니다. 탐색 막대를 드래그하여 각 애니메이션 프레임을 볼 수도 있습니다. 애니메이션 값에 대한 자세한 설명을 위해 updateTransitionanimate* 메서드에서 label 매개변수를 지정하면 됩니다.

2d3c5020ae28120b.png

7. 반복되는 애니메이션

현재 온도 옆의 새로고침 아이콘 버튼을 클릭해 보세요. 앱이 최신 날씨 정보를 로드하기 시작합니다 (역할을 의미함). 로드가 완료될 때까지 로드 표시기, 즉 회색 원과 막대가 표시됩니다. 이 표시기의 알파 값에 애니메이션을 적용하여 프로세스가 진행 중임을 더 명확히 해 줍니다.

C2912ddc2d73bdfc.png

LoadingRow 컴포저블에서 TODO 5를 찾습니다.

val alpha = 1f

이 값을 0f~1f 간에 반복적으로 애니메이션으로 표시하려고 합니다. 이러한 목적으로 InfiniteTransition를 사용할 수 있습니다. 이 API는 이전 섹션의 Transition API와 유사합니다. 둘 다 여러 값에 애니메이션을 적용하지만 Transition는 상태 변경에 따라 값에 애니메이션을 적용하는 반면, InfiniteTransition에는 무기한으로 값이 애니메이션 처리됩니다.

InfiniteTransition를 생성하려면 rememberInfiniteTransition 함수를 사용합니다. 그런 다음 각 애니메이션 값 변경을 InfiniteTransitionanimate* 확장 함수 중 하나로 선언할 수 있습니다. 이 경우 알파 값을 애니메이션으로 표시하므로 animatedFloat를 사용하겠습니다. initialValue 매개변수는 0f이고 targetValue 1f여야 합니다. 이 애니메이션의 AnimationSpec도 지정할 수 있지만 이 API는 InfiniteRepeatableSpec만 사용합니다. infiniteRepeatable 함수를 사용하여 만듭니다. 이 AnimationSpec는 모든 기간 기반 AnimationSpec를 래핑하여 반복 가능하도록 만듭니다. 예를 들어 결과 코드는 아래와 같아야 합니다.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

앱을 실행하고 새로고침 버튼을 클릭합니다. 이제 로드 표시기에 애니메이션이 표시됩니다.

ca4d1d5bfe87b2a9.gif

8. 동작 애니메이션

이 마지막 섹션에서는 터치 입력을 기반으로 애니메이션을 실행하는 방법을 알아봅니다. 이 상황에서는 몇 가지 고유한 사항을 고려해야 합니다. 첫째, 진행 중인 애니메이션을 터치 이벤트로 가로챌 수 있습니다. 둘째, 애니메이션 값이 유일한 정보 소스가 아닐 수도 있습니다. 즉, 애니메이션 값을 터치 이벤트에서 발생하는 값과 동기화해야 할 수 있습니다.

swipeToDismiss 수정자에서 TODO 6-1을 찾습니다. 여기서는 터치로 요소를 스와이프할 수 있는 수정자를 만들려고 합니다. 요소가 화면 가장자리에 플링되면 onDismissed 콜백을 호출하여 요소를 제거할 수 있습니다.

Animatable는 지금까지 확인된 최저 수준의 API입니다. 동작 시나리오에 유용한 여러 기능을 제공하므로, Animatable의 인스턴스를 만들고 이를 사용하여 스와이프할 수 있는 요소의 가로 오프셋을 나타냅니다.

val offsetX = remember { Animatable(0f) } // Add this line
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

TODO 6-2는 방금 터치 다운 이벤트를 수신했습니다. 현재 실행 중인 경우 애니메이션을 가로채야 합니다. Animatable에서 stop를 호출하면 됩니다. 애니메이션이 실행되고 있지 않으면 호출이 무시됩니다.

// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

TODO 6-3에서는 드래그 이벤트를 지속적으로 수신하고 있습니다. 터치 이벤트의 위치를 애니메이션 값에 동기화해야 합니다. 이 경우 AnimatablesnapTo를 사용할 수 있습니다.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    launch {
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)
    // Consume the gesture event, not passed to external
    change.consumePositionChange()
}

TODO 6-4는 요소가 방금 분리되었으며 플링된 위치입니다. 플링이 결정되는 최종 위치를 계산하여 요소를 원래 위치로 다시 슬라이드할지, 아니면 요소를 슬라이드하여 콜백을 호출해야 하는지 결정합니다.

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

TODO 6-5에서 애니메이션이 시작하려고 합니다. 하지만 그 전에 값과 상한을 Animatable로 설정하여 값이 도달하는 즉시 중지되도록 하려고 합니다. pointerInput 수정자를 사용하면 size 속성을 통해 요소의 크기에 액세스할 수 있습니다. 따라서 이 속성을 사용하여 경계를 가져옵니다.

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

TODO 6-6에서 마지막으로 애니메이션을 시작할 수 있습니다. 먼저 이전에 계산한 플링의 안정적 위치와 요소의 크기를 비교합니다. 안정적인 위치가 크기보다 작다면 플링의 속도가 충분하지 않은 것입니다. animateTo를 사용하여 값을 다시 0f로 애니메이션 처리할 수 있습니다. 그러지 않으면 animateDecay를 사용하여 플링 애니메이션을 시작합니다. 애니메이션이 완료되면 (일반적으로 이전에 설정한 경계로 인해) 콜백을 호출할 수 있습니다.

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

마지막으로 TODO 6-7을 참조하세요. 모든 애니메이션과 동작이 설정되어 있으므로 요소에 오프셋을 적용하는 것을 잊지 마세요.

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

이 섹션의 결과로 다음과 같은 코드가 생성됩니다.

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This `Animatable` stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the `Animatable` value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

앱을 실행하고 할 일 항목 중 하나를 스와이프해 봅니다. 요소가 기본 위치로 다시 슬라이드되거나 플링의 속도에 따라 멀어지는 것을 볼 수 있습니다. 애니메이션이 진행되는 동안에도 요소를 캡처할 수 있습니다.

7cdefce823f6b9bd.png

9. 수고하셨습니다

수고하셨습니다 기본적인 Compose 애니메이션 API를 배웠습니다.

animateContentSizeAnimatedVisibility와 같은 상위 수준 애니메이션 API를 사용하여 여러 일반적인 애니메이션 패턴을 빌드하는 방법을 알아보았습니다. 또한 단일 값을 애니메이션 처리하는 데 animate*AsState를 사용하고, 여러 값을 애니메이션 처리하는 데 updateTransition를 사용하고, 무기한 값에 애니메이션을 적용하는 데는 infiniteTransition를 사용할 수 있다는 사실도 알게 되었습니다. 또한 Animatable를 사용하여 터치 동작과 함께 맞춤 애니메이션을 빌드했습니다.

다음 단계

Compose 과정에 관한 다른 Codelab을 확인하세요.

자세한 내용은 Compose 애니메이션과 다음 참조 문서를 확인하세요.