기존 UI와 Compose 통합

뷰 기반의 UI를 사용하는 앱의 경우 전체 UI를 한 번에 재작성하지 않는 것이 좋습니다. 이 페이지에서는 새 Compose 요소를 기존 UI에 추가하는 방법을 설명합니다.

공유된 UI 이전

Compose로 점진적으로 이전하는 경우 공유된 UI 요소를 Compose와 뷰 시스템에 모두 사용해야 할 수 있습니다. 예를 들어 앱에 맞춤 CallToActionButton 구성요소가 있으면 Compose와 뷰 기반 화면에 모두 이 구성요소를 사용해야 할 수 있습니다.

Compose에서 공유된 UI 요소는 그 요소가 XML을 사용하여 스타일이 지정된 것인지 아니면 맞춤 뷰인지에 관계없이 앱 전체에서 재사용할 수 있는 컴포저블이 됩니다. 예를 들어 맞춤 클릭 유도 문구 Button 구성요소와 관련해 CallToActionButton 컴포저블을 만들 수 있습니다.

뷰 기반 화면에서 컴포저블을 사용하려면 AbstractComposeView에서 확장되는 맞춤 뷰 래퍼를 만들어야 합니다. 재정의된 Content 컴포저블의 경우 생성한 컴포저블을 아래 예에서와 같이 Compose 테마에 래핑된 상태로 둡니다.

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            backgroundColor = MaterialTheme.colors.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf<String>("")
    var onClick by mutableStateOf<() -> Unit>({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

구성 가능한 매개변수가 맞춤 뷰 내에서 변경 가능한 변수가 되는 것을 알 수 있습니다. 이렇게 하면 맞춤 CallToActionViewButton 뷰가 기존 뷰처럼 뷰 결합 등을 통해 확장 및 사용 가능하게 됩니다. 아래 예를 참고하시기 바랍니다.

class ExampleActivity : Activity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.something)
            onClick = { /* Do something */ }
        }
    }
}

맞춤 구성요소에 변경 가능한 상태가 포함된 경우 상태 정보 소스를 참고하세요.

테마 설정

Android 앱 테마를 설정하려면 Material Design에 따라 Android용 Material Design 구성요소(MDC) 라이브러리를 사용하는 것이 좋습니다. Compose 테마 설정 문서에 나와 있는 것처럼, Compose는 MaterialTheme 컴포저블을 사용하여 이러한 개념을 구현합니다.

Compose에서 새 화면을 만드는 경우 MaterialTheme를 적용한 다음, 머티리얼 구성요소 라이브러리에서 UI를 내보내는 컴포저블을 적용합니다. 머티리얼 구성요소(Button, Text 등)는 설정된 MaterialTheme에 종속되며 동작도 이 항목이 없으면 정의되지 않습니다.

모든 Jetpack Compose 샘플MaterialTheme를 기반으로 빌드된 맞춤 Compose 테마를 사용합니다.

여러 정보 소스

기존 앱에는 뷰를 위한 테마 및 스타일 설정이 상당히 많을 수 있습니다. 기존 앱에 Compose를 도입할 경우 Compose 화면에 MaterialTheme를 사용하려면 테마를 이전해야 합니다. 그러면 앱의 테마 설정은 뷰 기반 테마와 Compose 테마라는 두 가지 정보 소스를 갖습니다. 스타일 설정 변경은 여러 위치에서 이루어져야 합니다.

앱을 Compose로 완전히 이전할 계획이라면 결국에는 기존 테마의 Compose 버전을 생성해야 합니다. 문제는 개발 과정에서 Compose 테마를 일찍 생성하면 할수록 개발 중에 유지관리 작업을 더 많이 해야 한다는 점입니다.

MDC Compose 테마 어댑터

Android 앱에서 MDC 라이브러리를 사용하는 경우, MDC Compose 테마 어댑터 라이브러리를 통해 기존 뷰 기반 테마의 색상, 글꼴모양 테마를 컴포저블에 쉽게 재사용할 수 있습니다.

import com.google.android.material.composethemeadapter.MdcTheme

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Use MdcTheme instead of MaterialTheme
            // Colors, typography, and shape have been read from the
            // View-based theme used in this Activity
            MdcTheme {
                ExampleComposable(/*...*/)
            }
        }
    }
}

자세한 내용은 MDC 라이브러리 문서를 참고하세요.

AppCompat Compose 테마 어댑터

AppCompat Compose 테마 어댑터 라이브러리를 사용하면 AppCompat XML 테마를 쉽게 재사용해 Jetpack Compose에 테마를 지정할 수 있습니다. 이 라이브러리는 컨텍스트의 테마에 있는 색상글꼴 값을 사용해 MaterialTheme를 만듭니다.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            AppCompatTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

기본 구성요소 스타일

MDC와 AppCompat Compose 테마 어댑터 라이브러리는 모두 테마가 정의된 기본 위젯 스타일을 읽지 않습니다. 이는 Compose에 기본 컴포저블의 개념이 없기 때문입니다.

구성요소 스타일맞춤 디자인 시스템에 관한 자세한 내용은 테마 설정 문서를 참고하세요.

Compose에서 테마 오버레이

뷰 기반 화면을 Compose로 이전할 때 android:theme 속성을 사용하는 것에 유의하세요. Compose UI 트리의 관련 부분에 새로운 MaterialTheme가 필요할 수 있습니다.

이에 관한 자세한 내용은 테마 설정 가이드를 참고하세요.

WindowInsets 및 IME 애니메이션

Compose 1.2.0부터 수정자를 사용하여 레이아웃 내에 있는 WindowInsets를 처리할 수 있습니다. IME 애니메이션도 지원됩니다.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
              MyScreen()
            }
        }
    }
}

@Composable
fun MyScreen() {
    Box {
        LazyColumn(
            modifier = Modifier
                .fillMaxSize() // fill the entire window
                .imePadding() // padding for the bottom for the IME
                .imeNestedScroll() // scroll IME at the bottom
        )
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding() // padding for navigation bar
                .imePadding() // padding for when IME appears
            onClick = { }
        ) {
            Icon( /* ... */)
        }
    }
}

키보드 표시 공간을 위해 UI 요소를 위아래로 스크롤하는 모습을 보여주는 애니메이션

그림 2. IME 애니메이션

자세한 내용은 accompanists-insets 라이브러리 문서를 참고하세요.

프레젠테이션에서 분할 상태 우선순위 지정

기본적으로 View는 스테이트풀(Stateful)입니다. View는 표시할 내용뿐만 아니라 그 내용을 표시할 방법을 설명하는 필드를 관리합니다. View를 Compose로 변환할 때는 상태 호이스팅에서 설명한 것처럼 단방향 데이터 흐름이 되도록 렌더링되는 데이터를 분리해야 합니다.

예를 들어, View에는 뷰가 표시되는지, 표시되지 않는지, 사라졌는지를 나타내는 visibility 속성이 있습니다. 이 속성은 View의 고유한 속성입니다. 코드의 다른 부분에서 View의 공개 상태를 변경할 수도 있지만, View 자체만 현재 공개 상태를 알 수 있습니다. View가 표시되도록 하는 로직은 오류가 발생하기 쉽고 종종 View 자체에 연결됩니다.

반면, Compose를 사용하면 Kotlin의 조건부 로직을 사용하여 완전히 다른 컴포저블을 쉽게 표시할 수 있습니다.

if (showCautionIcon) {
    CautionIcon(/* ... */)
}

설계상 CautionIcon은 자신이 표시되고 있는 이유를 알 필요도 없고 관리할 필요도 없으며 자신이 컴포지션 내에 있는지 없는지를 나타내는 visibility에 관한 개념도 없습니다.

상태 관리와 프레젠테이션 로직을 명확하게 구분하여 상태를 UI로 변환하는 것처럼 콘텐츠 표시 방식을 더 자유롭게 변경할 수 있습니다. 상태 소유권이 더 유연하므로 필요한 경우 상태를 호이스팅할 수 있으면 컴포저블의 재사용 가능성이 커집니다.

구성요소의 캡슐화 및 재사용 촉진

View 요소는 종종 자신이 Activity, Dialog, Fragment 내에 있는지 또는 다른 View 계층 구조 내에 있는지 알 수 있습니다. 이러한 요소는 정적 레이아웃 파일에서 확장되는 경우가 많으므로 View의 전체 구조는 매우 견고합니다. 그 결과 결합이 더 긴밀해지고 View를 변경하거나 재사용하기가 더 어려워집니다.

예를 들어, 맞춤 View가 특정 ID를 가진 특정 유형의 하위 뷰를 갖는다고 가정하고 어떤 작업의 응답으로 속성을 직접 변경할 수 있습니다. 이는 이러한 View 요소를 함께 긴밀히 결합합니다. 맞춤 View는 하위 요소를 찾지 못하면 다운되거나 손상될 수 있고 하위 요소는 상위 맞춤 View가 없어서 재사용되지 못할 가능성이 있습니다.

재사용 가능한 컴포저블을 사용하는 Compose에서는 거의 문제가 되지 않습니다. 상위 요소는 상태와 콜백을 쉽게 지정할 수 있으므로, 재사용 가능한 컴포저블의 정확한 사용 위치를 모르더라도 이 컴포저블을 작성할 수 있습니다.

var isEnabled by rememberSaveable { mutableStateOf(false) }

Column {
    ImageWithEnabledOverlay(isEnabled)
    ControlPanelWithToggle(
        isEnabled = isEnabled,
        onEnabledChanged = { isEnabled = it }
    )
}

위의 예에서는 세 부분이 모두 더 캡슐화되어 덜 결합됩니다.

  • ImageWithEnabledOverlay는 현재 isEnabled 상태만 알면 됩니다. ControlPanelWithToggle의 존재 여부와 제어 방법은 알 필요가 없습니다.

  • ControlPanelWithToggleImageWithEnabledOverlay가 있는지 알 수 없습니다. isEnabled를 표시하는 방법은 0개, 1개 또는 그 이상 있을 수 있으며 ControlPanelWithToggle은 변경할 필요가 없습니다.

  • ImageWithEnabledOverlay 또는 ControlPanelWithToggle이 얼마나 깊이 중첩되었는지는 상위 요소에 중요하지 않습니다. 이러한 하위 요소는 변경사항을 애니메이션 처리하거나 콘텐츠를 바꾸거나 다른 하위 요소에 콘텐츠를 전달할 수 있습니다.

이 패턴을 컨트롤 반전이라고 하며, CompositionLocal 문서에서 자세히 알아볼 수 있습니다.

화면 크기 변경 처리

다양한 창 크기에 따라 다른 리소스를 사용하는 것은 반응형 View 레이아웃을 만드는 주요 방법 중 하나입니다. 정규화된 리소스는 여전히 화면 수준 레이아웃을 결정하는 옵션이지만, Compose를 사용하면 일반적인 조건부 로직으로 코드에서 레이아웃을 전체적으로 훨씬 더 쉽게 변경할 수 있습니다. BoxWithConstraints와 같은 도구를 사용하면 개별 요소에서 사용할 수 있는 공간에 따라 결정을 내릴 수 있지만, 이는 정규화된 리소스로는 불가능합니다.

@Composable
fun MyComposable() {
    BoxWithConstraints {
        if (minWidth < 480.dp) {
            /* Show grid with 4 columns */
        } else if (minWidth < 720.dp) {
            /* Show grid with 8 columns */
        } else {
            /* Show grid with 12 columns */
        }
    }
}

Compose가 제공하는 적응형 UI 빌드 기법에 관한 자세한 내용은 적응형 레이아웃 빌드를 참고하세요.

뷰를 사용한 중첩 스크롤

View 시스템과 Jetpack Compose 간의 중첩 스크롤은 아직 사용할 수 없습니다. 이 Issue Tracker 버그에서 관련 진행 상황을 확인할 수 있습니다.

RecyclerView의 Compose

Jetpack Compose는 DisposeOnDetachedFromWindow를 기본 ViewCompositionStrategy로 사용합니다. 즉, 뷰가 창에서 분리될 때마다 Composition이 삭제됩니다.

ComposeViewRecyclerView 뷰 홀더의 일부로 사용할 경우, RecyclerView가 창에서 분리될 때까지 기본 Composition 인스턴스가 메모리에 남아 있기 때문에 이 기본 전략은 비효율적입니다. ComposeView가 더 이상 RecyclerView에 필요하지 않은 경우 기본 Composition을 삭제하는 것이 좋습니다.

disposeComposition 함수를 사용하면 ComposeView의 기본 Composition을 수동으로 삭제할 수 있습니다. 다음과 같이 뷰가 재활용될 때 이 함수를 호출할 수 있습니다.

import androidx.compose.ui.platform.ComposeView

class MyComposeAdapter : RecyclerView.Adapter<MyComposeViewHolder>() {

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int,
    ): MyComposeViewHolder {
        return MyComposeViewHolder(ComposeView(parent.context))
    }

    override fun onViewRecycled(holder: MyComposeViewHolder) {
        // Dispose the underlying Composition of the ComposeView
        // when RecyclerView has recycled this ViewHolder
        holder.composeView.disposeComposition()
    }

    /* Other methods */
}

class MyComposeViewHolder(
    val composeView: ComposeView
) : RecyclerView.ViewHolder(composeView) {
    /* ... */
}

상호 운용성 API 가이드ComposeView의 ViewCompositionStrategy 섹션에 설명된 것처럼 Compose 뷰 홀더가 모든 시나리오에서 작동하도록 하려면 DisposeOnViewTreeLifecycleDestroyed 전략을 사용해야 합니다.

import androidx.compose.ui.platform.ViewCompositionStrategy

class MyComposeViewHolder(
    val composeView: ComposeView
) : RecyclerView.ViewHolder(composeView) {

    init {
        composeView.setViewCompositionStrategy(
            ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
        )
    }

    fun bind(input: String) {
        composeView.setContent {
            MdcTheme {
                Text(input)
            }
        }
    }
}

RecyclerView에 사용된 ComposeView의 작동 모습을 보려면 Sunflower 앱의 compose_recyclerview 분기를 확인하세요.