티스토리 뷰

안드로이드/코드

Compose로 메모장 만들기

알렌보이스 2022. 8. 1. 21:46
728x90

0. 기본 세팅

더보기

build.gradle(:app)

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'kotlin-kapt'
    id 'com.google.dagger.hilt.android'
}

android {
    compileSdk 32

    defaultConfig {
        applicationId "com.example.mymemo"
        minSdk 21
        targetSdk 32
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        vectorDrawables {
            useSupportLibrary true
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion "1.2.0-beta03"
    }
    packagingOptions {
        resources {
            excludes += '/META-INF/{AL2.0,LGPL2.1}'
        }
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.8.0'
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.compose.material3:material3:1.0.0-alpha15'
    implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
    implementation 'androidx.activity:activity-compose:1.5.1'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
    debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
    debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"

    // compose navigation
    def nav_version = "2.5.1"
    implementation "androidx.navigation:navigation-compose:$nav_version"

    // Room library
    def room_version = "2.4.3"
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

    // Lottie Animation
    implementation 'com.airbnb.android:lottie:5.2.0'
    implementation "com.airbnb.android:lottie-compose:5.2.0"

    // hilt
    implementation 'com.google.dagger:hilt-android:2.43'
    kapt 'com.google.dagger:hilt-compiler:2.43'
    implementation 'androidx.hilt:hilt-navigation-compose:1.0.0'

    // Lottie Animation
    implementation 'com.airbnb.android:lottie:5.2.0'
    implementation "com.airbnb.android:lottie-compose:5.2.0"

}

build.gradle(Mymemo)

buildscript {
    ext {
        compose_version = '1.3.0-alpha02'
    }
    dependencies {
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.43'
    }
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    id 'com.android.application' version '7.2.0' apply false
    id 'com.android.library' version '7.2.0' apply false
    id 'org.jetbrains.kotlin.android' version '1.6.21' apply false
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

AndroidManifest.xml

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

    <application
        android:name=".MyMemo"
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyMemo"
        tools:targetApi="31">
        <activity
            android:name=".view.MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MyMemo">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Database

@Database(
    entities = [MemoEntity::class],
    version = 1
)
abstract class MemoDatabase : RoomDatabase() {

    abstract fun memoDao(): MemoDao

    companion object {
        const val DATABASE_NAME = "my_memo.db"
    }

}
@Entity(
    indices = [Index(
        value = [
            "index", "title", "contents", "isSecret",
            "password", "timestamp", "colorGroup", "isImportance"], unique = true
    )]
)
data class MemoEntity(
    @PrimaryKey(autoGenerate = true) val index: Long = 0,
    @ColumnInfo(name = "title") val title: String = "",
    @ColumnInfo(name = "contents") val contents: String = "",
    @ColumnInfo(name = "isSecret") val isSecret: Boolean = false,
    @ColumnInfo(name = "isImportance") val isImportance: Boolean = false,
    @ColumnInfo(name = "password") val password: String = "",
    @ColumnInfo(name = "timestamp") val timestamp: Long = System.currentTimeMillis(),
    @ColumnInfo(name = "colorGroup") val colorGroup: Int = 0
) {
    fun mapper() = MemoItem(
        index = index,
        title = title,
        contents = contents,
        isSecret = isSecret,
        isImportance = isImportance,
        password = password,
        colorGroup = colorGroup,
        timestamp = timestamp
    )
}
@Dao
interface MemoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertMemoItem(memoEntity: MemoEntity): Long

    @Query("SELECT * FROM MemoEntity WHERE `title` LIKE :search")
    fun selectAllMemo(search : String): Flow<List<MemoEntity>>

    @Query("SELECT * FROM MemoEntity WHERE `index` = :index")
    fun selectMemoIndex(index: Long): Flow<MemoEntity>

    @Query("DELETE FROM MemoEntity WHERE `index` = :index")
    suspend fun deleteMemoIndex(index: Long)

    @Query("UPDATE  MemoEntity SET `isImportance` = :isImportance WHERE `index` = :index")
    suspend fun updateImportance(index: Long, isImportance: Boolean): Int

}

Data

data class MemoItem(
    val index: Long = 0,
    var title: String = "",
    var contents: String = "",
    var isSecret: Boolean = false,
    var isImportance: Boolean = false,
    var password: String = "",
    var colorGroup: Int = 0,
    val timestamp: Long = System.currentTimeMillis()
) {
    fun mapper() = MemoEntity(
        index = index,
        title = title,
        contents = contents,
        isSecret = isSecret,
        isImportance = isImportance,
        password = password,
        colorGroup = colorGroup,
        timestamp = timestamp
    )
}

수정을 위한 데이터 클래스입니다.

 

1. 메모 리스트 화면

기본 조건

  • 사용자는 작성한 메모 리스트를 볼 수 있어야 한다.
  • 메모 리스트에 노출되는 메모는 작성된 메모 문장 한 줄만 노출한다.
  • 비밀 메모의 경우 메모 문장이 노출되지 않아야 한다.
  • 비밀 메모는 메모 리스트에 “비밀 메모입니다" 혹은 잠금 표시로 노출되어야 한다.
  • 메모 리스트에서 비밀 메모 상세 보기 클릭 시 암호를 입력해야 메모 상세화면으로 이동이 가능하다.
  • 사용자는 메모를 검색할 수 있어야 한다.
  • 사용자는 메모를 삭제할 수 있어야 한다.

추가 or 수정 조건

  • 메모 리스트에 노출되는 문장은 메모의 제목이며 동일하게 한 줄로만 노출된다.
  • 사용자는 “날짜순, 제목순, 중요글만, 비밀글만”의 조건을 선택하여 리스트의 정렬을 변경할 수 있다.
  • 별 아이콘을 클릭하여 메모의 중요 메모 설정이 가능하다.
  • 사용자가 지정한 색상으로 메모 리스트의 아이템 색상을 표시한다.

리스트 조회

리스트는 검색을 기반으로 조회를 하게 됩니다.

@Query("SELECT * FROM MemoEntity WHERE `title` LIKE :search")
fun selectAllMemo(search : String): Flow<List<MemoEntity>>

Room에서는 위와 같이 호출되며 search의 내용에 따라 리스트의 조회 내용이 달라지게 됩니다.

fun selectAllMemo(search: String): Flow<List<MemoItem>> =
	db.selectAllMemo(search).map { it.map { entity -> entity.mapper() } }

MemoRepository에서는 MemoEntity에서 MemoItem으로 변경하여 반환합니다.

private val _list = mutableStateOf<List<MemoItem>>(emptyList())
val list : State<List<MemoItem>> = _list
init {
    selectAllMemo()
}

private fun selectAllMemo(search: String = "%%") {
    repository.selectAllMemo(search)
        .map { orderByList(it) }
        .onEach { _list.value = it }
        .onEmpty { _list.value = emptyList() }
        .catch { _list.value = emptyList() }
        .launchIn(viewModelScope)
}

private fun orderByList(list: List<MemoItem>) : List<MemoItem> {
    return when(_queryState.value) {
        // 날짜순
        0 -> { list.sortedBy { it.timestamp } }
        // 타이틀순
        1 -> { list.sortedBy { it.title } }
        // 중요글만
        2 -> { list.filter { it.isImportance } }
        // 비밀글만
        else -> { list.filter { it.isSecret } }
    }
}

기본적으로 검색어는 “%%”를 사용해서 모든 리스트를 불러오게 됩니다.

map{ orderByList(it) }을 통해 정렬 조건을 지정합니다.

orderByList에서는 조건에 맞게 리스트를 sortedBy 혹은 filter를 이용해 가공하게 됩니다.

가공된 리스트를 onEach를 통해 _list 변수에 담기게 됩니다.

val list = viewModel.list.value
/** 메모 리스트 **/
if (list.isEmpty()) {
    /** 빈 리스트 **/
    item {
        LottieAnimation(
            composition = composition,
            progress = { progress },
            modifier = Modifier.padding(horizontal = 25.dp)
        )
        Text(
            text = stringResource(id = R.string.empty_memo),
            style = Typography.titleMedium,
            color = MaterialTheme.colorScheme.secondary,
            textAlign = TextAlign.Center,
            modifier = Modifier.fillMaxWidth()
        )
    } // 빈 리스트
} else {
    /** 리스트 **/
    item {
        Spacer(modifier = Modifier.height(10.dp))

        list.forEachIndexed { index, item ->
            if (item.isSecret) {
                /** 비밀메모 **/
                SecretMemoItem(
                    secretClickListener = {
                        secretDialogState.value = true
                        secretIndex = index
                    },
                    modifier = Modifier.fillMaxWidth()
                )
            } else {
                /** 일반 메모 **/
                MemoItem(
                    memoItem = item,
                    viewModel = viewModel,
                    clickListener = {
                        routeAction.navToDetail(it)
                    },
                    modifier = Modifier.fillMaxWidth()
                )
            }
            Spacer(modifier = Modifier.height(10.dp))
        }
        Spacer(modifier = Modifier.height(46.dp))
    } // 리스트
} // 메모 리스트

ViewModel의 list를 감지하고 있는 UI 쪽에서는 비어있을 경우에는 LottieAnimation을 이용해서 안내를 해줍니다.

메모 종류에 따라 비밀 메모, 일반 메모를 그려주게 됩니다.

val field = viewModel.searchState.value
InputBar(
    modifier = Modifier.padding(vertical = 10.dp),
    field = field,
    hint = stringResource(id = R.string.guide_input_search),
    hintColor = if (isSystemInDarkTheme()) White80 else Black80
) {
    viewModel.event(ListEvent.WriteSearch(it))
}

InputBar는 OutlineTextField를 공통적으로 사용하기 위해 만들어서 사용하였습니다.

검색의 경우 TextField의 내용이 변경될 때마다 viewModel.event(ListEvent.WriteSearch(it)을 호출하게 됩니다.

fun event(event: ListEvent) {
	when(event) {
	    is ListEvent.WriteSearch -> {
	        _searchState.value = event.search
	        selectAllMemo("%${_searchState.value}%")
	    }
	    is ListEvent.ChangeQuery -> {
	        _queryState.value = event.queryIndex
	        selectAllMemo("%${_searchState.value}%")
	    }
	}
}

ViewModel에서는 변경된 내용을 받아서 _searchState에 저장한 뒤 다시 검색을 진행하게 됩니다.

정렬 조건 변경도 위와 같은 방식으로 이루어지게 됩니다.

그 외 기능

UI 쪽에서 클릭 등의 이벤트 발생 > ViewModel에게 이벤트 내용 전달 > ViewModel에서 Data 상태 변경 (DB 업데이트 등) > 감지하고 있던 UI 반영

기능들은 다음의 순서대로 작업을 진행하게 되며 이는 다른 페이지에서도 동일하게 적용됩니다.

 

2. 메모 작성 화면

기본 조건

  • 사용자에게 메모를 작성/수정 기능을 제공해야 한다.
  • 비밀 메모로 설정 시 비밀번호 입력창이 뜨고 비밀번호를 입력하면 비밀 메모로 설정된다.
  • 일반 메모를 비밀 메모로, 비밀 메모를 일반 메모로 변경이 가능해야 한다.
  • 내용을 작성할 때 작성된 메모의 글자 수가 노출되어야 한다.

추가 조건

  • 메모 작성 시 제목과 내용으로 분리되며 필수로 작성하여야 한다.
  • 사용자는 메모의 색상을 지정할 수 있다.

메모 작성

private val _memoItem = mutableStateOf(MemoItem())
val memoItemState : State<MemoItem> = _memoItem

init {
    savedStateHandle.get<Long>("index")?.let {
        selectMemo(it)
    }
}

수정 시에는 NavHost에서 값을 전달받아서 해당 메모의 내용을 불러와서 저장하게 됩니다.

fun event(event: WriteEvent) {
	when(event) {
    is WriteEvent.WriteTitle -> {
        _memoItem.value = _memoItem.value.copy(title = event.title)
    }
    is WriteEvent.WriteContents -> {
        _memoItem.value = _memoItem.value.copy(contents = event.contents)
    }
	}
}

기능 구현 방식은 이전과 동일합니다.

TextField나 CheckBox, ColorSelector 등 이벤트가 발생하게 되면 변경된 값을 event()을 통해 ViewModel에게 전달하게 됩니다.

event()에서는 전달받은 값을 저장할 때 기존에 _memoItem의 값을 copy한 후 변경된 값을 변경하여 저장합니다.

_memoItem의 값이 변경되었으므로 감지하고 있던 UI 쪽에서는 다시 UI를 그리게 됩니다.

색상 선택

@Composable
fun ColorSelector(
    index: Int,
    colorGroup: ColorGroup,
    clickListener: (Int) -> Unit
) {
    Card(
        shape = CircleShape,
        border = BorderStroke(2.dp, if(isSystemInDarkTheme()) White else Black),
        colors = CardDefaults.cardColors(
            containerColor = Color(colorGroup.mainColor)
        ),
        modifier = Modifier
            .size(60.dp)
            .clickable(
                indication = null,
                interactionSource = remember {
                    MutableInteractionSource()
                }
            ) {
                clickListener(index)
            }
    ) {

        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(2.dp)
        ) {
            Canvas(modifier = Modifier.size(58.dp)) {
                drawArc(
                    color = Color(colorGroup.subColor),
                    startAngle = 270f,
                    sweepAngle = 180f,
                    useCenter = true,
                    style = Fill
                )
            }
        }
    }
}

전체 원 안에 반반 색상이 칠해진 UI를 구현하였습니다.

전체를 Card로 감싸고 전체 원의 모양과 색상일 지정합니다.

Canvas를 활용하여 반 원을 그려주고 색상을 적용시켜 배치를 하였습니다.

 

3. 메모 상세 화면

기본 조건

  • 작성한 메모의 상세 내용을 볼 수 있어야 한다.

추가 조건

  • 메모의 중요 체크를 변경 할 수 있어야 한다.

위의 두 개의 페이지와 구현 방식의 차이가 딱히 없으므로 내용은 생략합니다.

 

4. 다이얼로그

메모 삭제 다이얼로그

비밀 메모의 비밀번호 확인 다이얼로그

 

728x90
댓글