-
Notifications
You must be signed in to change notification settings - Fork 0
4.4.5 작가 포트폴리오
JI YOON LEE edited this page Mar 5, 2023
·
21 revisions
// HomeFragment.kt
vpMain.post {
if (SharedPreferencesUtil(requireContext()).getViewPagerInit()) {
vpMain.currentItem = 1
mainViewPagerAdapter.notifyDataSetChanged()
SharedPreferencesUtil(requireContext()).changeViewPagerInit(false)
}
}
- 메뉴 순서가 지도 → 홈 → 마이페이지 순서로 되어 있는데, 우리 앱에서는 홈이 가장 먼저 뜨게 하려 함
- post() 함수를 사용해 뷰페이저 뷰가 생성되고, currentItem이 1로 설정되도록 함
- 뷰를 생성한 후 무언가 동작하도록 할 때 post() 함수를 사용
// 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에 접근할 지 정해 결과값 지정
// 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
// 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로 처리하는 코드
// 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이면 바로 게시물 페이지로 이동하도록 함
- 기능
작가는 갤러리로부터,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
- 기능
사진(최대 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) } } } } }
-
기능
Amazon S3의 원격이미지 URL을 로컬 File
로 다운로드 받은 뒤, Recyclerview를 통해 해당 File을 ImageView로 표출합니다.이때 ANR 방지를 위해 해당 과정은 반드시 Main Thread가 아닌 쓰레드에서 수행되어야 하므로, Coroutine의 Dispatcher.IO를 활용하였습니다.
작가는 이를 통해 이전에 등록했던 작가 포트폴리오의 프로필 이미지를 가져와 수정할 수 있습니다.
실행 화면 |
---|
원격 이미지 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))
}
}
}
}
- 기능
사진(최대 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) } } } } }