[안드로이드] 리사이클러뷰를 활용한 문제 풀기

Junyoung Park·2022년 1월 4일
0

ToyProject

목록 보기
5/11
  • 지난 시간, 로컬 환경에서 asset 내 존재하는 db를 (만일 로컬 db 디렉토리 내 퀴즈 문제를 담은 db가 존재하지 않을 경우) 복사하는 과정까지 포스팅했다. 이후 메인 화면을 설계, 탭/스와이프를 사용한 프래그먼트를 띄우고 버튼을 클릭하면 문제를 풀어 채점하는 과정까지 진행했다.

Shared Preferences

shared preferences는 db에서 많은 정보를 읽고 쓸 수 있는 것과 같이 특정 값을 저장할 수 있는 일종의 전역 변수이다. 작은 양의 데이터를 가지고 있기 때문에 읽고 쓰는 속도가 빠른 것이 장점이다.

왜 shared preference를 사용할까?

솔직하게 말하자면, 퀴즈 어플 내에서 shared preference가 기능적으로 필수적이지는 않다! 로컬 환경에서 접속, 다른 사용자와 함께 특정 db를 공유하는 상황도 아니기 때문이다. 하지만 어플을 개발하면서 닉네임이 없으면 뭔가 허전할 것 같다는 생각도 들었고, 그렇다면 이를 활용한 shared preference도 간단하게 조작해보자는 생각이 들었다(사실 이를 활용해 db 체크를 할 수도 있겠지만 앞서 언급했듯 db 체크는 db_helper라는 함수를 사용해 따로 구현했다!).

val sharedPreference = getSharedPreferences("tag", 0)
...

val nickname = nickname_input.getText().toString()
            if (nickname.isNullOrEmpty()) {
            Toast.makeText(applicationContext, "닉네임을 입력하세요!", Toast.LENGTH_LONG).show()
            } else {
                Toast.makeText(applicationContext, "${nickname}님 안녕하세요!", Toast.LENGTH_LONG).show()
                val editor = sharedPreference.edit()
                editor.putString("nickname", nickname)
                editor.apply()
                Log.d("shared", "shared preference saved")

위는 MainActiviy.kt의 레이아웃을 띄우기 전에 사전에 입력한 닉네임이 존재하는지 여부를 확인하는 코드다. 사실 홈으로 들어가려면 닉네임을 필수적으로 입력했기 때문에, 메인 화면이 보이는 건 처음 어플을 실행한 순간 단 한 순간밖에 없다.

  • shared preference라는 개념이 Flask에서 session를 사용했을 때와 동일해서 매우 흥미로웠다. 일종의 전역 변수를 통해 빠르게 값을 캐시하자는 의도라고 생각했다.

Fragments / Adapter

자, 이름을 입력한 뒤 홈 화면에 왔다. 퀴즈 어플의 핵심 기능인 문제 출제와 오답 노트, 점수 등이 기록되는 화면이다. 이때 여러 액티비티를 통해서 각각 구현할 수도 있었겠지만, 그렇다면 불필요한 액티비티 수가 늘어남은 물론이고 화면을 사용하는 사람이 여러 번 클릭해야 하는 불편함이 있을 것 같았다.

화면 하나에서 여러 개를 스와이프하면 어떨까?

책에서 본 프래그먼트 개념이 떠올라 곧바로 TestActivity(닉네임을 입력하는 Main에서 넘어와 주요 기능으로 들어가는 버튼이 달린 액티비티)에 어댑터를 연결해주었다.

 val fragmentList = listOf(FragmentType(), FragmentTest(), FragmentReview(), FragmentInfo())
        val adapter = FragmentAdapter(supportFragmentManager, 1)
        adapter.fragmentList = fragmentList
        after_login_viewpager.adapter = adapter
        after_login_tablayout.setupWithViewPager(after_login_viewpager)

TestActivity.kt에서 화면을 넘길 때 다양하게 넘기고 싶었기 때문에 화면 맨 아래에 탭을 두면서 스와이프 기능도 넣었다.

    var fragmentList = listOf<Fragment>()

    private val name: ArrayList<String> = ArrayList()

    override fun getCount(): Int {
        return fragmentList.size
    }

    override fun getItem(position: Int): Fragment {
        return fragmentList.get(position)
    }

    override fun getPageTitle(position: Int): CharSequence? {

        Log.d("getPageTitle", "getPageTitle!, ${position}")
        return when (position) {
            0 -> "유형별"
            1 -> "기출별"
            2 -> "오답 노트"
            else -> "나의 정보"
        }
    }

"과연 지금 화면에서 어떤 창을 띄워야 하는가?"를 결정하기 위해 어댑터에서는 언제나 position을 계산하고 이를 엮어주는데, 이는 이후 리사이클러뷰에서 어댑터가 하는 기능과 완전히 동일하다. position을 계산한 뒤 이에 해당하는 뷰를 fragmentList에 내가 TestActiviy에서 넣어준 프래그먼트 인덱스에서 가져오는 것이다.

Recyclerviews / Adapter

val db_helper = DBHelper(this)
        var adapter = CustomAdapter()
        var num:Int?
        var questions:MutableList<question>
        var quiz_type = true
        var submited = false
        if (intent.hasExtra("type_num")){
            Log.d("type_num", "유형별 퀴즈를 시작합니다.")
            quiz_type = true
            num = intent.getIntExtra("type_num", 0)
            if (num < 6) {
            questions= db_helper.get_question1(-1, num, true)
            } else {
                questions = db_helper.get_question2(-1,true)
            }
            adapter.listData = questions
            recyclerView.adapter = adapter
            adapter.num = num
            recyclerView.layoutManager = LinearLayoutManager(this)
        } else if (intent.hasExtra("test_num")) {
            Log.d("test_num", "기출별 퀴즈를 시작합니다.")
            quiz_type = false
            num = intent.getIntExtra("test_num", 0)
            var question1s = db_helper.get_question1(num, -1, false)
            var question2s = db_helper.get_question2(num, false)
            questions = (question1s + question2s) as MutableList<question>
            adapter.listData = questions
            adapter.num = num
            recyclerView.adapter = adapter
            recyclerView.layoutManager = LinearLayoutManager(this)
        }

자, 프래그먼트 화면에 버튼을 넣고 "어떤 유형의 문제를 풀지" 결정할 수 있었다. 프래그먼트에서 다른 액티비티로 전환할 때 이 버튼을 누른다면 "어느 유형"을 풀지 putExtra로 값을 넘겨줄 수 있었고, 이를 넘겨받은 QuestionActiviy.kt에서 이를 getExtra로 꺼내서 확인한다.

이에 따라 db_helper의 question을 조회해오는 함수(사전 구현)에 특정 값을 넣고 리스트를 가져온다. 이 가져온 데이터를 어댑터에 넣어 리사이클러뷰에 띄울 것이다. 앞서 프래그먼트를 활용한 로직과 같다.

class CustomAdapter: RecyclerView.Adapter<Holder>() {
    var listData = mutableListOf<question>()
    var listAnswer = mutableMapOf("get_keys" to 0)
    var question_set = true
    var num = 0

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_question, parent, false)
        return Holder(view)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        val question = listData.get(position)

        if (question_set) {holder.setQuestion(question)} else {holder.setQuestionDetail(question, listAnswer)}

        holder.itemView.question_choice_check.setOnCheckedChangeListener{group, checkedid -> when (checkedid){
            R.id.question_choice1 -> listAnswer[get_key(question)] = 1
            R.id.question_choice2 -> listAnswer[get_key(question)] = 2
            R.id.question_choice3 -> listAnswer[get_key(question)] = 3
            R.id.question_choice4 -> listAnswer[get_key(question)] = 4
        }
        }

        holder.itemView.question_toggle.setOnClickListener{
            if (holder.itemView.question_box.visibility == View.GONE){
                Log.d("toggle plus", "지문 박스 확인")
                holder.itemView.question_box.visibility = View.VISIBLE
                holder.itemView.question_toggle.setBackgroundResource(R.drawable.circle_minus)
            } else {
                Log.d("toggle minus", "지문 박스 줄이기")
                holder.itemView.question_box.visibility = View.GONE
                holder.itemView.question_toggle.setBackgroundResource(R.drawable.circle_plus)
            }
            notifyDataSetChanged()
        }


        val layoutParams = holder.itemView.layoutParams
        layoutParams.height = 1100


        holder.itemView.requestLayout()
    }

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


    override fun getItemViewType(position: Int): Int {
        return position
    }

}
  • 홀더는 내가 구현한 리사이클러뷰 형태로 각 아이템을 가진다. 앞서 QuestionActivity.kt에서 넘겨받았던 각 데이터 하나가 입력되어 사용자의 입장에서는 쉽게 말해 '문제 하나'로 출력된다.

나는 문제를 풀고 제출한 뒤 곧바로 내가 선택한 답안을 채점하고, 문제 해설 역시 띄워주고자 했기 때문에 어댑터에서 버튼 클릭 리스너를 달아주었다(클릭 리스너가 두 개인데, 위의 것은 라디오 버튼 클릭 이후 이를 맵에 저장/지문 박스가 있는 문제에서 지문을 펼치고 닫는다).

        question_submit.setOnClickListener {
            val answers = adapter.listAnswer
            val questions = adapter.listData
            val num = adapter.num
            var total:Int = 0

            Log.d("size_answers", "${answers.size}")
            Log.d("size_questions", "${questions.size}")

            if (!submited && answers.size != (questions.size+1)) {
                val builder = AlertDialog.Builder(this)
                builder.setTitle("채점").setMessage("답을 모두 고르지 않았습니다. 제출하시겠습니까?")
                .setPositiveButton("예", DialogInterface.OnClickListener{dialog, id ->
                    submited = true
                    for (question in questions) {
                        if (answers[get_key(question)] != null) {
                            if (answers[get_key(question)] == question.answer) {
                                total += 1
                            } else {
                                db_helper.insert_review(question.type!!, question.test_num!!, question.number!!, answers[get_key(question)]!!)
                            }
                        }
                    }
                    if (quiz_type) {
                        db_helper.insert_score(0, num, total)
                    } else {
                        db_helper.insert_score(num, 0, total)
                    }
                    adapter.question_set = false
                    adapter.notifyDataSetChanged()
                    question_submit.text = "돌아가기"
                })
                .setNegativeButton("아니오", DialogInterface.OnClickListener {dialog, id ->

                })
                builder.show() } else if (submited) {
                val intent = Intent(this, TestActivity::class.java)
                startActivity(intent)
                finish()
            } else { for (question in questions) {
                if (answers[get_key(question)] != null) {
                    if (answers[get_key(question)] == question.answer) {
                        total += 1
                    } else {
                        db_helper.insert_review(question.type!!, question.test_num!!, question.number!!, answers[get_key(question)]!!)
                    }
                }
            }
            // 기출 문제면 type_num을 0, test_num을 기출 회차 입력한다. 유형 문제면 test_num을 0, type_num을 유형 회차 입력한다.
            if (quiz_type) {
                db_helper.insert_score(0, num, total)
            } else {
                db_helper.insert_score(num, 0, total)
            }
            adapter.question_set = false
            adapter.notifyDataSetChanged()
            question_submit.text = "돌아가기"
            submited = true}
        }

문제 제출을 했을 때 "지금까지 내가 입력한 답안"을 기존의 문제와 비교하면서 점수를 기록해야 한다. "문제를 맞힌 경우"만 점수로 체크, "문제를 풀었는데 틀린 경우"만 오답 노트에 기록했다. 물론 다른 식으로 구현할 수도 있었지만 내가 생각해보기에 문제가 기출 유형인 경우에는 100문제나 되는 등 다소 많았기 때문이다. 그렇게 된다면 엄청난 양의 오답이 오답 노트 데이터베이스에 기록되어야 하며, 생각보다 유효성이 떨어진다고 판단했다.

문제를 제출하기 전 "문제를 모두 풀지 않았다면" alaert dialog가 뜨고, 그렇지 않으면 곧바로 제출된다. 물론 alert가 떴을 때에도 그대로 제출 가능하다. 제츨이 끝난 뒤 버튼을 재활용해서 다시 이전 액티비티로 연결하는 intent를 띄우도록 만들었다. 생각해보니 더 좋은 방법이 있을 것 같은데, 특히 모바일 환경에서 주로 사용하는 방법을 잘 모르기에 주먹구구 식으로 만든 부분이 많았다.

Reviews and Scores

위 기능을 구현했기 때문에 이를 적절히 활용해 오답노트와 점수를 보여줄 예정인데, 점수는 db_helper의 SELECT를 통해 간단히 점수만 보여주고 (평균을 통해 시험 합격 정도를 시각화, 보여준다!), 오답노트는 지금까지 틀린 문제를 그 틀린 횟수만큼 가중치를 두어 위에서부터 보여줄 예정이다.

다시 말해 SELECT를 할 때 Count를 통해 GROUP BY로 묶어서 ORDER BY DESC할 예정이다. 그렇다면 동일한 문제를 여러 번 틀렸을 때 많이 틀렸다면 그 문제부터 위에서 확인할 수 있을 것이다. 여기에서 추가해야 하는 기능은 다음과 같다.

  1. 리사이클러뷰 삭제: 화면에 띄어놓은 리사이클러뷰 각 아이템을 클릭, 옆으로 스와이프시켜줄 때 (즉 치워버릴 때) 뷰에서 사라지도록 한다. 옆으로 사라질 때 이를 반영한 전체 홀더들이 빈 자리를 채워서 위로 올라오거나 / 밑으로 내려간다.

  2. 데이터베이스 내 데이터 삭제: 1번이 일어날 때 아이템이 가지고 있는 그 문제의 기출 회차와 문제를 확인할 수 있다. dh_helper를 통해 이를 delete해준다. 사실상 간단한 함수만 구현하면 된다.

여기에서 생각해볼 문제가 1번의 '자동으로 여백을 채우는 기능'인데, 나름 검색해본 결과 각 아이템이 가지고 있는 position을 +- 1을 해주거나, 새로운 position을 계산해주는 기능을 덧붙이면 보다 간단할 것 같다.

Plus

위 리사이클러뷰의 어댑터 내에서 아이템의 특정 토글 스위치를 누르면 이에 해당하는 지문 박스가 나오도록 세팅해두었는데, 사실상 '전체 크기'는 펼친 상태로 아이템을 설정해두었기 때문에 간격이 생각했던 것보다 컸다(그렇지 않다면 지문 박스를 펼쳤을 때 서로 겹쳐졌다). 일단은 간격을 넓혀서 일단 마무리 지었는데, 사실상 생각한 기능은 '펼친다면' 그 크기에 알맞게 밑의 아이템이 내려가고, '닫는다면' 그 줄어든 크기에 알맞게 밑의 아이템이 자동으로 올라오는 깔끔한 형식이다.

어떻게 구현할 수 있을까?

이 역시 아마 다른 부분을 구현한 뒤 다듬을 것 같은데, 1). Expandable View 2). Scroll View 등을 활용해야 할 것 같다. 물론 지금 상태로서는 양자가 과연 차이가 있는지 여부도 확신하지 못하겠지만 말이다.

profile
JUST DO IT

0개의 댓글

관련 채용 정보