Compose 상호 운용성

Jetpack Compose는 기존의 뷰 기반 UI 접근 방식과 함께 작동하도록 설계되었습니다. 새 앱을 빌드하는 경우 가장 좋은 옵션은 Compose를 사용하여 UI 전체를 구현하는 것일 수 있습니다. 하지만 기존 앱을 수정하는 경우에는 앱을 한꺼번에 전부 이전하고 싶지 않을 수 있습니다. 이 경우에는 대신 기존의 UI 디자인 구현에 Compose를 결합할 수 있습니다.

앱에 Compose 채택

Compose를 뷰 기반 UI와 통합할 수 있는 두 가지 주요 방법은 다음과 같습니다.

  • 완전히 새로운 Compose 기반 화면을 생성하거나 기존 활동, 프래그먼트 또는 뷰 레이아웃에 Compose 요소를 추가하여 기존 UI에 Compose 요소를 추가할 수 있습니다.

  • 구성 가능한 함수에 뷰 기반 UI 요소를 추가할 수 있습니다. 이렇게 하면 Compose 기반 디자인에 Android 뷰를 추가할 수 있습니다.

전체 앱을 Compose로 이전하는 작업은 프로젝트에 필요한 세부사항을 통해 단계별로 진행하는 것이 가장 좋습니다. 한 번에 한 화면씩 이전하거나, 프래그먼트 또는 다른 재사용 가능한 UI 요소를 한 번에 하나씩 이전할 수도 있습니다. 다음과 같이 여러 가지 접근 방법을 사용할 수 있습니다.

  • 상향식 접근 방법에서는 모든 요소가 구성 가능한 함수로 변환될 때까지 Button 또는 TextView 같은 화면의 작은 UI 요소부터 이전한 다음 ViewGroup 요소를 이전합니다.

  • 하향식 접근 방법에서는 FrameLayout, ConstraintLayout 또는 RecyclerView 같은 프래그먼트 또는 뷰 컨테이너부터 이전한 다음 화면의 작은 UI 요소를 이전합니다.

이러한 접근 방법에서는 각 화면이 내장되어 있다고 가정하지만, 디자인 시스템과 같은 공유된 UI도 Jetpack Compose로 이전할 수 있습니다. 자세한 내용은 아래에서 공유된 UI 이전을 참고하세요.

상호 운용성 API

앱에 Compose를 채택하는 동안 Compose와 뷰 기반 UI를 결합할 수 있습니다. 다음에는 Compose로의 전환을 보다 쉽게 할 수 있는 API, 권장사항 및 팁이 나와 있습니다.

Android 뷰의 Compose

뷰 기반 디자인을 사용하는 기존 앱에 Compose 기반 UI를 추가할 수 있습니다.

전적으로 Compose에 기반하는 새로운 화면을 생성하려면 활동에서 setContent() 메서드를 호출하고 원하는 구성 가능한 함수를 전달하도록 합니다.

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

        setContent { // In here, we can call composables!
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

이 코드는 Compose 전용 앱에서 찾을 수 있는 코드와 거의 비슷해 보입니다.

Compose UI 콘텐츠를 프래그먼트 또는 기존 뷰 레이아웃에 통합하려면 ComposeView를 사용하고 setContent() 메서드를 호출합니다. ComposeView는 Android View입니다. ComposeViewViewTreeLifecycleOwner에 연결해야 합니다. ViewTreeLifecycleOwner를 사용하면 구성을 유지하면서 뷰를 반복적으로 연결하고 분리할 수 있습니다. ComponentActivity, FragmentActivityAppCompatActivity는 모두 ViewTreeLifecycleOwner를 구현하는 클래스의 예입니다.

다음과 같이 다른 View와 마찬가지로 ComposeView를 XML 레이아웃에 배치할 수 있습니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/hello_world"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello Android!" />

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

</LinearLayout>

Kotlin 소스 코드에서 XML로 정의된 레이아웃 리소스의 레이아웃을 확장합니다. 그런 다음, XML ID를 사용하여 ComposeView를 가져오고 setContent()를 호출하여 Compose를 사용합니다.

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout for this fragment
        return inflater.inflate(
            R.layout.fragment_example, container, false
        ).apply {
            findViewById<ComposeView>(R.id.compose_view).setContent {
                // In Compose world
                MaterialTheme {
                    Text("Hello Compose!")
                }
            }
        }
    }
}

위, 아래로 배치되어 있으며 서로 약간 다른 두 텍스트 요소

그림 1. 뷰 UI 계층 구조에서 Compose 요소를 추가하는 코드의 출력을 보여줍니다. 'Hello Android!' 텍스트는 TextView 위젯에 의해 표시됩니다. 'Hello Compose!' 텍스트는 Compose 텍스트 요소에 의해 표시됩니다.

또한 전체 화면이 Compose를 사용하여 빌드된 경우 프래그먼트에 ComposeView를 직접 포함할 수도 있습니다. 이렇게 하면 전적으로 XML 레이아웃 파일을 사용하지 않아도 됩니다.

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                MaterialTheme {
                    // In Compose world
                    Text("Hello Compose!")
                }
            }
        }
    }
}

동일한 레이아웃에 ComposeView 요소가 여러 개 있다면 savedInstanceState가 작동하기 위해서는 각 요소에 고유한 ID가 있어야 합니다. 이에 관한 자세한 내용은 SavedInstanceState 섹션을 참조하세요.

class ExampleFragment : Fragment() {

  override fun onCreateView(...): View = LinearLayout(...).apply {
      addView(ComposeView(...).apply {
        id = R.id.compose_view_x
        ...
      })
      addView(TextView(...))
      addView(ComposeView(...).apply {
        id = R.id.compose_view_y
        ...
      })
    }
  }
}

다음과 같이 ComposeView ID는 res/values/ids.xml 파일에 정의되어 있습니다.

<resources>
    <item name="compose_view_x" type="id" />
    <item name="compose_view_y" type="id" />
</resources>

Compose의 Android 뷰

Compose UI에 Android 뷰 계층 구조를 포함할 수 있습니다. 이 접근 방식은 AdView 또는 MapView와 같이 Compose에서 아직 사용할 수 없는 UI 요소를 사용하려는 경우에 특히 유용합니다. 이 접근 방식을 사용하면 직접 디자인한 맞춤 뷰를 재사용할 수도 있습니다.

뷰 요소 또는 계층 구조를 포함하려면 AndroidView 컴포저블을 사용해야 합니다. AndroidView에는 View를 반환하는 람다가 전달됩니다. 또한 AndroidView는 뷰가 확장될 때 호출되는 update 콜백도 제공합니다. AndroidView는 콜백 내에서 읽은 State가 변경될 때마다 재구성됩니다.

@Composable
fun CustomView() {
    val selectedItem = remember { mutableStateOf(0) }

    // Adds view to Compose
    AndroidView(
        modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
        factory = { context ->
            // Creates custom view
            CustomView(context).apply {
                // Sets up listeners for View -> Compose communication
                myView.setOnClickListener {
                    selectedItem.value = 1
                }
            }
        },
        update = { view ->
            // View's been inflated or state read in this block has been updated
            // Add logic here if necessary

            // As selectedItem is read here, AndroidView will recompose
            // whenever the state changes
            // Example of Compose -> View communication
            view.coordinator.selectedItem = selectedItem.value
        }
    )
}

@Composable
fun ContentExample() {
    Column(Modifier.fillMaxSize()) {
        Text("Look at this CustomView!")
        CustomView()
    }
}

XML 레이아웃을 삽입하려면 androidx.compose.ui:ui-viewbinding 라이브러리에서 제공하는 AndroidViewBinding API를 사용합니다. 그렇게 하려면 프로젝트에서 뷰 결합을 사용 설정해야 합니다.

여타 여러 내장 컴포저블과 마찬가지로 AndroidView는 상위 컴포저블에서 위치를 설정하는 등의 목적으로 사용할 수 있는 Modifier 매개변수를 취합니다.

@Composable
fun AndroidViewBindingExample() {
    AndroidViewBinding(ExampleLayoutBinding::inflate) {
        exampleView.setBackgroundColor(Color.GRAY)
    }
}

Compose에서 Android 프레임워크 호출

Compose는 Android 프레임워크 클래스와 밀접하게 연결되어 있습니다. 예를 들어 Compose는 Activity 또는 Fragment 같은 Android 뷰 클래스에서 호스팅되며, Context, 시스템 리소스, Service 또는 BroadcastReceiver 같은 Android 프레임워크 클래스를 사용해야 할 수 있습니다.

시스템 리소스에 관해 자세히 알아보려면 Compose의 리소스 문서를 참고하세요.

컴포지션 로컬

CompositionLocal 클래스를 사용하면 구성 가능한 함수를 통해 암시적으로 데이터를 전달할 수 있습니다. 일반적으로 이 클래스는 UI 트리의 특정 노드의 값과 함께 제공됩니다. 이 값은 구성 가능한 함수에서 CompositionLocal을 매개변수로 선언하지 않아도, 구성 가능한 하위 요소에 사용할 수 있습니다.

CompositionLocal은 Compose에서 Context, Configuration 또는 View 같은 Android 프레임워크 유형의 값을 전파하는 데 사용됩니다. 이러한 프레임워크에서 Compose 코드가 해당 LocalContext, LocalConfiguration 또는 LocalView와 함께 호스팅됩니다. IDE에서 자동 완성 기능을 사용할 때 검색 가능성을 높이기 위해 CompositionLocal 클래스에 Local 접두사가 지정됩니다.

current 속성을 사용하여 CompositionLocal의 현재 값에 액세스합니다. 예를 들어 아래 코드는 LocalContext.current를 호출하여 Compose UI 트리의 관련 부분에서 사용 가능한 Context를 사용하여 맞춤 뷰를 생성합니다.

@Composable
fun rememberCustomView(): CustomView {
    val context = LocalContext.current
    return remember { CustomView(context).apply { /*...*/ } }
}

더욱 완전한 예는 이 문서 끝에 있는 우수사례: BroadcastReceivers 섹션을 참고하세요.

기타 상호작용

필요한 상호작용에 관해 정의된 유틸리티가 없다면 일반적인 Compose 가이드라인인 데이터는 아래로 흐르고 이벤트는 위로 흐름을 따르는 것이 좋습니다(Compose 이해에서 더 자세히 논의). 예를 들어 다음 컴포저블은 서로 다른 활동을 시작합니다.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // get data from savedInstanceState
        setContent {
            MaterialTheme {
                ExampleComposable(data, onButtonClick = {
                    startActivity(/*...*/)
                })
            }
        }
    }
}

@Composable
fun ExampleComposable(data: DataExample, onButtonClick: () -> Unit) {
    Button(onClick = onButtonClick) {
        Text(data.title)
    }
}

일반 라이브러리와 통합

Compose가 ViewModel, Flow, Paging 또는 Hilt 같은 일반 라이브러리와 어떻게 통합되는지 알아보려면 Compose와 일반 라이브러리와의 통합 가이드를 참고하세요.

테마 설정

Android 앱 테마를 설정하려면 머티리얼 디자인에 따라 Android용 머티리얼 디자인 구성요소(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 {
            MdcTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

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

AppCompat Compose 테마 어댑터

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

import com.google.accompanist.appcompattheme.AppCompatTheme

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 애니메이션

WindowInsetsaccompanist-insets 라이브러리를 사용하여 처리할 수 있습니다. 이 라이브러리는 레이아웃 내에서 이를 처리할 수 있는 컴포저블과 수정자를 제공하고 IME 애니메이션을 지원합니다.

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

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
                ProvideWindowInsets {
                    MyScreen()
                }
            }
        }
    }
}

@Composable
fun MyScreen() {
    Box {
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding(), // Move it out from under the nav bar
            onClick = { }
        ) {
            Icon( /* ... */)
        }
    }
}

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

그림 2. accompanist-insets 라이브러리를 사용하는 IME 애니메이션.

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

화면 크기 변경 처리

화면 크기에 따라 서로 다른 XML 레이아웃을 사용하는 앱을 이전하는 경우 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 */
        }
    }
}

아키텍처 및 상태 정보 소스

UDF(단방향 데이터 흐름) 아키텍처 패턴은 Compose에서 원활하게 작동합니다. 이 대신 앱에 MVP(Model View Presenter) 같은 다른 유형의 아키텍처 패턴을 사용하는 경우에는 Compose 채택 전이나 채택 과정에서 UI의 관련 부분을 UDF로 이전하는 것이 좋습니다.

Compose에서의 ViewModel

아키텍처 구성요소 ViewModel 라이브러리를 사용하면 viewModel() 함수를 호출하여 모든 컴포저블에서 ViewModel에 액세스할 수 있습니다. 이에 관한 설명은 Compose와 일반 라이브러리와의 통합 문서에 나와 있습니다.

Compose를 채택할 때 서로 다른 컴포저블에 동일한 ViewModel 유형을 사용하는 것에 주의해야 합니다. ViewModel 요소가 뷰 수명 주기 범위를 따르기 때문입니다. 이 범위는 호스트 활동, 프래그먼트 또는 탐색 그래프(탐색 라이브러리가 사용되는 경우)가 됩니다.

예를 들어 활동에서 컴포저블이 호스팅되면 viewModel()은 항상 동일한 인스턴스를 반환하고 이 인스턴스는 활동이 끝날 때만 지워집니다. 다음 예에서는 동일한 GreetingViewModel 인스턴스가 호스트 활동 아래의 모든 컴포저블에 재사용되기 때문에 동일한 사용자에게 인사말이 두 번 표시됩니다. 생성된 첫 번째 ViewModel 인스턴스가 다른 컴포저블에 재사용됩니다.

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

        setContent {
            MaterialTheme {
                Column {
                    Greeting("user1")
                    Greeting("user2")
                }
            }
        }
    }
}

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

또한 탐색 그래프에 따라 ViewModel 요소의 범위가 지정되므로 탐색 그래프에서 대상에 해당하는 컴포저블은 ViewModel의 다른 인스턴스를 갖습니다. 이 경우 ViewModel은 관련 대상의 수명 주기로 범위가 국한되고 대상이 백스택에서 삭제되면 지워집니다. 다음 예에서는 사용자가 프로필 화면으로 이동하면 GreetingViewModel의 새 인스턴스가 생성됩니다.

@Composable
fun MyScreen() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            Greeting(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

상태 정보 소스

Compose를 UI의 특정 부분에 채택할 경우 Compose와 뷰 시스템 코드 간에 데이터를 공유해야 할 수 있습니다. 가능한 경우 두 플랫폼 모두에 적용된 UDF 권장사항을 따르는 또 다른 클래스에 공유 상태를 캡슐화하는 것이 좋습니다. 예를 들면 공유된 데이터의 스트림을 노출해 데이터 업데이트를 내보내는 ViewModel에 캡슐화하는 것이 좋습니다.

하지만 이 방법은 공유할 데이터가 변경 가능한 요소이거나 UI 요소와 긴밀히 연결된 경우에는 가능하지 않을 수도 있습니다. 이 경우 어느 한 시스템이 정보 소스여야 하고, 그 시스템이 데이터 업데이트를 다른 시스템과 공유해야 합니다. 일반적으로 정보 소스는 UI 계층 구조의 루트에 더 가까운 요소가 소유해야 합니다.

정보 소스로서의 Compose

SideEffect 컴포저블을 사용하여 Compose 상태를 비 Compose 코드에 게시할 수 있습니다. 이 경우 정보 소스는 상태 업데이트를 전송하는 컴포저블에 보관됩니다.

예를 들어 OnBackPressedDispatcher에서 뒤로 버튼을 누르는 동작을 수신 대기하려면 OnBackPressedCallback을 등록해야 합니다. 콜백을 사용해야 하는지 전달하려면 SideEffect를 사용하여 값을 업데이트합니다.

@Composable
fun BackHandler(
    enabled: Boolean,
    backDispatcher: OnBackPressedDispatcher,
    onBack: () -> Unit
) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

부수 효과에 관한 자세한 내용은 수명 주기 및 부수 효과 문서를 참고하세요.

정보 소스로의 뷰 시스템

뷰 시스템이 상태를 소유하고 이를 Compose와 공유하는 경우 mutableStateOf 객체에 상태를 래핑하여 Compose에서 상태가 스레드로부터 안전하도록 만들어야 합니다. 이 접근 방법을 사용하는 경우 구성 가능한 함수는 더 이상 정보 소스를 갖지 않기 때문에 단순해집니다. 하지만 뷰 시스템이 변경 가능한 상태와, 그 상태를 사용하는 뷰를 업데이트해야 합니다.

다음 예에서 CustomViewGroup은 내부에 TextField 컴포저블이 있는 TextViewComposeView를 포함하고 있습니다. TextView는 사용자가 TextField에 입력하는 내용을 표시해야 합니다.

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

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

공유된 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 */ }
        }
    }
}

맞춤 구성요소에 변경 가능한 상태가 포함되어 있으면 위의 상태 정보 소스 섹션을 확인하세요.

테스트

createAndroidComposeRule() API를 사용하여 결합된 뷰와 Compose 코드를 함께 테스트할 수 있습니다. 자세한 내용은 Compose 레이아웃 테스트를 참고하세요.

우수사례: BroadcastReceivers

Compose에 이전하거나 구현하고자 하는 기능과 관련해 보다 실질적인 예를 보여주고 CompositionLocal부수 효과를 보여주기 위해 구성 가능한 함수에서 BroadcastReceiver를 등록해야 한다고 가정하겠습니다.

이 방법에서는 LocalContext를 이용해 현재 컨텍스트와 rememberUpdatedStateDisposableEffect 부수 효과를 사용합니다.

@Composable
fun SystemBroadcastReceiver(
    systemAction: String,
    onSystemEvent: (intent: Intent?) -> Unit
) {
    // Grab the current context in this part of the UI tree
    val context = LocalContext.current

    // Safely use the latest onSystemEvent lambda passed to the function
    val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)

    // If either context or systemAction changes, unregister and register again
    DisposableEffect(context, systemAction) {
        val intentFilter = IntentFilter(systemAction)
        val broadcast = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                onSystemEvent(intent)
            }
        }

        context.registerReceiver(broadcast, intentFilter)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            context.unregisterReceiver(broadcast)
        }
    }
}

@Composable
fun HomeScreen() {

    SystemBroadcastReceiver(Intent.ACTION_BATTERY_CHANGED) { batteryStatus ->
        val isCharging = /* Get from batteryStatus ... */ true
        /* Do something if the device is charging */
    }

    /* Rest of the HomeScreen */
}