ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Android] BottomSheetDialogFragment 사용하기 (Modal Bottom Sheet)
    안드로이드 2024. 6. 3. 22:09

    ✏️ TIL(Today I Learned)

    폐의약품 수거함 위치 서비스 앱을 만들기 시작했다.

    지도에 표시된 마커를 클릭하면, 바텀시트로 해당 수거처의 정보를 제공하려고 한다.

     

    왼쪽 상단에는 수거처의 타입(약국 or 보건소 등등)

    그 아래에는 이름과 현재 위치로 부터의 거리 그리고 수거처의 폐의약품 수거 횟수와 좋아요한 횟수이다.

    아래에는 도로명 주소와 데이터 기준 일자로 구성되도록 했다.

     

    하트 아이콘을 클릭하면, 해당 수거함의 수거 횟수가 증가하면서 보관함에 저장이 된다.

     

     

     

    우선 윗 쪽 모서리가 둥근 바텀 시트를 만들기 위해 drawable 폴더에

    shape_bottom_sheet.xml 을 만들어서 Radius를 설정한다.

    <?xml version="1.0" encoding="utf-8"?>
    <shape xmlns:android="http://schemas.android.com/apk/res/android"
        android:shape="rectangle">
        <corners android:topLeftRadius="24dp"
            android:topRightRadius="24dp"/>
        <solid android:color="@android:color/white"/>
    </shape>

     

    그 다음 themes.xml에 style을 생성한다.

    <style name="RoundedBottomSheetDialogTheme" parent="Theme.Design.Light.BottomSheetDialog">
        <item name="bottomSheetStyle">@style/RoundBottomSheetStyle</item>
    </style>
    <style name="RoundBottomSheetStyle" parent="Widget.Design.BottomSheet.Modal">
        <item name="android:background">@drawable/shape_bottom_sheet</item>
    </style>

     

    BottomSheetFragment에서 이 스타일을 가져와주면 적용이 된다.

    override fun getTheme(): Int {
        return R.style.RoundedBottomSheetDialogTheme
    }

     

     

     

    이렇게 UI를 만든 다음,

    HomeFragment에서 마커의 클릭리스너을 통해 바텀시트를 보이게 한다.

    // HomeFragment.kt
    
    private fun onMarkerClick(
        markerLatitude: Double?,
        markerLongitude: Double?,
        pharmacyInfoList: PharmacyItem.PharmacyInfo
    ): Overlay.OnClickListener {
        return Overlay.OnClickListener {
            if (markerLatitude != null && markerLongitude != null) {
                moveCamera(markerLatitude, markerLongitude)
            }
            showBottomSheet(pharmacyInfoList)
            true
        }
    }
    
    private fun showBottomSheet(markerInfo: PharmacyItem.PharmacyInfo): Boolean {
        val bottomSheetFragment = BottomSheetFragment.newInstance(markerInfo)
        bottomSheetFragment.show(childFragmentManager, bottomSheetFragment.tag)
        return true
    }

     

    BottomSheetFragment에서

    수거 횟수와 좋아요 횟수는 Firebase의 Realtime Database에 저장하였으며,

    좋아요한 수거함들은 로그인 없이도 저장할 수 있도록 Shared Preference에 저장했다.

    mvvm 패턴을 사용했기 때문에 ViewModel에 medicineCount와 heartCount를 라이브 데이터로 사용해서,

    observe를 통해 바로 UI에 반영되도록 만들었다.

    class BottomSheetFragment : BottomSheetDialogFragment() {
    
        private var _binding: FragmentBottomSheetBinding? = null
        private val binding get() = _binding!!
        private lateinit var pharmacyInfo: PharmacyItem.PharmacyInfo
    
        private val sharedViewModel: SharedViewModel by activityViewModels {
            SharedViewModel.Factory(
                requireContext().getSharedPreferences(
                    "prefs",
                    Context.MODE_PRIVATE
                )
            )
        }
    
        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View {
            _binding = FragmentBottomSheetBinding.inflate(inflater, container, false)
            arguments?.getParcelable<PharmacyItem.PharmacyInfo>(Const.PHARMACY)?.let { pharmacyInfo ->
                this.pharmacyInfo = pharmacyInfo
            }
    
            return binding.root
        }
    
        override fun getTheme(): Int {
            return R.style.RoundedBottomSheetDialogTheme
        }
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
    
            initView()
        }
    
        private fun initView() {
            setupPharmacyInfo()
            setupHeartIcon()
            observeViewModel()
            fetchCounts()
        }
    
        private fun setupPharmacyInfo() {
            with(binding) {
                tvFacilityType.text = pharmacyInfo.collectionLocationClassificationName
                tvFacilityName.text = pharmacyInfo.collectionLocationName
                tvDistance.text = pharmacyInfo.distance?.toInt().toString() + " m"
                tvAddress.text = pharmacyInfo.streetNameAddress
                tvDate.text = pharmacyInfo.dataDate
    
                if (pharmacyInfo.phoneNumber.isNullOrEmpty()) {
                    llPhone.visibility = View.GONE
                } else {
                    tvPhone.text = pharmacyInfo.phoneNumber
                }
            }
        }
    
        private fun setupHeartIcon() {
            updateHeartIcon(sharedViewModel.isPharmacyInfoLiked(pharmacyInfo))
    
            binding.ivHeart.setOnClickListener {
                val isHeartFilled = toggleHeartIcon()
                pharmacyInfo.streetNameAddress?.let { address ->
                    val facilityName = pharmacyInfo.collectionLocationName ?: ""
                    sharedViewModel.updateHeartCount(address, isHeartFilled, facilityName)
                }
            }
        }
    
        private fun updateHeartIcon(isLiked: Boolean) {
            if (isLiked) {
                binding.ivHeart.setImageResource(R.drawable.ic_heart_fill)
                binding.ivHeart.tag = "filled"
            } else {
                binding.ivHeart.setImageResource(R.drawable.ic_heart_empty)
                binding.ivHeart.tag = "empty"
            }
        }
    
        private fun toggleHeartIcon(): Boolean {
            val isHeartFilled = binding.ivHeart.tag == "filled"
            updateHeartIcon(!isHeartFilled)
            return !isHeartFilled
        }
    
        private fun observeViewModel() {
            with(binding) {
                sharedViewModel.heartCount.observe(viewLifecycleOwner) { count ->
                    tvHeartNumber.text = count.toString()
                }
    
                sharedViewModel.medicineCount.observe(viewLifecycleOwner) { count ->
                    tvMedicineNumber.text = count.toString()
                }
    
                sharedViewModel.updateHeartResult.observe(viewLifecycleOwner) { success ->
                    if (success) {
                        if (binding.ivHeart.tag == "filled") {
                            sharedViewModel.addLikedItem(pharmacyInfo)
                        } else {
                            sharedViewModel.removeLikedItem(pharmacyInfo)
                        }
                    } else {
                        toggleHeartIcon()
                    }
                }
    
                sharedViewModel.fetchError.observe(viewLifecycleOwner) { event ->
                    event.getContentIfNotHandled()?.let { isError ->
                        if (isError) {
                            Toast.makeText(requireContext(), getString(R.string.fetch_error), Toast.LENGTH_SHORT).show()
                            sharedViewModel.resetFetchError()
                        }
                    }
                }
            }
        }
    
        private fun fetchCounts() {
            pharmacyInfo.streetNameAddress?.let {
                sharedViewModel.fetchHeartCount(it)
                sharedViewModel.fetchMedicineCount(it)
            }
        }
    
        override fun onDestroyView() {
            super.onDestroyView()
            _binding = null
        }
    
        companion object {
            fun newInstance(pharmacyInfo: PharmacyItem.PharmacyInfo): BottomSheetFragment {
                val bundle = Bundle().apply {
                    putParcelable(Const.PHARMACY, pharmacyInfo)
                }
                return BottomSheetFragment().apply {
                    arguments = bundle
                }
            }
        }
    }
Designed by Tistory.