컴포즈를 잘 다루기 위해서는 상태에 대해서 명확한 이해가 필요합니다. 기존에 XML로 UI를 설계했을 때는 상태에 대한 개념이 존재하지 않았지만, 컴포즈에서는 상태가 존재합니다. 상태 변경에 따라 UI가 업데이트되기 때문에 상태에 대해서 잘 이해해야만 컴포즈를 잘 다룰 수 있습니다.
컴포즈로 UI를 구현할 때는 컴포저블 함수 계층 트리를 생성합니다. 컴포저블 함수는 데이터를 받고, 해당 데이터를 이용해서 사용자 인터페이스 레이아웃 영역을 만드는 데 필요한 데이터는 주로 부모 함수에서 상태로 선언됩니다. 따라서, 부모 컴포저블의 상태 값 변화가 모든 자식 컴포저블에 반영되어, 해당 상태가 전달됩니다. 컴포즈에서는 이 동작을 재구성이라는 동작으로 실행합니다.
상태 값이 변경될 때마다 사용자 인터페이스의 전체 컴포넌트 트리를 재구성하면 사용자 인터페이스의 렌더링과 업데이트를 하는 데에 비효율적이기 때문에 지능적 재구성 기법을 사용해서 컴포즈는 해당 상태 변화에 직접 영향을 받는 함수들(해당 상태 값을 읽는 함수들)만 재구성합니다.
즉, 재구성이란 상태 값이 변경되면 해당 상태 값을 읽는 함수들을 다시 호출하고, 새로운 상태 값을 전달하는 것입니다.
상태 값을 선언할 때는 mutableState 객체로 해당 값을 감싸야 합니다.
mutableState는 observable type으로 참조되는 컴포즈 클래스입니다. 따라서, 구독이 가능하기 때문에 상태 변수를 읽는 모든 함수는 observable state를 구독합니다. 상태 값이 변경되면 구독하고 있던 모든 함수의 재구성이 트리거 됩니다.
var textState by { mutableStateOf("") }
해당 상태 값이 변경되면 구독하는 모든 함수의 재구성을 트리거 하는 observable state를 제공합니다.
하지만 위 코드에서는 중요한 한 가지가 누락되었습니다.
상태는 재구성 과정에서도 기억되어야 하므로 현재 상태 값을 유지하려면 remember 키워드를 사용해야 합니다.
(remember 키워드가 누락되게 되면 재구성될 때마다 빈 문자열로 초기화됩니다.)
@Comoposable
fun CustomTextFiled(){
var textState by remember { mutableStateOf("") }
val onTextChange = { text: String ->
textState = text
}
TextField(
value = textState,
onValueChange = onTextChange
)
}
사용자 입력에 따라 상태가 변경될 수 있도록 이벤트 핸들러를 작성해서 컴포즈 함수에 제공해야 합니다. onTextChange 함수에 의해서 사용자 입력에 따라 textState의 상태가 변경되어 TextField가 재구성되게 됩니다.
만약 onTextChange와 같은 이벤트 핸들러를 제공하지 않는다면 재구성되지 않기 때문에 TextField 내에 텍스트가 변경되지 않는 것을 볼 수 있습니다.
var textState by remember { mutableStateOf("") }
위와 같이 상태를 선언하게 되면 by 키워드를 통해서 프로퍼티를 위임하게 됩니다. 따라서 mutableState 값 프로퍼티를 직접 참조하지 않고 상태 값에 접근할 수 있습니다. 이 방식이 제일 간결하게 코드를 작성할 수 있어서 선호하는 방식입니다.
val onTextChange = { text: String ->
textState = text
}
TextField(
value = textState,
onValueChange = onTextChange
)
var textState = remember { mutableStateOf("") }
위의 형태를 사용한다면 mutableState 인스턴스의 value 프로퍼티를 읽고 설정해야 합니다. 따라서 아래와 같이 접근하여 값을 설정할 수 있습니다.
val onTextChange = { text: String ->
textState.value = text
}
TextField(
value = textValue.value,
onValueChange = onTextChange
)
var (textValue, setText) = remember { mutableStateOf("") }
mutableState 객체에 대한 접근을 값과 setter 함수를 제공할 수 있습니다.
val onTextChange = { text: String ->
setText(text)
}
TextField(
value = textValue,
onValueChange = onTextChange
)
한 컴포저블에 저장된 상태는 자식 컴포저블 함수에 의해 직접 변경돼서는 안 된다는 개념입니다.
따라서, 부모 컴포저블에서 이벤트 핸들러를 선언하고 자식 컴포즈블에 상태 값과 함께 이벤트 핸들러를 파라미터로 전달합니다. 자식 컴포저블에서는 값이 변경될 때마다 해당 이벤트 핸들러를 호출해서 현재 설정값을 전달하고 이벤트 핸들러를 통해 부모 컴포저블은 새로운 값으로 상태를 업데이트합니다.
@Composable
fun ParentComposable() {
var isSwitchOn by remember { mutableStateOf(false) }
val onSwitchChange = { value: Boolean ->
isSwitchOn = value
}
ChildComposable(
isOn = isSwitchOn,
onSwitchChange = onSwitchChange
)
}
@Composable
fun ChildComposable(isOn: Boolean, onSwitchChange: (Boolean) -> Unit) {
Switch(
checked = isOn,
onCheckedChange = onSwitchChange
)
}
이러한 방식을 따르게 되면 isSwitchOn에 할당된 값은 이벤트 핸들러를 통해서 ParentComposable 안에서만 변경되고 ChildComposable에 의해 직접 업데이트되지 않습니다.
상태 호이스팅이란 자식 컴포저블에서 이를 호출한 부모 컴포저블로 들어 올린다는 의미입니다.
앞서 단방향 흐름에서 본 코드처럼 부모 컴포저블에서 자식 컴포저블을 호출하면 이벤트 핸들러와 함께 상태가 전달됩니다. 따라서, 자식 컴포저블을 비상태 컴포저블로 만들 수 있기 때문에 재사용성이 좋아진다는 장점을 가지게 됩니다. 부모 컴포저블에서 상태를 관리하기 때문에 다른 컴포저블에게 상태를 전달할 수 있어 유연함을 높일 수 있다는 장점도 가지게 됩니다.
상태 호이스팅은 컴포저블의 직계 부모로만 제한되지 않기 때문에 컴포저블 계층 안에서는 원하는 만큼 들어 올릴 수 있고 필요한 만큼 자식 계층을 통해 전달할 수 있습니다. 여러 자식 컴포저블에서 동일한 상태 값에 접근이 필요한 경우에는 공통의 부모로 상태 호이스팅을 함으로써 구현할 수 있습니다.
상태가 있어야 하는 모든 컴포저블에서 상태를 읽을 수 있도록 LazyListState 가 대화 화면으로 호이스팅되어서 상태가 필요한 자식 컴포저블에서 접근할 수 있습니다.
화면 회전, 다크모드 전환 등에 의해서 Configuration Change가 발생하게 됩니다. 이러한 변경이 발생하게 되면 액티비티 소멸시키고 다시 생성하기 때문에 새롭게 초기화된 액티비티는 이전 상태 값을 기억하지 못하게 됩니다. 이러한 Configuration 변경 과정에서도 상태 값을 유지하려면 컴포즈가 제공하는 remembrerSaveable 키워드를 사용해야 합니다. rememberSaveable 키워드를 사용하면 재구성(Recomposition) 뿐만 아니라 Configuration 변경에서도 상태가 유지됩니다.
var isSwitchOn by rememberSaveable { mutalbeStateOf("") }
컴포즈를 사용해서 리팩토링했던 결과 느낀 점은 적절히 State Hoisting을 함을 통해 컴포저블 함수의 재사용성을 높여서 여러 UI를 구현할 때 재사용할 수 있어서 편리함을 느꼈습니다. 이전에 XML로 구현할 때는 반복되는 UI를 구현할 때는 Custom View를 구현해야 했기 때문에 많은 코드가 필요했습니다.
하지만, 컴포즈를 사용한다면 공통으로 사용할 컴포저블을 Stateless 상태로 만듦으로써 간소화된 코드로 여러 화면에서 재사용할 수 있다는 점이 큰 장점으로 느꼈습니다.