안드로이드 : diary – ViewHolder click

안드로이드 : diary – RecyclerView 포스트에서 일기 데이터를 내려받아 리스트로 보여주는 방법을 알아보았어.

이번에는 특정 날짜의 일기 항목을 클릭해서 일기 데이터 편집 화면으로 전환하는 방법을 구현해보려고 해.

그러기 위해서는 ViewHolder 를 클릭할 수 있는 방법을 구현해야 하거든.

어댑터 클래스 DiaryAdapter 에 아래와 같이 인터페이스와 리스너를 선언해주자.

DiaryAdapter.kt
class DiaryAdapter(private val diaries: List<Diary>) : RecyclerView.Adapter<DiaryAdapter.DiaryItemViewHolder>() {

    interface ItemClickListner {
        fun onItemClick(view: View, pos: Int)
    }
    
    private var itemClickListner: ItemClickListner? = null
    
    fun setItemClickListener(listner: ItemClickListner) {
        itemClickListner = listner
    }
...

뷰홀더에 대해서 클릭리스너를 등록하고, itemClickListener 가 설정되어 있을 경우에 onItemClick 이 호출되게 하자.

DiaryAdapter.kt
...
    override fun onBindViewHolder(holder: DiaryItemViewHolder, position: Int) {
        holder.tvdate.text = diaries[position].date

        holder.tvweekname.text = ""
        val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
        dateFormat.parse(diaries[position].date)?.let {
            val cal = Calendar.getInstance()
            cal.time = it
            val dayOfWeek = cal.get(Calendar.DAY_OF_WEEK)
            val weekNames = arrayListOf("일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일")
            holder.tvweekname.text = weekNames[dayOfWeek - 1]
        }

        holder.tvcontent.text = diaries[position].content
        
        holder.itemView.setOnClickListener {
            itemClickListner?.onItemClick(it, position)
        }
    }
}

이제 어댑터에 ItemClickListener 를 설정해주자.

ListFragment.kt
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
...
        val adapter: DiaryAdapter = recycler.adapter as DiaryAdapter
        adapter.setItemClickListener(object: DiaryAdapter.ItemClickListner {
            override fun onItemClick(view: View, pos: Int) {
                findNavController().navigate(
                    R.id.action_listFragment_to_editFragment
                    , bundleOf("id" to diaries[pos].id)
                )
            }
        })

        return v
    }
...

navigate 함수로 일기데이터의 고유번호를 전달하고 있는데, EditFragment 에서는 이 값을 받아서 처리해야겠지?

EditFragment.kt
...
class EditFragment : Fragment() {
//    // TODO: Rename and change types of parameters
//    private var param1: String? = null
//    private var param2: String? = null
    private var id: Long? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
//            param1 = it.getString(ARG_PARAM1)
//            param2 = it.getString(ARG_PARAM2)
            id = it.getLong("id")
        }
    }
...

안드로이드 스튜디오가 샘플로 작성해 준 파라미터 관련 코드는 주석처리하고 getLong 으로 “id” 파라미터를 구하는 코드를 추가했어.

이제 fragment_edit.xml 에 일기 데이터를 표시하기 위한 UI 컴포넌트를 배치하고 일기 데이터를 읽어오는, 그리고 저장하는 RestAPI 호출 기능을 구현하면 되겠네.

fragment_edit.xml 의 UI 레이아웃은 아래와 같이 구성했어.


이번에는 일기 데이터를 가져오기 위한 /diary/{id} URL 의 RestAPI 호출 코드를 작성해보자.

DiaryService 에 아래 함수를 작성해줄께.

DiaryService.kt
interface DiaryService {

...
    @GET("/diary/{id}")
    fun getDiary(
        @Header("Authorization") token: String
        , @Path("id") id: Long
    ): Call<Diary>
}

EditFragment 클래스에는 위 함수를 사용하는 코드를 다음과 같이 작성해주면 돼.

EditFragment.kt
class EditFragment : Fragment() {
    private var id: Long? = null
    private lateinit var mainActivity: MainActivity

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("http://diary.woohahaapps.com:8080")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
...
    override fun onAttach(context: Context) {
        super.onAttach(context)
        mainActivity = context as MainActivity
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val v: View = inflater.inflate(R.layout.fragment_edit, container, false)

        val buttonSaveDiary: Button = v.findViewById(R.id.button_save)
        buttonSaveDiary.setOnClickListener {
            findNavController().navigate(R.id.action_editFragment_to_listFragment)
        }

        val diaryService: DiaryService = retrofit.create(DiaryService::class.java)

        id?.let {
            val token = "Bearer=" + mainActivity.getPref("TOKEN", "")
            diaryService.getDiary(token, id!!)
                .enqueue(object: Callback<Diary> {
                    override fun onResponse(call: Call<Diary>,
                                            response: Response<Diary>) {
                        Log.d("DATA", response.toString())
                        if (response.isSuccessful.not()) {
                            Log.d("ERROR", "CODE=" + response.code().toString())
                        }

                        response.body()?.let {
                            val editDate: EditText = v.findViewById(R.id.edit_date)
                            val editContent: EditText = v.findViewById(R.id.edit_content)
                            editDate.setText(it.date)
                            editContent.setText(it.content)
                        } ?: run {
                            Log.d("ERROR", "body is null")
                        }
                    }

                    override fun onFailure(call: Call<Diary>,
                                           t: Throwable) {
                        Log.d("FAIL", t.toString())
                    }

                })
        }

        return v
    }
...

이렇게까지 코딩하고나면 일기 목록에서 항목을 클릭했을 때 해당 일기 항목을 수정할 수 있는 화면으로 전환이 되고, 일기 날짜와 일기 내용이 화면에 표시가 되지.

그러면 EditFragment 에서 일기 내용을 저장하는 RestAPI 호출을 해보도록 할께.

일기를 수정해서 저장할 때 사용하는 RestAPI 는 PUT 메소드를 사용하지.


Path 로 일기 고유번호를 전달하고, Request Body 로는 일기 데이터를 전달하고 있는데, 현재 구성되어 있는 Diary 클래스 멤버에는 email 이 없으니까 다음과 같이 추가해준 다음에 진행할께.

data class Diary(
    @SerializedName("id") val id: Long
    , @SerializedName("diary_date") val date: String
    , @SerializedName("diary_content") val content: String
    , @SerializedName("email") val email: String
)

EditFragment 에서 고유번호에 대한 일기 데이터를 수신받아서 Diary 클래스형 멤버로 저장하는 코드도 추가해볼께.

EditFragment.kt
...
class EditFragment : Fragment() {
    private var id: Long? = null
    private lateinit var mainActivity: MainActivity
    private var diary: Diary? = null
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
...
        id?.let {
            val token = "Bearer=" + mainActivity.getPref("TOKEN", "")
            diaryService.getDiary(token, id!!)
                .enqueue(object: Callback<Diary> {
                    override fun onResponse(call: Call<Diary>,
                                            response: Response<Diary>) {
                        Log.d("DATA", response.toString())
                        if (response.isSuccessful.not()) {
                            Log.d("ERROR", "CODE=" + response.code().toString())
                        }

                        response.body()?.let {
                            diary = it
                            val editDate: EditText = v.findViewById(R.id.edit_date)
                            val editContent: EditText = v.findViewById(R.id.edit_content)
                            editDate.setText(diary!!.date)
                            editContent.setText(diary!!.content)
                        } ?: run {
                            Log.d("ERROR", "body is null")
                        }
                    }

                    override fun onFailure(call: Call<Diary>,
                                           t: Throwable) {
                        Log.d("FAIL", t.toString())
                    }

                })
        }
...

EditFragment 클래스에 Diary 클래스형 멤버를 추가한 상태에서 고유번호의 일기 데이터를 내려받았을 때 diary 변수에 이를 저장부터 해주고 UI 에 이 변수의 데이터를 설정해주는 방식으로 변경해봤어.

이렇게 수정하면 일기 날짜와 일기 내용을 수정하고 저장하게 되면 일기 날짜, 일기 내용 UI 의 데이터를 diary 변수에 대입한 후에 diary 데이터를 그대로 사용할 수가 있게 되지.

이제 /diary/{id} URL 을 PUT 메소드로 사용하는 코드를 추가해볼께.

button_save ID 의 버튼이 눌렸을 때 UI 에 입력되어 있는 데이터를 diary 에 우선 대입하고 RestAPI 를 호출하는 순서로 코딩하면 되겠지.

...
        val buttonSaveDiary: Button = v.findViewById(R.id.button_save)
        buttonSaveDiary.setOnClickListener {
            val editDate: EditText = v.findViewById(R.id.edit_date)
            val editContent: EditText = v.findViewById(R.id.edit_content)
            diary?.date = editDate.text.toString()
            diary?.content = editContent.text.toString()
            findNavController().navigate(R.id.action_editFragment_to_listFragment)
        }
...

diary 는 null 일 수도 있기 때문에 위 코드처럼 작성했는데, date 와 content 가 val 로 선언되어 있기 때문에 값을 대입할 수가 없다고 나와. 그래서 Diary 클래스의 멤버를 val 이 아닌 var 로 수정해줄께.

data class Diary(
    @SerializedName("id") val id: Long
    , @SerializedName("diary_date") var date: String
    , @SerializedName("diary_content") var content: String
    , @SerializedName("email") val email: String
)

id 와 email 은 변경될 일도 없고 변경되어서도 안되기 때문에 그대로 두었어.

이번에는 RestAPI를 호출할 차례야.

interface DiaryService {
...
    @PUT("/diary/{id}")
    fun saveDiary(
        @Header("Authorization") token: String
        , @Path("id") id: Long
        , @Body params: Diary
    ): Call<Diary>
}

DiaryService 인터페이스에 새로 추가한 saveDiary 함수를 사용하는 코드를 작성해볼께.

EditFragment.kt
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val v: View = inflater.inflate(R.layout.fragment_edit, container, false)

        val diaryService: DiaryService = retrofit.create(DiaryService::class.java)

        val buttonSaveDiary: Button = v.findViewById(R.id.button_save)
        buttonSaveDiary.setOnClickListener {
            val editDate: EditText = v.findViewById(R.id.edit_date)
            val editContent: EditText = v.findViewById(R.id.edit_content)
            diary?.date = editDate.text.toString()
            diary?.content = editContent.text.toString()

            diary?.let { it1 ->
                val token = "Bearer=" + mainActivity.getPref("TOKEN", "")
                diaryService.saveDiary(token, it1.id, it1)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful.not()) {
                                Log.d("ERROR", "CODE=" + response.message())
                            }

                            response.body()?.let {
                                if (response.code() == 200) {
                                    findNavController().navigate(R.id.action_editFragment_to_listFragment)
                                } else {
                                    Log.d("ERROR", "CODE=" + response.code())
                                }
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            }
        }
...

이렇게 수정해서 실행시켰더니 아래와 같은 에러가 발생했어.


java.io.EOFException: End of input at line 1 column 1 path $ 에러는 response 값이 null 인 경우에 발생하는 exception 이라고 하네.

참고: https://velog.io/@soyoung-dev/KotlinError-java.io.EOFException-End-of-input-at-line-1-column-1-path

위 링크를 참고해서 NullOnEmptyConverterFactory 클래스를 작성하고 아래와 같이 코드를 추가했어.

class EditFragment : Fragment() {
    private var id: Long? = null
    private lateinit var mainActivity: MainActivity
    private var diary: Diary? = null

    private val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl("http://diary.woohahaapps.com:8080")
        .addConverterFactory(NullOnEmptyConverterFactory())
        .addConverterFactory(GsonConverterFactory.create())
        .build()
...

디버그모드로 실행시켜봤는데, 내가 예상했던 것과는 다른 데이터가 내려왔어.


response code 가 200 으로 성공한 경우에는 일기 데이터 목록으로 전환하도록 코드를 수정했어.

...
            diary?.let { it1 ->
                val token = "Bearer=" + mainActivity.getPref("TOKEN", "")
                diaryService.saveDiary(token, it1.id, it1)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful) {
                                findNavController().navigate(R.id.action_editFragment_to_listFragment)
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            }
...

RestAPI 를 조금 더 효율적으로 작성하는 방법을 공부해야 할 것 같아.

어쨌든 기존 일기를 수정하는 작업까지 완료했어. 이번에는 새 일기를 작성하는 코드를 작성해보려고 해.

fragment_list.xml 에서 RecyclerView 하단에 버튼을 배치하고 새 일기 쓰기 버튼으로 사용하려고 해.


“일기 쓰기” 버튼에 대한 클릭리스너를 아래와 같이 작성해봤어. 사실 안드로이드 : diary – RecyclerView 에서 주석처리했던 코드를 주석해제시킨거야.

ListFragment.kt
...
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val v: View = inflater.inflate(R.layout.fragment_list, container, false)

        val buttonNewDiary : Button = v.findViewById(R.id.button_new_diary)
        buttonNewDiary.setOnClickListener {
            findNavController().navigate(R.id.action_listFragment_to_editFragment)
        }
...

이제 “일기 쓰기” 버튼을 클릭하면 일기 작성 화면으로 이동하겠지만, EditFragment 클래스의 멤버 변수 id 와 diary 는 모두 null 로 설정된 상태겠지. 이런 상황에서 EditFragment 의 “Save Diary” 버튼을 클릭하면 POST 메소드의 /diary URL 을 호출하도록 해야 해.


DiaryService 클래스에 POST 메소드의 /diary URL 호출 함수를 작성해볼께.

interface DiaryService {
...
    @POST("/diary")
    fun createDiary(
        @Header("Authorization") token: String
        , @Body params: Diary
    ): Call<Diary>
}

fragment_edit.xml 에 배치된 “Save Diary” 클릭리스너 함수에서 diary 가 null 인 경우에 대한 처리코드는 다음과 같이 작성했어.

...
        val buttonSaveDiary: Button = v.findViewById(R.id.button_save)
        buttonSaveDiary.setOnClickListener {
            val editDate: EditText = v.findViewById(R.id.edit_date)
            val editContent: EditText = v.findViewById(R.id.edit_content)
            diary?.date = editDate.text.toString()
            diary?.content = editContent.text.toString()

            val token = "Bearer=" + mainActivity.getPref("TOKEN", "")

            val tokenParts = mainActivity.getPref("TOKEN", "").split(".")
            val decoder = Base64.getUrlDecoder()
            val header = String(decoder.decode(tokenParts[0]))
            val payload = String(decoder.decode(tokenParts[1]))
            val signature = String(decoder.decode(tokenParts[2]))
            val email = JSONObject(payload).get("sub")

            diary?.let { it1 ->
                diaryService.saveDiary(token, it1.id!!, it1)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful) {
                                findNavController().navigate(R.id.action_editFragment_to_listFragment)
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            } ?: run {
                val diaryNew = Diary(null, editDate.text.toString(), editContent.text.toString(),
                    email.toString()
                )
                Log.d("DATA", diaryNew.toString())
                diaryService.createDiary(token, diaryNew)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful) {
                                findNavController().navigate(R.id.action_editFragment_to_listFragment)
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            }
        }
...

EditFragment 클래스의 멤버변수 diary 가 null 이라는 것은 새로운 일기를 작성하는 경우이기 때문에 새 일기 데이터를 저장할 diaryNew 변수를 선언했어. 이 경우 id 가 null 일 수 있기 때문에 Diary 클래스의 id 를 null 허용으로 변경했지.

data class Diary(
    @SerializedName("id") val id: Long?
    , @SerializedName("diary_date") var date: String
    , @SerializedName("diary_content") var content: String
    , @SerializedName("email") var email: String
)

그리고 email 주소에 현재 로그인한 사용자의 email 주소값을 설정해주어야 하기 때문에 val 에서 var 로 수정하고, 현재의 jwt 토큰으로부터 로그인정보를 구해서 email 주소에 설정을 해주고 있어.

RestAPI 호출이 성공한 경우에는 일기 목록 화면으로 다시 이동시켰지.

이제 필수적인 기능은 모두 완성이 되었는데 프래그먼트 전환상의 문제가 있어. Navigation 의 Back 버튼을 사용했을 때 나타날 필요가 없는 프래그먼트가 여전히 보이는 문제점이지.

전환 히스토리에서 남겨두지 말아야 할 프래그먼트인 경우에는 popBackStack() 함수를 이용해서 프래그먼트를 전환하도록 수정했어.

LoginFragment.kt
...
                        response.body()?.let {
                            val result: ResponseLoginResult = response.body()!!
                            val token = result.body
                            mainActivity.setPref("TOKEN", token)

                            mainActivity.setLoginState(true)
                            //findNavController().navigate(R.id.action_loginFragment_to_listFragment)
                            findNavController().popBackStack()
                        }
...
EditFragment.kt
...
            diary?.let { it1 ->
                diaryService.saveDiary(token, it1.id!!, it1)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful) {
                                //findNavController().navigate(R.id.action_editFragment_to_listFragment)
                                findNavController().popBackStack()
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            } ?: run {
                val diaryNew = Diary(null, editDate.text.toString(), editContent.text.toString(),
                    email.toString()
                )
                Log.d("DATA", diaryNew.toString())
                diaryService.createDiary(token, diaryNew)
                    .enqueue(object: Callback<Diary> {
                        override fun onResponse(call: Call<Diary>, response: Response<Diary>) {
                            Log.d("DATA", response.toString())
                            if (response.isSuccessful) {
                                //findNavController().navigate(R.id.action_editFragment_to_listFragment)
                                findNavController().popBackStack()
                            }
                        }

                        override fun onFailure(call: Call<Diary>, t: Throwable) {
                            Log.d("FAIL", t.toString())
                        }
                    })
            }
        }
...

이렇게 완성된 diary 프로그램은 Back 버튼을 누르더라도 불필요한 프래그먼트가 보이지 않게 되지.

Leave a Comment