=== TodoList (일정표) ===
- 기능 소개
할 일 목록을 표시
할 일을 데이터베이스에 추가, 수정, 삭제
- 주된 도구
ListView (목록을 표현하는 리스트 형태의 뷰)
Realm (모바일용 데이터베이스)
1. 프로젝트 생성
두 개의 화면을 사용할 것임 (할 일 목록 표시 화면 + 편집 화면)
데이터 베이스로는 모바일에서 SQLite를 대체할 정도로 인기가 높은 Realm을 사용할 것임
(Realm은 SQL 문법을 몰라도 사용 가능함)
프로젝트명 : TodoList
minSdkVersion : 19
기본 액티비티 : Basic Activity
Anko 라이브러리 설정 할 것.
VertorDrawable 하위 호환성 설정 할 것.
* 참고)
Basic Activity는 지금까지의 액티비티와 조금 다른 면이 있습니다.
플로팅 액션 버튼(FAB)과 메뉴가 미리 작성되어 있고 activity_main.xml과 content_main.xml 두 개의 레이아웃 파일이 생성됩니다.
activity_main.xml의 컴포넌트 트리 창을 보면, 'include...'를 통해 content_main.xml 파일이 포함되어 있음을 알 수 있습니다.
(include 속성을 이용해 여러 개의 화면을 구성할 수 있음)
2. 할 일 목록을 표시하는 화면의 레이아웃 추가
content_main.xml의 기본 텍스트 뷰 삭제
Autoconnect 모드 on
팔레트 창에서 Legacy 카테고리의 ListView를 끌어다 화면 정중앙에 배치
(id: listView, layout_width: match_constraint, layout_height: match_constraint, 상하좌우 여백: 모두 0)
3. 편집 화면의 레이아웃 추가
File > New > Activity > 'Empty Activity'
(이름: EditActivity)
activity_edit.xml에 팔레트 창 Widgets 카테고리에서 CalendarView를 끌어다 화면 상단에 배치
id: calendarView
layout_width, layout_height: wrap_content
위, 좌우 여백: 0
팔레트 창 Text 카테고리에서 Plain Text를 끌어다 달력 아래에 적당히 배치 (할 일 편집용으로 쓸 것임)
Autoconnect 모드는 부모 레이아웃과의 제약을 자동으로 생성하지만, 자식 뷰들간의 제약은 생성되지 않습니다.
아마도 좌우 여백 제약은 추가되어 있을 것입니다. EditText와 CalendarView와의 제약을 추가합시다.
id: todoEditText
layout_width: match_constraint
layout_height: wrap_content
위, 좌우 여백: 8
inputType: text
hint: 할 일
text: (공백)
완료버튼용, 삭제버튼용 벡터이미지를 추가
팔레트 창 Buttons 카테고리에서 FloatingActionButton을 끌어다 우측 하단에 배치 (편집 완료 버튼으로 사용할 것임)
이미지는 프로젝트에 이미 추가한 완료버튼 이미지를 지정해서 사용하고 다음 속성을 지정.
id: doneFab
하단, 우측 여백: 16
backgroundTint: @android:color/holo_orange_light
tint: @android:color/white
팔레트 창 Buttons 카테고리에서 FloatingActionButton을 끌어다 좌측 하단에 배치 (삭제 버튼으로 사용할 것임)
이미지는 프로젝트에 이미 추가한 삭제버튼 이미지를 지정해서 사용하고 다음 속성을 지정.
id: deleteFab
하단, 좌측 여백: 16
tint: @android:color/white
Activity_main.xml에서 일정 추가버튼의 벡터 이미지를 아래 이미지로 변경
srcCompat: @drawable/ic_add_black_24dp
tint: @android:color/white
4. Realm 데이터 베이스 사용 준비
비만도 계산기를 구현할 때에는 데이터 베이스를 사용하지 않고 SharedPreferences를 이용해 데이터를 간단히 저장했었습니다.
그러나 데이터가 많고 복잡하다면 데이터 베이스를 활용하는 것이 효율적입니다.
안드로이드는 SQLite를 지원하지만 다루기가 어렵고 코드량도 많아 Realm 데이터 베이스를 활용해 보기로 합시다.
안드로이드에서는 앱 별로 격리된 데이터 베이스를 가질 수 있습니다.
앱 간에 자신의 데이터 베이스를 공개하려면 프로바이더를 이용하면 됩니다.
참고) 데이터 베이스에서의 테이블은 엑셀의 시트와 같습니다.
1) 사용 준비
Build.gradle(project)에 플러그인 정보 설정
dependencies {
...
classpath "io.realm:realm-gradle-plugin:6.0.0"
}
Build.gradle(module)에 플러그인 추가
apply plugin: 'kotlin-kapt'
apply plugin: 'realm-android'
2) 할 일 정보 데이터 베이스의 모양
데이터 베이스 명: todolist
테이블 명 : todo
id (Long) |
title (String) |
date (Long) |
1 |
청소 |
2020. 05. 25 |
2 |
빨래 |
2020. 05. 25 |
3 |
공부 |
2020. 05. 27 |
3) 모델 클래스 작성
① File > New > Kotlin > 'File/Class' (클래스명: Todo)
② 위 데이터 베이스를 클래스화 한 모양 --> "모델 클래스" 라고 부름
class Todo(
@PrimaryKey var id: Long = 0, // PrimaryKey: 중복되지 않는 유일키
var title: String = "",
var date: Long = 0
) {
}
이 모델 클래스를 Realm에서 테이블로 사용하려면,
클래스 명 앞에 open 키워드를 사용하고
RealmObject 클래스를 상속받으면 됩니다.
-- Todo.kt 코딩
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() {
}
4) Realm 초기화
앱 실행시 가장 먼저 Realm이 초기화 되도록해야 액티비티들이 공동으로 데이터 베이스를 사용할 수 있습니다.
이 역할을 하는 클래스를 하나 새로 만듭시다.
File > New > 'Kotlin File/Class' (클래스명: MyApplication, 종류: Class)
-- MyApplication.kt
import android.app.Application
import io.realm.Realm
class MyApplication : Application() { // Application 클래스를 상속
override fun onCreate() { // 이 메서드는 액티비티가 생성되기 전에 호출됨
super.onCreate()
Realm.init(this) // Realm 초기화
}
}
-- AndroidManifest.xml (application 엘리먼트에 name 속성을 추가 --> 앱에서 사용하는 전체 액티비티에
공동으로 사용하는 객체를 초기화할 때 이런 방법을 사용합니다)
<application
android:name = ".MyApplication" // <-- 이 행을 추가
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".EditActivity"></activity>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
5. EditActivity.kt 코딩
-- EditActivity.kt
package com.tistory.todolist
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
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.yesButton
import java.util.*
class EditActivity : AppCompatActivity() {
val realm = Realm.getDefaultInstance() // Realm 인스턴스 얻기
val calendar: Calendar = Calendar.getInstance() // 캘린더 객체 생성(오늘 날짜로 초기화됨)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_edit)
// 인텐트로 id를 전달해서 데이터 베이스의 삽입/변경/삭제를 분기
// id=-1 (추가모드)
val id = intent.getLongExtra("id", -1L)
if (id == -1L) {
insertMode() // 추가 모드
} else {
updateMode(id) // 삭제 모드
}
// 캘린더 뷰의 날짜를 선택했을 때 캘린더 객체에 설정
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() {
deleteFab.visibility = View.GONE // 삭제 버튼 숨기기
// visiblity 프로퍼티: setVisibility()
// VISIBLE : 보임, INVISIBLE : 영역은 차지하지만 보이지는 않음, GONE : 숨김
doneFab.setOnClickListener { insertTodo() } // 완료 버튼 클릭시 insertTodo() 호출
}
// 삭제 모드
private fun updateMode(id: Long) {
// id에 해당하는 객체를 화면에 표시
val todo = realm.where<Todo>().equalTo("id", id).findFirst()!!
todoEditText.setText(todo.title)
calendarView.date = todo.date
// 완료 버튼 클릭시 updateTodo() 호출
doneFab.setOnClickListener { updateTodo(id) }
// 삭제 버튼 클릭시 deleteTodo() 호출
deleteFab.setOnClickListener { deleteTodo(id) }
}
override fun onDestroy() {
super.onDestroy()
realm.close() // Realm 인스턴스 해제
}
// 데이터 베이스 할 일 삽입
private fun insertTodo() {
realm.beginTransaction() // *** 트랜잭션 시작 ***
// 트랜잭션 시작 (트랜젝션: 데이터베이스의 작업단위)
// beginTransaction ~ commitTransaction 사이의 코드들은
// 전체가 하나의 작업(트랜잭션)이며 도중에 에러가 나면 일괄 취소됨
// 데이터베이스의 추가/삭제/업데이트는 항상 이 사이에 작성해야 함
val newItem = realm.createObject<Todo>(nextId())
newItem.title = todoEditText.text.toString()
newItem.date = calendar.timeInMillis
realm.commitTransaction() // *** 트랜잭션 종료 ***
alert("일정이 추가 되었습니다") {
yesButton { finish() }
}.show()
}
// 데이터 베이스 할 일 변경
private fun updateTodo(id: Long) {
realm.beginTransaction()
val updateItem = realm.where<Todo>().equalTo("id", id).findFirst()!!
// where<Todo>() : 테이블의 모든 값을 얻어옴
// .equalTo(필드명, Long) : 해당 '필드명'의 Long형 id값의 데이터를 가져옴
// findFirst() : 첫 번째 데이터
updateItem.title = todoEditText.text.toString()
updateItem.date = calendar.timeInMillis
// timeInMillis 프로퍼티 : 날짜를 가져오는 getTimeInMilles()
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()
}
// Realm은 자동 키 증가를 지원하지 않으므로 아래 메서드를 만들었음
private fun nextId() : Int {
val maxId = realm.where<Todo>().max("id")
// where<Todo>() : 테이블의 모든 값을 얻어옴
// .max(필드명) : 현재 '필드명'중 가장 큰 값을 얻음 (Number형)
if (maxId != null) {
return maxId.toInt() + 1
}
return 0
}
}
6. MainActivity.kt 코딩
메인 액티비티에 할 일 추가 버튼 클릭 시, EditActivity를 시작하도록 리스너 등록
-- MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
fab.setOnClickListener { // 할 일 추가 버튼 클릭 리스너
startActivity<EditActivity>()
}
}
7. 메인 액티비티에 할 일 목록 표시
타이머 예제의 랩 기록과 같이 적은 목록을 표시할 때는 스크롤 뷰로 충분했지만,
표시해야 할 목록의 양이 많아질 경우엔 리스트 뷰를 사용합니다.
스크롤 뷰 |
모든 아이템을 메모리에 로드하므로 데이터가 많아지면 많은 메모리를 차지 |
리스트 뷰 |
뷰를 재사용하고 화면에 보이는 것만 동적으로 로딩 하므로 적은 메모리를 사용 |
리스트 뷰에 데이터를 출력하려면 읽어올 데이터 소스와 리스트 뷰의 입력부간에 데이터 형식을 맞춰주기 위한
어댑터가 필요하다. 이 어댑팅 작업은 출력 속도에 큰 영향을 주므로 아주 중요합니다.
리스트 뷰에 목록을 출력할 때에는 '뷰홀더' 방식을 사용합니다.
뷰홀더 방식이란 딱 한 번 생성한 레이아웃에 내용만 수정해서 재사용하는 방식을 말합니다.
사용하는 메모리나 성능 면에서 효율적이죠.
1) 어댑터 사용 준비
-- build.gradle (Module)
dependencies {
implementation 'io.realm:android-adapters:2.1.1'
...
}
2) 목록을 표시할 레이아웃 리소스 파일 작성
res/layout 폴더에서 마우스 컨텍스트 메뉴 > File > New > Layout resource file
(파일명: item_todo , 레이아웃: ConstraintLayout)
(item_todo.xml 파일 작업)
날짜 표시용 TextView를 좌측 상단에 배치
id: text1
layout_width: match_constraint
위 여백:0, 좌우 여백: 8
text: (공백)
(붓)text: 2020/05/28 (디자인 시 보일 텍스트 아무거나)
textAppearance: AppCompat.Body1
할 일 표시용 TextView를 좌측 상단에 배치
id: text2
layout_width: match_constraint
위, 좌우 여백: 8
text: (공백)
(붓)text: 청소하기 (디자인 시 보일 텍스트 아무거나)
textAppearance: AppCompat.Body2
전체 레이아웃의 layout_height 속성: wrap_content
3) 어댑터 클래스 작성
File > New > 'Kotlin File/Class' (이름: TodoListAdapter, 종류: Class)
-- TodoListAdapter.kt
package com.tistory.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){ // RealmBaseAdapter 를 상속
// RealmBaseAdapter의 추상 메서드 구현
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
// 이 메서드는 매 아이템이 화면에 보일 때마다 호출됨
// 이 메서드에 리스트 뷰의 각 아이템에 표시할 뷰를 구성하면 됨
// position (출력할 리스트 뷰 내의 아이템 위치)
// convertView (재활용되는 아이템의 뷰. 처음 아이템이 작성되기 전엔 null, 이후에는 그 전에 작성했던 뷰를 전달)
// parent (부모 뷰. 여기서는 리스트 뷰의 참조를 가리킴)
val vh: ViewHolder
val view: View
if (convertView == null) { // null이면 레이아웃을 작성
view = LayoutInflater.from(parent?.context).inflate(R.layout.item_todo, parent, false)
// LayoutInflater : XML 레이아웃 파일을 불러옴
// inflate() : XML 레이아웃 파일을 뷰로 전환
// false : XML 파일을 불러왔을 경우
vh = ViewHolder(view)
view.tag = vh // tag 프로퍼티에는 모든 데이터형의 객체를 저장할 수 있음
} else { // null이 아니면 이전에 작성된 convertView를 재사용
view = convertView
vh = view.tag as ViewHolder // view.tag는 Any형이므로 ViewHolder 타입으로 형변환
}
// adapterData : RealmBaseAdapter가 제공하는 프로퍼티로서 이를 통해 데이터에 접근할 수 있음
if (adapterData != null) { // 데이터가 있으면.
val item = adapterData!![position]
vh.textTextView.text = item.title
vh.dateTextView.text = DateFormat.format("yyyy/MM/dd", item.date)
}
return view
}
override fun getItemId(position: Int): Long {
// 리스트뷰 클릭 이벤트 처리시 인자로 position, id등이 넘어옴. 이 때의 id 값을 결정
if (adapterData!= null) {
return adapterData!![position].id // adapterView가 Realm 데이터를 가지고 있으므로
// 해당 위치의 id를 반환해줘야 함
}
return super.getItemId(position)
}
}
class ViewHolder(view: View) {
val dateTextView: TextView = view.findViewById(R.id.text1)
val textTextView: TextView = view.findViewById(R.id.text2)
}
4) 할 일 목록 표시
-- MainActivity.kt
package com.tistory.todolist
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar
import io.realm.Realm
import io.realm.Sort
import io.realm.kotlin.where
import kotlinx.android.synthetic.main.activity_main.*
import org.jetbrains.anko.listView
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(toolbar)
fab.setOnClickListener { view ->
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
.setAction("Action", null).show()
}
fab.setOnClickListener { // 할 일 추가 버튼 클릭 리스너
startActivity<EditActivity>()
}
val realmResult = realm.where<Todo>().findAll().sort("date", Sort.DESCENDING)
// 할 일 목록을 날짜순으로 모두 가져옴
val adapter = TodoListAdapter(realmResult) // 할 일 목록이 담긴 어댑터 생성
listView().adapter = adapter // 어댑터 지정 (이 때 목록이 출력됨)
realmResult.addChangeListener { _ -> adapter.notifyDataSetChanged() } // 데이터가 변경될 경우 어댑터에 적용됨
// notifyDataSetChanged() : 데이터 변경을 통지하여 목록을 다시 출력함
listView().setOnItemClickListener { parent, view, position, id -> // 리스트 뷰 아이템 클릭시 처리
startActivity<EditActivity>("id" to id) // 기존 id 존재 여부에 따라 새 할 일 추가 또는 수정
}
}
override fun onDestroy() {
super.onDestroy()
realm.close()
}
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)
}
}
}
실행 결과)
여기까지 9개의 코틀린 예제를 통해서 안드로이드 주요 기능들을 골고루 훑어봤습니다.
안드로이드 기기가 발전되어 오면서 추가된 여러 센서 기능들과 기기 자체 성능이 다양한 제조업체를 통해 이뤄지다 보니 마치 웹 브라우저에 플러그인들이 더덕더덕 붙어 있는 듯한 느낌입니다.