[3주차] 리스트 스크롤 만들기
- 이번 주는 앱 화면에 단어 여러 개를 세로로 정렬하고 버튼을 작동시키는 주차입니다.
오늘의 목표
- 아래로 쭉 스크롤되는 리스트가 나옵니다.
- 정렬 버튼으로 리스트 순서를 바꿀 수 있습니다.
- 눈 모양 버튼 누르면 단어 뜻이 ●●●●●로 변합니다. 다시 누르면 보입니다.
- 휴지통 누르면 지워집니다.
1. 3주차 데이터 창고 파일 만들기
- 왼쪽 폴더 트리에서 data 폴더 우클릭 ➜ New -> Kotlin Class/File 클릭.
- 이름 칸에 WordRepository 입력 후 엔터 ➜ 3주차 준비 코드 - WordRepository.kt 전체 복붙 (데이터를 저장소에 읽고 쓰는 데이터 창고입니다).
- MainActivity.kt 파일을 열기.
- super.onCreate(savedInstanceState) 바로 아랫줄에 WordRepository.load(this)적기.
2. 3주차 UI 코드 이식
- 2주차에 작업했던 ui 폴더 안의 HomeScreen.kt 파일 열기.
- 스크롤을 맨 밑으로 내려서 3주차 완성 하단 코드 안의 전체 텍스트 복사.
- 기존 HomeScreen.kt 내용을 모조리 싹 다 지우고 방금 복사한 코드로 덮어쓰기.
- 상단 초록색 (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)
)
}
}
}
}
'Tech > 앱동-kotlin 부트캠프' 카테고리의 다른 글
| 앱동 kotlin 세미나 PPT (0) | 2026.05.08 |
|---|---|
| [4주차].새 단어 입력과 팝업창 (0) | 2026.05.04 |
| [2주차].단어 리스트 UI 그리기 (0) | 2026.04.06 |
| [1주차].안드로이드 스튜디오 시작: 첫 화면 띄우기 (0) | 2026.03.31 |
| [0주차].안드로이드 스튜디오 설치 & 개발환경 세팅 가이드 (0) | 2026.03.31 |