티스토리 뷰

앱개발 심화 과제

 

선택 과제 구현

 

구현사항

- 공통
    - MainActivity의 하단 메뉴를 Bottom Navigation 또는 ViewPager+tablayout으로 변경
    - MVVM 패턴을 적용합니다. (ViewModel, LiveData)
    - 검색 결과 화면은 보관함을 다녀와도 유지됩니다.
    - 보관한 이미지 리스트는 앱 재시작 후 다시 보여야 합니다.
    
- 첫 번째 fragment : 검색 결과
    - 검색은 키워드 하나에 이미지 검색과 동영상 검색을 동시에 사용, 두 검색 결과를 합친 리스트를 사용합니다.
    - 두 검색 결과를 datetime 필드를 이용해 정렬하여 출력합니다. (최신부터 나타나도록)
    - 검색 결과 아이템에 [이미지] 또는 [동영상]를 표시합니다.
    - 검색 결과화면에서 마지막 결과로 스크롤시 다음 페이지가 자동 검색 되도록 구현합니다.(무한스크롤 기능)
    - 스크롤을 최상단으로 이동시키는 플로팅 액션 버튼을 추가합니다.
    - 아이템 선택시 SharedPreference에 저장합니다. (DB 사용 금지)

- 두 번째 fragment: 내 보관함
    - SharedPreference에 저장된 리스트를 불러와 화면에 표시합니다.
    - 보관함에서 이미지 선택시 저장 리스트에서 삭제되며 화면에서도 삭제되도록 구현합니다.

구현사항이 꽤나 복잡해졌다...

 

 

폴더 구조

- data/: 애플리케이션의 데이터 로직과 API 호출을 처리
    - api/
        - RetrofitInstance: Retrofit 인스턴스 및 설정 관련 로직
        - RetrofitInterface: Retrofit을 통한 API 호출 인터페이스 정의
    - model/
        - ImageResponse: 이미지 정보를 저장하는 데이터 모델
        - VideoResponse: 비디오 정보를 저장하는 데이터 모델
    
- ui/: 애플리케이션의 UI를 담당하는 Fragment 및 Adapter 위치
    - store/
        - StoreAdapter: 보관함 화면에서 사용하는 리사이클러뷰 어댑터
        - StoreFragment: 보관함 화면 UI 및 로직 처리
    - search/
        - SearchAdapter: 검색 화면에서 사용하는 리사이클러뷰 어댑터
        - SearchFragment: 이미지 검색 화면 UI 및 로직 처리

- utils/
    - Utils: 프로젝트 전반에 사용되는 유틸리티 함수 모음
    - Constants: 프로젝트 전반에 사용되는 상수 값 모음

- viewmodel/: MVVM의 ViewModel을 포함하며, UI 로직과 데이터의 중개 역할을 합니다. LiveData를 사용하여 UI의 상태 및 데이터 변경을 관찰합니다. 비즈니스 로직이 구현되어 있습니다.
    - store/
        - StoreViewModel: 보관함 화면의 데이터 및 로직 처리를 담당
    - search/
        - SearchViewModel: 검색 화면의 데이터 및 로직 처리를 담당
        - SearchViewModelFactory: SearchViewModel 생성을 위한 Factory 클래스
    - SharedViewModel: 여러 Fragment 간에 공유되는 데이터를 관리

- MainActivity: 애플리케이션의 메인 엑티비티

MVVM 패턴을 적용해 프로젝트를 Model, View, ViewModel로 분류해놓는다. 

 

 

gradle 설정

plugins {
    ...
    id("kotlin-parcelize")
}

android {
    ...
    buildFeatures {
        viewBinding = true
    }
}

dependencies {
    implementation("androidx.fragment:fragment-ktx:1.8.2")
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-gson:2.11.0")
    implementation("com.github.bumptech.glide:glide:4.16.0")
    ....
}

필요한 그래들 설정을 추가한다.

 

 

Bottom Navigation 추가

 

res 폴더 우클릭 > New > Android Resource Directory 눌러서 

menu, navigation 리소스 폴더를 추가한다.

 

// navigation/nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@+id/nav_search">

    <fragment
        android:id="@+id/nav_search"
        android:name="com.example.search.ui.search.SearchFragment"
        android:label="@drawable/ic_search"
        tools:layout="@layout/fragment_search" />

    <fragment
        android:id="@+id/nav_store"
        android:name="com.example.search.ui.store.StoreFragment"
        android:label="@string/frag_store_name"
        tools:layout="@layout/fragment_store" />

</navigation>

 네비게이션 파일에 만들어둔 프래그먼트 2개를 추가한다.

초기 프래그먼트는 검색 결과 프래그먼트로 설정한다.

 

// menu/bottom_nav_menu

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">

    <item
        android:id="@+id/nav_search"
        android:icon="@drawable/ic_search"
        android:title="@string/frag_search_name" />

    <item
        android:id="@+id/nav_store"
        android:icon="@drawable/ic_storage"
        android:title="@string/frag_store_name" />

</menu>

BottomNavigationView에 들어갈 메뉴 아이템들을 선언한다.

아이템의 아이디는 내비게이션의 프래그먼트 아이디와 동일하게 설정해야한다.

 

// drawable/nav_bottom_item_color

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_checked="true" android:color="@color/black" />
    <item android:state_checked="false" android:color="#808080" />
</selector>

BottomNavigationView의 아이템 선택여부에 따른 색상을 선언한다.

 

// layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_bottom"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:navGraph="@navigation/nav_graph" />


    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/nav_bottom"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="@color/white"
        app:itemIconTint="@drawable/nav_bottom_item_color"
        app:itemTextColor="@drawable/nav_bottom_item_color"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:menu="@menu/bottom_nav_menu" />


</androidx.constraintlayout.widget.ConstraintLayout>

메인에 내비게이션 프래그먼트와 하단 내비게이션 뷰를 배치한다.

 

BottomNavigationView에 메뉴를 설정해도 레이아웃이 반영이 안될텐데

그럴 땐 디버깅 한 번 하고 나면 설정한 아이템 레이아웃이 제데로 나온다.

 

 

프로그래스바 추가

<ProgressBar
    android:id="@+id/pb_search"
    style="?android:attr/progressBarStyleLarge"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:visibility="invisible"
    ... />

검색화면 프래그먼트에 위치한 리싸이클러뷰의 중앙에다가

프로그래스바를 배치시킨 후 안보이게 설정해놓는다.

 

 

플로팅 액션버튼 추가

 

플로팅 액션 버튼 이미지를 2개 추가한다.

 

// drawable/fab_selector

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/fab_pressed" android:state_pressed="true" />
    <item android:drawable="@drawable/fab_primary" android:state_pressed="false" />
</selector>

selecter를 이용해 버튼 상태에 따라 다른 이미지를 반환하도록 한다.

 

<com.google.android.material.floatingactionbutton.FloatingActionButton
    android:id="@+id/fab_top"
    android:layout_width="40dp"
    android:layout_height="40dp"
    android:layout_marginEnd="16dp"
    android:layout_marginBottom="24dp"
    android:elevation="0dp"
    android:src="@drawable/fab_selector"
    app:fabCustomSize="40dp"
    app:layout_constraintBottom_toBottomOf="@+id/rv_searchResult"
    app:layout_constraintEnd_toEndOf="@+id/rv_searchResult"
    app:maxImageSize="40dp"
    app:tint="@null"
    tools:ignore="ContentDescription" />

검색화면 프래그먼트에 위치한 리싸이클러뷰의 우측 하단에 배치시킨다.

 

 

아이템 설정

<ImageView
    android:id="@+id/iv_thumbnail"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:adjustViewBounds="true" // 이미지 비율 유지
    android:maxHeight="240dp" // 최대 높이 설정
    android:minHeight="30dp" // 최소 높이 설정
    android:scaleType="fitXY" // 이미지뷰의 각 면에 이미지가 꽉차게 설정
    ... />

이미지와 동영상의 높이는 다르다는 걸 고려해 두 썸네일의

크기에 대응할 수 있도록 이미지뷰의 속성을 설정한다.

 

<TextView
    android:id="@+id/tv_title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:maxLines="1"
    ... />

필수 과제에서는 사이트 이름을 출력했으나 비디오 검색 api는 

사이트 이름을 반환하지 않아 제목으로 대체하였다.

 

제목은 최대 1줄까지 출력하도록 설정한다.

 

 

MainActivity

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 바인딩 설정
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        enableEdgeToEdge() // Status Bar까지 화면을 확장 시킴
        
        // Status Bar나 Navigation Bar의 크기에 맞춰 적절한 패딩을 설정
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            // BottomNavigationView를 추가했으므로 하단 패딩은 0으로 설정
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, 0)
            insets
        }

        // 메인 프래그먼트의 NavController를 가져와 Navigation Component에서 프래그먼트 간의 탐색을 관리함
        val navController = findNavController(R.id.nav_host_fragment)

        // BottomNavigationView를 NavController와 연결함
        NavigationUI.setupWithNavController(binding.navBottom, navController)
    }
}

프래그먼트 간의 네비게이션을 설정하게 되면

화면전환이 발생해도 프래그먼트의 상태가 저장이 된다.

 

따라서 검색 화면에서 검색을 진행한 후, 보관함 화면으로 이동하고

다시 검색 화면으로 돌아와도 검색 화면은 검색 결과를 출력한 상태를 유지한다.

 

 

Constants

object Constants {
    // Kakao Image Search API의 기본 URL
    const val BASE_URL = "https://dapi.kakao.com/"

    // Kakao API를 사용하기 위한 인증 헤더
    const val AUTH_HEADER = "KakaoAK ffee0de3b0664df4ef6c5ed489a3f384"

    // 앱의 Shared Preferences 파일 이름
    const val PREFS_NAME = "com.example.search.pref"

    // 마지막 검색어를 저장하기 위한 키 값
    const val LAST_QUERY = "LAST_QUERY"

    // 이미지 검색 타입 코드
    const val SEARCH_TYPE_IMAGE = 0

    // 비디오 검색 타입 코드
    const val SEARCH_TYPE_VIDEO = 1
}

Constants 싱글톤 객체에 검색 타입 코드 상수를 추가한다. 

 

 

응답 받아올 데이터 클래스 작성

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

여기있는 문서를 참고해서 response를 받아올 데이터 클래스를 작성한다. 

 

data class ImageResponse(
    @SerializedName("documents")
    val documents: ArrayList<Documents>,

    @SerializedName("meta")
    val meta: Meta
) {
    data class Documents(
        @SerializedName("collection")
        val collection: String,

        @SerializedName("thumbnail_url")
        val thumbnailUrl: String,

        @SerializedName("image_url")
        val imageUrl: String,

        @SerializedName("width")
        val width: Int,

        @SerializedName("height")
        val height: Int,

        @SerializedName("display_sitename")
        val displaySitename: String,

        @SerializedName("doc_url")
        val docUrl: String,

        @SerializedName("datetime")
        val datetime: String
    )

    data class Meta(
        @SerializedName("is_end")
        val isEnd: Boolean,

        @SerializedName("pageable_count")
        val pageableCount: Int,

        @SerializedName("total_count")
        val totalCount: Int
    )
}

JSON 데이터를 데이터 클래스로 매핑하기 위한 구조를 정의한다.

SerializedName을 이용해 JSON 필드 이름과 Kotlin 프로퍼티를 매핑한다.

 

Document와 Meta를 중첩 클래스로 선언하여 클래스명 중복 문제를 방지한다.

VideoResponse 데이터 클래스도 이것처럼 작성하면 된다.

 

선택 과제에서는 필요한 속성만 받아오도록 작성했지만

필수 과제에서는 두 개의 API를 한 번에 호출해야 하고 무한 스크롤 구현을 위해

pageableCount값도 받아와야돼서 양식에 맞춰 데이터 클래스를 작성했다.

 

 

Retrofit Interface

interface RetrofitInterface {
    @GET("v2/search/image")
    fun searchImage(
        @Header("Authorization") key: String,
        @Query("query") query : String,
        @Query("sort") sort : String,
        @Query("page") page: Int,
        @Query("size") size: Int
    ) : Call<ImageResponse>

    @GET("v2/search/vclip")
    fun searchVideo(
        @Header("Authorization") apiKey: String,
        @Query("query") query: String,
        @Query("sort") sort: String,
        @Query("page") page: Int,
        @Query("size") size: Int
    ): Call<VideoResponse>
}

Retrofit 인터페이스 내에 비디오/이미지 검색 api 호출 함수를 작성해준다.

헤더와 쿼리 속성을 모두 파라미터로 받아 함수 호출 부분에서 검색 설정을 쉽게 확인할 수 있도록 했다.

 

 

Retrofit Instance

object RetrofitInstance {
    val apiService: RetrofitInterface by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(RetrofitInterface::class.java)
    }
}

싱글톤 객체 내에 Retrofit 인스턴스 변수를 생성 후 지연초기화 설정을 한다.

 

 

Utils

object Utils {

    // 날짜 문자열 포맷팅
    fun getFormatDate(date: String): String {
        // 입출력 형식 지정
        val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.getDefault())
        val outputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
        val strToDate = inputFormat.parse(date) // 입력 문자열을 Date? 객체로 변환
        return strToDate?.let { outputFormat.format(it) } ?: "" // null 체크 후 지정한 형식으로 변환
    }

    // 선택한 아이템 저장 (키: 아이템 JSON 문자열, 값: 아이템 url)
    fun addStorageItem(context: Context, item: Item) {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        prefs.edit { putString(item.url, Gson().toJson(item)) }
    }

    // 선택한 아이템 삭제
    fun delStorageItem(context: Context, url: String) {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        prefs.edit { remove(url) } // 키(url)를 기준으로 아이템 삭제
    }

    // 저장된 아이템들 가져오기
    fun getStorageItems(context: Context): ArrayList<Item> {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        val selectedItems = ArrayList<Item>()
        // prefs.all은 북마크 된 모든 아이템들에 해당함
        for ((_, value) in prefs.all) {
            // 북마크된 아이템(JSON 문자열)을 Item 객체로 변환 후 추가
            val item = Gson().fromJson(value as String, Item::class.java)
            selectedItems.add(item)
        }
        return selectedItems
    }

    // 토스트 메시지 띄우기
    fun showToast(context: Context, text: String ){
        Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
    }
}

Utils 싱글톤 객체에는 프로젝트에서 전역적으로 쓰일 함수를 선언한다.

 

SharedPreference를 이용하여 아이템의 고유한 값에 해당하는

url을 기준으로 아이템을 로컬 저장소에 추가하거나 삭제한다. 

 

SharedPreference는 코틀린 객체를 저장하거나 불러오는 기능을 지원하지 않기에

Gson 객체를 활용하여 검색화면에서 선택한 아이템을 저장하고 불러와야한다.

 

 

Item

class Item(var type: Int, var title: String, var dateTime: String, var url: String) {
    var isLike = false
}

타입 속성을 추가해 아이템의 타입에 따라 다른 제목을 출력하도록 한다.

 

아이템 선택 여부에 해당하는 isLike를 클래스의 본문에 정의하여

아이템 객체를 생성할 때 isLike를 설정하지 않아도 되도록 한다.

 

 

SearchViewModel

class SearchViewModel(private val apiService: RetrofitInterface) : ViewModel() {

    // 검색 결과에 대한 LiveData 선언
    private val _searchResults = MutableLiveData<List<Item>>()
    val searchResults: LiveData<List<Item>> get() = _searchResults

    // 로딩 상태에 대한 LiveData 선언
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> get() = _isLoading

    // 페이지, 결과 아이템 등의 변수 선언
    var pageCnt = 1
    var itemList = ArrayList<Item>()
    var maxImagePage = 1
    var maxVideoPage = 1

    var isImageSearchFinished = false
    var isVideoSearchFinished = false

    // 검색을 실행하는 메서드
    fun search(query: String, page: Int) {
        itemList.clear() // 이전 검색결과를 모두 제거
        _isLoading.value = true // 검색이 끝날 때까지 로딩 실행

        // 비동기 작업을 위해 검색 완료 여부를 변수로 선언
        isImageSearchFinished = false
        isVideoSearchFinished = false

        // 페이지 범위 내에서 이미지와 비디오 검색을 수행
        if (page <= maxImagePage) getImageItems(query, page)
        if (page <= maxVideoPage) getVideoItems(query, page)
    }

    // 이미지 검색 결과를 가져오는 메소드
    private fun getImageItems(query: String, page: Int) {
        apiService.searchImage(AUTH_HEADER, query, "accuracy", page, 40)
            .enqueue(object : Callback<ImageResponse> {
                override fun onResponse(
                    call: Call<ImageResponse>,
                    response: Response<ImageResponse>
                ) {
                    // api 호출의 반환값이 null이 아닌지 1차로 확인
                    response.body()?.meta?.let { meta ->
                        // 검색 결과가 존재하는지 2차로 확인
                        if (meta.totalCount > 0) {
                            // 검색 결과를 itemList에 추가
                            for (document in response.body()!!.documents) {
                                val title = document.displaySitename
                                val datetime = document.datetime
                                val url = document.thumbnailUrl
                                itemList.add(Item(SEARCH_TYPE_IMAGE, title, datetime, url))
                            }
                            // 검색 가능한 페이지 수 갱신
                            maxImagePage = meta.pageableCount
                        }
                    }
                    isImageSearchFinished = true
                    checkSearchCompletion()
                }

                override fun onFailure(p0: Call<ImageResponse>, p1: Throwable) {
                    Log.e("#jblee", "onFailure: ${p1.message}")
                }

            })
    }

    // 비디오 검색 결과를 가져오는 메서드
    private fun getVideoItems(query: String, page: Int) {
        apiService.searchVideo(AUTH_HEADER, query, "accuracy", page, 15)
            .enqueue(object : Callback<VideoResponse> {
                override fun onResponse(
                    call: Call<VideoResponse>,
                    response: Response<VideoResponse>
                ) {
                    response.body()?.meta?.let { meta ->
                        if (meta.totalCount > 0) {
                            for (document in response.body()!!.documents) {
                                val title = document.title
                                val datetime = document.datetime
                                val url = document.thumbnail
                                itemList.add(Item(SEARCH_TYPE_VIDEO, title, datetime, url))
                            }
                            maxVideoPage = meta.pageableCount
                        }
                    }
                    isVideoSearchFinished = true
                    checkSearchCompletion()
                }

                override fun onFailure(call: Call<VideoResponse>, t: Throwable) {
                    Log.e("##jblee", "onFailure: ${t.message}")
                }

            })
    }

    // 이미지와 비디오 검색이 모두 완료되었는지 확인하는 메서드
    private fun checkSearchCompletion() {
        if (isImageSearchFinished && isVideoSearchFinished) {
            searchResult()
        }
    }

    // 검색 결과를 LiveData에 설정하는 메서드
    private fun searchResult() {

        // 날짜 별로 정렬
        Collections.sort(itemList) { list, c -> c.dateTime.compareTo(list.dateTime) }

        // 검색 결과를 LiveData에 설정
        _searchResults.value = itemList

        // 로딩 상태 업데이트
        _isLoading.value = false
    }
}

검색 화면 뷰모델에서는 검색 결과와 로딩 상태를 LiveData로 관리한다. 

 

비동기적으로 이미지/비디오 검색 api를 호출하여 두 검색이 모두 완료되는 시점에

최종 검색결과를 날짜별로 정렬하고 해당 LiveData와 로딩 상태를 업데이트한다.

 

무한 스크롤 구현을 위해 api호출 후 검색 가능한 페이지 수를 갱신한다.

 

 

SearchViewModelFactory

class SearchViewModelFactory(private val apiService: RetrofitInterface) :
    ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return SearchViewModel(apiService) as T
    }
}

매개변수를 받는 뷰모델은 해당 매개변수를 전달하기 위해

ViewModelProvider.Factory를 구현한 팩토리 클래스를 정의해줘야한다.

 

팩토리 클래스의 create 함수는 제네릭 타입 'T'의 뷰모델을 반환하므로

반환할 뷰모델을 'as T'로 타입 캐스팅 해줘야 한다.

 

 

SharedViewModel

class SharedViewModel : ViewModel() {

    // 삭제된 아이템 url 리스트 선언
    private val _deletedItemUrls = MutableLiveData<List<String>>()
    val deletedItemUrls: LiveData<List<String>> get() = _deletedItemUrls

    // 삭제된 아이템 url 리스트 갱신
    fun addDeletedItemUrls(url: String) {
        val currentList = _deletedItemUrls.value ?: emptyList()
        _deletedItemUrls.value = currentList + url
    }

    // 삭제된 아이템 url 리스트 초기화
    fun clearDeletedItemUrls() {
        _deletedItemUrls.value = emptyList()
    }
}

여러 프래그먼트에서 공유되는 데이터를 처리하는 뷰모델을 생성한다.  

deletedItemUrls는 보관함 화면에서 변경되고 검색 화면에서 변경 사항을 감지한다.

 

 

SearchAdpater

class SearchAdapter(private val mContext: Context) :
    RecyclerView.Adapter<SearchAdapter.ViewHolder>() {

    // 검색된 아이템들
    var items = ArrayList<Item>()

    // 아이템 전체 삭제
    fun clearItem() {
        items.clear()
        notifyDataSetChanged()
    }

    // 뷰홀더 선언
    inner class ViewHolder(private val binding: ItemBinding) :
        RecyclerView.ViewHolder(binding.root), View.OnClickListener {

        fun bind(item: Item) {
            val type = if (item.type == SEARCH_TYPE_VIDEO) "[동영상] " else "[이미지] "
            Glide.with(mContext).load(item.url).into(binding.ivThumbnail)
            binding.tvTitle.text = type + item.title
            binding.tvDatetime.text = getFormatDate(item.dateTime)
            binding.ivLike.visibility = if (item.isLike) View.VISIBLE else View.INVISIBLE
        }

        init {
            // 아이템의 썸네일과 아이콘을 누르면 북마크 상태 변경
            binding.ivThumbnail.setOnClickListener(this)
            binding.ivLike.setOnClickListener(this)

        }

        // 아이템 좋아요 여부에 따른 클릭 이벤트 처리
        override fun onClick(view: View) {
            val position = adapterPosition
            val item = items[position]

            if (!item.isLike) {
                addStorageItem(mContext, item)
                item.isLike = true
            } else {
                delStorageItem(mContext, item.url)
                item.isLike = false
            }

            notifyItemChanged(position)
        }
    }

    ...
}

뷰홀더에서 View.onClickListener를 상속해 아이템 클릭 이벤트를 구현한다.

 

 

SearchFragment

class SearchFragment : Fragment() {

    // 뷰 바인딩 변수 선언
    private var _binding: FragmentSearchBinding? = null
    private val binding get() = _binding!!

    // 어댑터 관련 변수 선언
    private lateinit var mContext: Context
    private lateinit var adapter: SearchAdapter
    private lateinit var gridmanager: StaggeredGridLayoutManager

    // 뷰모델 변수 선언
    private val apiService = RetrofitInstance.apiService
    private val viewModel: SearchViewModel by viewModels { SearchViewModelFactory(apiService) }
    private val sharedViewModel by activityViewModels<SharedViewModel>()

    // 검색 상태 변수들
    private var query = ""
    private var loading = true
    private var pastVisibleItems = 0
    private var visibleItemCount = 0
    private var totalItemCount = 0

    // 프래그먼트의 컨텍스트 변수 초기화
    override fun onAttach(context: Context) {
        super.onAttach(context)
        mContext = context
    }

    // 프래그먼트 UI 생성
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentSearchBinding.inflate(inflater, container, false)
        return binding.root
    }

    // UI의 초기 설정 진행
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setUpView()
        setUpListener()

        observeViewModel()
    }

    // 리사이클러뷰 스크롤 리스너 - 스크롤 시 다음 페이지의 데이터 불러옴
    private var onScrollListener = object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            visibleItemCount = gridmanager.childCount // 현재 화면에 표시하고 있는 아이템 개수
            totalItemCount = gridmanager.itemCount // 어댑터가 포함하고 있는 전체 아이템 개수

            //현재 페이지에서 보이는 첫 번째 아이템 위치 업데이트
            val firstVisibleItems = gridmanager.findFirstVisibleItemPositions(null)
            if (firstVisibleItems.isNotEmpty()) {
                pastVisibleItems = firstVisibleItems[0]
            }

            // 리싸이클러뷰의 끝에 도달 했을 때 다음 페이지의 데이터를 로드해 무한 스크롤 구현
            if (loading && visibleItemCount + pastVisibleItems >= totalItemCount) {
                loading = false
                viewModel.pageCnt += 1
                viewModel.search(query, viewModel.pageCnt)
            }

            // 리싸이클러뷰의 y좌표에 따라 플로팅 액션 버튼 활성화
            if (dy > 0 && binding.fabTop.visibility == View.VISIBLE) {
                binding.fabTop.hide()
            } else if (dy < 0 && binding.fabTop.visibility != View.VISIBLE) {
                binding.fabTop.show()
            }

            // 위쪽으로 스크롤이 불가하다면 (리싸이클러뷰의 스크롤이 최상단에 위치한다면)
            if (!recyclerView.canScrollVertically(-1)) {
                binding.fabTop.hide() // 플로팅 액션 버튼을 숨김
            }
        }
    }

    // 뷰 초기 설정
    private fun setUpView() {
        // 어댑터 설정
        adapter = SearchAdapter(mContext)
        binding.rvSearchResult.adapter = adapter

        // 아이템 높이가 불규칙한 그리드 형식으로 배치
        gridmanager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
        binding.rvSearchResult.layoutManager = gridmanager

        // 리싸이클러뷰 설정
        binding.rvSearchResult.addOnScrollListener(onScrollListener) // 무한 스크롤 설정
        binding.rvSearchResult.itemAnimator = null // 아이템 클릭 애니메이션 비활성화

        // 검색 전에 플로팅 액션 버튼은 보이지 않도록 설정
        binding.fabTop.visibility = View.INVISIBLE
    }

    // 키보드 숨김 메소드
    private fun hideKeyboard(view: View) {
        val inputMethodManager =
            activity?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        inputMethodManager.hideSoftInputFromWindow(view.windowToken, 0)
        view.clearFocus()
    }

    // 프래그먼트 내 리스너 설정
    private fun setUpListener() {

        // 플로팅 액션 버튼 동작 정의
        binding.fabTop.setOnClickListener {
            // 리싸이클러뷰의 스크롤을 최상단으로 부드럽게 이동
            binding.rvSearchResult.smoothScrollToPosition(0)
        }

        // 검색 버튼 동작 정의
        binding.btnSearch.setOnClickListener {
            hideKeyboard(it) // 키보드 숨김

            // 검색어를 입력하지 않았을 땐 토스트 메시지를 띄움
            if (binding.etSearch.text.toString() == "") {
                showToast(mContext, "검색어를 입력해 주세요.")
            } else {
                adapter.clearItem() // 기존 아이템들 삭제
                query = binding.etSearch.text.toString() // 검색어 초기화
                loading = false // 로딩 완료
                viewModel.search(query, viewModel.pageCnt) // 검색 실행
            }
        }
    }


    // ViewModel에서 데이터 변화를 관찰
    private fun observeViewModel() {

        // 검색 결과에 변화가 감지되면 아이템 리스트 초기화
        viewModel.searchResults.observe(viewLifecycleOwner) { items ->
            adapter.items.addAll(items)
            adapter.notifyDataSetChanged()
        }

        // 로딩 상태의 변화를 감지해 프로그래스바 활성화/비활성화
        viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
            binding.pbSearch.visibility = if (isLoading) View.VISIBLE else View.INVISIBLE
            loading = !isLoading
        }

        // 삭제된 아이템 url 리스트에 변화가 감지되면 (보관함에서 아이템을 삭제하면)
        sharedViewModel.deletedItemUrls.observe(viewLifecycleOwner) { urls ->
            urls.forEach { url ->
                // url을 통해 보관함에서 삭제된 아이템을 찾아
                val targetItem = adapter.items.find { it.url == url }

                // 해당 아이템의 북마크를 비활성화함
                targetItem?.let {
                    it.isLike = false
                    val itemIndex = adapter.items.indexOf(it)
                    adapter.notifyItemChanged(itemIndex)
                }
            }

            // url 리스트를 빈 목록으로 초기화한다.
            sharedViewModel.clearDeletedItemUrls()
        }
    }

    // 프래그먼트를 전환할 떄 바인딩 객체를 null로 설정
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

코드가 참 길어서 다 설명은 못하겠고 햇갈리는 거 하나만 설명하겠다. (주석 참고)

 

SearchViewModel의 isLoading은 데이터 로딩 상태를 나타내는 변수로

두 api를 모두 호출하기 전까지 false에 해당하며 프로그래스바의 가시성 여부를 제어한다.

 

 loading은 현재 페이지의 데이터가 다 불러와졌을때 false로 설정되기에

isLoading의 반대값을 가지며 로딩 중에 새로운 페이지 데이터 요청을 방지하는데 쓰인다.

(무한 스크롤에서 페이지 데이터를 중복해서 호출되지 않도록 방지하는 역할을 한다.)

 

어으 헷갈려;;

 

 

StoreViewModel

class StoreViewModel : ViewModel() {

    // 저장된 아이템들에 대한 LiveData 선언
    private val _storedItems = MutableLiveData<ArrayList<Item>>()
    val storedItems: LiveData<ArrayList<Item>> get() = _storedItems

    // 저장된 아이템들을 가져오는 함수
    fun getStoredItems(context: Context) {
        _storedItems.value = Utils.getStorageItems(context)
    }

    // 특정 아이템을 삭제하는 함수
    fun deleteItem(context: Context, item: Item) {

        // 아이템 삭제 여부를 키값에 저장
        Utils.delStorageItem(context, item.url)

        // 삭제된 아이템 정보를 반영하여 LiveData 업데이트
        _storedItems.value?.let { items ->
            items.remove(item)
            _storedItems.value = items
        }
    }
}

검색화면에서 클릭한 아이템 가져오고 삭제하는 로직만 구현해주면된다.

 

 

StoreAdpater

class StoreAdapter(private val mContext: Context) :
    RecyclerView.Adapter<StoreAdapter.ViewHolder>() {

    // 북마크된 아이템
    var items = ArrayList<Item>()

    // 아이템 클릭 리스너 인터페이스
    interface ItemClick {
        fun onClick(item: Item, position: Int)
    }

    var itemClick: ItemClick? = null

    // 뷰홀더 선언
    inner class ViewHolder(private val binding: ItemBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(item: Item) {
            val type = if (item.type == SEARCH_TYPE_VIDEO) "[동영상] " else "[이미지] "
            Glide.with(mContext).load(item.url).into(binding.ivThumbnail)
            binding.tvTitle.text = type + item.title
            binding.tvDatetime.text = getFormatDate(item.dateTime)

            binding.itemView.setOnClickListener {
                val position = adapterPosition
                itemClick?.onClick(items[position], position)
            }
        }
    }

    ...
}

아이템 클릭 이벤트는 뷰모델 클래스의 메소드를 호출하여 구현하므로

아이템 클릭 리스너 인터페이스를 생성해 프래그먼트 클래스에서 정의해준다.

 

 

StoreFragment

class StoreFragment : Fragment() {

    // 뷰 바인딩 변수 선언
    private var _binding: FragmentStoreBinding? = null
    private val binding get() = _binding!!

    // 어댑터 관련 변수 선언
    private lateinit var mContext: Context
    private lateinit var adapter: StoreAdapter
    private lateinit var gridmanager: StaggeredGridLayoutManager

    // 뷰모델 변수 선언
    private val sharedViewModel by activityViewModels<SharedViewModel>()
    private val viewModel: StoreViewModel by viewModels()

    // 프래그먼트의 컨텍스트 변수 초기화
    override fun onAttach(context: Context) {
        super.onAttach(context)
        mContext = context
    }
    
    // 프래그먼트 UI 생성
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentStoreBinding.inflate(inflater, container, false)
        return binding.root
    }
    
    // UI 초기 설정 진행
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel.getStoredItems(mContext) // 데이터 불러오기
        setAdapter()
        observeViewModel()
    }

    private fun setAdapter(){
        // 어댑터 초기화
        adapter = StoreAdapter(mContext)
        binding.rvStoreResult.adapter = adapter

        // 아이템 높이가 불규칙한 그리드 형식으로 배치
        gridmanager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL)
        binding.rvStoreResult.layoutManager = gridmanager

        // 아이템 클릭 시 각 뷰모델의 LiveData를 변경해 아이템을 삭제처리를 진행함
        adapter.itemClick = object : StoreAdapter.ItemClick {
            override fun onClick(item: Item, position: Int) {
                viewModel.deleteItem(mContext, item)
                sharedViewModel.addDeletedItemUrls(item.url)
            }
        }
    }

    private fun observeViewModel() {
        // 저장된 아이템 리스트를 관찰하여 UI 업데이트
        viewModel.storedItems.observe(viewLifecycleOwner) { items ->
            adapter.items = items
            adapter.notifyDataSetChanged()
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

뷰모델을 활용해 저장된 아이템 리스트를 불러오고 삭제하는 로직을 추가한다.

 

 

회고

처음 적용해보는 MVVM 패턴이라 생소한 개념이 많아 

이것저것 정말 많이 찾아보면서 코드를 분석했던 것 같다.

 

분량이 너무 많아서 원래는 좀 글을 나눠서 쓰려했는데

쓰다보니까 어떻게 나누기도 애매해서 한 번에 정리했다. 

 

MVVM 패턴이니까 글도 모델 부분 구현, 뷰 부분 구현...

뭐 이런식으로 나눌까 했는데 나중에 다시 볼 거 생각하면

프로젝트 따라 만들 때 작성한 파일 순서대로 설명하는게 좋겠다 싶었다.

 

뭔가 그대로 따라치기는 싫어서 변수/클래스명도 바꾸고 코드 간략화도 해보고

구조도 살짝 바꾸고 그랬는데 그러면서 자잘한 오류들을 많이 만난 것 같다.

그런 점에서 역시 개발공부할 때는 코드를 따라서 한 번 쳐보는게 좋은 것 같다.

 

코드 분석, 프로젝트 따라 만들기 2일 + 오류 수정, 블로그 작성 1일 해서

총 3일 걸렸는데 작성하는데 상당히 오래걸렸던 글이었다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
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
글 보관함