SwiftUI로 Nanki (단어장 앱)을 만들어보면서 알게된 점을 총 정리해서 작성했다.
생각보다 간단하게 처리하고 사용할 수 있는게 많아서.. SwiftUI에 적응하기 위해서는 UI를 다양하게 많이! 작성해봐야 할듯.
[GitHub - JustHm/Nanki
Contribute to JustHm/Nanki development by creating an account on GitHub.
github.com](https://github.com/JustHm/Nanki)
UI 관련
List
List Style
List{
...
}
.listStyle(.sidebar)
리스트의 기본 스타일은 insetGrouped
로 지정되어있다.
그 외에도 여러개가 있는데, sidebar
를 적어놓은 이유는 섹션헤더를 접었다 폈다 하고싶으면 그냥 이거로 바꿔주면 된다.
추가로 sidebar
스타일은 iPad 에서 볼때는 정말 사이드바처럼 왼쪽에 따로 표시된다. (아이폰은 화면이 작으니 그냥 한 화면으로 보임)
List Inset & cell background color modify
List {
Section {
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
listRowBackground()
메서드에서 색을 변경할 수 있다. insetGrouped 상태일때 셀이 아닌거처럼 보이게 하기 유용함
listRowInsets()
메서드를 사용해 인셋을 변경할 수 있다. 아예 없애서 사용할수도 있다.
ForEach with Enumerated
ForEach(Array(list.wordList.enumerated()), id: \.offset) { item in
Button { ...
}
ForEach
문에서 index를 함께 사용하고 싶다면 Range로 접근하게 하는법도 방법이지만,
다른 방법으로 enumerated()
를 사용하면 인덱스와 아이템을 튜플로 반환받아 사용할 수 있다.
하지만 그냥 넘기면 안되고 Array
로 감싸 넘겨줘야한다.
Disclosure Group Cell
DisclosureGroup("단어 퀴즈") {
NavigationLink {
QuizView(list: list.wordList)
} label: {
Text("주관식 퀴즈")
}
.disabled(list.wordList.isEmpty)
NavigationLink {
MultipleChoiceQuizView(list: list.wordList)
} label: {
Text("객관식 퀴즈")
}
.disabled(list.wordList.count < 4)
NavigationLink {
PairQuizView(list: list)
} label: {
Text("짝 맞추기 게임")
}
.disabled(list.wordList.count < 4)
}
셀을 클릭했을 때 접었다 폈다 하면서 내부 데이터를 보여주는 그룹!!
Section Header를 접었다 폈다 하는것과 다르게 셀을 접었다가 폈다 한다.
Text
Text(option)
.foregroundColor(.primary) // 글자색 검정으로 통일
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(getOptionColor(option), lineWidth: 2)
)
외곽선을 만들어 텍스트뷰를 표시하는방법
RoundedRectangle
을 background
에 사용해서 그려주면 된다. stroke
는 외곽선!!
Button
Hover Button
.overlay(alignment: .bottomTrailing) {
if isCanEdit {
Button(action: {addsheet.toggle()}) {
Image(systemName: "plus")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
.foregroundColor(.white)
.padding()
}
.background(Circle().fill(Color.blue))
.frame(width: 64, height: 64)
.offset(x:-15)
}
}
뷰에 overlay
를 사용해 버튼을 만들어줬다.
버튼의 경우 background
메서드를 사용해 모양을 지정해주고 라벨로는 이미지를 사용했다.
크기는 background
아래 frame
으로 기본크기를 지정하고 버튼라벨은 그에 맞게 조정해주면 된다.
ContextMenu
.contextMenu {
if isCustom {
Button("Delete", systemImage: "trash", role: .destructive) {
withAnimation { store.deleteWordSet(id: item.id) }
}
}
}
버튼이나 셀, 또는 카드를 꾹 누르면 나오던 메뉴바들이 있는데, contextMenu
메서드를 사용해서 구현할 수 있다.
하지만 여기서 직접 NavigationLink를 걸 수 없다 (이동안됌.) 새로운 방식을 알아보거나 플래그를 두면 가능할지도.
TextField
@FocusState private var wordFocus: Bool
@FocusState private var meaningFocus: Bool
Section("단어") {
TextField("Input Word", text: $wordInput)
.onSubmit { meaningFocus = true }
.focused($wordFocus)
.submitLabel(.done)
}
Section("의미") {
TextField("Input meaning", text: $meaningInput)
.focused($meaningFocus)
.submitLabel(.done)
}
@FocusState
라는 프로퍼티래퍼를 사용해서 TextField의 포커스를 확인할 수 있다.
onSubmit
메서드로 확인이 눌렸으면 다음 포커스를 할 변수에 true를 넣어주어 전환가능.
submitLabel
메서드는 키보드에 엔터에 해당하는 키를 어떤 타입으로 사용할지 결정할 수 있다. (디자인도 변경됌)
CustomView
Flip Card Face
코드 길이가 길어서 주석으로 설명.
struct CardFace: View {
let text: String // 카드에 들어갈 텍스트
let isFlipped: Bool // 카드 뒤집기 애니메이션용 플래그
let isFront: Bool // 앞인지 뒤인지 확인용 플래그
var body: some View {
Rectangle()
.foregroundColor(isFront ? .green : .blue)
.overlay(
Text(text)
.frame(maxWidth: 300, maxHeight: 200) // 카드와 동일한 고정 프레임 지정 (최대치로)
.font(.system(size: 30, weight: .bold))
.foregroundColor(.white)
.rotation3DEffect( // 뒤집는 애니메이션을 사용하기 위해 rotation3DEffect 사용
.degrees(isFront ? 0 : 180), // Flip 플래그를 기준으로 0도 : 180도 로 뒤집음
axis: (x: 0, y: 1, z: 0) // 회전 축 설정
)
.lineLimit(nil) // 라인 수 제한 없음 (frame을 설정해서 프레임을 벗어나면...으로 표시됨
.multilineTextAlignment(.leading)
.padding(4)
)
.cornerRadius(10)
.frame(width: 300, height: 200)
.shadow(color: .gray, radius: 10) // 그림자 입히기 (Rectangle에)
.rotation3DEffect( // 마찬가지로 텍스트만 회전하면 안되니까 넣어줌.
.degrees(isFlipped ? 180 : 0),
axis: (x: 0, y: 1, z: 0)
)
}
}
struct ContentView: View {
...
ZStack {
// 두 카드를 겹쳐놓아 양면 카드처럼 한것이라 생각하면 됌.
// 뒤집어져 안보이는 쪽은 투명처리됌. (안하면 뒤집어져서 좌우 반전된 그대로 보여짐)
// 앞면 (단어)
CardFace(text: list[index].title, isFlipped: isFlipped, isFront: true)
.opacity(isFlipped ? 0 : 1)
// 뒷면 (의미)
CardFace(text: list[index].meaning, isFlipped: isFlipped, isFront: false)
.opacity(isFlipped ? 1 : 0)
}
.onTapGesture {
withAnimation {
isFlipped.toggle() // 토글 시 애니메이션이 되어야한다고 보내야지 부드럽게 Flip 함.
}
}
...
}
여기서 생소한 메서드는 rotation3DEffect
였다.
이 메서드는 3D 축을 기준으로 지정한 각도만큼 회전시켜준다.
Toast message
.overlay(ToastMessage(toastMessage: $toastMessage, showToast: $showToast)) // 보여질 뷰에 overlay
struct ToastMessage: View {
@Binding var toastMessage: String
@Binding var showToast: Bool
var body: some View {
VStack {
Spacer()
Text(toastMessage)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(10) // 여기서 cornerRadius를 background 뒤에 적용
.opacity(showToast ? 1 : 0)
.offset(y: showToast ? 0 : 20)
}
.padding(.bottom, 40)
.animation(.easeInOut(duration: 0.1), value: showToast)
}
}
Others-
Dismiss View
@Environment(\.dismiss) var dismiss
// usage
dismiss()
@Environment
에 keyPath를 사용해 화면 종료 메서드를 접근해 사용? 할 수 있다. (접근해서 레퍼런스를 들고오는지는 불분명)
View Initializer
init(list: Binding<WordSet>, isCanEdit: Bool) {
//Property Wrapper가 달려있는 변수의경우 _를 붙여서 접근할 수 있다.
//State를 기본값을 받아 초기화 하는경우 State(initialValue:) 함수를 사용해 초기화할 수 있다.
_list = list
_title = State(initialValue: list.wrappedValue.title)
self.isCanEdit = isCanEdit
}
초기화 구문을 커스텀해 만들 수 있다 (당연함)
하지만 프로퍼티래퍼가 붙은 변수들은 그냥 주입할 수 없다!
접근을 위해 변수명 앞에 _
을 붙여줘야하고, 데이터 주입시에도 프로퍼티래퍼에 맞는 초기화 메서드를 사용해 저장해야한다.
@State
-State(initialValue:)
@StateObject
-StateObject(wrappedValue:)
Time API
private var timer: Timer? // 타이머 객체
@Published var timeCount: Int = 0 // 타이머 객체가 1초 지날때마다 카운트해줄 변수
@Published var elapsedTime: String = "" // 그 값을 00:00 으로 변환해 뷰에서 출력할 변수
// 타이머 시작
timer = Timer.scheduledTimer(
withTimeInterval: 1.0, // 1초에 한번씩
repeats: true) { [weak self] _ in // 반복
self?.timeCount += 1 // 1초에 한번씩 timeCount가 1씩 올라가게된다!
}
// 진행시간 확인용 (Combine)
$timeCount // 1초에 한번씩 값이 바뀌면 변경사항을 publisher가 감지하고 아래 내용을 실행함.
.map{self.formatSecondsToMinutesSeconds($0)} // 포맷에 맞춰 String으로 변환
.assign(to: &$elapsedTime) // 변환된 값을 elapsedTime에 주입함.
// 타이머 종료
timer?.invalidate() // 타이머 종료 메서드
timer = nil // 타이머 객체 release
주석으로 설명되어있음.
Formatter (DateFormatter with time)
private func formatSecondsToMinutesSeconds(_ seconds: Int) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second] // 분, 초만 표시
formatter.unitsStyle = .positional // "0:00" 형식
formatter.zeroFormattingBehavior = .pad // 01:05 같은 형식 유지
return formatter.string(from: TimeInterval(seconds)) ?? "0:00"
}
Time
객체를 사용할 때 Int 값을 00:00 포맷에 맞게 변경하기위한 함수
Bundle Resource Load
private func loadGalleryList() {
for fileName in GalleryWordList.allCases { // enum: String으로
// Bundle.main.url을 하면 내가 만들고 있는 앱의 번들에 접근할 수 있다.
// forResource는 번들 내 리소스에서 접근할때 쓰는 파라미터
guard let url = Bundle.main.url(forResource: fileName.rawValue, withExtension: "json"),
let data = try? Data(contentsOf: url) else { continue }
do {
let list = try JSONDecoder().decode(WordSet.self, from: data)
gallery.append(list)
} catch {
print("Error decoding JSON: \(error)")
return
}
}
}
Bundle에 있는 리소스 파일 (여기서는 JSON 파일을 넣어놓고 불러왔음)을 불러올 수 있다.
FileManager와 다르게 이건 앱 Bundle (Package) 내에서 파일을 가져오는 방법이다.