Tech/앱동-kotlin 부트캠프

[3주차].리스트 스크롤 만들기

JSJH._. 2026. 4. 26. 18:57

[3주차] 리스트 스크롤 만들기

  • 이번 주는 앱 화면에 단어 여러 개를 세로로 정렬하고 버튼을 작동시키는 주차입니다.

오늘의 목표

  • 아래로 쭉 스크롤되는 리스트가 나옵니다.
  • 정렬 버튼으로 리스트 순서를 바꿀 수 있습니다.
  • 눈 모양 버튼 누르면 단어 뜻이 ●●●●●로 변합니다. 다시 누르면 보입니다.
  • 휴지통 누르면 지워집니다.

1. 3주차 데이터 창고 파일 만들기

  1. 왼쪽 폴더 트리에서 data 폴더 우클릭 ➜ New -> Kotlin Class/File 클릭.
  2. 이름 칸에 WordRepository 입력 후 엔터 ➜ 3주차 준비 코드 - WordRepository.kt 전체 복붙 (데이터를 저장소에 읽고 쓰는 데이터 창고입니다).
  3. MainActivity.kt 파일을 열기.
  4. super.onCreate(savedInstanceState) 바로 아랫줄에 WordRepository.load(this)적기.

2. 3주차 UI 코드 이식

  1. 2주차에 작업했던 ui 폴더 안의 HomeScreen.kt 파일 열기.
  2. 스크롤을 맨 밑으로 내려서 3주차 완성 하단 코드 안의 전체 텍스트 복사.
  3. 기존 HomeScreen.kt 내용을 모조리 싹 다 지우고 방금 복사한 코드로 덮어쓰기.
  4. 상단 초록색 (Run) 클릭.

3. 기본적인 android 문법 (3주차)

1. remember { mutableStateOf() }

Compose에서 버튼 클릭이나 데이터 변경에 따라 화면이 바뀌게 하려면 상태(State)를 선언해야 합니다.

var sortType by remember { mutableStateOf(SortType.LATEST) }
  • mutableStateOf(초기값): 변경을 감지할 수 있는 상태값 생성.
  • remember { }: 화면이 다시 그려져도(Recomposition) 값이 초기화되지 않고 기억됨.
  • 값이 바뀌면 → 이 상태를 참조하는 UI만 자동으로 다시 그려짐.

2. mutableStateListOf

UI가 데이터의 변경을 관찰할 수 있도록 지원하는 상태 감지형 리스트 컴포넌트입니다.

  • 리스트 내부의 요소가 추가 및 삭제될 때 Compose 시스템이 이를 감지하여 해당 UI를 자동으로 재구축(Recomposition).

3. LazyColumn { }과 items()

화면에 표시되는 항목들만 동적으로 렌더링하여 메모리와 성능을 최적화하는 세로 스크롤 레이아웃입니다.

LazyColumn {
    items(words, key = { it.id }) { word ->
        WordItem(word = word)
    }
}
  • items(명단): 전달된 컬렉션 개수만큼 반복하며 람다 블록 내부에 있는 뷰 컴포넌트를 스크롤 위치에 맞게 생성.
  • key = { it.id }: 데이터 변경 및 정렬 시 뷰의 재사용 성능을 높이기 위해 각 항목에 부여하는 고유 식별자.

4. LocalContext.current

안드로이드 시스템 내장 자원에 접근하기 위해 현재 실행 중인 Context 객체를 가져옵니다.

  • val context = LocalContext.current로 가져온 인스턴스를 저장소 접근 권한이 포함된 메서드 파라미터로 전달.

5. 상태(State) 와 토글

text = if (word.isHidden) "●●●●●" else word.meaning
  • 컴포즈의 조건적 렌더링. isHidden의 Boolean 값 따라 출력 텍스트 변화.
  • 클릭 시 toggleHidden 함수를 호출하여 Boolean 값을 반전, 해당 메모리를 관찰하고 있던 상태 UI가 즉시 업데이트.

6. .sortedBy { } 및 .sortedByDescending { }

리스트 내부의 객체를 특정 기준 프로퍼티에 따라 정렬하는 코틀린 표준 라이브러리 함수입니다.

  • Descending: 지정한 기준을 큰 값부터 작은 값 순으로 내림차순 정렬. 최신순 정렬.
  • 오름차순: 지정한 기준을 작은 값부터 큰 값 순으로 정렬. 알파벳 A-Z 구별에 사용.

7. onClick = { }

사용자가 컴포넌트를 터치(클릭)했을 때 호출되는 이벤트 콜백입니다.

  • 람다식 내부에 데이터베이스 수정, UI 상태 변경 등의 비즈니스 로직을 정의.

4. 에러 대응

발생할 수 있는 문제

  • 지웠던 단어가 앱을 껐다 켜면 다시 나타남
    정상 작동 중입니다. 에러 방지용 초기 더미데이터가 복구되도록 강사가 설계해두었습니다.
  • 화면이 안 나옴 / 빨간줄 점벅
    빨간줄 뜬 곳에 텍스트 커서(마우스) 올리고 Alt + Enter 누른 뒤 Import 클릭.

3주차 준비 코드

  • WordRepository.kt 파일
package com.example.bootcamp_wordlist.data // data 폴더에 넣었으므로 .data로 끝납니다. (지우지 마세요)

import android.content.Context
import androidx.compose.runtime.mutableStateListOf

// 강사가 미리 설계해둔 데이터 창고 구조입니다.
object WordRepository {
    val words = mutableStateListOf<Word>()

    private const val PREF_NAME = "word_prefs"
    private const val KEY_WORDS = "words"
    private const val SEPARATOR = "||"
    private const val FIELD_SEP = "::"

    fun load(context: Context) {
        val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
        val raw = prefs.getString(KEY_WORDS, "") ?: ""
        words.clear()
        if (raw.isNotBlank()) {
            raw.split(SEPARATOR).forEach { entry ->
                val parts = entry.split(FIELD_SEP)
                if (parts.size == 4) {
                    words.add(
                        Word(
                            id = parts[0].toLongOrNull() ?: return@forEach,
                            spelling = parts[1],
                            meaning = parts[2],
                            isHidden = parts[3].toBoolean()
                        )
                    )
                }
            }
        }
        if (words.isEmpty()) {
            words.addAll(listOf(
                Word(id = 1L, spelling = "apple", meaning = "사과"),
                Word(id = 2L, spelling = "banana", meaning = "바나나"),
                Word(id = 3L, spelling = "cherry", meaning = "체리"),
            ))
            save(context)
        }
    }

    private fun save(context: Context) {
        val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
        val raw = words.joinToString(SEPARATOR) {
            "${it.id}${FIELD_SEP}${it.spelling}${FIELD_SEP}${it.meaning}${FIELD_SEP}${it.isHidden}"
        }
        prefs.edit().putString(KEY_WORDS, raw).apply()
    }

    fun addWord(context: Context, spelling: String, meaning: String) {
        // id 기본값인 System.currentTimeMillis()가 자동으로 고유 id 역할을 함
        words.add(Word(spelling = spelling, meaning = meaning))
        save(context)
    }

    fun deleteWord(context: Context, id: Long) {
        words.removeIf { it.id == id }
        save(context)
    }

    fun toggleHidden(context: Context, id: Long) {
        val index = words.indexOfFirst { it.id == id }
        if (index != -1) {
            words[index] = words[index].copy(isHidden = !words[index].isHidden)
            save(context)
        }
    }
}

3주차 완성 하단 코드

  • HomeScreen.kt 전체 파일 (통째로 덮어쓰기 하세요)
package com.example.bootcamp_wordlist.ui // ui 폴더에 넣었으므로 .ui로 끝납니다. (지우지 마세요)

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.bootcamp_wordlist.data.Word
import com.example.bootcamp_wordlist.data.WordRepository

// 정렬 상태 제어 열거형
enum class SortType { LATEST, ALPHABET }

@Composable
fun HomeScreen() {
    // 리스트 원본 데이터 연동
    // WordRepository.words = 창고에서 전체 단어 리스트를 가져옴
    // mutableStateListOf로 만들어져 있어서 리스트가 바뀌면 화면도 자동 갱신됨
    val words = WordRepository.words

    // remember { mutableStateOf(...) } = 정렬 상태를 기억하는 변수
    // 이 값이 바뀌면 → sortedWords가 바뀌고 → LazyColumn이 자동으로 다시 그려짐
    var sortType by remember { mutableStateOf(SortType.LATEST) }

    // when = 코틀린의 switch문. sortType 값에 따라 다른 정렬 결과를 반환
    // sortedByDescending { it.id } = id 큰 것(최신)부터 내림차순 정렬
    // sortedBy { it.spelling } = spelling 알파벳 오름차순 정렬
    val sortedWords = when (sortType) {
        SortType.LATEST -> words.sortedByDescending { it.id }
        SortType.ALPHABET -> words.sortedBy { it.spelling }
    }

    // Box = 자식 요소들을 Z축으로 겹쳐 쌓는 레이아웃
    // 여기선 Column(리스트) 위에 FAB 버튼을 둥둥 띄우기 위해 사용
    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
        Column(modifier = Modifier.fillMaxSize()) {

            // 상단 헤더: 제목 + 정렬 버튼 2개를 감싸는 회색 영역
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(Color(0xFFE0E0E0)) // 헤더 배경색 (연회색)
                    .padding(start = 24.dp, end = 24.dp, top = 48.dp, bottom = 16.dp)
                    // top = 48.dp: 상단 상태바(시계/배터리) 영역과 겹치지 않게 여백 확보
            ) {
                Text(text = "단어장", fontSize = 22.sp, fontWeight = FontWeight.Bold)
                Spacer(modifier = Modifier.height(16.dp)) // 제목과 버튼 사이 세로 여백

                // Row 안에 버튼 2개를 가로로 나란히 배치
                // spacedBy(8.dp) = 버튼과 버튼 사이에 8dp 간격 자동 삽입
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    // weight(1f) = 가로 공간을 1:1로 똑같이 나눠 가짐
                    SortButton(
                        text = "최신순",
                        isSelected = sortType == SortType.LATEST, // 현재 선택된 버튼인지 비교
                        onClick = { sortType = SortType.LATEST }, // 클릭 시 상태값 변경 → 화면 갱신
                        modifier = Modifier.weight(1f)
                    )
                    SortButton(
                        text = "알파벳순",
                        isSelected = sortType == SortType.ALPHABET,
                        onClick = { sortType = SortType.ALPHABET },
                        modifier = Modifier.weight(1f)
                    )
                }
            }

            // LazyColumn = 스크롤 가능한 세로 리스트
            // 화면에 보이는 항목만 그려서 수백 개도 버벅임 없이 처리 가능
            // weight(1f) = 헤더 높이를 뺀 나머지 세로 공간을 전부 차지
            LazyColumn(
                modifier = Modifier.fillMaxWidth().weight(1f),
                // contentPadding = 리스트 전체의 안쪽 여백 (개별 카드 여백과 다름)
                contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 24.dp),
                // spacedBy = 카드와 카드 사이의 간격
                verticalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                // items() = words 리스트 개수만큼 반복해서 WordItem을 찍어냄
                // key = { it.id } = 각 카드에 고유 번호표를 붙여줌
                //   → 정렬/삭제 시 Compose가 어떤 카드가 바뀐 건지 정확히 알아서 부드럽게 처리
                items(sortedWords, key = { it.id }) { word ->
                    WordItem(word = word) // word 데이터 하나씩 카드 UI에 넘겨주기
                }
            }
        }
    }
}

// ─── 정렬 버튼 컴포넌트 ───
// isSelected: 이 버튼이 현재 선택된 상태인지 (true면 검정, false면 흰색)
@Composable
fun SortButton(text: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
    Button(
        onClick = onClick,
        modifier = modifier.height(40.dp),
        shape = RoundedCornerShape(12.dp), // 모서리 둥글기
        // if-else로 선택 여부에 따라 색상을 동적으로 바꿈
        // isSelected = true  → 검정 배경 + 흰 글씨 (선택됨)
        // isSelected = false → 흰 배경 + 회색 글씨 (선택 안 됨)
        colors = ButtonDefaults.buttonColors(
            containerColor = if (isSelected) Color(0xFF1A1A1A) else Color.White,
            contentColor = if (isSelected) Color.White else Color(0xFF666666)
        ),
        elevation = ButtonDefaults.buttonElevation(0.dp) // 그림자 없애기
    ) {
        Text(text = text, fontSize = 14.sp)
    }
}

// ─── 단어 카드 컴포넌트 ───
@Composable
fun WordItem(word: Word) {
    // LocalContext.current = 안드로이드 시스템 자원(SharedPreferences 등)에 접근하는 열쇠
    // WordRepository 함수 호출 시 파라미터로 넘겨줘야 함
    val context = LocalContext.current

    // Card = 흰 배경의 둥근 네모 박스
    Card(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(16.dp), // 모서리 16dp만큼 둥글게
        colors = CardDefaults.cardColors(containerColor = Color.White),
        elevation = CardDefaults.cardElevation(0.dp) // 그림자 없애기
    ) {
        // Row = 영단어 / 뜻 / 버튼 2개를 가로로 나란히 배치
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 14.dp), // 카드 안쪽 여백
            verticalAlignment = Alignment.CenterVertically // 세로 기준 가운데 정렬
        ) {
            // 영단어 텍스트
            // weight(1f) = 오른쪽 버튼들이 차지하고 남은 공간을 뜻 텍스트와 1:1로 나눔
            Text(
                text = word.spelling,
                fontSize = 18.sp,
                fontWeight = FontWeight.SemiBold, // 반굵게
                modifier = Modifier.weight(1f)
            )

            // 뜻 텍스트: isHidden이 true면 점 5개, false면 진짜 뜻 표시
            // if-else가 Text의 text 파라미터 안에 바로 들어갈 수 있음
            Text(
                text = if (word.isHidden) "●●●●●" else word.meaning,
                fontSize = 14.sp,
                // 숨김 상태일 때는 연한 회색, 보임 상태일 때는 진한 회색
                color = if (word.isHidden) Color(0xFFCCCCCC) else Color(0xFF888888),
                modifier = Modifier.weight(1f)
            )

            // 눈 아이콘 버튼: 누를 때마다 isHidden 값을 true ↔ false 뒤집기
            // word.id를 넘겨야 어떤 카드의 상태를 바꿀지 창고가 찾을 수 있음
            IconButton(onClick = { WordRepository.toggleHidden(context, word.id) }) {
                Icon(
                    // isHidden 상태에 따라 눈 아이콘 모양도 바뀜 (열린 눈 ↔ 닫힌 눈)
                    imageVector = if (word.isHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
                    contentDescription = if (word.isHidden) "뜻 보이기" else "뜻 숨기기",
                    tint = Color(0xFFAAAAAA) // 아이콘 색상 회색으로
                )
            }

            // 휴지통 버튼: 누르면 이 word.id를 가진 단어를 창고에서 완전 삭제
            IconButton(onClick = { WordRepository.deleteWord(context, word.id) }) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "삭제",
                    tint = Color(0xFFAAAAAA)
                )
            }
        }
    }
}