테스트를 하던 중 Recyclerview 와 LazyColumn 을 통해서 리스트로 뿌리는 화면을 구현했을 때 동작의 차이를 발견할 수 있었는데
검색어를 입력한 후에 어느정도 스크롤을 내린 뒤, 새로운 검색어를 입력할 경우, 기존 Recyclerview 를 통한 방식의 경우 별도의 코드 추가 없이 리스트가 갱신되었을 경우, 스크롤이 최상단으로 이동하는 것을 확인할 수 있었는데, LazyColumn 은 이전의 스크롤 위치를 유지하였다.
똑같은 화면, 기능을 만드는 것이 목표이기에 Compose 쪽에 스크롤 관련 추가 작업을 해주기로 하였다.
LazyColumn 사용할 경우 이 같은 작업을 LazyListState 를 이용하여 animateScrollToItem(0) 함수 호출을 통해 구현할 수 있다.
단, Compose 에서 animateTo~ 와 같이 animate prefix 가 붙은 함수들은 suspend 키워드가 붙어있기 때문에 코루틴 스코프내에서 호출해줘야 하며, 언제 이 함수를 호출할지 그 트리거를 정해야 한다.
첫번째로 들었던 생각은 검색어가 변경되면 API 가 호출될 것이기 때문에 리스트가 갱신될 것이고 그렇다면 리스트를 LaunchedEffect 의 key 로 설정하면 가능할 것이라는 생각이 들어 다음과 같이 코드를 작성해보았다.
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun VideoScreen(
videos: LazyPagingItems<VideoItem>,
onClickSeeVideoDetail: (String) -> Unit,
) {
val listState = rememberLazyListState()
// key 값을 LazyPagingItems 로 두어 변화가 발생할 경우 block 내의 함수를 실행
LaunchedEffect(key1 = videos) {
// 리스트의 스크롤을 최상단으로 이동
listState.animateScrollToItem(0)
}
// LazyColumn 에 LazyListState 연동
LazyColumn(state = listState) {
items(
count = videos.itemCount,
key = videos.itemKey(key = { video -> video.url }),
contentType = videos.itemContentType()
) { index ->
val item = videos[index]
item?.let {
VideoCard(videoItem = it, onClick = onClickSeeVideoDetail)
}
}
}
}
위와 같이 작성한 뒤에 제대로 동작하는지 확인을 해봤지만, 스크롤은 그대로 이전의 위치를 유지하였다.
animateScrollToItem(0) 함수 자체가 문제는 아닐 것이므로, 트리거가 동작을 하지 않았다는 것을 예측할 수 있었는데, 그렇다면 videos: LazyPagingItems<VideoItem>
가 변하지 않았다는 것인가?
맞다. 변하지 않았다.
자료들을 통해 조사를 해본 결과, LaunchedEffect는 key 가 인스턴스일 경우, 그 인스턴스가 변하였는지를 판단한다. LazyPagingItems 는 mutable 한 타입이기 때문에 그 값이 변해도 동일한 인스턴스를 유지한다. 따라서 인스턴스가 변하지 않았기 때문에 트리거가 되지 않은 것이다.
만약 key 로 사용된 값이 data class일때, data class 의 필드들의 값이 변하지 않아도, 새로운 인스턴스를 생성했다면 값이 변했다고 판단하여 트리거가 작동하게 된다.
그렇다면?
immutable 타입의 인스턴스, 또는 원시 타입의 key 를 사용해야 원하는 동작을 실행시킬 수 있다.
immutable 타입의 객체는 값이 변할 수 없기 때문에, 객체의 값을 수정하게 될 경우 새로운 인스턴스가 생성된다.
String type은 immutable 하기 때문에 String 타입인 DebouncedSearchQuery 를 key 로 선정하였다.
SearchQuery 는 입력에 따라 즉시 변경되는 값이기 때문에 원하지 않는 타이밍에 스크롤의 변화가 생길 수 있기 때문이다.
변경된 코드는 다음과 같다.
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun VideoScreen(
debouncedSearchQuery: String,
videos: LazyPagingItems<VideoItem>,
onClickSeeVideoDetail: (String) -> Unit,
) {
val listState = rememberLazyListState()
// videos 는 값이 변해도 인스턴스가 동일하기 때문에 변하지 않음
// debouncedSearchQuery 를 key 로 사용해야 함
// String 은 immutable 타입이기 때문에 한번 생성된 문자열은 변경될 수 없으며, 문자열을 수정하면 새로운 문자열이 생성됨
LaunchedEffect(key1 = debouncedSearchQuery) {
listState.animateScrollToItem(0)
}
LazyColumn(state = listState) {
items(
count = videos.itemCount,
key = videos.itemKey(key = { video -> video.url }),
contentType = videos.itemContentType()
) { index ->
val item = videos[index]
item?.let {
VideoCard(videoItem = it, onClick = onClickSeeVideoDetail)
}
}
}
}
https://youtube.com/shorts/skF2baTXFwc?feature=share
(gif 파일 용량이 커서 유튜브 링크로 대체)
성공적으로 스크롤이 최상단에 위치되는 것을 확인할 수 있었다.