본문 바로가기

책/오준석의 안드로이드 생존코딩 코틀린편

13. Todo 리스트

프로젝트명 Todo 리스트
기능
  • 할일 목록을 표시한다.
  • 할 일을 데이터페이스에 추가, 수정, 삭제한다.
핵심구성요소
  • ListView : 목록을 표현하는 리스트형 뷰이다.
  • Realm : 모바일용 데이터 베이스이다.
라이브러리 설정
  • Anko : 인텐트, 다이얼로그, 로그 등을 구현하는 데 도움이 되는 라이브러리
  • Realm : 객체 중심 저 메모리 모바일 데이터 베이스
  1. 준비하기 : 프로젝트 생성 및 안드로이드 설정
  2. 스텝1 : 레이아웃 작성
  3. 스텝2 : Realm 데이터 베이스
  4. 스텝3 : 리스트 뷰와 데이터베이스 연동

준비하기

Anko라이브러리 추가

참조 : https://abstractask.tistory.com/21

 

5. 비만도 계산기

프로젝트명 BmiCalculator 기능 키와 몸무게를 임력 하고 결과 버튼을 누르면 다른 화면에서도 비반도 결과를 문자와 그림으로 보여줍니다. 마지막에 입력했던 키와 몸무게는 자동으로 저장됩니다.

abstractask.tistory.com

벡터 드로어블 하위 호환 설정

안드로이드 5.0 미만의 기기에서도 벡터 이미지가 잘 표시되도록 설정한다.

defaultConfig {
    vectorDrawables.useSupportLibrary = true
}

Basic Activity 작성

책의 예제에서는 Use a Fragment 여부가 있어 프래그먼트생성을 생략할 수 있게 되어 있지만 현재(4.0버전) 에서는 해당 옵션이 없고 기본적으로 프래그먼트를 생성하도록 되어 있다.

스텝1 레이아웃 작성

Basic Activity분석

  1. activity_main.xml, content_main.xml, fragment_first.xml, fragment_second.xml 총 네개의 파일이 생성된다.
  2. activity_main.xml은 다음과 같이 구성되어 있다.
    • 액션바(AppbarLayout, toolbar)
    • content_main.xml (include 되어 있음)
    • 플로팅 액션 버튼 (fab)

첫 번째 화면의 레이아웃 작성

우선 예제와 맞추기 위해 content_main.xml 파일에 다음 컴포넌트를 삭제한다.

content_main.xml 에서 Autoconnect모드 Lagacy 카테고리 -> ListView를 중앙에 배치한다.

ID listView
layout_width match_constraint
layout_height match_constraint

두 번째 액티비티 추가

  • File -> New -> Activity -> Empty Activity를 클릭
  • Activity Name 을 EditActivity로 입력하고 저장한다. 
  • 생성된 activit_edit.xml의 레이아웃을 수정한다.

달력 표시용 CalendarView 배치

ID calendarView
layout_width wrap_content
layout_height wrap_content
위,오른쪽,왼쪽 여백 0

할일을 입력하는 EditText 추가

ID todoEditText
layout_width match_constraint
layout_height wrap_content
위,오른쪽,왼쪽 여백 8
inputType text
hint 할 일
text (공백)

필요한 이미지 리소스 추가

  • File -> New -> Vector Asset을 클릭 하여 다음 아이콘들을 추가한다.
  • 팝업이 뜨면 Clip Art아이콘 클릭 한다.
  • done, delete, add 이미지 리소스를 추가한다.

완료 버튼 추가

배치 Button 카테고리 FloatingActionButton 클릭 후 레이아웃 오른쪽 하단에 배치
scrCompat @drawable/ic_baseline_done_24
ID doneFab
layout_width wrap_content
layout_height wrap_content
아래,오른쪽 여백 16
backgroundTint @android:color/holo_orange_light
hint @android:color/white

삭제 버튼 추가

배치 Button 카테고리 FloatingActionButton 클릭 후 레이아웃 왼쪽 하단에 배치
scrCompat @drawable/ic_baseline_delete_24
ID deleteFab
layout_width wrap_content
layout_height wrap_content
아래,왼쪽 여백 16
backgroundTint @android:color/holo_red_dark
hint @android:color/white

activity_edit.xml의 배치된 결과

 

activity_main.xml 에서 추가 버튼의 변경

 

처음 Basic Activity로 선택 후 프로젝트를 작성하면 activity_main.xml 에는 이미 ID가 fab인  FloatingActionButton이 오른쪽 하단에 이미 배치가 되어 있다. 이것을 재사용하도록 한다. 아래 이미지와 같이 fab 버튼이 변경 되었다.

ID fab
scrCompat @drawable/ic_baseline_add_24
backgroundTint @android:color/holo_red_dark
hint @android:color/white

스텝2 Realm 데이터 베이스

안드로이드에서는 SQLite를 지원하지만 좀 더 개발하기 쉬운 Realm을 사용한다. Realm은 SQL문법을 전혀 몰라도 사용가능하다.

 

Realm 데이터 베이스 준비 및 사용법

./build.gradle 파일 플러그인 추가 

buildscript {
    ....
    dependencies {
        ....
        classpath 'io.realm:realm-gradle-plugin:5.2.0'
    }
}

./app/build.gradle 파일 플러그인 추가

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

// 순서도 중요하다.
apply plugin: 'kotlin-kapt'
apply plugin: 'realm-android'

android {
....
}

dependencies {
....
}


Realm 초기화

File -> New -> Kotlin/Class 클릭 MyApplication 이름으로 클래스 생성 하고 다음 과 같이 작성

package abstractask.example.todolist

import android.app.Application
import io.realm.Realm

class MyApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        // Realm 초기화
        Realm.init(this)
    }
}

매니페스트 파일에서 다음을 추가 수정

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="abstractask.example.todolist">

    <application
        ....
        android:name=".MyApplication"
        >
        ....
    </application>
</manifest>

 

다음 데이터 베이스 설계에 맞는 모델 클래스 생성

데이터베이스명 todolist
테이블명 todo
  • id: Long, 자동증가, 고유한 값
  • title: String, 할 일 내용
  • date: Long, 시간
package abstractask.example.todolist

import io.realm.RealmObject
import io.realm.annotations.PrimaryKey

open class Todo ( // 클래스 명이 테이블명이 됨
    @PrimaryKey var id: Long = 0
    ,var title: String = ""
    ,var date: Long = 0
): RealmObject()

Realm 사용

MainActivity에서 EditActivity 로 이동하기 위해 다음과 같이 코드 추가

package abstractask.example.todolist

import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import androidx.appcompat.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import kotlinx.android.synthetic.main.activity_main.*
import org.jetbrains.anko.startActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(findViewById(R.id.toolbar))

        // 새 할일 추가
        fab.setOnClickListener{
            startActivity<EditActivity>()
        }
        /*
        findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { view ->
            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                    .setAction("Action", null).show()
        }
        */
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        // Inflate the menu; this adds items to the action bar if it is present.
        menuInflater.inflate(R.menu.menu_main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        return when (item.itemId) {
            R.id.action_settings -> true
            else -> super.onOptionsItemSelected(item)
        }
    }
}

 

EditActivity 에서 사용

package abstractask.example.todolist

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
import kotlinx.android.synthetic.main.activity_edit.*
import org.jetbrains.anko.alert
import org.jetbrains.anko.calendarView
import org.jetbrains.anko.yesButton
import java.util.*

class EditActivity : AppCompatActivity() {

    // 1 realm 인스턴스 얻기
    val realm = Realm.getDefaultInstance()

    val calendar: Calendar = Calendar.getInstance()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_edit)

        /*
        getLongExtra(name: String, defaultValue: Long)
         */
        // 업데이트 조건
        val id = intent.getLongExtra("id", -1L)
        if(id == -1L){
            insertMode()
        }else {
            updateMode(id)
        }

        // 캘린터 뷰의 날짜를 선택했을 때 Calendar 객체에 설정
        calendarView.setOnDateChangeListener{ view, year, month, dayOfMonth ->
            calendar.set(Calendar.YEAR, year)
            calendar.set(Calendar.MONTH, month)
            calendar.set(Calendar.DAY_OF_MONTH, dayOfMonth)
        }
    }

    // 추가 모드 초기화
    private fun insertMode(){
        /*
        VISIBLE : 보이게 함
        INVISIBLE : 영역은 차지하지만 보이지 않음
        GONE : 보이지 않음
         */
        // 삭제 버튼을 감추기
        deleteFab.visibility = View.GONE

        // 완료 버튼을 클릭하면 추가
        doneFab.setOnClickListener{
            insertTodo()
        }
    }

    // 수정 모드 초기화
    private fun updateMode(id: Long){
        // id에 해당하는 객체를 화면에 표시
        val todo = realm.where<Todo>().equalTo("id",id).findFirst()!!
        todoEditText.setText(todo.title)
        calendarView.date = todo.date

        doneFab.setOnClickListener{
            updateTodo(id)
        }
        // 삭제 버튼을 클릭하면 삭제
        deleteFab.setOnClickListener{
            deleteTodo(id)
        }
    }

    private fun insertTodo(){
        // 트랜잭션 시작
        realm.beginTransaction()

        // 새 객체 셍성
        /*
        createObject<T: RealmModel>(primaryKeyValue: Any?)
           - primaryKeyValue: 기본키를 지정한다.
         */
        val newItem = realm.createObject<Todo>(nextId())

        // 값 설정
        newItem.title = todoEditText.text.toString()
        newItem.date = calendar.timeInMillis

        // 트랜잭션 종료
        realm.commitTransaction()

        // 다이얼로그 표시 : anko 라이브러리 추가가 되어 있어야 사용이 가능하다.
       alert("내용이 변경되었습니다."){
            yesButton {
                // 다이얼 로그 확인 버튼을 누르면 현재 액티비티를 종료한다.
                finish()
            }
       }.show()
    }
    private fun nextId(): Int {
        /*
          현재 열명의 가장 큰 값을 리턴
          max(fieldName: String)
            -  fieldName : 찾고자 하는 열명
         */
        val maxId = realm.where<Todo>().max("id")
        return if(maxId != null) maxId.toInt()+1 else 0
    }
    private fun updateTodo(id: Long) {
        realm.beginTransaction()
        val updateItem = realm.where<Todo>().equalTo("id", id).findFirst()!!

        // 값 수정
        updateItem.title = todoEditText.text.toString()
        updateItem.date = calendar.timeInMillis

        realm.commitTransaction()
        alert("내용이 변경되었습니다."){
            yesButton {
                finish()
            }
        }.show()
    }
    private fun deleteTodo(id: Long){
        realm.beginTransaction()
        val deleteItem = realm.where<Todo>().equalTo("id", id).findFirst()!!

        // 삭제할 객체
        deleteItem.deleteFromRealm()

        realm.commitTransaction()
        alert("내용이 삭되었습니다."){
            yesButton {
                finish()
            }
        }.show()
    }
    override fun onDestroy() {
        super.onDestroy()

        // 2 인스턴스 해제
        realm.close()
    }
}

 

스텝3 리스트 뷰와 데이터 베이스 연동

어댑터 작성

  • ListView는 실제 데이터와 연동을 하려면 어댑터를 작성해야 한다.
  • 어댑터는 데이터를 ListView에 어떻게 표시할지를 정의하는 객체이다.
  • 기본적으로 BaseAdapter를 상속받아서 작성하지만 Realm은 아래 의존성을 추가하고 RealmBaseAdapter클래스를 상속받아 구현하게 된다.
dependencies {
    ....    
    // adapter 의존성 추가
    implementation 'io.realm:android-adapters:2.1.1'
    ....
}

RealmBaseAdapter 를 상속 받은 TodoListAdapter 기본 틀을 작성한다.

package abstractask.example.todolist

import android.view.View
import android.view.ViewGroup
import io.realm.OrderedRealmCollection
import io.realm.RealmBaseAdapter

class TodoListAdapter(realmResult: OrderedRealmCollection<Todo>): RealmBaseAdapter<Todo>(realmResult) {

    /*
    아이템에 표시하는 뷰를 구성한다.
    getView(position: Int, convertView: View?, parent: ViewGroup?)
       - position: 리스트 뷰의 아이템 위치
       - convertView: 재활용되는 아이템의 뷰
       - parent: 부모 뷰 즉 여기서는 리스트 뷰의 참조
     */
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        TODO("Not yet implemented")
    }
}

/*
뷰 홀더 패턴
 - 리스트를 표시할 때 성능을 향상시킬 목적으로 뷰 홀더 패턴을 적용한다. 
 - getView 메서드가 아이템에 화면에 표시될 때마다 호출되므로 최대한 효율적인 코드를 작성해야 한다.
 */
class ViewHolder(view: View) {

}

아이템 레이아웃 작성

res/layout 디렉터리에서 우클릭 File -> New -> Layout resource file 클릭 res/layout/item_todo.xml 파일을 생성한다.

날짜를 표시할 TextView를 추가한다.

ID text1
layout_width match_constraint
위,오른쪽,왼쪽 여백 8
text (공백)
🔧 text
(디자인 시 보이는 텍스트)
2018/06/12
위,오른쪽,왼쪽 여백 8
textAppearance AppCompat.Body1

title을 추가할 TextView 를 추가한다.

ID text2
layout_width match_constraint
위 여백 0
위,오른쪽,왼쪽 여백 8
text (공백)
🔧 text
(디자인 시 보이는 텍스트)
청소하기
오른쪽,왼쪽,아래 여백 8
textAppearance AppCompat.Body2

두개의 TextView를 포함하는 ConstraintLayout의 속성을 다음과 같이 수정한다. 

layout_height wrap_content

 

최종적인 item_todo.xml의 레이아웃은 다음과 같다. 

 

뷰 홀더 패턴을 이용하여 다음과 같이 TodoListAdapter 에 코드를 추가한다.

package abstractask.example.todolist

import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import io.realm.OrderedRealmCollection
import io.realm.RealmBaseAdapter

class TodoListAdapter(realmResult: OrderedRealmCollection<Todo>)
: RealmBaseAdapter<Todo>(realmResult) {

    /*
    아이템에 표시하는 뷰를 구성한다.
    getView(position: Int, convertView: View?, parent: ViewGroup?)
       - position: 리스트 뷰의 아이템 위치
       - convertView: 재활용되는 아이템의 뷰
       - parent: 부모 뷰 즉 여기서는 리스트 뷰의 참조
     */
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        val vh: ViewHolder
        val view: View

        if(convertView == null) {
            /*
            xml 레이아웃 파일을 읽어서 뷰로 변환한다.
            inflate(resource: Int, root: ViewGroup, attachToRoot: Boolean)
              - resource: 불러올 레이아웃 XML 리소스ID를 지정
              - root: 불러온 레이아웃 파일이 붙을 뷰그룹인 parent를 지정
              - attachToRoot: XML파일을 불러올 때는 false지정
             */
            view = LayoutInflater
                .from(parent?.context)
                .inflate(R.layout.item_todo,parent,false)
            vh = ViewHolder(view)
            view.tag = vh;
        } else {
            view = convertView
            vh = view.tag as ViewHolder
        }

        if(adapterData != null){
            val item = adapterData!![position]
            vh.textTextView.text = item.title
            vh.dateTextView.text = DateFormat.format("yyyy/mm/dd",item.date)
        }
        return view
    }

    // 리스트 뷰를 클릭하여 이벤트를 처리할 때 인자로 position, id등이 넘어오게 되는데 
    // 이때 넘어오는 id값을 결정한다.  
    // 데이터 베이스를 다룰 때마다 고유한 아이디를 가지고 있는데, 그것을 반환하도록 결정한다.
    override fun getItemId(position: Int): Long {
        if(adapterData != null ) {
            return adapterData!![position].id
        }
        return super.getItemId(position)
    }
}

/*
뷰 홀더 패턴
 - 리스트를 표시할 때 성능을 향상시킬 목적으로 뷰 홀더 패턴을 적용한다.
 - getView 메서드가 아이템에 화면에 표시될 때마다 호출되므로 최대한 효율적인 코드를 작성해야 한다.
 */
class ViewHolder(view: View) {
    val dateTextView: TextView = view.findViewById(R.id.text1)
    val textTextView: TextView = view.findViewById(R.id.text2)
}

할일 목록 표시

package abstractask.example.todolist

import android.os.Bundle
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import androidx.appcompat.app.AppCompatActivity
import android.view.Menu
import android.view.MenuItem
import io.realm.Realm
import io.realm.Sort
import io.realm.kotlin.where
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.content_main.*
import org.jetbrains.anko.startActivity

class MainActivity : AppCompatActivity() {

    val realm = Realm.getDefaultInstance()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setSupportActionBar(findViewById(R.id.toolbar))

        // 전체 할 일 정보를 가져와서 날짜 순으로 내림차순 정렬
        val realmResult = realm.where<Todo>()
            .findAll()
            .sort("date", Sort.DESCENDING)

        val adapter = TodoListAdapter(realmResult)
        listView.adapter = adapter

        // 데이터가 변경되면 어뎁터에 적용
        // 어뎁터에 notifyDataSetChange() 메서드를 호출하면 
        // 데이터 변경을 통지하여 리스트를 다시 표시하게 된다.
        realmResult.addChangeListener { _ -> adapter.notifyDataSetChanged() }

        listView.setOnItemClickListener{ parent, view, position, id ->
            //할 일 수정
            startActivity<EditActivity>("id" to id)
        }

        // 새 할일 추가
        fab.setOnClickListener{
            startActivity<EditActivity>()
        }
        /*
        findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { view ->
            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                    .setAction("Action", null).show()
        }
        */


    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        // Inflate the menu; this adds items to the action bar if it is present.
        menuInflater.inflate(R.menu.menu_main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem): Boolean {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        return when (item.itemId) {
            R.id.action_settings -> true
            else -> super.onOptionsItemSelected(item)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        realm.close()
    }

}

빌드 시 오류발생처리 

2020-06-26 11:54:27.436 11989-11989/abstractask.example.todolist D/AndroidRuntime: Shutting down VM
2020-06-26 11:54:27.437 11989-11989/abstractask.example.todolist E/AndroidRuntime: FATAL EXCEPTION: main
    Process: abstractask.example.todolist, PID: 11989
    java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/appcompat/R$drawable;
        at androidx.appcompat.widget.AppCompatDrawableManager$1.<init>(AppCompatDrawableManager.java:63)

확인 해본 결과 android studio 4.0으로 올린 후 버젼이 변경되어서 그런듯 하다.

참조 : https://qastack.kr/programming/60393230/app-crashes-during-run-time-after-updating-to-android-studio-3-6

 

Android Studio 3.6으로 업데이트 한 후 런타임 동안 앱이 충돌 함

 

qastack.kr

다음으로 버젼 변경 후 재 빌드

buildscript {
....
    dependencies {
    ....
        //classpath 'io.realm:realm-gradle-plugin:5.2.0'
        classpath 'io.realm:realm-gradle-plugin:6.0.2'

    }
}

결과