연습장/실습

6주차_ Kakao API 활용 앱 3. RecyclerView, 검색/좋아요 기능

아이른 2024. 5. 9. 17:20

1. 결과 화면

앱 처음 화면 검색어 입력 아이템 클릭 시 좋아요 표시

 

2. RecyclerView UI

① fragment_main.xml

  • xml에서 layoutManager 설정 가능

 

② item_grid.xml

 

 

3. RecyclerView

3-1. RecyclerViewAdapter / ViewHolder

① ViewHolder을 RecyclerViewAdapter 내부에서 만드느냐, 외부에서 만드느냐에 따라 adpater 접근이 다름

  • 외부에서 만들어 접근 
class KakaoRecyclerViewAdapter() : RecyclerView.Adapter<KakaoViewHoler>() {}
  • 내부에서 만들어 접근
class KakaoRecyclerViewAdapter() : RecyclerView.Adapter<RecyclerView.KakaoViewHoler>() {}

 

② KakaoRecyclerViewAdapter.kt

interface KakaoImageClickListener {
    fun onClickItem(kakaoData: Documents)
}//클릭 이벤트

class KakaoRecyclerViewAdapter(
    private val kakaoImageClickListener: KakaoImageClickListener
) : RecyclerView.Adapter<KakaoViewHoler>() {//KakaoViewHoler 외부파일로 꺼냄

    var kakoList: List<Documents> = emptyList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KakaoViewHoler {
        val layoutInflater = LayoutInflater.from(parent.context)
        return KakaoViewHoler(
            binding = ItemGridBinding.inflate(layoutInflater, parent, false),
            kakaoImageClickListener = kakaoImageClickListener
        )
    }

    override fun onBindViewHolder(holder: KakaoViewHoler, position: Int) {
        val kakaoItem = kakoList[position]
        holder.bind(kakaoItem = kakaoItem)
    }

    override fun getItemCount(): Int {
        return this.kakoList.size
    }

    fun submitList(item: List<Documents>) {
        this.kakoList = item
        notifyDataSetChanged()
    }
}

 

③ KakoViewHolder.kt

class KakaoViewHoler(
    private val binding: ItemGridBinding,
    private val kakaoImageClickListener: KakaoImageClickListener
    //inner class: 밖에 있는 변수, 함수 접근 가능 <-> class: 접근 불가하여 클릭이벤트 생성자 주입
) : RecyclerView.ViewHolder(binding.root) {

    private var kakaoData: Documents? = null

    init {
        binding.root.setOnClickListener {
            kakaoData?.let {
                kakaoImageClickListener.onClickItem(kakaoData = it)
            }
        }
    }

    fun bind(kakaoItem: Documents) {
        this.kakaoData = kakaoItem
        //서버 이미지 사용 : Glide
        Glide.with(binding.root.context).load(kakaoItem.thumbnail_url).into(binding.ivProfile)
        //with: Activity,Fragment로 부터 가져온 Context, load: 이미지 로드, into: 이미지를 보여줄 view
        binding.tvTitle.text = kakaoItem.display_sitename
        binding.tvDate.text = kakaoItem.datetime.toString()
    }
}

 

3-2. RecyclerView 연결

① MainFragment.kt

class MainFragment : Fragment() {

    private lateinit var binding: FragmentMainBinding

    private val adapter: KakaoRecyclerViewAdapter =
        KakaoRecyclerViewAdapter(kakaoImageClickListener = object : KakaoImageClickListener {
            override fun onClickItem(kakaoData: Documents) {
                //isLike 데이터 전달/이동
            }
        })

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }


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

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

        binding.recyclerView.adapter = this.adapter
        communicateNetWork("")
        
    }

    private fun communicateNetWork(query: String) = lifecycleScope.launch {
         val responseData = NetWorkClient.kakaoNetWork.getKakao(query, 80)
         Log.d("debug100", responseData.toString())
         
         adapter.submitList(responseData.documents) //데이터 갱신
        }
    }
}

 

4. 검색 기능 및 키보드/Focus 숨기기

  • 검색어를 입력하고 검색 버튼을 눌렀을 때, 키보드/Focus 숨기기
class MainFragment : Fragment() {

    private lateinit var binding: FragmentMainBinding

    private val adapter: KakaoRecyclerViewAdapter =
        KakaoRecyclerViewAdapter(kakaoImageClickListener = object : KakaoImageClickListener {
            override fun onClickItem(kakaoData: Documents) {
            }
        })

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    }


    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentMainBinding.inflate(inflater, container, false)

        return binding.root
    }

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

        binding.recyclerView.adapter = this.adapter

        binding.btnSearchClick.setOnClickListener {
            if (binding.etSearchText.text.toString().isNotEmpty()) {
                communicateNetWork(binding.etSearchText.text.toString())
                hideKeyboard()
                requireActivity().currentFocus!!.clearFocus() //Focus 숨기기
            } else {
                return@setOnClickListener
            }
        }
    }

    private fun communicateNetWork(query: String) = lifecycleScope.launch {
         val responseData = NetWorkClient.kakaoNetWork.getKakao(query, 80)
         Log.d("debug100", responseData.toString())
            
         adapter.submitList(responseData.documents) 
    }

    private fun hideKeyboard(){ //키보드 숨기기
        val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(requireActivity().currentFocus?.windowToken, 0)
    }
}
  • 공백 및 특수문자 검색 시 HttpException 발생 : 예외처리

    private fun communicateNetWork(query: String) = lifecycleScope.launch {
        try {
            val responseData = NetWorkClient.kakaoNetWork.getKakao(query, 80)
            Log.d("debug100", responseData.toString())
            adapter.submitList(responseData.documents)
        }catch (e: retrofit2.HttpException){
        
        }
    }

 

5. 아이템을 클릭하였을 때, 좋아요(하트) 송출

① data class에서 좋아요 기능을 위한 변수 생성 

data class Documents(
...
    var isLike : Boolean
    //val isLike : Boolean > 4번
)

 

② isLake : 데이터 변경 처리

class KakaoViewHoler(
    private val binding: ItemGridBinding,
    private val kakaoImageClickListener: KakaoImageClickListener
) : RecyclerView.ViewHolder(binding.root) {

    private var kakaoData: Documents? = null

    init {
        binding.root.setOnClickListener {
            kakaoData?.let {
                kakaoImageClickListener.onClickItem(kakaoData = it)
                it.isLike = !it.isLike //데이터 바뀜
        }
    }

    fun bind(kakaoItem: Documents) {
        this.kakaoData = kakaoItem
        Glide.with(binding.root.context).load(kakaoItem.thumbnail_url).into(binding.ivProfile)
        //with : Activity,Fragment로 부터 가져온 Context
        //load : 이미지 로드, into : 이미지를 보여줄 view
        binding.tvTitle.text = kakaoItem.display_sitename
        binding.tvDate.text = kakaoItem.datetime.toString()

        when(kakaoItem.isLike){//좋아요 
            true -> binding.ivLike.isVisible = true
            false -> binding.ivLike.isVisible = false
        }
    }
}

 

③ notifyDataSetChanged() : 바뀐 데이터를 리사이클러뷰에 알려줌

  • 방법 1. ViewHolder : 일반 클래스 > inner 클래스로 변경하여 notifyDataSetChanged() 호출
더보기
inner class KakaoViewHoler(
    private val binding: ItemGridBinding,
    private val kakaoImageClickListener: KakaoImageClickListener
) : RecyclerView.ViewHolder(binding.root) {

    private var kakaoData: Documents? = null

    init {
        binding.root.setOnClickListener {
            kakaoData?.let {
                kakaoImageClickListener.onClickItem(kakaoData = adapterPosition)
                it.isLike = !it.isLike //바뀐 데이터를
                notifyDataSetChanged() //리사이클러뷰에 알려줌
            }
        }
    }

    fun bind(kakaoItem: Documents) {
        this.kakaoData = kakaoItem
        Glide.with(binding.root.context).load(kakaoItem.thumbnail_url).into(binding.ivProfile)

        binding.tvTitle.text = kakaoItem.display_sitename
        binding.tvDate.text = kakaoItem.datetime.toString()

        when(kakaoItem.isLike){
            true -> binding.ivLike.isVisible = true
            false -> binding.ivLike.isVisible = false
        }
    }
}
  • 방법 2. RecyclerView가 연결되는 Fragment/Activity에서 notifyDataSetChanged() 호출
더보기
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {//뷰 변경
    super.onViewCreated(view, savedInstanceState)


    adapter = KakaoRecyclerViewAdapter(object : KakaoImageClickListener{
        override fun onClickItem(kakaoData: Documents) {
            adapter.notifyDataSetChanged()
        }
    })
    binding.recyclerView.adapter = this.adapter

    binding.btnSearchClick.setOnClickListener {
        if (binding.etSearchText.text.toString().isNotEmpty()) {
            communicateNetWork(binding.etSearchText.text.toString())
            hideKeyboard()
            requireActivity().currentFocus!!.clearFocus()
        } else {
            return@setOnClickListener
        }
    }
}
  • 방법 3. RecyclerView 내부에서 notifyDataSetChanged() 호출
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KakaoViewHoler {
    val layoutInflater = LayoutInflater.from(parent.context)
    return KakaoViewHoler(
        binding = ItemGridBinding.inflate(layoutInflater, parent, false),
        object : KakaoImageClickListener{
            override fun onClickItem(kakaoData: Documents) {
                notifyDataSetChanged()
            }
        }
    )
}

 

 

④ 클릭 이벤트 구문은 communicateNetWork()가 동작하는 곳에서 작성 : 클릭한 데이터는 RecyclerViewAdapter의 데이터가 아닌 카카오 API를 통해서 가져오는 데이터이기 때문

 

더보기
  •  KakaoViewHolder
class KakaoViewHoler(
    private val binding: ItemGridBinding,
    private val kakaoImageClickListener: KakaoImageClickListener,
) :
    RecyclerView.ViewHolder(binding.root) {

    private var kakaoData: Documents? = null

    init {
        binding.ivProfile.setOnClickListener {
            kakaoData?.let {
                kakaoImageClickListener.onClickItem(kakaoData = it)
            }
        }
    }

    fun bind(kakaoItem: Documents) {
        this.kakaoData = kakaoItem
        Glide.with(binding.root.context).load(kakaoItem.thumbnail_url).into(binding.ivProfile)

        binding.tvTitle.text = kakaoItem.display_sitename
        binding.tvDate.text = kakaoItem.datetime.toString()

        when(kakaoItem.isLike){
            true -> binding.ivLike.isVisible = true
            false -> binding.ivLike.isVisible = false
        }
    }
}

 

  • KakaoRecyclerViewAdapter
interface KakaoImageClickListener {
    fun onClickItem(kakaoData: Documents)
}

class KakaoRecyclerViewAdapter(
    private val kakaoImageClickListener: KakaoImageClickListener

) : RecyclerView.Adapter<KakaoViewHoler>() {

    var kakoList: List<Documents> = emptyList()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KakaoViewHoler {
        val layoutInflater = LayoutInflater.from(parent.context)
        return KakaoViewHoler(
            binding = ItemGridBinding.inflate(layoutInflater, parent, false),
            kakaoImageClickListener = kakaoImageClickListener
        )
    }

    override fun onBindViewHolder(holder: KakaoViewHoler, position: Int) {
        val kakaoItem = kakoList[position]
        holder.bind(kakaoItem = kakaoItem)
    }

    override fun getItemCount(): Int {
        return this.kakoList.size
    }

    fun submitList(item: List<Documents>) {
        this.kakoList = item
        notifyDataSetChanged()
    }
}

 

  • MainFragment
class MainFragment : Fragment() {

    private lateinit var binding: FragmentMainBinding
    private var kakaoDataList = mutableListOf<Documents>()

    private val adapter: KakaoRecyclerViewAdapter by lazy {
        createAdapter()
    }

    override fun onCreateView(inflater: LayoutInflater,container: ViewGroup?,savedInstanceState: Bundle?
    ): View? {
        binding = FragmentMainBinding.inflate(inflater, container, false)

        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) { //뷰 변경
        super.onViewCreated(view, savedInstanceState)

        binding.recyclerView.adapter = this.adapter

        binding.btnSearchClick.setOnClickListener {
            if (binding.etSearchText.text.toString().isNotEmpty()) {
                communicateNetWork(binding.etSearchText.text.toString())
                hideKeyboard()
                requireActivity().currentFocus!!.clearFocus()
            } else {
                return@setOnClickListener
            }
        }
    }

    private fun communicateNetWork(query: String) = lifecycleScope.launch {

        try {
            val responseData = NetWorkClient.kakaoNetWork.getKakao(query, 80)
            Log.d("debug100", responseData.toString())
            kakaoDataList.addAll(responseData.documents) //클릭 한 이미지 데이터 추가
            adapter.submitList(kakaoDataList) //데이터 갱신
        } catch (e: retrofit2.HttpException) {

        }
    }

    private fun hideKeyboard() {
        val imm =
            requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(requireActivity().currentFocus?.windowToken, 0)
    }

    private fun createAdapter(): KakaoRecyclerViewAdapter {
        return KakaoRecyclerViewAdapter(kakaoImageClickListener = object : KakaoImageClickListener {
            override fun onClickItem(kakaoData: Documents) {
                val index = kakaoDataList.indexOf(kakaoData) //인덱스 찾기
                kakaoDataList.set(
                    index, kakaoData.copy( //isLike가 val이므로 copy 이용
                        isLike = !kakaoData.isLike
                    )
                )
                adapter.submitList(kakaoDataList) //kakaoDataList 데이터 갱신
            }
        })
    }
}