Skip to content

4.4.5 작가 포트폴리오

JI YOON LEE edited this page Mar 5, 2023 · 21 revisions

WrittenBy Write About
WrittenBy Write About


구현 방법

1. 주변 작가 목록

(1) 앱 첫화면 설정

// HomeFragment.kt

vpMain.post {
    if (SharedPreferencesUtil(requireContext()).getViewPagerInit()) {
        vpMain.currentItem = 1
        mainViewPagerAdapter.notifyDataSetChanged()
        SharedPreferencesUtil(requireContext()).changeViewPagerInit(false)
    }
}
  • 메뉴 순서가 지도 → 홈 → 마이페이지 순서로 되어 있는데, 우리 앱에서는 홈이 가장 먼저 뜨게 하려 함
  • post() 함수를 사용해 뷰페이저 뷰가 생성되고, currentItem이 1로 설정되도록 함
    • 뷰를 생성한 후 무언가 동작하도록 할 때 post() 함수를 사용

(2) 네비게이션 백스택 값 처리(역할, 주소)

// HomeFragment.kt

findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData<String>("Role")?.observe(viewLifecycleOwner){
    homeViewModel.changeRole(requireContext(), Types.Role.getRoleType(it))
}
findNavController().currentBackStackEntry?.savedStateHandle?.getLiveData<AddressDomainDto>("curAddress")?.observe(viewLifecycleOwner){
    homeViewModel.getCurrentAddress()
}
  • activity의 onActivityResult처럼 fragment에서는 결과값을 받아오려면 NavBackStackEntry와 LiveData를 활용하면 된다
  • 변경된 Role 값에 따라 뷰를 세팅하기 위해 key값이 Role인 값을 observe하고, 변경된 주소 값에 따라 뷰를 세팅하기 위해 key값이 curAddress인 값을 observe한다
// AddressFragment.kt & AddressSearchFragment.kt & AddressMapFragment.kt

findNavController().previousBackStackEntry?.savedStateHandle?.set("curAddress", curAddress)
또는
findNavController().getBackStackEntry(R.id.mainFragment).savedStateHandle["curAddress"] = addressDomainDto
  • previousBackStackEntry로 자기 바로 전의 back stack에 접근해 결과값 지정할 수 있다
  • getBackStackEntry로 명시적으로 어떤 back stack에 접근할 지 정해 결과값 지정

(3) 주변 작가 필터링

// HomeFragment.kt

private fun setChipEvent() {
    binding.apply {
        chipPopular.setOnClickListener {
            setChipsEnabled(popular = false, avg = true, cnt = true)
            homeViewModel.filter = "heart"
            homeViewModel.getPhotographerInfoByAddressInfo(curAddress, homeViewModel.filter)
        }
        chipReviewAvg.setOnClickListener {
            setChipsEnabled(popular = true, avg = false, cnt = true)
            homeViewModel.filter = "score"
            homeViewModel.getPhotographerInfoByAddressInfo(curAddress, homeViewModel.filter)
        }
        chipReviewCnt.setOnClickListener {
            setChipsEnabled(popular = true, avg = true, cnt = false)
            homeViewModel.filter = "review"
            homeViewModel.getPhotographerInfoByAddressInfo(curAddress, homeViewModel.filter)
        }
    }
}

private fun setChipsEnabled(popular: Boolean, avg: Boolean, cnt: Boolean) {
    binding.apply {
        chipPopular.isEnabled = popular
        chipReviewAvg.isEnabled = avg
        chipReviewCnt.isEnabled = cnt
    }
}
  • 필터 칩 클릭했을 때
    • 세 개의 칩의 활성화 여부 결정하는 setChipsEnabled 함수에 인자값 넘겨줌
    • 뷰모델에 필터 정보 저장하고 해당 값으로 api call


2. 작가 포트폴리오 조회

(1) 작가 포트폴리오 조회 툴바 처리

// HomeFragment.kt

menu.findItem(R.id.메뉴id).isVisible = true/false
  • 작가인지/고객인지에 따라 포트폴리오 화면 메뉴 visible 여부를 결정하는 코드
// HomeFragment.kt

binding.tbHome.apply {
    Glide.with(this)
        .asBitmap()
        .load(Constants.IMAGE_BASE_URL + photoUrl)
        .circleCrop()
        .into(object: CustomTarget<Bitmap>(){
            override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
                menu.findItem(R.id.메뉴id).icon = BitmapDrawable(resources, resource)
            }
            override fun onLoadCleared(placeholder: Drawable?) {}
        })
}
  • 툴바 메뉴 이미지 URL 서버에서 받아와서 Glide로 처리하는 코드

(2) 작가 포트폴리오/게시물 화면 이동 분기 처리

// PortfolioFragment.kt

if (args.goToDetail) {
    val action = PortfolioFragmentDirections.actionPortfolioFragmentToPostDetailFragmentWithPop(isFromMap = args.isFromMap, postId = args.postId, photographerId = args.photographerId)
    findNavController().navigate(action)
} else {
    // 작가와 게시물 id 모두 -1인 경우 -> 오류 발생
    if (args.photographerId<0 && args.postId<0) {
        showToast(requireContext(), requireContext().getString(R.string.msg_common_error, "정보를 불러오는"), Types.ToastType.ERROR)
        moveToPopUpSelf()
    }
    else setPhotographerId()
}
  • 포트폴리오 페이지와 게시물 페이지를 sub navigation으로, 그 navigation에서 startDestination을 포트폴리오 페이지로 선택해놔서 mainFragment에서 게시물 페이지로 바로 이동은 불가능하고, 포트폴리오 페이지를 거쳐서 가야함
  • navArgs로 boolean 타입인 goToDetail을 둬서 이 값이 true이면 바로 게시물 페이지로 이동하도록 함


3. 작가 포트폴리오 등록

(1) TedImagePicker를 활용한 갤러리 기능 구현

  • 기능
    작가는 갤러리로부터, 1장의 사진 만을 가져올 수 있으며, 자유롭게 선택한 사진을 취소할 수 있습니다. 또한, 사진 선택 시 Image Crop 기능을 제공합니다.

실행 화면
사진 선택 전 사진 선택 사진 선택 후

구현 코드
  • PermissionUtils.kt
// Utils
object PermissionUtils {
    // TedImagePicker을 활용한 갤러리 접근 권한 처리, 람다 함수로 모듈화
    fun actionGalleryPermission(context : Context, maxNum: Int, maxMsg : String, action:(List<Uri>)->Unit) {
        TedImagePicker.with(context)
            .title("사진 선택")
            .max(maxNum, maxMsg)
            .startMultiImage { uriList -> action(uriList) }
    }
}

  • PhotographerProfileFragment.kt
// Fragment
class PhotographerProfileFragment() : BaseBottomSheetDialogFragment<FragmentPhotographerProfileBinding>(FragmentPhotographerProfileBinding::inflate) {
    private val viewModel : PhotographerWriteGraphViewModel by navGraphViewModels(R.id.registerPortFolioGraph)

    override fun initView() { }

    override fun setEvent() {
        setClickListener()
    }

    private fun setClickListener(){
        binding.apply {
            layoutSelectImageGallery.setOnClickListener {
                // 모듈화된 갤러리 접근 권한 처리 함수를 활용해, 이미지 URI 반환하고 이를 CropFragment에 전달.
                actionGalleryPermission(requireContext(), 1, "프로필 사진은 한 장만 선택가능합니다."){
                    viewModel.profileBitmap = (ImageUtils.resizeImage(requireContext(), it[0]))
                        .getRotateInfo(ExifInterface(requireContext().contentResolver.getPathFromURI(it[0])))
                    findNavController().navigate(R.id.photographerProfileCropFragment)
                }
            }
            layoutRemoveImage.setOnClickListener {
                viewModel.uploadProfileImage(null)
                findNavController().navigate(R.id.action_photographerProfileFragment_pop)
            }
        }
    }
}

  • PhotographerProfileCropFragment.kt
// Fragment
class PhotographerProfileCropFragment : BaseFragment<FragmentImageCropBinding>(
    FragmentImageCropBinding::bind, R.layout.fragment_image_crop
) {
    private val viewModel : PhotographerWriteGraphViewModel by navGraphViewModels(R.id.registerPortFolioGraph)

    override fun initView() {
        initToolbar()
        val image = viewModel.profileBitmap
        if(image == null){
            showToast(requireContext(), "이미지를 가져올 수 없습니다.", Types.ToastType.ERROR)
            moveToPopUpSelf()
        }
        binding.cropImageView.setImageBitmap(image)
    }

    override fun setEvent() {
        binding.btnSave.setOnClickListener {
            // ImageCrop Library를 활용해, 전달된 이미지 URI를 Crop 후 이를 File로 변환해 반환.
            // 이때, Image Bitmap을 File로 변환하는 코드는 (2)ImageUtil에서 설명.
            val croppedImgBitmap = binding.cropImageView.getCroppedImage(500,500)
            val croppedImgFile = croppedImgBitmap?.convertBitmapToFile(context = requireContext())
            viewModel.uploadProfileImage(croppedImgFile)
            moveToWritePortfolio()
        }
    }


    private fun initToolbar(){
        val toolbar : Toolbar = binding.layoutToolbar.tbToolbar
        toolbar.initToolbar("사진 편집", true) { moveToPopUpSelf() }
    }

    private fun moveToPopUpSelf() = findNavController().navigate(R.id.action_photographerProfileCropFragment_pop)
    private fun moveToWritePortfolio() = findNavController().navigate(R.id.action_photographerProfileCropFragment_to_writePortfolioFragment)

}

(2) ImageUtil을 통한 이미지 리소스 최적화 👉 Link Here


(3) 모든 항목 입력 시 작가 포트폴리오 등록

  • 기능
    사진(최대 1장), 작가 소개, 카테고리 & 가격 설정, 활동 지역 설정, 입금 계좌의 정보가 모두 입력되었을 시, 작가는 포트폴리오를 등록할 수 있습니다.

실행 화면
포폴 등록 전 항목 입력 후 등록 포폴 등록 후
모든 항목 입력 전 항목 입력 후 포폴 등록 포폴 등록 성공

구현 코드
  • ImageUtils.kt
// Image -> RequestBody로 만드는 함수, 확장 함수로 모듈화

fun File?.convertToRequestBody() : RequestBody = requireNotNull(this).asRequestBody("image/*".toMediaTypeOrNull())

  • BaseViewModel.kt
abstract class BaseViewModel : ViewModel() {
    
    // Image -> MultipartBody로 만드는 함수, 공용 함수로 모듈화

    fun makeMultiPartBody(jsonKey: String, file: File) : MultipartBody.Part {
        val requestBody = file.convertToRequestBody()
        return MultipartBody.Part.createFormData(jsonKey, file.name, requestBody)
    }
}

  • PhotographerWriteGraphViewModel.kt

    👈 코드 보기 (Code Here)

    // ViewModel
    class PhotographerWriteGraphViewModel : BaseViewModel() {
        private val photographerRepository = Application.repositoryInstances.getPhotographerRepository()
    
        var profileBitmap : Bitmap? = null
        private val _photographerRequestDomainDto : PhotographerRequestDomainDto = PhotographerRequestDomainDto()
        private val _photographerDataResponse : SingleLiveData<PhotographerRequestDomainDto> = SingleLiveData(_photographerRequestDomainDto)
        val photographerDataResponse : SingleLiveData<PhotographerRequestDomainDto>
            get() = _photographerDataResponse
        private val _checkDataResponse : SingleLiveData<Boolean> = SingleLiveData(null)
        val checkDataResponse : SingleLiveData<Boolean>
            get() = _checkDataResponse
        private val _profileImageResponse : SingleLiveData<File?> = SingleLiveData<File?>(null)
        val profileImageResponse : SingleLiveData<File?>
            get() = _profileImageResponse
    
        fun getData() = this._photographerRequestDomainDto
    
        fun uploadData(image:File?, introduction:String, categories : List<CategoryDomainDto>, places:List<PlaceDomainDto>, account:AccountDomainDto){
            _photographerRequestDomainDto.profileImg = image
            _photographerRequestDomainDto.introduction = introduction
            _photographerRequestDomainDto.categories = categories
            _photographerRequestDomainDto.places = places
            _photographerRequestDomainDto.account = account
            _profileImageResponse.postValue(image)
            photographerDataResponse.postValue(_photographerRequestDomainDto)
            _checkDataResponse.postValue(checkData())
        }
    
        fun uploadProfileImage(image:File?){
            _photographerRequestDomainDto.profileImg = image
            _profileImageResponse.postValue(image)
            _checkDataResponse.postValue(checkData())
        }
    
        fun uploadIntroductionData(introduction: String?){
            _photographerRequestDomainDto.introduction = introduction
            _checkDataResponse.postValue(checkData())
        }
        fun uploadCategoriesData(categories : List<CategoryDomainDto>){
            _photographerRequestDomainDto.categories = categories
            _checkDataResponse.postValue(checkData())
        }
        fun uploadPlacesData(places: List<PlaceDomainDto>){
            _photographerRequestDomainDto.places = places
            _checkDataResponse.postValue(checkData())
        }
        fun uploadAccountData(account:AccountDomainDto){
            _photographerRequestDomainDto.account = account
            _checkDataResponse.postValue(checkData())
        }
    
        // 입력 정보가 변경되었을 시, 모든 입력 정보가 입력되었는지 체크
        private fun checkData() : Boolean {
            if (_photographerRequestDomainDto.profileImg==null || _photographerRequestDomainDto.introduction==null||_photographerRequestDomainDto.categories.any { it.isEmpty }||
                _photographerRequestDomainDto.places.any { it.isEmpty }||_photographerRequestDomainDto.account?.isEmpty==true) return false
            return true
        }
    
        val registerPhotographerResponse: SingleLiveData<NetworkUtils.NetworkResponse<Any>>
            get() = photographerRepository.registerPhotographerInfoResponseLiveData
    
        // 서버에 작가 포트폴리오 등록 요청 후, 요청 결과 리턴
        fun registerPhotographerInfo() = viewModelScope.launch{
            val photographerRequestDto = _photographerRequestDomainDto.makeToPhotographerRequestDto()
            val image = makeMultiPartBody(REQUEST_KEY_IMAGE_PROFILE, photographerRequestDto.profileImg)
            photographerRepository.registerPhotographerInfo(image = image, photographerDto = photographerRequestDto.photographerDto)
        }
    
    }

  • PhotographerWritePortFolioFragment.kt

    👈 코드 보기 (Code Here)

    // Fragment
    class PhotographerWritePortFolioFragment : BaseFragment<FragmentWritePhotographerPortfolioBinding>
        (FragmentWritePhotographerPortfolioBinding::bind, R.layout.fragment_write_photographer_portfolio) {
        private val navArgs : RegisterPortFolioGraphArgs by navArgs()
        private val viewModel : PhotographerWriteGraphViewModel by navGraphViewModels(R.id.registerPortFolioGraph)
        private lateinit var categoryRVAdapter: CategoryRVAdapter
        private lateinit var placeRVAdapter : PlaceRVAdapter
        private var accountDto = AccountDomainDto()
    
    
        override fun initView() {
            initToolbar()
            initAdapter()
            setObserver()
            // 포트폴리오 수정인지 등록인지 로직 체크
            if (navArgs.photographerResponseDto!=null && viewModel.getData().profileImg==null) {
                setModifyPortFolioView(navArgs.photographerResponseDto!!)
            }
            else setWritePortFolioView()    
        }
        override fun setEvent() {
            setClickListener()
        }
    
        // 옵저버 처리
        private fun setObserver(){
            viewModel.apply {
                profileImageResponse.observe(viewLifecycleOwner){
                    Glide.with(binding.imagePhotographerProfile)
                        .load(it)
                        .into(binding.imagePhotographerProfile)
                }
                photographerDataResponse.observe(viewLifecycleOwner){
                    setData(it)
                }
                // 입력 정보가 모두 입력되었는지 여부 체크 후, 업로드 버튼의 클릭 활성화/비활성화
                checkDataResponse.observe(viewLifecycleOwner){
                    if (it) setButtonEnable()
                    else setButtonDisable()
                }
                // 서버에 작가 포트폴리오 등록 요청 후, 결과 분기 처리
                registerPhotographerResponse.observe(viewLifecycleOwner){
                    when(it){
                        is NetworkUtils.NetworkResponse.Loading -> {
                            showLoadingDialog(requireContext())
                        }
                        is NetworkUtils.NetworkResponse.Success -> {
                            dismissLoadingDialog()
                            showToast(requireContext(), getString(R.string.msg_photographer_success), Types.ToastType.SUCCESS)
                            findNavController().previousBackStackEntry?.savedStateHandle?.set("Role", Types.Role.PHOTOGRAPHER.getValue())
                            moveToPop()
                        }
                        is NetworkUtils.NetworkResponse.Failure -> {
                            showToast(requireContext(), requireContext().getString(R.string.msg_common_error, "포트폴리오 등록"), Types.ToastType.ERROR)
                            dismissLoadingDialog()
                        }
                    }
                }
            }
        }
        
        // 클릭 이벤트 처리
        private fun setClickListener(){
            binding.apply {
                // 프로필 버튼 클릭시, 프로필 관련 바텀 시트 호출
                btnPhotographerProfileChange.setOnClickListener {
                    findNavController().navigate(R.id.action_writePortfolioFragment_to_photographerProfileFragment)
                }
                // 카테고리 추가 버튼 클릭 시, 카테고리 항목의Recyclerview 요소 추가
                layoutPhotographerCategory.btnAdd.setOnClickListener {
                    categoryRVAdapter.addData()
                }
                // 장소 추가 버튼 클릭 시, 장소 항목의 Recyclerview 요소 추가
                layoutPhotographerPlace.btnAdd.setOnClickListener {
                    placeRVAdapter.addData()
                }
                // 업로드 버튼이 활성화 된 경우 클릭 시, 서버에 작가 포트폴리오 등록 요청
                btnUpload.setOnClickListener {
                    if (navArgs.photographerResponseDto!=null) viewModel.modifyPhotographerInfo()
                    else viewModel.registerPhotographerInfo()
                }
            }
        }
    
        // 어댑터 처리
        private fun initAdapter(){
            // 카테고리 항목 RecyclerView 어댑터 처리
            categoryRVAdapter = CategoryRVAdapter(viewModel, binding.layoutPhotographerCategory.btnAdd).apply {
                setItemClickListener(object :CategoryRVAdapter.ItemClickListener{
                    override fun onClickBtnDelete(view: View, position: Int, dto: CategoryDomainDto) {
                        categoryRVAdapter.deleteData(position)
                    }
                })
            }.also { binding.layoutPhotographerCategory.rvPhotographerCategory.adapter = it }
    
            // 장소 항목 RecyclerView 어댑터 처리
            placeRVAdapter = PlaceRVAdapter(viewModel, binding.layoutPhotographerPlace.btnAdd).apply {
                setItemClickListener(object :PlaceRVAdapter.ItemClickListener{
                    override fun onClickBtnDelete(view: View, position: Int, dto: PlaceDomainDto) {
                        placeRVAdapter.deleteItem(position)
                    }
                })
            }.also { binding.layoutPhotographerPlace.rvPhotographerPlace.adapter = it }
    
            // 작가 소개 정보 항목 Text 감지 어댑터 처리
            binding.etPhotographerInfo.doOnTextChanged { text, _, _, _ ->
                val introduce = if (text.isNullOrBlank()) null else binding.etPhotographerInfo.getString()
                viewModel.uploadIntroductionData(introduce)
            }
    
            // 작가 계좌 정보 항목 어댑터 처리
            setAccountAdapter()
        }
    
        // 포트폴리오 등록 초기 UI 화면 설정
        private fun setWritePortFolioView(){
            initToolbar()
            setButtonDisable()
        }
    
    
        private fun initToolbar(){
            val toolbar : Toolbar = binding.layoutToolbar.tbToolbar
            toolbar.initToolbar("작가 포트폴리오 정보", true) {
                findNavController().navigate(R.id.action_writePortfolioFragment_pop)
            }
        }
    
        private fun setAccountAdapter(accountBank:String?=null, accountNum:String?=null) {
            binding.layoutPhotographerAccount.apply {
                tvPhotographerAccount.setOnItemClickListener { _, _, _, _ ->
                    accountDto.accountBank = tvPhotographerAccount.getString()
                    accountDto.isEmpty = accountDto.accountNum == null
                    viewModel.uploadAccountData(accountDto)
                }
    
                etPhotographerAccount.doAfterTextChanged {
                    it?.let {
                        if (it.isEmpty()){
                            accountDto.accountNum = null
                            accountDto.isEmpty = true
                        }else{
                            accountDto.accountNum = it.toString()
                            accountDto.isEmpty = false
                        }
                        viewModel.uploadAccountData(accountDto)
                    }
                }
                tvPhotographerAccount.setText(accountBank)
                tvPhotographerAccount.setAdapter(Spinners.getSelectedArrayAdapter(requireContext(), R.array.spinner_account))
                etPhotographerAccount.setText(accountNum)
            }
        }
    
        private fun setData(dto: PhotographerRequestDomainDto) {
            binding.apply {
                dto.account?.let { accountDto = it }
                etPhotographerInfo.setText(dto.introduction)
                categoryRVAdapter.setListData(dto.categories as ArrayList<CategoryDomainDto>)
                placeRVAdapter.setListData(dto.places as ArrayList<PlaceDomainDto>)
                setAccountAdapter(dto.account?.accountBank, dto.account?.accountNum)
            }
        }
    
        private fun setButtonEnable() {
            binding.btnUpload.apply {
                isEnabled = true
                setBackgroundResource(R.drawable.rectangle_blue400_radius_8)
            }
        }
        private fun setButtonDisable() {
            binding.btnUpload.apply {
                isEnabled = false
                setBackgroundResource(R.drawable.rectangle_gray400_radius_8)
            }
        }
    
        private fun moveToPop() = findNavController().navigate(R.id.action_writePortfolioFragment_pop)
    
    }
    

  • CategoryRVAdapter.kt

    👈 코드 보기 (Code Here)

    // 카테고리 항목 - 동적 RecyclerView Adapter
    class CategoryRVAdapter(private val viewModel : PhotographerWriteGraphViewModel, private val addBtnView:Button, private val limit:Int=5) : RecyclerView.Adapter<CategoryRVAdapter.Holder>() {
    private val itemList : ArrayList<CategoryDomainDto> = arrayListOf()
    
    // RV 요소 데이터 리스트 반환
    fun getListData() : ArrayList<CategoryDomainDto> = itemList
    
    // RV 요소 데이터 리스트 설정
    fun setListData(dataList: ArrayList<CategoryDomainDto>){
        itemList.clear()
        itemList.addAll(dataList)
        notifyDataSetChanged()
    }
    
    // RV 요소 추가 - 최대 5개까지
    fun addData() {
        if (itemCount<=limit){
            itemList.add(CategoryDomainDto())
            notifyItemInserted(itemCount-1)
            viewModel.uploadCategoriesData(getListData())
        }
    }
    
    // RV 요소 삭제
    fun deleteData(index: Int){
        itemList.removeAt(index)
        notifyDataSetChanged()
        viewModel.uploadCategoriesData(getListData())
    }
    
    // RV 요소 갯수 카운트 -> 최대 5개로 제한하며, 요소가 없을 경우 emptyView로 설정
    override fun getItemCount(): Int {
        if (itemList.isEmpty()) itemList.add(CategoryDomainDto())
        else if (itemList.size==limit) addBtnView.visibility = View.GONE
        else addBtnView.visibility = View.VISIBLE
        return itemList.size
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        return  Holder(ItemPhotographerCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }
    
    override fun onBindViewHolder(holder: Holder, position: Int) {
        val dto = itemList[position]
        holder.apply {
            bindInfo(position, dto)
            itemView.tag = dto
        }
    }
    interface ItemClickListener{
        fun onClickBtnDelete(view: View, position: Int, dto:CategoryDomainDto)
    }
    private lateinit var itemClickListener: ItemClickListener
    fun setItemClickListener(itemClickListener: ItemClickListener){ this.itemClickListener = itemClickListener }
    
    
    // RV 요소 Holder 세팅 -> 동적으로 갯수를 추가하고 삭제할 수 있으며, 빈 입력항목이 있는 지 실시간으로 체킹하여 외부 Fragment에 전달.
    inner class Holder(private val binding: ItemPhotographerCategoryBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bindInfo(position: Int, dto: CategoryDomainDto) {
            binding.apply {
                tvPhotographerCategory.apply {
                    setText(dto.name)
                    setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, R.array.spinner_category))
                    setOnItemClickListener { _, _, position, _ ->
                        dto.name = this.getString()
                        dto.categoryId = position + 1
                        dto.isEmpty = !(dto.price!=null && dto.description!=null)
                        viewModel.uploadCategoriesData(getListData())
                    }
                }
                etPhotographerCategoryPrice.apply {
                    clearPriceTextChangeListener(this)
                    val priceString = if (dto.price==null) null else dto.price!!.makeComma()
                    setText(priceString)
                    dto.priceTextWatcher = OnCurrentPriceTextWatcher(binding, dto, this)
                    addTextChangedListener(dto.priceTextWatcher)
                }
                etPhotographerCategoryDetail.apply {
                    clearContentTextChangeListener(this)
                    setText(dto.description)
                    dto.contentTextWatcher = OnCurrentContentTextWatcher(dto)
                    addTextChangedListener(dto.contentTextWatcher)
                }
                if (position==0) btnDelete.visibility = View.INVISIBLE
                else btnDelete.visibility = View.VISIBLE
                btnDelete.setOnClickListener {
                    itemClickListener.onClickBtnDelete(it, position, dto)
                }
            }
        }
    
        private fun clearPriceTextChangeListener(priceEditText : EditText){
            for (position in 0 until itemCount){
                priceEditText.removeTextChangedListener(itemList[position].priceTextWatcher)
            }
        }
        private fun clearContentTextChangeListener(contentEditText: EditText){
            for (position in 0 until itemCount){
                contentEditText.removeTextChangedListener(itemList[position].contentTextWatcher)
            }
        }
    }
    
    inner class OnCurrentPriceTextWatcher(private val binding : ItemPhotographerCategoryBinding, private val dto:CategoryDomainDto, private val editText: EditText?) : TextWatcher {
        private val editTextWeakReference: WeakReference<EditText> = WeakReference<EditText>(editText)
    
        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { }
        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { }
        override fun afterTextChanged(editable: Editable) {
            val editText: EditText = editTextWeakReference.get() ?: return
            val s = editable.replace("""[,.원]""".toRegex(), "")
            if (s.isEmpty()) {
                binding.tvPrice.visibility = View.INVISIBLE
                dto.price = null
                dto.isEmpty = true
                viewModel.uploadCategoriesData(getListData())
                return
            }
            editText.removeTextChangedListener(this)
            val price = s.toInt()
            val formatted: String = price.makeComma()
            editText.setText(formatted)
            editText.setSelection(formatted.length)
            editText.addTextChangedListener(this)
            binding.tvPrice.visibility = View.VISIBLE
            dto.price = price
            dto.isEmpty = dto.name == null
            viewModel.uploadCategoriesData(getListData())
        }
    }
    
    inner class OnCurrentContentTextWatcher(private val dto:CategoryDomainDto) : TextWatcher {
        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { }
        override fun afterTextChanged(s: Editable) {
            dto.description = if (s.isEmpty()) null else s.toString()
            dto.isEmpty = s.isEmpty()
            viewModel.uploadCategoriesData(getListData())
        }
    }
    }

  • PlaceRVAdapter.kt

    👈 코드 보기 (Code Here)

    // 장소 항목 - 동적 RecyclerView Adapter
    class PlaceRVAdapter(private val viewModel : PhotographerWriteGraphViewModel, private val addBtnView:Button, private val limit:Int=5) : RecyclerView.Adapter<PlaceRVAdapter.Holder>() {
    private val itemList : ArrayList<PlaceDomainDto> = arrayListOf()
    
    // RV 요소 데이터 리스트 반환
    fun getListData() : ArrayList<PlaceDomainDto> = itemList
    
    // RV 요소 데이터 리스트 설정
    fun setListData(dataList: ArrayList<PlaceDomainDto>){
        itemList.clear()
        itemList.addAll(dataList)
        notifyDataSetChanged()
    }
    
    
    // RV 요소 추가 - 최대 5개까지
    fun addData() {
        if (itemCount<=limit){
            itemList.add(PlaceDomainDto())
            notifyDataSetChanged()
            viewModel.uploadPlacesData(getListData())
        }
    }
    
    // RV 요소 삭제
    fun deleteItem(index: Int){
        itemList.removeAt(index)
        notifyDataSetChanged()
        viewModel.uploadPlacesData(getListData())
    }
    
    
    // RV 요소 갯수 카운트 -> 최대 5개로 제한하며, 요소가 없을 경우 emptyView로 설정
    override fun getItemCount(): Int {
        if (itemList.isEmpty()) itemList.add(PlaceDomainDto())
        else if (itemList.size==limit) addBtnView.visibility = View.GONE
        else addBtnView.visibility = View.VISIBLE
        return itemList.size
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        return  Holder(ItemPhotographerPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }
    
    override fun onBindViewHolder(holder: Holder, position: Int) {
        val dto = itemList[position]
        holder.apply {
            bindInfo(position, dto)
            itemView.tag = dto
        }
    }
    
    interface ItemClickListener{
        fun onClickBtnDelete(view: View, position: Int, dto:PlaceDomainDto)
    }
    private lateinit var itemClickListener: ItemClickListener
    fun setItemClickListener(itemClickListener: ItemClickListener){ this.itemClickListener = itemClickListener }
    
    
    
    // RV 요소 Holder 세팅 -> 동적으로 갯수를 추가하고 삭제할 수 있으며, 빈 입력항목이 있는 지 실시간으로 체킹하여 외부 Fragment에 전달.
    inner class Holder(private val binding: ItemPhotographerPlaceBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bindInfo(position: Int, dto: PlaceDomainDto) {
            binding.apply {
                tvPhotographerPlaceFirst.run {
                    setOnItemClickListener { _, _, position, _ ->
                        val resource = Spinners.getSelectedPlaceArrayResource(this.getString())
                        tvPhotographerPlaceSecond.setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, resource))
                        tvPhotographerPlaceSecond.isEnabled = true
                        tvPhotographerPlaceSecond.text = null
                        dto.isEmpty = true
                        dto.first = this.getString()
                        dto.firstId = position
                    }
                    setText(dto.first)
                    setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, R.array.spinner_region))
                }
                tvPhotographerPlaceSecond.apply {
                    isEnabled = false
                    setOnItemClickListener { _, _, position, _ ->
                        dto.isEmpty = false
                        dto.second = this.getString()
                        dto.secondId = position
                        viewModel.uploadPlacesData(getListData())
                    }
                    setText(dto.second)
                    dto.first?.let {
                        isEnabled = true
                        val resource = Spinners.getSelectedPlaceArrayResource(it)
                        setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, resource))
                    }
                }
                if (position==0) btnDelete.visibility = View.INVISIBLE
                else btnDelete.visibility = View.VISIBLE
                btnDelete.setOnClickListener { itemClickListener.onClickBtnDelete(it, position, dto) }
    
            }
        }
    }
    
    }


4. 작가 포트폴리오 수정


(1) 원격 URL Image 다운로드 후 Local File 생성

  • 기능
    Amazon S3의 원격 이미지 URL을 로컬 File로 다운로드 받은 뒤, Recyclerview를 통해 해당 File을 ImageView로 표출합니다.

    이때 ANR 방지를 위해 해당 과정은 반드시 Main Thread가 아닌 쓰레드에서 수행되어야 하므로, Coroutine의 Dispatcher.IO를 활용하였습니다.

    작가는 이를 통해 이전에 등록했던 작가 포트폴리오의 프로필 이미지를 가져와 수정할 수 있습니다.


실행 화면
원격 이미지 URL 원격 이미지 URL -> 로컬 File
이미지 URL 이미지 URL->File
구현 코드
  • Constants.kt
object Constants {
    const val IMAGE_BASE_URL = "https://원격 Resource URL/"

}
  • WritePostFragment.kt
class WritePostFragment : BaseFragment<FragmentWritePostBinding>(FragmentWritePostBinding::bind, R.layout.fragment_write_post) {

    private val navArgs : WritePostFragmentArgs by navArgs()
    private val portfolioGraphViewModel: PortfolioGraphViewModel by navGraphViewModels(R.id.portfolioGraph)


   // Image URL -> Image File로 만드는 함수, 만든 뒤 해당 Image를 UI View에 표출
       private fun setModifyPortFolioView(photographerResponseDto: PhotographerResponseDto){
        initToolbar()
        binding.apply {
            btnUpload.text = "수정하기"
            lifecycleScope.launch(Dispatchers.IO) {
                val profileImg = BitmapFactory.decodeStream(URL(Constants.IMAGE_BASE_URL +photographerResponseDto.profileImg).openConnection().getInputStream()).convertBitmapToFile(context = requireContext())!!
                viewModel.uploadProfileImage(profileImg)
                viewModel.uploadData(profileImg, photographerResponseDto.introduction, photographerResponseDto.categories.map { it.toCategoryDto() }, photographerResponseDto.places.map { it.toPlaceDomainDto() }, AccountDomainDto(false,photographerResponseDto.bank, photographerResponseDto.account))
            }
        }
    }    

}

(2) 모든 항목 입력 시 작가 포트폴리오 수정

  • 기능
    사진(최대 1장), 작가 소개, 카테고리 & 가격 설정, 활동 지역 설정, 입금 계좌의 정보가 모두 입력되었을 시, 작가는 포트폴리오를 수정할 수 있습니다.

실행 화면
항목 변경 후 포폴 수정 포폴 수정 후
항목 변경 후 포폴 수정 포폴 수정 성공

구현 코드
  • ImageUtils.kt
// Image -> RequestBody로 만드는 함수, 확장 함수로 모듈화

fun File?.convertToRequestBody() : RequestBody = requireNotNull(this).asRequestBody("image/*".toMediaTypeOrNull())

  • BaseViewModel.kt
abstract class BaseViewModel : ViewModel() {
    
    // Image -> MultipartBody로 만드는 함수, 공용 함수로 모듈화

    fun makeMultiPartBody(jsonKey: String, file: File) : MultipartBody.Part {
        val requestBody = file.convertToRequestBody()
        return MultipartBody.Part.createFormData(jsonKey, file.name, requestBody)
    }
}

  • PhotographerWriteGraphViewModel.kt

    👈 코드 보기 (Code Here)

    // ViewModel
    class PhotographerWriteGraphViewModel : BaseViewModel() {
        private val photographerRepository = Application.repositoryInstances.getPhotographerRepository()
    
        var profileBitmap : Bitmap? = null
        private val _photographerRequestDomainDto : PhotographerRequestDomainDto = PhotographerRequestDomainDto()
        private val _photographerDataResponse : SingleLiveData<PhotographerRequestDomainDto> = SingleLiveData(_photographerRequestDomainDto)
        val photographerDataResponse : SingleLiveData<PhotographerRequestDomainDto>
            get() = _photographerDataResponse
        private val _checkDataResponse : SingleLiveData<Boolean> = SingleLiveData(null)
        val checkDataResponse : SingleLiveData<Boolean>
            get() = _checkDataResponse
        private val _profileImageResponse : SingleLiveData<File?> = SingleLiveData<File?>(null)
        val profileImageResponse : SingleLiveData<File?>
            get() = _profileImageResponse
    
        fun getData() = this._photographerRequestDomainDto
    
        fun uploadData(image:File?, introduction:String, categories : List<CategoryDomainDto>, places:List<PlaceDomainDto>, account:AccountDomainDto){
            _photographerRequestDomainDto.profileImg = image
            _photographerRequestDomainDto.introduction = introduction
            _photographerRequestDomainDto.categories = categories
            _photographerRequestDomainDto.places = places
            _photographerRequestDomainDto.account = account
            _profileImageResponse.postValue(image)
            photographerDataResponse.postValue(_photographerRequestDomainDto)
            _checkDataResponse.postValue(checkData())
        }
    
        fun uploadProfileImage(image:File?){
            _photographerRequestDomainDto.profileImg = image
            _profileImageResponse.postValue(image)
            _checkDataResponse.postValue(checkData())
        }
    
        fun uploadIntroductionData(introduction: String?){
            _photographerRequestDomainDto.introduction = introduction
            _checkDataResponse.postValue(checkData())
        }
        fun uploadCategoriesData(categories : List<CategoryDomainDto>){
            _photographerRequestDomainDto.categories = categories
            _checkDataResponse.postValue(checkData())
        }
        fun uploadPlacesData(places: List<PlaceDomainDto>){
            _photographerRequestDomainDto.places = places
            _checkDataResponse.postValue(checkData())
        }
        fun uploadAccountData(account:AccountDomainDto){
            _photographerRequestDomainDto.account = account
            _checkDataResponse.postValue(checkData())
        }
    
        // 입력 정보가 변경되었을 시, 모든 입력 정보가 입력되었는지 체크
        private fun checkData() : Boolean {
            if (_photographerRequestDomainDto.profileImg==null || _photographerRequestDomainDto.introduction==null||_photographerRequestDomainDto.categories.any { it.isEmpty }||
                _photographerRequestDomainDto.places.any { it.isEmpty }||_photographerRequestDomainDto.account?.isEmpty==true) return false
            return true
        }
    
    
        val modifyPhotographerResponse: SingleLiveData<NetworkUtils.NetworkResponse<PhotographerResponseDto>>
        get() = photographerRepository.modifyPhotographerInfoResponseLiveData
    
        // 서버에 작가 포트폴리오 수정 요청 후, 요청 결과 리턴
        fun modifyPhotographerInfo() = viewModelScope.launch{
        val photographerModifyRequestDto = _photographerRequestDomainDto.makeToPhotographerModifyRequestDto()
        val image = makeMultiPartBody(REQUEST_KEY_IMAGE_PROFILE, photographerModifyRequestDto.profileImg)
        photographerRepository.modifyPhotographerInfo(image = image, photographerDto = photographerModifyRequestDto.photographerDto)
    }
    
    }

  • PhotographerWritePortFolioFragment.kt

    👈 코드 보기 (Code Here)

    // Fragment
    class PhotographerWritePortFolioFragment : BaseFragment<FragmentWritePhotographerPortfolioBinding>
        (FragmentWritePhotographerPortfolioBinding::bind, R.layout.fragment_write_photographer_portfolio) {
        private val navArgs : RegisterPortFolioGraphArgs by navArgs()
        private val viewModel : PhotographerWriteGraphViewModel by navGraphViewModels(R.id.registerPortFolioGraph)
        private lateinit var categoryRVAdapter: CategoryRVAdapter
        private lateinit var placeRVAdapter : PlaceRVAdapter
        private var accountDto = AccountDomainDto()
    
    
        override fun initView() {
            initToolbar()
            initAdapter()
            setObserver()
            // 포트폴리오 수정인지 등록인지 로직 체크
            if (navArgs.photographerResponseDto!=null && viewModel.getData().profileImg==null) {
                setModifyPortFolioView(navArgs.photographerResponseDto!!)
            }
            else setWritePortFolioView()    
        }
        override fun setEvent() {
            setClickListener()
        }
    
        // 옵저버 처리
        private fun setObserver(){
            viewModel.apply {
                profileImageResponse.observe(viewLifecycleOwner){
                    Glide.with(binding.imagePhotographerProfile)
                        .load(it)
                        .into(binding.imagePhotographerProfile)
                }
                photographerDataResponse.observe(viewLifecycleOwner){
                    setData(it)
                }
                // 입력 정보가 모두 입력되었는지 여부 체크 후, 업로드 버튼의 클릭 활성화/비활성화
                checkDataResponse.observe(viewLifecycleOwner){
                    if (it) setButtonEnable()
                    else setButtonDisable()
                }
                // 서버에 작가 포트폴리오 수정 요청 후, 결과 분기 처리
                modifyPhotographerResponse.observe(viewLifecycleOwner){
                    when(it){
                        is NetworkUtils.NetworkResponse.Loading -> {
                        showLoadingDialog(requireContext())
                        }
                        is NetworkUtils.NetworkResponse.Success -> {
                        dismissLoadingDialog()
                        showToast(requireContext(), getString(R.string.msg_photographer_modify_success), Types.ToastType.SUCCESS)
                        moveToPop()
                        }
                        is NetworkUtils.NetworkResponse.Failure -> {
                        showToast(requireContext(), requireContext().getString(R.string.msg_common_error, "포트폴리오 수정"), Types.ToastType.ERROR)
                        dismissLoadingDialog()
                    }
                    }
                }
            }
        }
        
        // 클릭 이벤트 처리
        private fun setClickListener(){
            binding.apply {
                // 프로필 버튼 클릭시, 프로필 관련 바텀 시트 호출
                btnPhotographerProfileChange.setOnClickListener {
                    findNavController().navigate(R.id.action_writePortfolioFragment_to_photographerProfileFragment)
                }
                // 카테고리 추가 버튼 클릭 시, 카테고리 항목의Recyclerview 요소 추가
                layoutPhotographerCategory.btnAdd.setOnClickListener {
                    categoryRVAdapter.addData()
                }
                // 장소 추가 버튼 클릭 시, 장소 항목의 Recyclerview 요소 추가
                layoutPhotographerPlace.btnAdd.setOnClickListener {
                    placeRVAdapter.addData()
                }
                // 수정 버튼이 활성화 된 경우 클릭 시, 서버에 작가 포트폴리오 수정 요청
                btnUpload.setOnClickListener {
                    if (navArgs.photographerResponseDto!=null) viewModel.modifyPhotographerInfo()
                    else viewModel.registerPhotographerInfo()
                }
            }
        }
    
        // 어댑터 처리
        private fun initAdapter(){
            // 카테고리 항목 RecyclerView 어댑터 처리
            categoryRVAdapter = CategoryRVAdapter(viewModel, binding.layoutPhotographerCategory.btnAdd).apply {
                setItemClickListener(object :CategoryRVAdapter.ItemClickListener{
                    override fun onClickBtnDelete(view: View, position: Int, dto: CategoryDomainDto) {
                        categoryRVAdapter.deleteData(position)
                    }
                })
            }.also { binding.layoutPhotographerCategory.rvPhotographerCategory.adapter = it }
    
            // 장소 항목 RecyclerView 어댑터 처리
            placeRVAdapter = PlaceRVAdapter(viewModel, binding.layoutPhotographerPlace.btnAdd).apply {
                setItemClickListener(object :PlaceRVAdapter.ItemClickListener{
                    override fun onClickBtnDelete(view: View, position: Int, dto: PlaceDomainDto) {
                        placeRVAdapter.deleteItem(position)
                    }
                })
            }.also { binding.layoutPhotographerPlace.rvPhotographerPlace.adapter = it }
    
            // 작가 소개 정보 항목 Text 감지 어댑터 처리
            binding.etPhotographerInfo.doOnTextChanged { text, _, _, _ ->
                val introduce = if (text.isNullOrBlank()) null else binding.etPhotographerInfo.getString()
                viewModel.uploadIntroductionData(introduce)
            }
    
            // 작가 계좌 정보 항목 어댑터 처리
            setAccountAdapter()
        }
        
        // 포트폴리오 수정 초기 UI 화면 설정
        private fun setModifyPortFolioView(photographerResponseDto: PhotographerResponseDto){
            initToolbar()
            binding.apply {
                btnUpload.text = "수정하기"
                lifecycleScope.launch(Dispatchers.IO) {
                    val profileImg = BitmapFactory.decodeStream(URL(Constants.IMAGE_BASE_URL +photographerResponseDto.profileImg).openConnection().getInputStream()).convertBitmapToFile(context = requireContext())!!
                    viewModel.uploadProfileImage(profileImg)
                    viewModel.uploadData(profileImg, photographerResponseDto.introduction, photographerResponseDto.categories.map { it.toCategoryDto() }, photographerResponseDto.places.map { it.toPlaceDomainDto() }, AccountDomainDto(false,photographerResponseDto.bank, photographerResponseDto.account))
                }
            }
        }
    
    
        private fun initToolbar(){
            val toolbar : Toolbar = binding.layoutToolbar.tbToolbar
            toolbar.initToolbar("작가 포트폴리오 정보", true) {
                findNavController().navigate(R.id.action_writePortfolioFragment_pop)
            }
        }
    
        private fun setAccountAdapter(accountBank:String?=null, accountNum:String?=null) {
            binding.layoutPhotographerAccount.apply {
                tvPhotographerAccount.setOnItemClickListener { _, _, _, _ ->
                    accountDto.accountBank = tvPhotographerAccount.getString()
                    accountDto.isEmpty = accountDto.accountNum == null
                    viewModel.uploadAccountData(accountDto)
                }
    
                etPhotographerAccount.doAfterTextChanged {
                    it?.let {
                        if (it.isEmpty()){
                            accountDto.accountNum = null
                            accountDto.isEmpty = true
                        }else{
                            accountDto.accountNum = it.toString()
                            accountDto.isEmpty = false
                        }
                        viewModel.uploadAccountData(accountDto)
                    }
                }
                tvPhotographerAccount.setText(accountBank)
                tvPhotographerAccount.setAdapter(Spinners.getSelectedArrayAdapter(requireContext(), R.array.spinner_account))
                etPhotographerAccount.setText(accountNum)
            }
        }
    
        private fun setData(dto: PhotographerRequestDomainDto) {
            binding.apply {
                dto.account?.let { accountDto = it }
                etPhotographerInfo.setText(dto.introduction)
                categoryRVAdapter.setListData(dto.categories as ArrayList<CategoryDomainDto>)
                placeRVAdapter.setListData(dto.places as ArrayList<PlaceDomainDto>)
                setAccountAdapter(dto.account?.accountBank, dto.account?.accountNum)
            }
        }
    
        private fun setButtonEnable() {
            binding.btnUpload.apply {
                isEnabled = true
                setBackgroundResource(R.drawable.rectangle_blue400_radius_8)
            }
        }
        private fun setButtonDisable() {
            binding.btnUpload.apply {
                isEnabled = false
                setBackgroundResource(R.drawable.rectangle_gray400_radius_8)
            }
        }
    
        private fun moveToPop() = findNavController().navigate(R.id.action_writePortfolioFragment_pop)
    
    }
    

  • CategoryRVAdapter.kt

    👈 코드 보기 (Code Here)

    // 카테고리 항목 - 동적 RecyclerView Adapter
    class CategoryRVAdapter(private val viewModel : PhotographerWriteGraphViewModel, private val addBtnView:Button, private val limit:Int=5) : RecyclerView.Adapter<CategoryRVAdapter.Holder>() {
    private val itemList : ArrayList<CategoryDomainDto> = arrayListOf()
    
    // RV 요소 데이터 리스트 반환
    fun getListData() : ArrayList<CategoryDomainDto> = itemList
    
    // RV 요소 데이터 리스트 설정
    fun setListData(dataList: ArrayList<CategoryDomainDto>){
        itemList.clear()
        itemList.addAll(dataList)
        notifyDataSetChanged()
    }
    
    // RV 요소 추가 - 최대 5개까지
    fun addData() {
        if (itemCount<=limit){
            itemList.add(CategoryDomainDto())
            notifyItemInserted(itemCount-1)
            viewModel.uploadCategoriesData(getListData())
        }
    }
    
    // RV 요소 삭제
    fun deleteData(index: Int){
        itemList.removeAt(index)
        notifyDataSetChanged()
        viewModel.uploadCategoriesData(getListData())
    }
    
    // RV 요소 갯수 카운트 -> 최대 5개로 제한하며, 요소가 없을 경우 emptyView로 설정
    override fun getItemCount(): Int {
        if (itemList.isEmpty()) itemList.add(CategoryDomainDto())
        else if (itemList.size==limit) addBtnView.visibility = View.GONE
        else addBtnView.visibility = View.VISIBLE
        return itemList.size
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        return  Holder(ItemPhotographerCategoryBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }
    
    override fun onBindViewHolder(holder: Holder, position: Int) {
        val dto = itemList[position]
        holder.apply {
            bindInfo(position, dto)
            itemView.tag = dto
        }
    }
    interface ItemClickListener{
        fun onClickBtnDelete(view: View, position: Int, dto:CategoryDomainDto)
    }
    private lateinit var itemClickListener: ItemClickListener
    fun setItemClickListener(itemClickListener: ItemClickListener){ this.itemClickListener = itemClickListener }
    
    
    // RV 요소 Holder 세팅 -> 동적으로 갯수를 추가하고 삭제할 수 있으며, 빈 입력항목이 있는 지 실시간으로 체킹하여 외부 Fragment에 전달.
    inner class Holder(private val binding: ItemPhotographerCategoryBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bindInfo(position: Int, dto: CategoryDomainDto) {
            binding.apply {
                tvPhotographerCategory.apply {
                    setText(dto.name)
                    setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, R.array.spinner_category))
                    setOnItemClickListener { _, _, position, _ ->
                        dto.name = this.getString()
                        dto.categoryId = position + 1
                        dto.isEmpty = !(dto.price!=null && dto.description!=null)
                        viewModel.uploadCategoriesData(getListData())
                    }
                }
                etPhotographerCategoryPrice.apply {
                    clearPriceTextChangeListener(this)
                    val priceString = if (dto.price==null) null else dto.price!!.makeComma()
                    setText(priceString)
                    dto.priceTextWatcher = OnCurrentPriceTextWatcher(binding, dto, this)
                    addTextChangedListener(dto.priceTextWatcher)
                }
                etPhotographerCategoryDetail.apply {
                    clearContentTextChangeListener(this)
                    setText(dto.description)
                    dto.contentTextWatcher = OnCurrentContentTextWatcher(dto)
                    addTextChangedListener(dto.contentTextWatcher)
                }
                if (position==0) btnDelete.visibility = View.INVISIBLE
                else btnDelete.visibility = View.VISIBLE
                btnDelete.setOnClickListener {
                    itemClickListener.onClickBtnDelete(it, position, dto)
                }
            }
        }
    
        private fun clearPriceTextChangeListener(priceEditText : EditText){
            for (position in 0 until itemCount){
                priceEditText.removeTextChangedListener(itemList[position].priceTextWatcher)
            }
        }
        private fun clearContentTextChangeListener(contentEditText: EditText){
            for (position in 0 until itemCount){
                contentEditText.removeTextChangedListener(itemList[position].contentTextWatcher)
            }
        }
    }
    
    inner class OnCurrentPriceTextWatcher(private val binding : ItemPhotographerCategoryBinding, private val dto:CategoryDomainDto, private val editText: EditText?) : TextWatcher {
        private val editTextWeakReference: WeakReference<EditText> = WeakReference<EditText>(editText)
    
        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { }
        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { }
        override fun afterTextChanged(editable: Editable) {
            val editText: EditText = editTextWeakReference.get() ?: return
            val s = editable.replace("""[,.원]""".toRegex(), "")
            if (s.isEmpty()) {
                binding.tvPrice.visibility = View.INVISIBLE
                dto.price = null
                dto.isEmpty = true
                viewModel.uploadCategoriesData(getListData())
                return
            }
            editText.removeTextChangedListener(this)
            val price = s.toInt()
            val formatted: String = price.makeComma()
            editText.setText(formatted)
            editText.setSelection(formatted.length)
            editText.addTextChangedListener(this)
            binding.tvPrice.visibility = View.VISIBLE
            dto.price = price
            dto.isEmpty = dto.name == null
            viewModel.uploadCategoriesData(getListData())
        }
    }
    
    inner class OnCurrentContentTextWatcher(private val dto:CategoryDomainDto) : TextWatcher {
        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}
        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { }
        override fun afterTextChanged(s: Editable) {
            dto.description = if (s.isEmpty()) null else s.toString()
            dto.isEmpty = s.isEmpty()
            viewModel.uploadCategoriesData(getListData())
        }
    }
    }

  • PlaceRVAdapter.kt

    👈 코드 보기 (Code Here)

    // 장소 항목 - 동적 RecyclerView Adapter
    class PlaceRVAdapter(private val viewModel : PhotographerWriteGraphViewModel, private val addBtnView:Button, private val limit:Int=5) : RecyclerView.Adapter<PlaceRVAdapter.Holder>() {
    private val itemList : ArrayList<PlaceDomainDto> = arrayListOf()
    
    // RV 요소 데이터 리스트 반환
    fun getListData() : ArrayList<PlaceDomainDto> = itemList
    
    // RV 요소 데이터 리스트 설정
    fun setListData(dataList: ArrayList<PlaceDomainDto>){
        itemList.clear()
        itemList.addAll(dataList)
        notifyDataSetChanged()
    }
    
    
    // RV 요소 추가 - 최대 5개까지
    fun addData() {
        if (itemCount<=limit){
            itemList.add(PlaceDomainDto())
            notifyDataSetChanged()
            viewModel.uploadPlacesData(getListData())
        }
    }
    
    // RV 요소 삭제
    fun deleteItem(index: Int){
        itemList.removeAt(index)
        notifyDataSetChanged()
        viewModel.uploadPlacesData(getListData())
    }
    
    
    // RV 요소 갯수 카운트 -> 최대 5개로 제한하며, 요소가 없을 경우 emptyView로 설정
    override fun getItemCount(): Int {
        if (itemList.isEmpty()) itemList.add(PlaceDomainDto())
        else if (itemList.size==limit) addBtnView.visibility = View.GONE
        else addBtnView.visibility = View.VISIBLE
        return itemList.size
    }
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        return  Holder(ItemPhotographerPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false))
    }
    
    override fun onBindViewHolder(holder: Holder, position: Int) {
        val dto = itemList[position]
        holder.apply {
            bindInfo(position, dto)
            itemView.tag = dto
        }
    }
    
    interface ItemClickListener{
        fun onClickBtnDelete(view: View, position: Int, dto:PlaceDomainDto)
    }
    private lateinit var itemClickListener: ItemClickListener
    fun setItemClickListener(itemClickListener: ItemClickListener){ this.itemClickListener = itemClickListener }
    
    
    
    // RV 요소 Holder 세팅 -> 동적으로 갯수를 추가하고 삭제할 수 있으며, 빈 입력항목이 있는 지 실시간으로 체킹하여 외부 Fragment에 전달.
    inner class Holder(private val binding: ItemPhotographerPlaceBinding) : RecyclerView.ViewHolder(binding.root) {
        fun bindInfo(position: Int, dto: PlaceDomainDto) {
            binding.apply {
                tvPhotographerPlaceFirst.run {
                    setOnItemClickListener { _, _, position, _ ->
                        val resource = Spinners.getSelectedPlaceArrayResource(this.getString())
                        tvPhotographerPlaceSecond.setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, resource))
                        tvPhotographerPlaceSecond.isEnabled = true
                        tvPhotographerPlaceSecond.text = null
                        dto.isEmpty = true
                        dto.first = this.getString()
                        dto.firstId = position
                    }
                    setText(dto.first)
                    setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, R.array.spinner_region))
                }
                tvPhotographerPlaceSecond.apply {
                    isEnabled = false
                    setOnItemClickListener { _, _, position, _ ->
                        dto.isEmpty = false
                        dto.second = this.getString()
                        dto.secondId = position
                        viewModel.uploadPlacesData(getListData())
                    }
                    setText(dto.second)
                    dto.first?.let {
                        isEnabled = true
                        val resource = Spinners.getSelectedPlaceArrayResource(it)
                        setAdapter(Spinners.getSelectedArrayAdapter(itemView.context, resource))
                    }
                }
                if (position==0) btnDelete.visibility = View.INVISIBLE
                else btnDelete.visibility = View.VISIBLE
                btnDelete.setOnClickListener { itemClickListener.onClickBtnDelete(it, position, dto) }
    
            }
        }
    }
    
    }


Clone this wiki locally