티스토리 뷰

AdvancedStateAndSideEffectsCodelab

1. 어떤 내용을 배울까? Introduction

젯팩 컴포즈의 State 와 Side Effects 에 대한 고급 개념을 실습해봤다. 로직이 사소하지 않은 stateful 한 컴포저블의 state holder 를 만드는 방법을 다룬다. 컴포즈 코드에서 코루틴과 suspend 함수를 사용하는 방법과 어떻게 다양한 유즈케이스를 위해 side effects 를 트리거하는지 알아 보았다.

미완성된 프로젝트 하나를 통해 점차적으로 개선하면서 완성하여 아래와 같은 앱을 완성하려고한다.

https://developer.android.com/static/codelabs/jetpack-compose-advanced-state-side-effects/img/b2c6b8989f4332bb.gif?authuser=2

참고로 코드랩에 기본적인 테스트 코드가 작성되어 있기 때문에, 코드랩 중간 중간 테스트를 돌려보면서 잘 돌아가나 보도록 하자.

(시간 나면 해보기) Displaying the map on the details screen

디테일 스크린에 맵을 표시하려면 API key 를 받고 설정해야하는데.. 이건 시간나면 해보자.

3. 뷰 모델에 있는 Flow 를 소비해보자! : Consuming a Flow from the ViewModel

StateFlow.collectAsState() 를 사용하면 된다. 컴포저블에서 collectAsState()StateFlow 로부터 값을 수집하고 최신 값을 Compose’s State API 를 통해 가져옵니다. 그러면 새로 방출되는 값에 의해 리컴포저블이 가능합니다

val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()

또한 컴포즈 에서는 많이 사용되는 스트림 데이터형식인 LiveData 와 rxjava 를 지원한다 (아래 참고)

  • [LiveData.observeAsState()](https://developer.android.com/reference/kotlin/androidx/compose/runtime/livedata/package-summary?authuser=2#observeAsState(androidx.lifecycle.LiveData)) included in the androidx.compose.runtime:runtime-livedata:$composeVersion artifact.
  • [Observable.subscribeAsState()](https://developer.android.com/reference/kotlin/androidx/compose/runtime/rxjava2/package-summary?authuser=2#subscribeAsState(io.reactivex.Observable,kotlin.Any)) included in the androidx.compose.runtime:runtime-rxjava2:$composeVersion or androidx.compose.runtime:runtime-rxjava3:$composeVersion artifact.

4. LaunchedEffect 와 rememberUpdatedState

코드랩에서 말하고 있는 랜딩 스크린은 우리가 익히 알고있는 스플래시 화면과 동일한듯하다.

onTimeout 콜백 으로 랜딩 스크린을 적절할 때 사라지게 해줄 예정.

컴포즈 에서는 백그라운드에서 안전하게 코루틴을 실행할 수 있게 해주는 API 를 제공한다. (백그라운드 작업으로 보통 코루틴 코틀린을 사용하도록 권장된다.)

샘플 앱 에서는 백앤드와 직접 통신은 하지 않고 delay 중단 함수를 사용해서 이를 시뮬레이션 할 것이다.

컴포즈에서 사이드 이팩트는 컴포저블 함수 스코브 밖에서 발생하는 앱의 상태변화를 말한다. (예를 들어 사용자가 버튼을 탭할 때 새 화면을 열거나 앱이 인터넷에 연결되어 있지 않을 때 메시지를 표시하는 것)

사이드 이팩트 == 컴포저블 함수 스코프 밖에서 일어나는 상태변화

랜딩 화면을 표시하거나 숨기는 일은 onTimeout 콜백에서 일어나는데, 코루틴 안에서 이를 로드 해야하기 때문에, 상태 변경은 컴포저블 함수 내부가 아닌 코루틴 내부에서 발생하게된다.

이를 위해 컴포저블 내부에서 코루틴을 안전하게 수행할 수 있는 LaunchedEffect 를 이용할 수 있다! (LaunchedEffect 는 컴포즈 스코프 안에서 사이드 이팩트 코루틴을 실행 시킬 수 있다.)

LaunchedEffect 가 컴포지션 된다면 코루틴이 전달 블록 기반으로 실행되고, LaunchedEffect 가 컴포지션을 벗어나면 코루틴은 자동으로 캔슬된다. (컴포저블 생명주기에 따라 자연스럽게 캔슬 해준다는듯하다.)

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

LaunchedEffect 같은 몇몇 사이드 이팩트 API 는 가변 개수 키를 파라미터로 받습니다. 1개 이상의 키가 변할 때 이팩트를 재실행 하게 됩니다.

컴포저블 라이프사이클 동안 단 한번만 실행 하려면 키로 상수 값을 넘기면 됩니다.

위 코드에서는 onTimeout 의 변화에 대한 방지책이 없다. 만약에 side-effect 도중에 onTimeout 이 변경된다면 마지막에 호풀된 onTimeout() 이 무사히 마쳐진다는 보장이 없다. 이를 보장하기 위해서는 rememberUpdatedState API 를 사용해야 한다.

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes, 
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}
  • rememberUpdatedState 를 사용하여 항상 최신의 onTimeout 함수를 참조할 수 있다. (만약에 컴포저블이 리컴포지션 된다고 하더라도!)
  • LaunchedEffect(true) 를 통해서 랜딩 스크린 컴포저블의 라이프사이클과 연동하여 사이드이팩트를 줄 수 있으며, 리컴포지션 또는 onTimeout 이 변경된다고 하더라도 내부에 있는 (여기서는 delay) 작업이 또 다시 실행되지 않도록 할 수 있다.

이제 랜딩 스크린을 보여주도록 만들자! : Showing the landing screen

import androidx.compose.runtime.getValue // 요거 빼먹지 말자
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue // 요거 빼먹지 말자 (오토 임포트가 잘 안됨;)

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

5. rememberCoroutineScope 로 컴포저블 밖에서 코루틴 안전하게 실행하기

scaffoldStateDrawerState 를 갖고 있으며, DrawerState 는 네비게이션 드로워를 코드수준에서 닫고 열 수 있게 해줌. 하지만 코드랩의 openDrawer 콜백으로 scaffoldState.drawerState.open() 를 시도하면 에러가 발생한다! 왜냐면 open() 이 suspend 이기 때문

세이프한 코루틴 실행 외에도 몇몇 API는 suspend 인데, 그 예중 하나로 햄버거 메뉴 열고 닫기는 애니메이션 등으로 인해 suspend 되기 적합하기 때문

그럼 어떻게 호출해야할까? 컴포저블 내부가 아니라서 LaunchedEffect 사용도 불가능하다.

컴포즈의 라이프 사이클을 따르는 코루틴 스코프에서 suspend 함수를 실행하고 싶은데, 이 경우라면 rememberCoroutineScope 를 사용하면된다. (해당 스코프는 컴포지션 범위에서 벗어나면 자동으로 캔슬된다.) (이로써 컴포즈 스코프가 아닌 곳 에서도 코루틴을 안전하게 실행할 수 있게된다.)

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

LaunchedEffect vs rememberCoroutineScope

이전에 LaunchedEffect 를 rememberCoroutineScope 로 대체해서 스플래시 뷰를 띄우도록 해도 될까? 사실 되는 것 처럼 보인다. 하지만 공식 문서에서 설명하기를 컴포저블은 언제든지 컴포지션이 일어날 수 있는데, LaunchedEffect 는 컴포지션이 일어날 때 호출되는 것을 보장할 수 있다.

때문에 rememberCoroutineScope 로 코루틴을 호출하게 되면, 컴포지션 여부와 상관 없이 컴포즈에 의해 실행이되게 된다. 그렇기 때문에 리소스가 낭비되고 통제된 환경에서 실행할 수 없게된다.

6. Creating a state holder

코드랩 샘플 앱에서 목적지 검색으로 리스트를 필터링할 수 있고, 텍스트 스타일도 바뀌는 것을 알 수 있다.

(검색 시에 텍스트 앞에 To 가 붙고, 비행기 아이콘이 활성화 되고 있다)

https://developer.android.com/static/codelabs/jetpack-compose-advanced-state-side-effects/img/dde9ef06ca4e5191.gif?authuser=2

왜 state holder 를 사용하여 상태를 호이스팅 해야하는가 Why?

로직이 내부에 다들어있기 때문에, 상태를 호이스팅하지 않음, 그렇기에 테스트가 어려워진다. 더 복잡해 질 수 있고 내부 상태는 싱크하기가 더 어려워 질 수도 있다.

내부 상태를 책임지는 state holder 를 하나 만들어서 한 곳에서 상태를 관리할 수 있게 하자. 그렇게 함으로써 상태를 동기화 하기 쉽게 할 수 있고 한클래스 안에서 관련 로직을 그룹화할 수 있다. 이런 상태는 쉽게 호이스팅 될 수 있고 (위로) 컴포저블에서 소비될 수도 있다.

다른 앱 등에서 재사용되어야 하는 로우한 UI 이기 때문에 상태를 호이스팅하는 것은 좋은 아이디어다.

더 유연하고 더 컨트롤하기 좋게 할 수 있다는 말씀

상태 홀더 정의 Creating the state holder

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

위와 같은 상태 홀더 클래스를 하나 만들자.

  • mutableStateOf 로 만듦으로써 텍스트가 변하면 리컴포지션이 일어날 수 있도록 한다.
  • var 로 지정하여 외부에서 수정할 수 있게 한다.
  • 초기 텍스트 값을 위한 initialText 를 생성시 주입받아야 한다.
  • isHint 로 힌트인지 판단하는 프로퍼티가 있다.

로직이 나중에 더 복잡해 진다고 쳤을 때, EditableUserInputState 만 수정하면 된다.

Remembering the state holder

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

상태 홀더를 컴포저블 에서 기억되게 하여, 쓸데없이 계속해서 새로 초기화 되는 것을 피해야 하고, 생성하는 부분을 컴포저블로 지정하여 보일러플레이트를 줄일 수 있다.

remember 를 사용하면 액티비티 재생성 시에는 기억할 수 없다. 이 이슈를 해결하기 위해선 rememberSaveable 을 사용해야 한다. 그러나 액티비티나 프로세스 생생성 시도 내부적으로 유지된다. 직접 번들로 저장할 필요 없이 상태를 유지해준다는 건데, EditableUserInputState 클래스의 경우에는 이를 저장하는 방법을 Saver 를 통해 알줘야 한다.

Creating a custom saver

Saver 는 어떤 객체가 어떻게 Saveable 로 변환되는지를 기술한다.

  • save 는 원본 값을 saveable 로 변환한다.
  • restore 는 saveable 을 원본 인스턴스로 변환한다.

우리의 경우 EditableUserInputState 클래스에 대해 Saver의 사용자 지정 구현을 만드는 대신 listSaver 또는 mapSaver(목록 또는 맵에 저장할 값을 저장함)와 같은 기존 Compose API 중 일부를 사용하여 코드 양을 줄일 수 있습니다. 우리가 써야 한다는 것.

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

EditableUserInputState 와 밀접하게 사용되는 static 함수 이므로 companion object 로 정의해준다.

listSaverEditableUserInputState 를 저장하고 복구하는 세이버를 구현한다.

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

커스텀 세이버 사용.

Using the state holder

위에서 정의한 state holder 를 실제 사용해보자.

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

State holder callers

기존 사용처도 수정 해준다.

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

입력이 변경될 때마다 LaunchedEffect를 사용하여 부작용을 트리거하고 onToDestinationChanged 람다를 호출

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

// 이 부분 추가됨 
    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint } // 텍스트가 hint 가 아닐 떄만
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

snapshotFlow 를 사용하여 State<T> 객체를 Flow 로 변환 하는데..

snapshotFlow 내부에서 editableUserInputState 상태가 변경 되면, Flow 는 새 값을 쏜다.

rememberUpdatedState 를 통해서 항상 최신의 onToDestinationChanged 함수를 참조 가능하고 여기에 collect 한 값을 쏴주는 것이다.

7. DisposableEffect

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

맵뷰의 기본 구현은, 라이프 사이클과 연동이 되어 있지 않다.

물론 구동은 잘 되는 것 처럼 보이지만, 라이프 사이클을 따르지 않는 다는 것이 문제다.

(pause, stop 등의 시점을 알 수 없으니, mapView 를 효율적으로 돌리기 어렵다)

여기서 MapView 는 컴포저블이 아니라 View 다.

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

맵View 와 라이프사이클을 연동하는 옵저버를 하나 정의할건데, 이걸 LocalLifecycleOwner 와 연결하면 되는데 연결만 하는게 아니라 제거 해주는 작업도 필요하다. 이때 DisposableEffect 를 사용한다.

DisposableEffect 는 사이드 이팩트인데, 키 값이 변동되거나 클린업이 필요 한 경우 즉 컴포저블이 컴포지션을 벗어나는 경우에 발생된다.

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle // 현재 생명주기 가져오기
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose { // 라이프사이클이 변경되거나, 컴포저블이 컴포지션을 떠날 때 제거된다. // 수명주기 or mapView 가 변경될 때마다 옵저버가 제거되고 올바른 수명주기에 추가된다.
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

(위 코드의 주석 설명 참고)

앱 동작에 변경이 생기지는 않으며, 생명주기를 옳바르게 연동했다는 점이 포인트다.

8. produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    // TODO Codelab: produceState step - Show loading screen while fetching city details
    val cityDetails = remember(viewModel) { viewModel.cityDetails }
    if (cityDetails is Result.Success<ExploreModel>) {
        DetailsContent(cityDetails.data, modifier.fillMaxSize())
    } else {
        onErrorLoading()
    }
}

DetailsScreen 의 경우 현재 뷰 모델의 cityDetails 를 로드하여 사용하는데, 이는 UI 스레드를 저하시킬 수 있다. 그러므로 이를 코루틴 내부로 이동하고 차라리 로딩을 보여주도록 개선해보자.

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

위 상태로 스크린의 상태를 모델링 할 수 있다. 즉, 데이터가 표시되는 상태, 로딩 상태 그리고 에러상태

UI 상태를 매핑하기 위해서 컴포즈에서는 produceState 를 사용할 수 있다.

produceState 는 컴포즈 상태가 아닌 상태를 컴포즈 상태로 만들어준다.

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ... 
}

produceState 로 상태를 생성하고, viewModel 내부 데이터 로드에 따라 변경한다.

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

상태에 따라 화면을 그려준다.

9. derivedStateOf

scroll to top 기능을 구현해보자. 구현은 간단할 수 있지만, derivedStateOf(kotlin.Function0) 이라는 처음보는 api 를 활용하는 것이 포인트.

다른 State에서 파생된 Compose State를 원할 때 derivedStateOf가 사용됩니다. 이 함수를 사용하면 연산에 사용된 상태 중 하나가 변경될 때마다 연산이 발생합니다.

유저가 스크롤을 해서 아이템 하나가 가려지는 것은 listState 를 통해서 listState.firstVisibleItemIndex > 0 으로 알 수 있다. 이때 firstVisibleItemIndexmutableStateOf 로 래핑된다. (observable 한 컴포즈 상태로 쓰일 수 있다는 것.)

이렇게 구현하지 마세요

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

대신 이렇게 구현하세요

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

derivedStateOf 를 사용하여 firstVisibleItemIndex > 0 인 경우에만 버튼을 보여지게.

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            // Box 를 사용하여 floating 버튼이 lazyList 위에 올라와 보이도록 함.
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            // rememberCoroutineScope 를 사용하여 button 의 onclick 에서 suspend fun 호출
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}
  • rememberCoroutineScope 를 사용하여 button 의 onclick 에서 suspend fun 호출
  • Box 를 사용하여 floating 버튼이 lazyList 위에 올라와 보이도록 함.

Reference

 

Jetpack Compose의 고급 상태 및 부작용  |  Android Developers

이 Codelab에서는 Jetpack Compose의 상태 및 부수 효과에 관한 고급 개념을 알아봅니다. 복잡한 스테이트풀(Stateful) 컴포저블의 상태 홀더를 만드는 방법, Compose 코드에서 코루틴을 만들고 정지 함수를

developer.android.com

2022.12.17 - [Android/Jetpack Compose] - [Jetpack Compose] 애니메이션 활용 및 기본 내용 정리

2022.12.16 - [Android/Jetpack Compose] - [Jetpack Compose] view + xml 기반에서 컴포즈로 마이그레이션

2022.12.15 - [Android/Jetpack Compose] - [Jetpack Compose] 테마 Theming 기본 정리

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함