Android/StoreInfo

<정리> 심화 개인과제 3

re트 2024. 1. 29. 20:22
728x90

저번주 금요일에 진행상황을 정리했어야 했지만 거의 자정까지 이어진 개발로 인해 오늘에 몰아서 작성한다.

다행히 금요일날 물고 늘어진 건 잘 해결이 됐고 오늘은 그 이후 딱 한가지 무한 스크롤 전까지 해결을 하며 B타입의 마무리가 보이고 있다.

물론 MVVM 패턴을 완벽하게 적용하지 못해서 이 부분은 튜터님께 물어보러 가야겠지만...

중간중간 정리하면서 했으면 차례대로 적을 수 있겠지만 몰아서 하는 것이기 때문에 실제 진행했던 순서와는 많이 다를 예정이다.

 

1. isSelected 변수 추가

이건 이전에 선택한 데이터들을 모아두는 리스트를 메인 액티비티에서 구성하는거 말고 다르게 하기 위해 선택한 방법이다.

데이터 클래스에 이 isSelected 변수를 추가했다.

이걸 넣는거 때문에 API 사용해서 데이터를 받아올 때 문제가 생기지 않을까 걱정을 했었지만 전혀 문제가 없었다.

기본값은 false로 주었다.

 

2. 데이터 클래스 추가

A타입을 진행할 때는 이미지 데이터만 쭉 받아왔는데 이제는 비디오 데이터까지 같이 받아서 정렬하여 리사이클러뷰에 보여줘야했다.

그래서 비디오 데이터들을 받는 데이터 클래스를 만들고 이미지 데이터와 비디오 데이터 클래스를 감싸는 sealed class Document를 추가했다.

sealed class Document {
    @Parcelize
    data class ImageDocument(
        @SerializedName("collection")
        val collection: String,
        @SerializedName("datetime")
        val dateTime: String,
        @SerializedName("display_sitename")
        val displaySiteName: String,
        @SerializedName("doc_url")
        val docUrl: String,
        @SerializedName("height")
        val height: Int,
        @SerializedName("image_url")
        val imageUrl: String,
        @SerializedName("thumbnail_url")
        val thumbnailUrl: String,
        @SerializedName("width")
        val width: Int,
        var isSelected: Boolean = false
    ): Document(), Parcelable

    @Parcelize
    data class VideoDocument(
        @SerializedName("title")
        val title: String,
        @SerializedName("url")
        val url: String,
        @SerializedName("datetime")
        val dateTime: String,
        @SerializedName("play_time")
        val playTime: Int,
        @SerializedName("thumbnail")
        val thumbnailUrl: String,
        @SerializedName("author")
        val author: String,
        var isSelected: Boolean = false
    ): Document(), Parcelable
}

 

또한 상위 데이터 클래스도 이미지와 비디오를 나눠서 구성했다.

이 부분은 그냥 복사해서 이름과 타입만 변경하면 됐다.

 

3. HTTP 메서드 추가

이미지 데이터만 가져오던 GET을 복붙해서 비디오 데이터를 가져올 수 있는 GET 메서드를 구성했다.

@Headers("Authorization: KakaoAK $RESTAPIKEY")
    @GET("v2/search/vclip")
    suspend fun searchVideo(@QueryMap param: HashMap<String, String>) : VideoSearchResponse

 

4. shared 뷰모델 구성

이제는 뷰모델을 공유해서 써야겠다고 느꼈다.

MVVM 패턴으로 변경해야했기도 했고 이렇게 함으로써 메인 액티비티에서 검색하는 기능을 검색 결과 프래그먼트로 옮기는 것에 도움이 되었다.

뷰모델을 공유할 때는 프래그먼트를 감싸는 액티비티를 기준으로 삼아 공유를 하게 된다.

그래서 액티비티에서 뷰모델을 선언할 때는 by viewModels()를 쓰고 프래그먼트에서 뷰모델을 선언할 때는 by activityViewModels()을 쓴다.

by viewModels()나 by activityViewModels()를 쓰려면 build.gradle.kts (Module :app)에서 

implementation("androidx.fragment:fragment-ktx:1.6.2")

해당 코드를 추가해야한다.

 

사용 방법은 액티비티나 프래그먼트나 기존 쓰던 방식대로 쓰면 된다.

 

5. ViewPager2와 BottomNavigationView 연결

이거는 탭레이아웃과는 또 다르더라

탭레이아웃에서는 그냥 TabLayoutMediator만 쓰면 됐었는데 바텀내비게이션을 쓸 때는 registerOnPageChangeCallback과 setOnItemSelectedListener를 채워줘야했다.

한쪽을 사용해서 페이지를 넘기면 다른 한쪽도 반영시켜주며 두 개를 언제나 함께 움직여준다는 느낌이었다.

viewPagerMain.registerOnPageChangeCallback(object :
    ViewPager2.OnPageChangeCallback() {
    override fun onPageSelected(position: Int) {
        super.onPageSelected(position)
        bottomNavigationViewMain.menu.getItem(position).isChecked = true
    }
})

bottomNavigationViewMain.setOnItemSelectedListener {
    when (it.itemId) {
        R.id.menu_search -> {
            viewPagerMain.currentItem = 0
            return@setOnItemSelectedListener true
        }

        R.id.menu_storage -> {
            viewPagerMain.currentItem = 1
            return@setOnItemSelectedListener true
        }

        else -> return@setOnItemSelectedListener false
    }
}

 

6. 객체 데이터를 SharedPreferences에 저장

생각보다 쉬웠다...라기 보다는 생각보다 좋은 정보가 인터넷에 있었다고 하는게 맞는 거 같다.

나는 그저 Document 데이터를 Gson을 사용해서 JSON으로 바꾼다음에 저장하면 되지 않을까 했다.

하지만 그냥 그렇게 될 수 없었던 것은 Document는 sealed class이며 그 안에 2개의 데이터클래스를 가지고 있기 때문이었다.

그래서 처음에 내 생각대로 짰을 때는 에러가 사라지지 않았다.

그 후에 열심히 서칭하다가 알게 된 것은 직렬화, 역직렬화를 할 때 data class를 구분할 수 있는 기준을 잡아주는 JsonDeserializer에 대해서 알게 되었다.

해당 클래스를 사용할 때 나는 ImageDocument에만 있는 image_url 값의 유무로 직렬화, 역직렬화를 진행했다.

class DocumentTypeAdapter : JsonDeserializer<Document> {
    override fun deserialize(
        json: JsonElement,
        typeOfT: Type,
        context: JsonDeserializationContext
    ): Document {
        val jsonObject = json.asJsonObject
        return if (jsonObject.has("image_url")) {
            context.deserialize(json, Document.ImageDocument::class.java)
        } else {
            context.deserialize(json, Document.VideoDocument::class.java)
        }
    }
}

 

그리고 이걸 사용할 때는 이 커스텀 타입 어댑터를 GsonBuilder에 등록하고 toJson, fromJson을 사용하면 됐다.

 

7. 뷰모델 사용하여 UI 갱신

정말 여기서 금요일의 대부분을 시간을 보냈고 오늘도 만만찮게 시간을 보냈다.

먼저 UI에 나타날 데이터들을 담을 클래스를 만들었다.

사실 완벽히 이게 필요한 이유를 아직까지는 체감하지 못하고 있지만 배운 걸 써먹는다는 개념으로 접근해서 진행했다.

그리고 이 클래스의 라이브 데이터를 선언했다.

private val _searchUiState: MutableLiveData<SearchResultUiState> = MutableLiveData(
    SearchResultUiState.init()
)
val searchUiState: LiveData<SearchResultUiState> get() = _searchUiState

 

그 다음은 함수들인데... 이걸 어떻게 정리하지?

어... 그러니까...?!

정말 간단하게는 sealed class 안에 있는 데이터 클래스에 맞춰서 분기 처리를 해줬다고 보면 된다.

리사이클러뷰 아이템을 선택했을 때는 현재 그 값이 선택된 데이터 리스트에 있는지 없는지를 체크하고 removeIf를 하거나 add를 했다.

물론 add를 할 때는 데이터 클래스를 정해줬다.

코드는 뭔가 좀 복잡해서 ㅎㅎ...

아! 정렬할 때도 데이터 클래스가 다르기 때문에 정렬기준을 따로 세워줘야했는데 나는 dateTime을 기준으로 해줬다.

최신 날짜가 위로 나오길 원했기에 이렇게 작성했다.

sortByDescending { result ->
    when (result) {
        is Document.ImageDocument -> result.dateTime
        is Document.VideoDocument -> result.dateTime
    }
}

 

선택 표시를 보여주는 함수를 작성할 때는 똑같이 현재 선택된 값을 검색결과 데이터 리스트에서 찾아서 데이터 클래스를 맞춰주고 해당 isSelected값을 반전시켜줬다.

반전만 시키면 라이브 데이터의 변경을 옵저버가 알아차리지 못하기 때문에 copy와 set을 사용해서 변경되었음을 알렸다.

 

이외에도 검색 버튼을 눌렀을 때 저장되어있는 선택 데이터 리스트와 같은 데이터가 있다면 선택표시가 나오도록 하는 함수나 서버에서 가져온 데이터를 정렬 후 라이브 데이터에 저장하는 함수, JSON 데이터를 List<Document>로 변환시켜 라이브 데이터에 저장하는 함수가 존재한다.

 

8. 검색 버튼 눌렀을 때 키보드 내리고 포커스 뺐기

이건 아주 간단한 코드로 해결할 수 있었다.

먼저 clearFocus로 EditText의 포커스를 뺐을 수 있었고 키보드 내리는 건 InputMethodManager를 사용해서 내려지게 했다.

처음에 액티비티에서 사용할 때는 그냥 잘 돼서 아무 생각이 없었는데 프래그먼트에서 쓰려고 하니까 그냥 복붙으로는 되지 않았다.

그러다가 알게 된 건 context가 필요하다는것이었고 requireContext()를 통해 접근을 하니까 잘 동작했다.

binding.etSearch.clearFocus()

val inputMethodManager =
    requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(
    requireActivity().window.decorView.applicationWindowToken,
    0
)

 

9. 플로팅 액션 버튼으로 상단 이동

이건 이전에 했던 개인 과제를 이용하여 해결했다.

리사이클러뷰에 addOnScrollListener를 붙이고 플로팅 액션 버튼 클릭 이벤트에 리사이클러뷰 smoothScrollToPosition(0)를 주니까 깔끔했다.

애니메이션 효과는 추가적으로 붙여줬다.

rvSearchList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    var curVisible = false
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
        if (newState == RecyclerView.SCROLL_STATE_IDLE && recyclerView.canScrollVertically(-1)
                .not()
        ) {
            fabSearchScrollUp.startAnimation(fadeOut)
            fabSearchScrollUp.isVisible = false
            curVisible = false
        }
    }

    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)
        if (dy > 0) {
            fabSearchScrollUp.isVisible = true
            if (curVisible.not()) fabSearchScrollUp.startAnimation(fadeIn)
            curVisible = true
        }
    }
})

fabSearchScrollUp.setOnClickListener {
    rvSearchList.smoothScrollToPosition(0)
}

 

10. ListAdapter

A타입을 할 때는 그냥 RecyclerView.Adapter를 사용했는데 B타입을 하면서 ListAdapter로 변경했다.

이전 틀은 거의 그대로 두고 뷰타입이 2개인걸 처리하는 부분과 DiffUtil 클래스를 상속받아 구현하는 클래스만 추가되었다.

처음에 '도대체 어디서 뷰타입을 설정하는거지?' 이러면서 그냥 코드를 돌렸는데 튕기더라ㅎㅎ

이전 과제와 서칭을 하면서 찾은건

override fun getItemViewType(position: Int): Int {
    return when (currentList[position]) {
        is Document.ImageDocument -> 0
        is Document.VideoDocument -> 1
    }
}

이게 필요하다는 것이었다.

역시 그냥 외운대로만 하면 놓쳐버린다니까...

 

뷰타입에 따라 뷰홀더를 다르게 생성해주는 코드는 다음과 같다.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return when (viewType) {
        0 -> {
            ImageSearchViewHolder(
                SearchResultImageBinding.inflate(
                    LayoutInflater.from(parent.context),
                    parent,
                    false
                )
            )
        }

        else -> {
            VideoSearchViewHolder(
                SearchResultVideoBinding.inflate(
                    LayoutInflater.from(parent.context),
                    parent,
                    false
                )
            )
        }
    }
}

 

이제 엄청나게 나의 금요일 시간을 빼앗은 문제가 등장하는데... 이건 뷰타입이 2개이기 전에 있었던 문제였다.

바로 내 보관함에서 데이터를 삭제할 때 누른 위치의 값이 사라지는 것이 아니라 다른 위치의 값이 사라지는 문제였다.

에러 메세지를 보면 잘못된 인덱스에 접근하는 것을 볼 수 있었는데 도저히 원인을 찾을 수 없었다.

리스트를 잘못 변경해서 옵저버가 알아차리지 못하는 건가 싶어서 관련 코드를 수정하여 디버깅 때 변화를 확인했고 리스트를 잘못 넘겨주나 싶어서 다른 형태로 변경변경하면서 어댑터 내에서 처리하도록도 해봤고 다른 라이브데이터는 잘 넘어가는지 테스트도 해봤다.

그렇게 3~4시간을 보내고 쉬면서 나의 님과 통화를 하다가 뷰홀더 관련으로 얘기를 해주는데 뭔가 홀린듯이 클릭 관련 인터페이스를 onBindViewHolder에서 빼놨던걸 bind() 함수 안에 넣고 돌리니까 됐다...!!!!(나의 님은 안드로이드 전혀 모름. 그냥 직접 검색하면서 말해주는 중이었음)

차이는 단지 'currentList[position]을 받아서 사용하느냐', 'position을 받아서 그 안에서 currentList[position]을 사용하느냐'였다.

똑같은 currentList[position]을 사용하는 건데 왜 그런지 이해가 되지않는다.

내가 추측하기로는 currentList의 사용위치에 따라서 다르게 접근되기 때문이 아닐까 싶다.(튜터님께 질문 예정)

 

11. 내 보관함 데이터들을 위한 레이아웃 추가

이전에는 검색 결과 데이터가 쓰는 레이아웃을 그대로 썼었는데 생각해보니 거기에는 내 보관함에서 필요없는 선택표시가 있고 나중에 내가 보관함 데이터 레이아웃을 꾸미고 싶을 때 불편할 거를 생각하니까 추가하는게 낫다는 결론에 이르렀다.

그래서 추가했다.

현재는 다 동일하고 선택표시 ImageView만 빠진 것이 유일한 차이다.

 

12. MVVM 패턴

지금 적용되어있는 부분은 검색 결과 데이터, 내 보관함 데이터, 검색어뿐이다.

이외에 sharedPreferences와 플로팅 액션 버튼 기능까지도 넘기고 싶은 마음이 있다.

반응형

'Android > StoreInfo' 카테고리의 다른 글

<정리> 심화 개인과제 5  (0) 2024.01.31
<정리> 심화 개인과제 4  (1) 2024.01.30
<정리> 심화 개인과제 2  (1) 2024.01.25
<정리> 챌린지반 과제2 - 3  (1) 2024.01.25
<강의> 안드로이드 앱 개발 심화 - Retrofit  (0) 2024.01.25