지난 포스팅에는 Retrofit을 사용하여 Kakao API 검색 결과를 불러오는 화면에 대해 정리했었다. 이어서 이미지 리스트에서 이미지를 클릭하면 내 보관함에 저장되고, 보관함에서 이미지를 삭제하면 검색 화면에도 반영되는 부분에 대해 정리해보겠다 ! 🤓
검색화면(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)
}
}
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는 아이템 리스트를 보여주고 아이템 삭제 기능만 있어서 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)
}
}
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()
}
}
- 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>