[4주차] 새 단어 입력과 팝업창
- 이번 주는 ➕ 버튼을 달아서 내가 직접 새 단어를 추가하고 영구 저장하는 주차입니다.
오늘의 목표
- 화면 구석에 더하기(➕) 버튼이 생깁니다.
- 버튼을 누르면 단어를 입력할 수 있는 팝업창(바텀시트)이 밑에서 올라옵니다.
- 단어와 뜻을 적고 저장을 누르면 진짜로 스크롤 리스트에 단어가 추가됩니다.
1. 4주차 UI 코드 이식
- 지난주에 쓰던 ui 폴더 안의 HomeScreen.kt 파일 열기.
- 스크롤을 맨 밑으로 내려서 4주차 완성 하단 코드 안의 전체 텍스트 복사.
- 기존 HomeScreen.kt 내용을 통째로 복붙하여 덮어쓰기.
- 상단 초록색 (Run) 클릭.
2. 기본적인 android 문법 (4주차)
1. var isSheetOpen by remember { mutableStateOf(false) }
UI 컴포넌트의 가시성 상태를 저장하고 컴포즈 트리가 이를 추적하게 만드는 선언문입니다.
- 상태값이 변경되면, 지정된 해당 상태를 참조하는 UI 노드 부분만 자동으로 재랜더링(Recomposition).
2. Box { }
내부 요소들을 Z축(깊이) 기준으로 겹쳐서 배치할 수 있는 레이아웃 뷰 그룹입니다.
- FAB를 우측 하단에 상대적으로 띄우거나, 모달(Modal) 뷰를 전체 화면 위로 오버레이 할 때 주로 사용.
3. OutlinedTextField { }
외곽선 테두리가 포함된 텍스트 입력 UI 컴포넌트입니다.
- onValueChange: 사용자가 키보드로 문자를 입력할 때마다 호출되는 콜백 루틴으로, 변수 상태를 실시간 업데이트.
4. FloatingActionButton
화면 콘텐츠 위의 고정된 위치에 배치되어, 주요 기능 전환을 수행하는 버튼 컴포넌트입니다.
5. Spacer(modifier = Modifier)
UI 컴포넌트 사이에 빈 공간을 생성하는 레이아웃 요소입니다.
- height나 width 속성을 부여하여 뷰 간의 물리적인 여백과 간격을 제어.
6. if (isSheetOpen) { } (선언적 렌더링)
명령형 언어처럼 뷰 요소의 VISIBLE / GONE 함수를 별도로 호출하지 않습니다.
- 상태값(isSheetOpen)이 true일 때만 해당 코드 블록 내부의 UI 컴포넌트가 뷰 트리에 렌더링되게 하는 조건문 처리 방식.
3. 에러 대응
발생할 수 있는 문제
- 단어 저장이 안 됨
팝업창 닫는 버튼만 눌렀거나, 저장(addWord) 코드가 잘 들어갔는지 체크. - 키보드가 입력창을 가림
imePadding() 코드가 빠졌는지 구조 확인.
4주차 완성 하단 코드
- HomeScreen.kt 전체 파일 (통째로 덮어쓰기 하세요)
package com.example.bootcamp_wordlist.ui // 지우지 마세요
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
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() {
val words = WordRepository.words
var sortType by remember { mutableStateOf(SortType.LATEST) }
// 모달창 가시성 상태 변수
var isSheetOpen by remember { mutableStateOf(false) }
val sortedWords = when (sortType) {
SortType.LATEST -> words.sortedByDescending { it.id }
SortType.ALPHABET -> words.sortedBy { it.spelling }
}
// 화면 겹쳐올리기용 Box 컨테이너
Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF5F5F5))) {
Column(modifier = Modifier.fillMaxSize()) {
// 상단 헤더 영역
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFE0E0E0))
.padding(start = 24.dp, end = 24.dp, top = 48.dp, bottom = 16.dp)
) {
Text(text = "단어장", fontSize = 22.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
SortButton("최신순", sortType == SortType.LATEST, { sortType = SortType.LATEST }, Modifier.weight(1f))
SortButton("알파벳순", sortType == SortType.ALPHABET, { sortType = SortType.ALPHABET }, Modifier.weight(1f))
}
}
// 단어 리스트 영역
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
// 하단 플로팅 버튼 여백(100.dp) 확보
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 100.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(sortedWords, key = { it.id }) { word ->
WordItem(word = word)
}
}
}
// 플로팅 추가 버튼(FAB) 영역
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding() // 시스템 하단바 여백 확보
.padding(24.dp)
) {
FloatingActionButton(
onClick = { isSheetOpen = true }, // 클릭 시 모달창 표시 상태로 변경
shape = CircleShape,
containerColor = Color.White,
contentColor = Color.DarkGray
) {
Icon(Icons.Default.Add, contentDescription = "단어 추가")
}
}
// 모달창 조건부 렌더링
if (isSheetOpen) {
AddWordSheet(onDismiss = { isSheetOpen = false })
}
}
}
// ─── 정렬 버튼 컴포넌트 (3주차와 동일) ───
@Composable
fun SortButton(text: String, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = onClick,
modifier = modifier.height(40.dp),
shape = RoundedCornerShape(12.dp),
// isSelected 값에 따라 색상 동적 변경 (선택됨: 검정/흰 글씨, 미선택: 흰/회색 글씨)
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)
}
}
// ─── 단어 카드 컴포넌트 (3주차와 동일) ───
@Composable
fun WordItem(word: Word) {
val context = LocalContext.current // 창고 접근용 열쇠
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = word.spelling, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
// isHidden true → 점 5개, false → 진짜 뜻 출력
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 토글
IconButton(onClick = { WordRepository.toggleHidden(context, word.id) }) {
Icon(
imageVector = if (word.isHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = "뜻 숨기기",
tint = Color(0xFFAAAAAA)
)
}
// 휴지통: 누르면 이 단어 영구 삭제
IconButton(onClick = { WordRepository.deleteWord(context, word.id) }) {
Icon(Icons.Default.Delete, contentDescription = "삭제", tint = Color(0xFFAAAAAA))
}
}
}
}
// ─── 단어 추가 BottomSheet ───
// onDismiss: 시트를 닫을 때 호출할 함수 (isSheetOpen = false 로 만들어줌)
@Composable
fun AddWordSheet(onDismiss: () -> Unit) {
val context = LocalContext.current
// 입력창에 사용자가 타이핑할 때마다 실시간으로 값이 바뀌는 State 변수
var spelling by remember { mutableStateOf("") }
var meaning by remember { mutableStateOf("") }
// Box로 전체 화면을 덮는 반투명 딤 배경 생성
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f)) // 40% 투명도 검정 오버레이
.clickable { onDismiss() } // 딤 배경 클릭 시 시트 닫기
) {
// 실제 시트 영역: 화면 아래쪽에 딱 붙여서 배치
Column(
modifier = Modifier
.align(Alignment.BottomCenter) // Box 안에서 아래 가운데 정렬
.fillMaxWidth()
// topStart, topEnd만 둥글게 → 위쪽만 깎인 반원형 시트 모양
.clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
.background(Color.White)
.navigationBarsPadding() // 하단 네비게이션 바 위에 올라오도록 여백 확보
.imePadding() // 키보드가 올라올 때 시트도 같이 위로 밀려남
.pointerInput(Unit) {
detectTapGestures { }
}
.padding(24.dp)
) {
// 상단 손잡이 바 (BottomSheet 디자인 관례)
Box(
modifier = Modifier
.width(48.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0xFFDDDDDD))
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.height(20.dp))
Text("새 단어 추가", fontSize = 20.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(20.dp))
// OutlinedTextField = 테두리가 있는 입력창
// value: 현재 입력된 텍스트 (spelling State와 연결)
// onValueChange: 글자 칠 때마다 호출 → spelling 변수를 새 값으로 업데이트
OutlinedTextField(
value = spelling,
onValueChange = { spelling = it }, // it = 방금 입력된 전체 텍스트
placeholder = { Text("영단어 입력") }, // 아무것도 없을 때 흐리게 보이는 힌트
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
singleLine = true // 엔터 눌러도 줄바꿈 안 됨 (한 줄 입력만 허용)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = meaning,
onValueChange = { meaning = it },
placeholder = { Text("뜻 입력") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
singleLine = true
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = {
// isNotBlank() = 공백만 있는 경우도 막아줌 (스페이스바 연타 방지)
if (spelling.isNotBlank() && meaning.isNotBlank()) {
// .trim() = 앞뒤 공백 제거 후 창고에 저장
WordRepository.addWord(context, spelling.trim(), meaning.trim())
onDismiss() // 저장 후 시트 닫기
}
},
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(12.dp),
// enabled = 두 칸 모두 입력됐을 때만 버튼 활성화 (하나라도 비면 회색으로 비활성)
enabled = spelling.isNotBlank() && meaning.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFE0E0E0),
contentColor = Color(0xFF333333)
)
) {
Text("저장", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}'Tech > 앱동-kotlin 부트캠프' 카테고리의 다른 글
| [5주차].플래시카드 화면 이동 (Navigation) (0) | 2026.05.11 |
|---|---|
| 앱동 kotlin 세미나 PPT (0) | 2026.05.08 |
| [3주차].리스트 스크롤 만들기 (0) | 2026.04.26 |
| [2주차].단어 리스트 UI 그리기 (0) | 2026.04.06 |
| [1주차].안드로이드 스튜디오 시작: 첫 화면 띄우기 (0) | 2026.03.31 |