본문 바로가기
♞ | 공부일지/♝ | TIL

[Android, 내일배움캠프] 공부일지(2024-08-02)

by immgga 2024. 8. 2.
오늘 공부한 내용 정리(2024년 8월 2일)

 

꾸준히 정진해 하늘로 계속 올라가는 열기구처럼 삶의 목표를 위해 나아가는 사람이 되자.

 

알고리즘 문제풀이

착신 전환 소동(Silver 3, 31409번, 마라톤)

https://rkdrkd-history.tistory.com/173

 

 

앱 개발 심화 개인 과제

무한 스크롤 구현

무한 스크롤 구현

무한 스크롤을 적용하기 위해서는 리스트에 마지막 데이터가 보일 때를 감지해서 새로운 페이지의 API를 재호출 해주어야 한다.

기존에는 API의 1페이지만 구현되었지만, 이제는 그 이후의 페이지들까지 같이 보이게 된다.

 

먼저 최하단 스크롤에 도달했을 때를 정의할 state를 생성한다.

val reachedBottom: Boolean by remember {
    derivedStateOf {
        val lastVisibleItem = state.layoutInfo.visibleItemsInfo.lastOrNull()
        lastVisibleItem?.index != 0 && lastVisibleItem?.index == state.layoutInfo.totalItemsCount - 1
    }
}

이 state는 스크롤이 최하단에 도달하면 true, 그렇지 않으면 false를 반환한다.

 

이를 이용해 reachedBottom이 변경될 때마다 호출되는 LaunchedEffect를 추가해 준다.

LaunchedEffect(reachedBottom) {
    if (reachedBottom) {
        loadMore()
    }
}

LaunchedEffect는 key값이 변경되었을 때 호출된다. reachedBottom이 true일 때, 다음 페이지 API를 불러온다.

 

간단한 이슈사항

state를 적용하다가 위 코드들을 다 적용했는데, LaunchedEffect가 호출되지 않는 문제가 있었다.

이 문제가 발생한 원인은 자신이 사용하는 LazyList의 state 파라미터에 아무것도 넣어주지 않아서 state가 먹히지 않았던 것이다.

LazyVerticalStaggeredGrid(
    columns = columns,
    modifier = modifier,
    state = state,
    contentPadding = contentPadding,
    verticalItemSpacing = verticalItemSpacing,
    horizontalArrangement = horizontalArrangement
)

내가 사용하는 LazyList에 state 파라미터에 생성한 LazyState를 달아 준다. state는 LazyVerticalStaggeredGrid의 경우에는 아래와 같이 생성할 수 있다.

val gridState = rememberLazyStaggeredGridState()

 

 

맨 위로 이동하는 버튼 생성

무한 스크롤을 구현했으니, 맨 위로 자동으로 이동할 수 있도록 해주는 버튼을 만들어 주자.

기존에 무한 스크롤로 계속 스크롤을 하다 보면 스크롤을 너무 많이 해서 맨 위를 보려면 한참 스크롤해야 하는 문제가 생기는데 이를 해결해 주기 위해 맨 위로 이동시켜 주는 버튼을 생성할 것이다.

이미 상단 스크롤이면 버튼이 띄워져 있을 이유가 없으니 없어지고, 스크롤을 하면 버튼이 생기는 방식으로 구현하겠다.

먼저 상단에 스크롤이 있는지 감지할 state를 생성해 준다.

val reachedFirst: Boolean by remember {
    derivedStateOf { gridState.firstVisibleItemIndex != 0 }
}
val coroutineScope = rememberCoroutineScope()

compose에서 coroutineScope는 아래의 코드와 같이 생성한다.

 

화면에서 보이고 없어지는 것을 자연스럽게 하기 위해 애니메이션을 넣어 줄 것이다.

composable에 animation을 넣어주기 위해서는 AnimatedVisibility를 사용한다.

AnimatedVisibility(
    visible = reachedFirst,
    modifier = Modifier
        .align(Alignment.BottomEnd)
        .padding(bottom = 80.dp, end = 24.dp),
    enter = fadeIn(),
    exit = fadeOut()
)

간단하게 enter에는 fadeIn 애니메이션을, exit에서는 fadeOut 애니메이션을 적용해 주었다.

 

버튼은 FloatingActionButton을 사용했다.

FAB도 big, small 버전이 있었다. big는 너무 커서 small을 사용했다.

SmallFloatingActionButton(
    onClick = {
        coroutineScope.launch {
            gridState.animateScrollToItem(0)
        }
    },
    containerColor = Color.Cyan,
    shape = CircleShape
) {
    Icon(
        imageVector = Icons.Default.KeyboardArrowUp,
        contentDescription = "상단으로 스크롤",
        tint = Color.White
    )
}

버튼의 onClick에 scope를 열어서 안에서 상단(0번째 아이템)으로 애니메이션 스크롤을 해준다.

 

 

저장소 구현하기

마지막으로 저장소 기능을 구현해 주겠다.

저장소에는 검색 후 나온 결과 리스트를 클릭하면 저장소에 추가된다.

저장소 데이터는 앱을 완전히 종료했다가 꺼도 사라지지 않아야 한다.

이를 구현하기 위해 SharedPreferences를 사용해야 한다.

먼저 기능 함수를 ViewModel에 따로 파서 만들어 주겠다.

기능 함수는 불러오기, 추가, 삭제를 만들었지만 분량 관계상 불러오기 함수만 보여 주겠다. 다른 함수는 하단에 코드 링크를 달아줄 테니 거기로 들어가서 봐보자.

class LockerViewModel: ViewModel() {
    private val _lockerList = MutableStateFlow<ArrayList<ResultDocument>>(arrayListOf())
    val lockerList: StateFlow<ArrayList<ResultDocument>> = _lockerList.asStateFlow()

    private val _isLoading = MutableStateFlow<Boolean?>(null)
    val isLoading: StateFlow<Boolean?> = _isLoading.asStateFlow()

    fun getData(sp: SharedPreferences) {
        viewModelScope.launch {
            val resultList: ArrayList<ResultDocument>
            val ref = sp.getString("items", "none")

            _isLoading.value = false
            if (ref != "none" && ref != "[]") {
                resultList = GsonBuilder().create().fromJson(ref, object: TypeToken<ArrayList<ResultDocument>>() {}.type)
                _lockerList.value.clear()
                _lockerList.value = resultList
                _isLoading.value = true
            } else {
                _lockerList.value = arrayListOf()
                _isLoading.value = true
            }
        }
    }
    
    // 아래에 코드 더 있음.
}

SharedPreferences에서는 쌩 list형태의 데이터를 담지 못한다. 그래서 리스트를 json으로 변경해서 저장해 주고, 불러올 때는 json을 List로 파싱 해서 사용해 주어야 한다.

많이 귀찮지만 어쩔 수 없다. 다른 방법이 없는 것 같았다.

 

ref에 json 값을 불러온다. 초기에는 ref가 none(디폴트), [](빈 리스트) 일 것이다. 이 때는 빈 리스트를 넣어 준다.

아닐 때는 미리 만들어 놓은 빈 arrayList 변수에 불러온 json을 파싱 해서 전부 넣어주고 stateFlow에 넣어 준다.

 

구현 중 발생한 문제사항

한창 구현 중에 저장소에서 데이터 삭제를 테스트해 보던 중 마지막 데이터가 삭제되지 않는 문제가 발생했다.

이 문제의 원인은 빠르게 찾을 수 있었다.

값을 기준으로 삭제를 하니까 값이 정상적으로 삭제되지 않고, 다른 데이터를 삭제하는 문제를 발견했다.

그래서 불러오는 Document들에 고유 uid를 붙여 주었다.

아래 코드는 mapper 코드의 일부인데, 기존 data class에 id 필드를 새로 달아 주고 랜덤 한 UUID가 생성되도록 해주었다.

private fun imageDocumentToResult(imageDocument: ImageDocument): ResultDocument {
    return ResultDocument(
        id = UUID.randomUUID().toString(),
        title = "[Image] ${imageDocument.displaySiteName}",
        imageUrl = imageDocument.thumbnailUrl,
        datetime = imageDocument.datetime
    )
}

private fun videoDocumentToResult(videoDocument: VideoDocument): ResultDocument {
    return ResultDocument(
        id = UUID.randomUUID().toString(),
        title = "[Video] ${videoDocument.title}",
        imageUrl = videoDocument.thumbnail,
        datetime = videoDocument.datetime
    )
}

 

그리고 서로 다른 UUID를 이용해서 삭제 로직을 다시 짜주었다(삭제 함수의 일부).

currentList = gson.fromJson(ref, object: TypeToken<ArrayList<ResultDocument>>() {}.type)
currentList.removeIf { it.id == removeDocument.id }

 

이렇게 변경해서 정상적으로 데이터가 삭제되었다.

 

마지막으로 구현 영상을 보고 마무리하겠다.

 

 


 

오늘 공부 내용 정리 및 회고

오늘 열심히 달려서 개인 프로젝트를 끝냈다.

원래 사용해야 하는 기술은 LiveData인데 StateFlow를 사용했다(이걸로 경고를 받진 않을 듯?).

이제 주말과 다음 주에는 수준별 학습반 과제를 해주어야겠다.

수준별 학습반 과제는 mvvm 적용만 남았기 때문에 주말에 좀 시간을 들이면 끝낼 수도 있겠다.

 

728x90