jetpack compose : state hoisting ☞ viewModel
Befor viewModel : 각각의 함수에서 List를 관리하고 적용함 => 전체를 동기화하고 데이터를 관리하기 불편
After ViewModel : 하나의 point에서 데이터를 관리 : single source of truth
================================================
befor viewModel
stateless 함수를 만듦(상태 hoisting) == > state 변수를 지정 ==> state에 값을 넣음
stateless 함수 : 자체적으로 내부에는 메모리에 저장 할 수 있는 state, remember 변수 가 없음
변경되는 내용에 대해서는 lambda로 처리 : (Any) -> Unit (onTextChange)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NoteInputTextField(
modifier: Modifier = Modifier,
text: String,
label: String,
maxLine: Int = 1,
onTextChange: (String) -> Unit,
onImeAction: () -> Unit = {}
) {
val keyboardController = LocalSoftwareKeyboardController.current
TextField(
value = text,
onValueChange = onTextChange,
label = { Text(text = label) },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
singleLine = true,
maxLines = maxLine,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done ),
keyboardActions = KeyboardActions(onDone = {
onImeAction()
keyboardController?.hide() }),
modifier = modifier,
)
}
statefull 함수 : 내부에 remember, state 변수를 갖음
이 변수를 이용해서 위에서 정의한 lambda를 정의 => 주로 TextFiel나 Button에서 값을 변경하는데 사용
여기에도 변경이 필요한 경우 lambda를 정의 해서 상태를 hoisting 함(onAddNote, onRemoveNote)
@Composable
fun NoteScreen(
notes: List<Note>,
onAddNote: (Note) -> Unit,
onRemoveNote: (Note) -> Unit,) {
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
Scaffold(topBar = {
TopAppBar(modifier = Modifier.padding(4.dp), backgroundColor = Color.LightGray) {
Text("Note App for viewModel exercise")
}
}) {
Column(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
NoteInputTextField(
text = title,
label = "title",
onTextChange = { title = it })
Spacer(modifier = Modifier.height(8.dp))
NoteInputTextField(
text = description,
label = "note",
onTextChange = { description = it})
NoteButton(text = "Click to Save",
onClick = {
if (title.isNotEmpty() && description.isNotEmpty()) {
// list에 저장, 추가 하기...
onAddNote(Note(title = title, description = description))
title = ""
description = ""
}
keyboardController?.hide() })
Divider()
LazyColumn() {
items(notes) { note ->
NoteRow(note = note, onNoteClicked = {
onRemoveNote(note)
})
}
}
}
}
}//if (it.all { char.isLetter() || char.isWhitespace()})
// val context= LocalContext.current
// Toast.makeText(context, "$title, $note", Toast.LENGTH_LONG).show()
@Composable
fun NoteRow(
modifier: Modifier = Modifier,
note: Note,
onNoteClicked: (Note) -> Unit,
) {
Surface(
modifier.padding(4.dp)
.clip(RoundedCornerShape(topEnd = 20.dp, bottomStart = 20.dp))
.fillMaxWidth(),
color = Color.LightGray,
elevation = 8.dp ) {
Column(
modifier
.clickable { onNoteClicked(note) }
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalAlignment = Alignment.Start ) {
Text( note.title, style = MaterialTheme.typography.subtitle2 )
Text(text = note.description, style = MaterialTheme.typography.subtitle1)
Text( text = note.entryDate //ofPattern("EEE, d MMM")
.format(DateTimeFormatter.ofPattern("u-M-d(E) H:m")),
style = MaterialTheme.typography.caption )
}
}
}
@Composable
fun NoteButton(
text: String,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
shape = RoundedCornerShape(40.dp),
modifier = Modifier
.padding(6.dp)
) {
Text(text = text, modifier = Modifier.padding(horizontal = 6.dp))
}
}
최종 데이터 입력 : 입력되는 데이터에 변경을 수행 하기 위해 위에서 hoisting된 lambda에 변경 내용을 적용
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ViewModelHoistingNoteAppTheme {
Surface(color = MaterialTheme.colors.background) {
val notes = remember { mutableStateListOf<Note>() }
// mutableListOf 로 하면 저장은 되는데, 삭제는 안됨 ? 그리고 가끔 리프레쉬 됨
NoteScreen( notes = notes,
onRemoveNote = { notes.remove(it) },
onAddNote = { notes.add(it) })
}
}
}
}
}
After ViewModel : 하나의 함수에 데이터를 제어
1. 관련 viewModel를 생성 함
class NoteViewModel: ViewModel() {
private var noteList = mutableStateListOf<Note>()
init {
noteList.addAll(NotesDataSource().loadNotes())
}
fun addNote(note: Note) = noteList.add(note)
fun removeNote(note: Note){
noteList.remove(note)
}
fun getAllNote(): List<Note> {
return noteList
}
}
2. 생성한 viewModel을 이용해서 mainActivity에서 사용한 list를 viewModel로 변경함
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ViewModelHoistingNoteAppTheme {
Surface(color = MaterialTheme.colors.background) {
val viewModel: NoteViewModel by viewModels()
NotesApp(noteViewModel = viewModel)
}
}
}
}
}
@Composable
fun NotesApp(noteViewModel: NoteViewModel = viewModel()){
val notesList = noteViewModel.getAllNote()
NoteScreen( notes = notesList,
onRemoveNote = { noteViewModel.removeNote(it) },
onAddNote = { noteViewModel.addNote(it) })
}
* 주의 할 점은 적용 시에 val viewModel: NoteViewModel by viewModels() => viewModel() 이 아님 "s"가 있음
================================================
https://developer.android.com/jetpack/compose/libraries?hl=ko
Compose 및 기타 라이브러리 | Jetpack Compose | Android Developers
Compose 및 기타 라이브러리 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Compose에서는 자주 이용하는 라이브러리를 사용할 수 있습니다. 이 섹션에서는 몇
developer.android.com
========================================================
MDN Web Docs에 따르면, 호이스팅(hoisting)이란 인터프리터가 변수와 함수의 메모리 공간을 선언 전에 미리 할당하는 것 이다.
구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태는 공통의 상위 항목에 위치해야 합니다. 이 프로세스를 상태 호이스팅이라고 합니다. 호이스팅이란 들어 올린다 또는 끌어올린다라는 의미입니다.
상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않고 버그가 발생하는 것을 방지할 수 있으며 컴포저블을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있습니다. 이에 반하여, 컴포저블의 상위 요소에서 제어할 필요가 없는 상태는 호이스팅되면 안 됩니다. 정보 소스는 상태를 생성하고 관리하는 대상에 속합니다.
예를 들어, 앱의 온보딩 화면을 만들어 보겠습니다.
다음 코드를 MainActivity.kt에 추가합니다.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
...
@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
// TODO: This state should be hoisted
var shouldShowOnboarding by remember { mutableStateOf(true) }
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = { shouldShowOnboarding = false }
) {
Text("Continue")
}
}
}
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen()
}
}
이 코드에는 여러 가지 새로운 기능이 포함되어 있습니다.
- OnboardingScreen이라는 새 컴포저블과 새 미리보기를 추가했습니다. 프로젝트를 빌드하면 동시에 여러 개의 미리보기가 있을 수 있습니다. 또한, 콘텐츠가 정확하게 정렬되는지 확인할 수 있도록 고정된 높이를 추가했습니다.
- 화면 중앙에 콘텐츠를 표시할 수 있도록 Column을 구성할 수 있습니다.
- shouldShowOnboarding은 = 대신 by 키워드를 사용하고 있습니다. 이 키워드는 매번 .value를 입력할 필요가 없도록 해주는 속성 위임입니다.
- 버튼을 클릭하면 shouldShowOnboarding이 false로 설정되지만, 아직 어디에서도 이 상태를 읽지 않습니다.
이제 앱에 이 새로운 온보딩 화면을 추가할 수 있습니다. 시작 시 이 화면을 표시하고 사용자가 'Continue'를 누르면 숨깁니다.
Compose에서는 UI 요소를 숨기지 않습니다. 대신, 컴포지션에 UI 요소를 추가하지 않으므로 Compose가 생성하는 UI 트리에 추가되지 않습니다. 간단한 조건부 Kotlin 로직을 사용하여 이 작업을 실행합니다. 예를 들어, 온보딩 화면이나 인사말 목록을 표시하려면 다음과 같이 합니다.
// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(modifier) {
if (shouldShowOnboarding) { // Where does this come from?
OnboardingScreen()
} else {
Greetings()
}
}
}
하지만, shouldShowOnboarding에 액세스할 수 없습니다. OnboardingScreen에서 만든 상태를 MyApp 컴포저블과 공유해야 합니다.
여기서는 상태 값을 상위 요소와 공유하는 대신 상태를 호이스팅합니다. 즉, 상태 값에 액세스해야 하는 공통 상위 요소로 상태 값을 이동하기만 하면 됩니다.
먼저 MyApp의 콘텐츠를 Greetings라는 새 컴포저블로 이동합니다. 대신 Greetings 메서드를 호출하도록 미리보기를 조정합니다.
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Greetings()
}
@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
private fun GreetingsPreview() {
BasicsCodelabTheme {
Greetings()
}
}
새로운 최상위 MyApp 컴포저블의 미리보기를 추가하여 동작을 테스트할 수 있습니다.
@Preview
@Composable
fun MyAppPreview() {
BasicsCodelabTheme {
MyApp(Modifier.fillMaxSize())
}
}
이제 MyApp에 다른 화면을 표시하는 로직을 추가하고 상태를 호이스팅합니다.
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(/* TODO */)
} else {
Greetings()
}
}
}
또한, shouldShowOnboarding을 온보딩 화면과 공유해야 하지만, 직접 전달하지는 않습니다. OnboardingScreen이 상태를 변경하도록 하는 대신 사용자가 Continue 버튼을 클릭했을 때 앱에 알리도록 하는 것이 더 좋습니다.
이벤트는 어떻게 전달할까요? 아래로 콜백을 전달합니다. 콜백은 다른 함수에 인수로 전달되는 함수로 이벤트가 발생하면 실행됩니다.
MyApp의 상태를 변경할 수 있도록 onContinueClicked: () -> Unit으로 정의된 온보딩 화면에 함수 매개변수를 추가해 보세요.
해결 방법:
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
@Composable
fun OnboardingScreen(
onContinueClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier
.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
이 방법은 상태가 아닌 함수를 OnboardingScreen에 전달하는 방식으로 이 컴포저블의 재사용 가능성을 높이고 다른 컴포저블이 상태를 변경하지 않도록 보호하고 있습니다. 일반적으로 이 방식은 작업을 간단하게 유지합니다. 다음은 이제 OnboardingScreen을 호출하기 위해 온보딩 미리보기를 어떻게 수정해야 하는지 보여주는 좋은 예입니다.
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
}
}
onContinueClicked를 빈 람다 표현식에 할당하는 것은 '아무 작업도 하지 않음'을 의미합니다. 이는 미리보기를 위해 완벽합니다.
아래 그림은 실제 앱에 더 가깝습니다. 수고하셨습니다.
지금까지의 전체 코드는 다음과 같습니다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
@Composable
fun OnboardingScreen(
onContinueClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen(onContinueClicked = {})
}
}
@Composable
private fun Greeting(name: String) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded.value = !expanded.value }
) {
Text(if (expanded.value) "Show less" else "Show more")
}
}
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
BasicsCodelabTheme {
Greetings()
}
}
@Preview
@Composable
fun MyAppPreview() {
BasicsCodelabTheme {
MyApp(Modifier.fillMaxSize())
}
}