[Android / Kotlin] Kakao API 이미지 검색 (2) 내 보관함

Subeen·2024년 2월 1일
0

Android

목록 보기
55/73

지난 포스팅에는 Retrofit을 사용하여 Kakao API 검색 결과를 불러오는 화면에 대해 정리했었다. 이어서 이미지 리스트에서 이미지를 클릭하면 내 보관함에 저장되고, 보관함에서 이미지를 삭제하면 검색 화면에도 반영되는 부분에 대해 정리해보겠다 ! 🤓

결과 화면

Repository

검색화면(SearchListFragment)에서 이미지 아이템을 클릭하면 저장소에서 아이템의 존재 여부를 확인하고 SharedPreferences를 사용하여 저장소에 저장하고 삭제하도록 해놨다.

    /**
     * 이미지 검색 화면에서 이미지를 클릭하면 저장소에 추가하기 위한 함수
     * id를 비교해서 존재하지 않을 경우에만 아이템을 추가한다.
     */
	override suspend fun saveStorageItem(searchModel: SearchModel) {
        val favoriteItems = getPrefsStorageItems().toMutableList()
        val findItem = favoriteItems.find { it.id == searchModel.id }

        if (findItem == null) {
            favoriteItems.add(searchModel)
            savePrefsStorageItems(favoriteItems)
        }
    }

    /**
     * 저장소에 저장된 이미지를 삭제하기 위한 함수
     * 해당 아이템이 저장소에 존재하면 아이템을 삭제한다.
     */
    override suspend fun removeStorageItem(searchModel: SearchModel) {
        val favoriteItems = getPrefsStorageItems().toMutableList()
        favoriteItems.removeAll { it.id == searchModel.id }
        savePrefsStorageItems(favoriteItems)
    }

보관함 화면에서는 이미지 클릭 이벤트를 통해 보관함에 저장된 아이템만 보여줘야 되기에 저장되어 있는 아이템 리스트 목록을 가져오는 getPrefsStorageItems 함수를 생성하였다. 가져오는 과정에서 Json 문자열을 객체로 변환해준다.

    /**
     * 저장소에 저장되어 있는 아이템을 리스트 목록으로 가져온다.
     */
    override suspend fun getStorageItems(): List<SearchModel> {
        return getPrefsStorageItems()
    }
    
    private fun getPrefsStorageItems(): List<SearchModel> {
        val jsonString = pref.getString(Constants.STORAGE_ITEMS, "")
        return if (jsonString.isNullOrEmpty()) {
            emptyList()
        } else {
            /**
             * Gson()을 사용하여 Json 문자열을 SearchModel 객체로 변환
             */
            Gson().fromJson(jsonString, object : TypeToken<List<SearchModel>>() {}.type)
        }
    }

StorageViewModel

class StorageViewModel(
    private val imageSearchRepository: ImageSearchRepository
) : ViewModel() {

    private val _storageItems = MutableLiveData<List<SearchModel>>()
    val storageItems: LiveData<List<SearchModel>> get() = _storageItems

	/*
     * 보관함 화면에서도 아이템 삭제가 가능하므로 imageSearchRepository.removeStorageItem를 통해 아이템을 삭제한다.
     * 삭제 후 화면 업데이트를 위해 변경 된 데이터셋을 다시 가져온다. 
     */
    fun removeStorageItem(searchModel: SearchModel) = viewModelScope.launch {
        imageSearchRepository.removeStorageItem(searchModel)
        
        getStorageItems()
    }

	// 저장소에 저장되어 있는 아이템을 리스트 목록으로 가져와서 LiveData를 업데이트 해준다. 
    fun getStorageItems() = viewModelScope.launch {
            val images = imageSearchRepository.getStorageItems()
            _storageItems.value = images
        }
}

class StorageViewModelProviderFactory(
    private val imageSearchRepository: ImageSearchRepository
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(StorageViewModel::class.java)) {
            return StorageViewModel(
                imageSearchRepository
            ) as T
        }
        throw IllegalArgumentException("ViewModel class not found")
    }
}

StorageFragment

StorageFragment는 아이템 리스트를 보여주고 아이템 삭제 기능만 있어서 SearchListFragment에 비해서 비교적 간단하다.


class StorageFragment : Fragment() {
    companion object {
        fun newInstance() = StorageFragment()
    }

    private var _binding: FragmentStorageBinding? = null

    private val binding get() = _binding!!

	// ViewModel 초기화 
    private val viewModel: StorageViewModel by viewModels {
        StorageViewModelProviderFactory(
            ImageSearchRepositoryImpl(requireActivity())
        )
    }

    private val storageListAdapter by lazy {
        StorageListAdapter(
            itemClickListener = {
            	/*
                 * 아이템 클릭시 삭제 확인을 위한 Bottom Sheet Dialog를 띄운다. 
                 */
                showBottomSheetDialog(it)
            }
        )
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentStorageBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initView()
        initViewModel()
    }

    override fun onResume() {
        super.onResume()
        viewModel.getStorageItems()
    }

    private fun initView() {
        binding.recyclerFavorite.adapter = storageListAdapter
    }

    private fun initViewModel() = with(viewModel) {
        // RecyclerView 데이터셋 업데이트 
        storageItems.observe(viewLifecycleOwner) {
            storageListAdapter.submitList(it)
        }
    }

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

	// 플로팅 버튼 클릭시 리스트의 최상단으로 이동 
    fun smoothScrollToTop() {
        binding.recyclerFavorite.smoothScrollToPosition(0)
    }

    /*
     * Bottom Sheet Dialog를 생성하고 표시하기 위한 함수 
     * onRemoveClickListener : Dialog 내에서 삭제 버튼을 클릭했을 때 호출되는 콜백 함수로 해당 아이템을 삭제한다. 
     */
    private fun showBottomSheetDialog(searchModel: SearchModel) {
        BottomSheetDialog(
            onRemoveClickListener = { removedItem ->
                viewModel.removeStorageItem(removedItem)
            },
            searchModel = searchModel // 클릭 된 아이템 정보 
        ).show(requireActivity().supportFragmentManager, null)
    }
}

Bottom Sheet Dialog

BottomSheetDialog.kt

class BottomSheetDialog(
    private val onRemoveClickListener: (SearchModel) -> Unit, // 삭제 이벤트에 대한 리스너 
    private val searchModel: SearchModel // 선택 된 아이템

) : BottomSheetDialogFragment() {

    private var _binding: StorageBottomSheetBinding? = null

    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = StorageBottomSheetBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initView()
    }

    private fun initView() {
    	// 취소 버튼에 대한 클릭 리스너를 설명하여 클릭시 Dialog를 dismiss 한다. 
        binding.btCancel.setOnClickListener {
            dismiss()
        }

		/*
         * 삭제 버튼에 대한 클릭 리스너를 설정하여 클릭하면 onRemoveClickListener가 호출되고
         * Dialog를 dismiss 한다. 
         */
        binding.btRemove.setOnClickListener {
            onRemoveClickListener.invoke(searchModel)
            dismiss()
        }
    }

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

}

storage_bottom_sheet.xml

  • android:clickable="true" : 레이아웃은 클릭 가능으로 표시하며 터치 이벤트에 응답할 수 있다.
  • android:focusable="true" : 레이아웃이 포커스 가능으로 표시되어 포커스를 받을 수 있다.
  • app:behavior_hideable="true" : BottomSheet를 숨길 수 있음을 지정하여 아래로 스와이프하여 BottomSheet를 숨길 수 있다.
  • app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> : 레이아웃이 BottomSheet로 동작해야 함을 나타낸다.
<?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"
    android:layout_width="match_parent"
    android:layout_height="300dp"
    android:background="@drawable/bg_bottom_sheet"
    android:clickable="true"
    android:focusable="true"
    app:behavior_hideable="true"
    app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

    <TextView
        android:id="@+id/tv_bottom_sheet_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="28dp"
        android:gravity="center"
        android:text="@string/storage_bottom_sheet_title"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_bottom_sheet_msg"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:gravity="center"
        android:text="@string/storage_bottom_sheet_message"
        android:textSize="12sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_bottom_sheet_title" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/btCancel"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="16dp"
        android:layout_marginTop="32dp"
        android:layout_marginEnd="8dp"
        android:background="@drawable/bg_bottom_cancel"
        android:text="@string/bt_cancel"
        app:layout_constraintEnd_toStartOf="@id/btRemove"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_bottom_sheet_msg" />


    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/btRemove"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="8dp"
        android:layout_marginTop="32dp"
        android:layout_marginEnd="16dp"
        android:background="@drawable/bg_bottom_remove"
        android:text="@string/bt_remove"
        android:textColor="@color/white"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/btCancel"
        app:layout_constraintTop_toBottomOf="@id/tv_bottom_sheet_msg" />

</androidx.constraintlayout.widget.ConstraintLayout>
profile
개발 공부 기록 🌱

0개의 댓글