코틀린 & Java/컴포즈 Compose

컴포즈(compose) 상호 운용 API 자세히 알아보기

코딩하는후운 2024. 2. 6. 10:14
반응형

대부분의 커스텀 컴포넌트를 제공하는 서드파티 라이브러리는 뷰로 작성되었고 컴포즈를 사용하면서 이를 재사용 할 수 있어야합니다.
예를들어 이미지피커, 색상선택, QR리더 등 뷰로 작성된 컴포넌트를 컴포즈에서 임베디드 해서 사용해야합니다.
컴포즈앱에서 뷰를 사용하는 방법을 알아봅니다

또한 임베디드한 뷰와 컴포즈 간에 데이터를 공유해야하기 때문에 ViewModel을 사용해 구현하는 방법을 알아봅니다.
그리고 뷰기반 앱에서 컴포즈 계층구조를 추가하는 방법도 알아봅니다.

주요 키워드

  • AndroidView
  • AndroidViewBinding
  • ComposeView

컴포즈 앱에서 뷰 나타내기

컴포즈 앱에 이미지피커, 색상선택, QR리더와 같은 서드파티 라이브러리를 포함한다고 가정해봅니다.
컴포저블 함수에 뷰를 추가해야하는데 어떻게 동작하는지 알아봅니다.

컴포즈 앱에 커스텀 컴포넌트 추가

바코드뷰가 추가된 레이아웃

<!--layout.xml-->
<?xml version="1.0" encoding="utf-8"?>
<com.journeyapps.barcodescanner.DecoratedBarcodeView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/barcode_scanner"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_alignParentTop="true" />

바코드뷰를 보여줄 액티비티 onCreate

// ZxingDemoActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val root = layoutInflater.inflate(R.layout.layout, null)
    barcodeView = root.findViewById(R.id.barcode_scanner)
    val formats = listOf(BarcodeFormat.QR_CODE, BarcodeFormat.CODE_39)
    barcodeView.barcodeView.decoderFactory = DefaultDecoderFactory(formats)
    barcodeView.initializeFromIntent(intent)
    val callback = object : BarcodeCallback {
        override fun barcodeResult(result: BarcodeResult) {
            if (result.text == null || result.text == text.value) {
                return
            }
            text.value = result.text
        }
    }
    barcodeView.decodeContinuous(callback)
    setContent {
        val state = text.observeAsState()
        state.value?.let {
            ZxingDemo(root, it)
        }
    }
}

ComponentActivity를 상속받은 ZxingDemoActivity의 onCreate에서 컴포저블 함수 ZxingDemo에 바코드뷰를 추가하는 모습입니다.

여기서 핵심이 되는 부분은 root 입니다

val root = layoutInflater.inflate(R.layout.layout, null)
// ...
setContent {
	ZxingDemo(root, it)
}

먼저 layout.xml 이 inflate 되고 root에 view를 할당한 후 컴포저블 함수 ZxingDemo에 전달한다.

// ZxingDemoActivity.kt
@Composable
fun ZxingDemo(root: View, value: String) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.TopCenter
    ) {
        AndroidView(modifier = Modifier.fillMaxSize(),
            factory = {
                root
            })
        if (value.isNotBlank()) {
            Text(
                modifier = Modifier.padding(16.dp),
                text = value,
                color = Color.White,
                style = MaterialTheme.typography.h4
            )
        }
    }
}

컴포저블 함수 ZxingDemo는 root(view) 를 전달받고 컴포저블 함수 AndroidView 의 고차함수 factory의 result로 전달한다

// AndroidView.android.kt
@Composable
@UiComposable
fun  AndroidView(
    factory: (Context) -> T,
    modifier: Modifier = Modifier,
    update: (T) -> Unit = NoOpUpdate
) {
    val dispatcher = remember { NestedScrollDispatcher() }
    val materializedModifier = currentComposer.materialize(
        modifier.nestedScroll(NoOpScrollConnection, dispatcher)
    )
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current

    // These locals are initialized from the view tree at the AndroidComposeView hosting this
    // composition, but they need to be passed to this Android View so that the ViewTree*Owner
    // functions return the correct owners if different local values were provided by the
    // composition, e.g. by a navigation library.
    val lifecycleOwner = LocalLifecycleOwner.current
    val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current

    ComposeNode(
        factory = createAndroidViewNodeFactory(factory, dispatcher),
        update = {
            updateViewHolderParams(
                modifier = materializedModifier,
                density = density,
                lifecycleOwner = lifecycleOwner,
                savedStateRegistryOwner = savedStateRegistryOwner,
                layoutDirection = layoutDirection
            )
            set(update) { requireViewFactoryHolder().updateBlock = it }
        }
    )
}

AndroidView() 는 내부적으로 createAndroidViewNodeFactory 를 통해서 root(view) 를 갖는 AndroidViewHolder 를 생성한다 (ComposeNode 자체가 read only 라서 몰라도 될듯)

결론은 view를 inflate 하고, AndroidView() 컴포저블 함수의 factory에 넘겨주면 컴포즈 앱에 커스텀 뷰를 간단하게 추가 가능하다

 

 

AndroidViewBinding()으로 뷰 계층 구조 인플레이팅

레이아웃 인플레이팅의 문제점을 해결하기 위해 뷰바인딩을 사용하게 되었습니다.
이번에는 뷰바인딩과 컴포저블 함수를 조합하는 경우에 대해 알아봅니다.

뷰바인딩으로 추가할 레이아웃

<!--custom.xml-->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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.textview.MaterialTextView
        android:id="@+id/textView"
        android:layout_width="0dp"
        android:layout_height="64dp"
        android:background="?colorSecondary"
        android:gravity="center"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
 
    <com.google.android.material.button.MaterialButton
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="@string/view_activity"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/textView" />
 
</androidx.constraintlayout.widget.ConstraintLayout>

뷰바인딩 예제로 사용할 Activity

// ComposeActivity.kt
class ComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel: MyViewModel by viewModels()
        viewModel.setSliderValue(intent.getFloatExtra(KEY, 0F))
        setContent {
            ViewIntegrationDemo(viewModel) {
                val i = Intent(
                    this,
                    ViewActivity::class.java
                )
                i.putExtra(KEY, viewModel.sliderValue.value)
                startActivity(i)
            }
        }
    }
}

뷰모델을 소개하는것과 같은 예제라서 viewModel이 정의 되어 있지만 지금은 컴포저블 함수 ViewIntegrationDemo 만 보겠습니다

// ComposeActivity.kt
@Composable
fun ViewIntegrationDemo(viewModel: MyViewModel, onClick: () -> Unit) {
    val sliderValueState = viewModel.sliderValue.observeAsState()
    Scaffold(modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(title =
            {
                Text(text = stringResource(id = R.string.compose_activity))
            })
        }) { padding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Slider(
                modifier = Modifier.fillMaxWidth(),
                onValueChange = {
                    viewModel.setSliderValue(it)
                },
                value = sliderValueState.value ?: 0F
            )
            AndroidViewBinding(
                modifier = Modifier.fillMaxWidth(),
                factory = CustomBinding::inflate
            ) {
                textView.text = sliderValueState.value.toString()
                button.setOnClickListener {
                    onClick()
                }
            }
        }
    }
}

일반적인 컴포저블 함수 ViewIntegrationDemo() 입니다

여기서 핵심이 되는 부분은 컴포저블 함수 AndroidViewBinding() 입니다

AndroidViewBinding(
    modifier = Modifier.fillMaxWidth(),
    factory = CustomBinding::inflate
) {
		// ...
}

CustomBinding::inflate() 함수를 factory 에 넘겨줍니다

// AndroidViewBinding.kt
@Composable
fun  AndroidViewBinding(
    factory: (inflater: LayoutInflater, parent: ViewGroup, attachToParent: Boolean) -> T,
    modifier: Modifier = Modifier,
    update: T.() -> Unit = {}
) {
    val viewBindingRef = remember { Ref() }
    val localView = LocalView.current
    // Find the parent fragment, if one exists. This will let us ensure that
    // fragments inflated via a FragmentContainerView are properly nested
    // (which, in turn, allows the fragments to properly save/restore their state)
    val parentFragment = remember(localView) {
        try {
            localView.findFragment()
        } catch (e: IllegalStateException) {
            // findFragment throws if no parent fragment is found
            null
        }
    }
    val fragmentContainerViews = remember { mutableStateListOf() }
    val viewBlock: (Context) -> View = remember(localView) {
        { context ->
            // Inflated fragments are automatically nested properly when
            // using the parent fragment's LayoutInflater
            val inflater = parentFragment?.layoutInflater ?: LayoutInflater.from(context)
            val viewBinding = factory(inflater, FrameLayout(context), false)
            viewBindingRef.value = viewBinding
            // Find all FragmentContainerView instances in the newly inflated layout
            fragmentContainerViews.clear()
            val rootGroup = viewBinding.root as? ViewGroup
            if (rootGroup != null) {
                findFragmentContainerViews(rootGroup, fragmentContainerViews)
            }
            viewBinding.root
        }
    }
    AndroidView(
        factory = viewBlock,
        modifier = modifier,
        update = { viewBindingRef.value?.update() }
    )

    // Set up a DisposableEffect for each FragmentContainerView that will
    // clean up inflated fragments when the AndroidViewBinding is disposed
    val localContext = LocalContext.current
    fragmentContainerViews.fastForEach { container ->
        DisposableEffect(localContext, container) {
            // Find the right FragmentManager
            val fragmentManager = parentFragment?.childFragmentManager
                ?: (localContext as? FragmentActivity)?.supportFragmentManager
            // Now find the fragment inflated via the FragmentContainerView
            val existingFragment = fragmentManager?.findFragmentById(container.id)
            onDispose {
                if (existingFragment != null && !fragmentManager.isStateSaved) {
                    // If the state isn't saved, that means that some state change
                    // has removed this Composable from the hierarchy
                    fragmentManager.commit {
                        remove(existingFragment)
                    }
                }
            }
        }
    }
}

factory = CustomBinding::inflate() 를 넘겨받은 AndroidViewBinding 은 결국 inflater를 찾고, view를 infalte 한 후 이전에 설명한 AndroidView 를 사용합니다

결론은 AndroidViewBinding 함수에 Binding.inflate() 를 넘겨주면 간단하게 커스텀뷰를 추가할 수 있습니다.

 

 

뷰와 컴포저블 함수 간 데이터 공유

컴포저블함수와 뷰모델

// MyViewModel.kt
class MyViewModel : ViewModel() {

    private val _sliderValue: MutableLiveData =
        MutableLiveData(0.1F)

    val sliderValue: LiveData
        get() = _sliderValue

    fun setSliderValue(value: Float) {
        _sliderValue.value = value
    }
}

뷰모델 예제로 사용할 ComponentActivity

// ComposeActivity.kt
class ComposeActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel: MyViewModel by viewModels()
        viewModel.setSliderValue(intent.getFloatExtra(KEY, 0F))
        setContent {
            ViewIntegrationDemo(viewModel) {
                val i = Intent(
                    this,
                    ViewActivity::class.java
                )
                i.putExtra(KEY, viewModel.sliderValue.value)
                startActivity(i)
            }
        }
    }
}

@Composable
fun ViewIntegrationDemo(viewModel: MyViewModel, onClick: () -> Unit) {
    val sliderValueState = viewModel.sliderValue.observeAsState()
    Scaffold(modifier = Modifier.fillMaxSize(),
        topBar = {
            TopAppBar(title =
            {
                Text(text = stringResource(id = R.string.compose_activity))
            })
        }) { padding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Slider(
                modifier = Modifier.fillMaxWidth(),
                onValueChange = {
                    viewModel.setSliderValue(it)
                },
                value = sliderValueState.value ?: 0F
            )
            AndroidViewBinding(
                modifier = Modifier.fillMaxWidth(),
                factory = CustomBinding::inflate
            ) {
                textView.text = sliderValueState.value.toString()
                button.setOnClickListener {
                    onClick()
                }
            }
        }
    }
}

코드가 길지만 여기서 핵심이 될 부분은

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val viewModel: MyViewModel by viewModels()
    setContent {
        ViewIntegrationDemo(viewModel) {
                        // ...
        }
    }
}

뷰모델을 생성 하고 ViewIntegrationDemo() 에 넘겨줍니다

@Composable
fun ViewIntegrationDemo(viewModel: MyViewModel, onClick: () -> Unit) {
    val sliderValueState = viewModel.sliderValue.observeAsState()
    Scaffold() {
				// ...
        Slider(
            modifier = Modifier.fillMaxWidth(),
            onValueChange = {
                viewModel.setSliderValue(it)
            },
            value = sliderValueState.value ?: 0F
        )
    }
}

컴포저블 내에서 viewModel의 liveData인 sliderValue 를 observeAsState를 통해서 State로 변환하고 컴포저블 내부에서 관리합니다

그리고 onValueChange 에서 set을 하고 변경사항이

value = sliderValueState.value ?: 0F

를 통해서 뷰에 적용됩니다

 

뷰바인딩과 뷰모델

// ViewActivity.kt
class ViewActivity : AppCompatActivity() {

    private lateinit var binding: LayoutBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewModel: MyViewModel by viewModels()
        viewModel.setSliderValue(intent.getFloatExtra(KEY, 0F))
        binding = LayoutBinding.inflate(layoutInflater)
        setContentView(binding.root)
        viewModel.sliderValue.observe(this) {
            binding.slider.value = it
        }
        binding.slider.addOnChangeListener { _, value, _ -> viewModel.setSliderValue(value) }
        binding.composeView.run {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
            setContent {
                val sliderValue = viewModel.sliderValue.observeAsState()
                sliderValue.value?.let {
                    ComposeDemo(it) {
                        val i = Intent(
                            context,
                            ComposeActivity::class.java
                        )
                        i.putExtra(KEY, it)
                        startActivity(i)
                    }
                }
            }
        }
    }
}

@Composable
fun ComposeDemo(value: Float, onClick: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .background(MaterialTheme.colors.secondary)
                .height(64.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = value.toString()
            )
        }
        Button(
            onClick = onClick,
            modifier = Modifier.padding(top = 16.dp)
        ) {
            Text(text = stringResource(id = R.string.compose_activity))
        }
    }
}

코드가 길지만 여기서 핵심이 될 부분은

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val viewModel: MyViewModel by viewModels()
    binding = LayoutBinding.inflate(layoutInflater)
    setContentView(binding.root)
    viewModel.sliderValue.observe(this) {
        binding.slider.value = it
    }
    binding.slider.addOnChangeListener { _, value, _ -> viewModel.setSliderValue(value) }
    binding.composeView.run {
        setContent {
            val sliderValue = viewModel.sliderValue.observeAsState()
            sliderValue.value?.let {
                ComposeDemo(it) {
                                        //...
                }
            }
        }
    }
}

뷰모델을 생성하고 liveData를 observe 해서 직접 binding에 접근해 slider값을 변경해줍니다.

binding의 slider에 리스너를 등록해 변경되는 값을 뷰모델에 set해줍니다

다음에 알아볼 composeView 가 나왔는데 컴포저블 함수로 이해하고 설명하자면

viewModel의 liveData인 sliderValue 를 observeAsState를 통해서 State로 변환하고 컴포저블 내부에서 관리합니다.

slider의 change listener를 통해 값이 변경되면 state가 변해 ComposeDemo를 다시 그리게 됩니다.

 

뷰 계층 구조에 컴포저블 임베디드

지금까지 AndroidView, AndroidViewBinding을 통해서 컴포즈 앱에 기존 뷰 계층을 임베디드 하는 방법을 알아봤는데, 반대로 기존의 뷰 계층 구조를 변경하지 않고 새로 추가하는 컴포넌트를 컴포저블 함수로 만들고 싶을수도 있습니다.

// layout.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="16dp"
    tools:context=".ViewActivity">
 
    <com.google.android.material.slider.Slider
        android:id="@+id/slider"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
 
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/slider" />
 
</androidx.constraintlayout.widget.ConstraintLayout>

androidx.compose.ui.platform.ComposeView 는 뷰 계층구조(xml)에서 컴포저블을 사용할 수 있게 만들어준다

ComposeView의 내부구현을 살펴보면 AbstractComposeView 를 상속받고 결국 ViewGroup 을 상속 받는다.

binding.composeView.run {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
    setContent {
        val sliderValue = viewModel.sliderValue.observeAsState()
        sliderValue.value?.let {
            ComposeDemo(it) {
                val i = Intent(
                    context,
                    ComposeActivity::class.java
                )
                i.putExtra(KEY, it)
                startActivity(i)
            }
        }
    }
}

xml 에 ComposeView를 추가한 후 Activity에서 setViewCompositionStrategy 컴포지션 전략을 지정해줘야한다. 예제처럼 간단한 경우 default 값인 ViewCompositionStrategy.DisposeOnDetachedFromWindow 를 사용한다. (developer 에서는 DisposeOnDetachedFromWindowOrReleasedFromPool가 디폴드라는데…? 업데이트 됐나봐요)

프래그먼트나 lifeCycleOwner에서 사용한다면 DisposeOnLifecycleDestroyed, DisposeOnViewTreeLifecycleDestroyed 와 같은것들을 사용한다

컴포지션 전략 지정해준 후 setContent로 기존 처럼 콘텐츠를 지정해주면 된다

 

 

참조 : 젯팩 컴포즈로 개발하는 안드로이드 UI, 사내 스터디

반응형