BookStore (2)
1. DetailView
List에 뜨는 내용을 클릭했을때 상세페이지가 보여지는 화면이다.
ScrollView가 핵심이다.
여기엔 제목, 저자, 내용, 이미지, 버튼(닫기, 담기) 이렇게 구성을 하면 될것같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@State var title: String = ""
@State var author: String = ""
@State var imageUrl: String = ""
@State var content: String = ""
@State var price: Int = 0
var body: some View {
VStack {
Text(title)
.font(.system(size: 25))
.fontWeight(.bold)
Spacer()
Text(author)
.font(.system(size: 15))
Spacer()
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
Image(systemName: "photo.artframe")
}
.frame(height: UIScreen.main.bounds.height * 0.4)
Spacer()
Text(price.toString())
Spacer()
ScrollView {
Text(content)
}
.padding(.horizontal, 25)
HStack {
Button {
print("closed")
} label: {
Text("닫기")
.fontWeight(.bold)
.foregroundStyle(.black)
.frame(width: UIScreen.main.bounds.width * 0.25, height: UIScreen.main.bounds.height * 0.05)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundStyle(.gray)
.opacity(0.5))
}
Button {
print("added")
} label: {
Text("담기")
.fontWeight(.bold)
.foregroundStyle(.black)
.frame(width: UIScreen.main.bounds.width * 0.65, height: UIScreen.main.bounds.height * 0.05)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundStyle(.green)
.opacity(0.5))
}
}
}
}
이렇게 구성을 하였다.
2. NavigationLink
1
2
3
4
5
6
7
8
9
10
11
List {
ForEach(apiViewModel.books) { book in
NavigationLink {
DetailView(book: book)
} label: {
ResultListCell(title: book.title,
author: book.authors.joined(separator: " "),
price: book.price)
}
}
}
여기서 포인트는 DetailView에도 우리가 선택한게 넘어가야하므로 ForEach구문에 있는 book을 공유해야 한다는 것이다.
작동완료.
DetailView NavigationBar button 제거
.navigationBarBackButtonHidden()
이거 하나 추가해주면 된다.
그리고 닫기 버튼을 눌렀을때 Dismiss가 되게 해본다.
검색한 글을 참고하여 작성한다.
생각보다 간단했다.
@Environment(\.dismiss) var dismiss
만들어주고
1
2
3
Button {
dismiss()
}
버튼에 적용해주면 끝
@Environment(.dismiss) var dismiss
@Environment(\.dismiss)
는 SwiftUI에서 화면을 닫는 동작을 처리하기 위해 사용하는 환경 값이다. 이 값은 뷰 계층에서 제공되며, 현재 뷰를 닫는 데 사용된다.
요소별 설명
1. @Environment
- SwiftUI에서 뷰 계층의 환경 값에 접근할 수 있도록 하는 프로퍼티 래퍼이다.
- 상위 뷰 또는 시스템에서 제공하는 데이터를 하위 뷰에서 읽을 때 사용한다.
2. (\.dismiss)
dismiss
는 SwiftUI의 환경 값(Environment Value) 중 하나로, 현재 화면을 닫는 기능을 제공한다.DismissAction
타입의 값을 반환하며, 이를 호출하면 화면이 닫힌다.
주의 사항
- 뷰 계층 내에서만 동작:
dismiss
는sheet
,popover
, 또는NavigationStack
내에서만 동작한다. 해당 구조 외에서는 아무런 동작도 수행하지 않는다.
- 읽기 전용:
@Environment
를 통해 읽은 값은 수정할 수 없다.
3. SwiftData
1. Modeling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Foundation
import SwiftData
@Model
class MarkedBookModel: Identifiable {
var id = UUID()
var authors: [String]
var contents: String
var price: Int
var publisher: String
var status: String
var thumbnail: String
var title: String
var url: String
init(id: UUID = UUID(), authors: [String], contents: String, price: Int, publisher: String, status: String, thumbnail: String, title: String, url: String) {
self.id = id
self.authors = authors
self.contents = contents
self.price = price
self.publisher = publisher
self.status = status
self.thumbnail = thumbnail
self.title = title
self.url = url
}
}
크게 특이사항은 없다. 기존 Document를 그대로 가져오고 let을 var로 바꿔준다.
2. Container / Context
1
2
3
4
5
6
7
8
9
10
@MainActor
class MarkViewModel: ObservableObject {
private let modelContainer: ModelContainer
private let modelContext: ModelContext
init() {
self.modelContainer = try! ModelContainer(for: MarkedBookModel.self, configurations: ModelConfiguration(isStoredInMemoryOnly: false))
self.modelContext = modelContainer.mainContext
}
}
이번에도 ViewModel에 Container를 만든다.
ContentView에서 해도 되긴하다.
3. CRUD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Read
func fetchRequest() {
do {
book = try modelContext.fetch(FetchDescriptor<MarkedBookModel>())
} catch {
fatalError()
}
}
func saveContext() {
do {
try modelContext.save()
fetchRequest()
} catch {
fatalError()
}
}
// Create
func addMark(object: Document) {
let item = MarkedBookModel(authors: object.authors,
contents: object.contents,
price: object.price,
publisher: object.publisher,
status: object.status,
thumbnail: object.thumbnail,
title: object.title,
url: object.url)
modelContext.insert(item)
saveContext()
}
// Delete
func deleteMark(object: MarkedBookModel) {
modelContext.delete(object)
saveContext()
}
// DeleteAll
func delteAllMark() {
do {
try modelContext.delete(model: MarkedBookModel.self)
saveContext()
} catch {
fatalError()
}
}
4. 의존성 주입
어제글에 언급을 하지 않았는데
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ContentView
@E@StateObject var apiViewModel = APIViewModel()
@StateObject var markViewModel = MarkViewModel()
TabView {
Tab("검색", systemImage: "magnifyingglass") {
MainView()
.environmentObject(apiViewModel)
.environmentObject(markViewModel)
}
Tab("담은 책 리스트", systemImage: "list.bullet.clipboard") {
MarkListView()
.environmentObject(markViewModel)
}
}
// MainView
@EnvironmentObject var markViewModel: MarkViewModel
@EnvironmentObject var apiViewModel: APIViewModel
NavigationLink {
DetailView(book: book)
.environmentObject(markViewModel)
}
이런식으로 의존성 주입을 하고있다.
이때 의존성 주입을받은 자식뷰에서 같은 방식으로 그 자식뷰의 자식뷰로 의존성주입이 가능하다.
작동하면
이런식으로 담기는게 확인이 된다.
5. 문제 해결
문제라고 할것까지는 없지만
1
CoreData: fault: Could not materialize Objective-C class named "Array" from declared attribute value type "Array<String>" of attribute named authors
담을때 이런 Warning이 발생.
그래서 MarkedBookModel과 ViewModel을 살짝 수정
1
2
3
4
var authors: String
func addMark(object: Document) {
let item = MarkedBookModel(authors: object.authors.joined(separator: ", ")
이렇게 수정을 해준다.
실행을하니 기존에 모델링되어있던 데이터와 충돌된 에러가 발생.
기존에 데이터가 있는데 함부로 모델링을 바꿔서 생긴것으로 추정.
아무래도 로컬데이터를 지워야할것으로 보인다.
관련된 로컬데이터 3개를 삭제해주고 재실행하면 해결
이젠 관련 에러가 사라졌음을 알 수 있다.
로컬데이터 위치 확인 코드
1
2
3
4
5
let fileManager = FileManager.default
if let containerURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
let storeURL = containerURL.appendingPathComponent("Model.sqlite")
print(storeURL)
}
6. 중복처리
같은걸 그대로 또 담을수는 없으니 중복처리를 하도록 한다.
이전에 했던것처럼 함수하나를 더 구현하도록 한다.
1
2
3
4
5
6
7
func checkDuplicate(object: Document) -> Bool {
if book.contains(where: { $0.title == object.title }) {
return true
} else {
return false
}
}
적용을 하면서 Alert도 띄워보도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Button {
if let book = book {
if markViewModel.checkDuplicate(object: book) {
isDuplicated = true
} else {
markViewModel.addMark(object: book)
}
}
} label: {
Text("담기")
.fontWeight(.bold)
.foregroundStyle(.black)
.frame(width: UIScreen.main.bounds.width * 0.65, height: UIScreen.main.bounds.height * 0.05)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundStyle(.green)
.opacity(0.5))
}
.alert(isPresented: $isDuplicated) {
Alert(title: Text("중복 확인"), message: Text("이미 담긴 책입니다."))
}
자꾸 Alert할때 이전에 한 UIAlertController가 생각나서 Alert를 만드려고 하는 성향을 보인다.
Docs 참고 하자.
확인을 하던 도중 앱을 재실행하고 담기를 하면 중복으로 저장이 되는것을 확인
생각 해보니
1
2
3
4
5
6
init() {
self.modelContainer = try! ModelContainer(for: MarkedBookModel.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: false))
self.modelContext = modelContainer.mainContext
fetchRequest()
}
init을 하면서 fetch를 안하다보니 배열이 처음에는 []라서 데이터가 담긴 것.
확인 완료.
7. DetailView 재활용 하기
MarkedListView에서도 DetailView를 살리고 싶어서
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@State var isFromMain: Bool
@State var book: Document?
@State var markedBook: MarkedBookModel?
var title: String = ""
var authors: String = ""
var imageURL: String = ""
var price: Int = 0
var contents: String = ""
init(isFromMain: Bool, book: Document, markedBook: MarkedBookModel) {
self.isFromMain = isFromMain
if isFromMain {
title = book.title
authors = book.authors.joined(separator: ", ")
imageURL = book.thumbnail
price = book.price
contents = book.contents
} else {
title = markedBook.title
authors = markedBook.authors
imageURL = markedBook.thumbnail
price = markedBook.price
contents = markedBook.contents
}
}
이렇게 하였는데 문제는
1
2
DetailView(isFromMain: true, book: book)
.environmentObject(markViewModel)
여기서 존재하지도 않는 markViewModel을 넣어줘야한다는것.
아이디어가 떠오르지않아 이부분만 GPT의 도움을 받았다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
init(isFromMain: Bool, book: Document? = nil, markedBook: MarkedBookModel? = nil) {
self.isFromMain = isFromMain
if let book = book, isFromMain {
self.book = book
title = book.title
authors = book.authors.joined(separator: ", ")
imageURL = book.thumbnail
price = book.price
contents = book.contents
} else if let markedBook = markedBook {
self.markedBook = markedBook
title = markedBook.title
authors = markedBook.authors
imageURL = markedBook.thumbnail
price = markedBook.price
contents = markedBook.contents
}
}
기본적으로 init할때 nil을 설정을 하고, MainView에서 넘어갈때 옵셔널 바인딩을 해주면서 받게된다.
작동 확인도 완료.
MarkedListView에 적용을 해본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
VStack {
List {
ForEach(viewModel.book) { book in
NavigationLink {
DetailView(isFromMain: false, markedBook: book)
} label: {
MarkListCell(imageURL: book.thumbnail,
title: book.title,
author: book.authors,
price: book.price)
}
}
}
}
이때 문제가 발생
담기를 했지만
1
2
3
if let book = book {
markViewModel.addMark(object: book)
}
여기서 optional이 되면서 데이터가 넘어가지않는걸 확인했다.
갑자기 잘되던게 안된다?
하지만 MainView에서 전달을 했는데 왜 안되는걸까 라는 생각이 들었다.
그래서 var book: Document?
이렇게 바꿔주었더니 해결
State
를 사용하고 아니고의 차이가 존재하는듯한데 이후에 정리를 해야할것같다.
8. 삭제기능 구현
삭제는 SwipeAction을 통해 구현한다.
1
2
3
4
5
6
7
.swipeActions(edge: .trailing) {
Button {
viewModel.deleteMark(object: book)
} label: {
Image(systemName: "trash")
}
}
그냥 Swipe를 쭉땡기면 삭제가 되니 여기도 Alert를 띄우도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.swipeActions(edge: .trailing) {
Button {
isDelete = true
} label: {
Image(systemName: "trash")
}
}
.alert(isPresented: $isDelete) {
Alert(title: Text("삭제하시겠습니까"),
primaryButton: .destructive(Text("확인"),
action:
{ viewModel.deleteMark(object: book) }),
secondaryButton: .cancel())
}
완료.
이젠 전체삭제를 하면 되는데 이건 NavigationBar가 필요하다.
NavigationBar는 NavigationStack말고 NavigationView가 필요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
isDeleteAll = true
} label: {
Text("전체 삭제")
}
.alert(isPresented: $isDeleteAll) {
Alert(title: Text("삭제하시겠습니까"),
primaryButton: .destructive(Text("확인"),
action:
{ viewModel.deleteAllMark()} ),
secondaryButton: .cancel())
}
}
}
이때 isDelete 변수를 하나로 해서 하면 되지않을까 했는데 해보니 하나만 삭제가 되는걸 확인했다.
그래서 isDeleteAll 변수를 하나더 만들어준다.
아마 순서 상 위에 지우는게 먼저 뜨기 때문에 그러지 않나 싶다.
전체삭제도 구현 완료
아무것도 없을때 삭제를 눌렀을때 Alert를 띄워본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
if !viewModel.book.isEmpty {
isDeleteAll = true
} else {
isEmpty = true
}
} label: {
Text("전체 삭제")
}
.alert(isPresented: $isDeleteAll) {
Alert(title: Text("전체 삭제하시겠습니까"),
primaryButton: .destructive(Text("확인"),
action: {
viewModel.deleteAllMark()
}),
secondaryButton: .cancel())
}
//
}
}
.alert(isPresented: $isEmpty) {
Alert(title: Text("안내"), message: Text("담은 책이 없습니다."))
}
alert를 처음에는 // 로 표시한곳에 했는데 되지 않았다.
alert를 중첩해서 사용하지는 않는듯 하다.
그렇게하니 버튼 자체가 먹통이 되는 문제가 발생했다.
9. UI다듬기
현재 NavigationTitle이 버튼 아래에 존재하므로 이부분을 수정하고
또한
이렇게 List가 좀 떨어져서 보여지는걸 다듬어 본다.
NavigationView Docs를 보려는데 Deprecated 예정이라고 한다. 그래서 NavigationStack Docs를 보고 Navigation부분을 다듬어 본다.
NavigationView를 지우고 VStack이 끝나는곳에 modifier를 달아주면 된다.
1
2
3
4
5
.navigationTitle("담은 책")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
.... 생략 ....
}
이때 DisplayMode inline을 하지않게되면 위 사진과 같이 나오게 된다.
10. 보완
지금은 담기하고나면 유져가 담았는지 담기지 않았는지 확인이 불가능
새로운 Alert를 띄워 확인시켜주면 좋아보인다.
1
2
3
.alert(isPresented: $isAdded) {
Alert(title: Text("추가 완료"), message: Text("책이 리스트에 추가되었습니다."))
}
Hstack 밑에 Modifier를 달아주었는데
추가하고 나면 뜨는것을 확인했으나, 중복에 대한 Alert가 뜨지않는게 확인되었다.
위와 똑같이 Modifier를 다른 UIComponent에 했는데 되지 않아서 검색을 해보았다.
StackOverflow에 똑같은 경우가 있었다.
이것을 보고 새롭게 바꿔본다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
enum AlertType {
case isDuplicated
case isAdded
}
@State var showAlert: Bool = false
@State var activeAlert: AlertType = .isDuplicated
Button {
if let book = book {
if markViewModel.checkDuplicate(object: book) {
self.activeAlert = .isDuplicated
} else {
markViewModel.addMark(object: book)
self.activeAlert = .isAdded
}
}
self.showAlert = true
} label: {
Text("담기")
.fontWeight(.bold)
.foregroundStyle(.black)
.frame(width: UIScreen.main.bounds.width * 0.65, height: UIScreen.main.bounds.height * 0.05)
.background(RoundedRectangle(cornerRadius: 10, style: .continuous)
.foregroundStyle(.green)
.opacity(0.5))
}
.alert(isPresented: $showAlert) {
switch activeAlert {
case .isDuplicated:
return Alert(title: Text("중복 확인"), message: Text("이미 담긴 책입니다."))
case .isAdded:
return Alert(title: Text("추가 완료"), message: Text("책이 리스트에 추가되었습니다."))
}
}
이렇게 해주었다.
확인완료
MarkListView도 바꿔주자.
1
2
3
4
5
enum AlertDelete {
case isDelete
case isAlldelete
case isEmpty
}
이렇게 만들어 주는 이유는
AlertType에 추가하게 되면 case를 추가한만큼 더 추가해줘야하기 때문…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
NavigationLink {
DetailView(isFromMain: false, markedBook: book)
} label: {
MarkListCell(imageURL: book.thumbnail,
title: book.title,
author: book.authors,
price: book.price)
}
.swipeActions(edge: .trailing) {
Button {
alertState = .isDelete
selectedBook = book
showAlert = true
} label: {
Image(systemName: "trash")
}
}
.navigationTitle("담은 책")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
if !viewModel.book.isEmpty {
alertState = .isAlldelete
} else {
alertState = .isEmpty
}
showAlert = true
} label: {
Text("전체 삭제")
}
}
}
.alert(isPresented: $showAlert) {
switch alertState {
case .isDelete:
return Alert(title: Text("삭제하시겠습니까"),
primaryButton: .destructive(Text("확인"),
action:
{ viewModel.deleteMark(object: selectedBook!) }),
secondaryButton: .cancel())
case .isAlldelete:
return Alert(title: Text("전체 삭제하시겠습니까"),
primaryButton: .destructive(Text("확인"),
action: {
viewModel.deleteAllMark()
}),
secondaryButton: .cancel())
case .isEmpty:
return Alert(title: Text("안내"), message: Text("담은 책이 없습니다."))
}
}
이때 delete의 book이 foreach에서 사라지므로 selectedBook을 만들어 주고 해결했다.
@State var selectedBook: MarkedBookModel?
그런데 이때
이 에러가 계속 발생했다.
위의 코드로 바꾸면서 문제를 해결을 하긴 했는데,
처음에는 var selectedBook: MarkedBookModel?
이렇게 했었는데 위와같은 에러가 발생.
아무래도 그대로 가져오면서 생긴 문제로 판단.
하지만 궁금해진게 단지 책을 담는거고 이것자체만으로는 ui상태변화랑 관련이 있는걸까
이전에도 @State
를 붙여서 해결을 하긴했는데, 갑자기 궁금해진다.
우선 해당에러가 뜨는 이유는 SwiftUI View 구조가 Struct로 이루어지기 때문이다.
검색을 해보니 참고글이 있어 확인해보니 역시나 @State를 사용한다. 이전에는 아무렇지 않게 그냥 사용을 했었는데 이런 내용이 있었다.
이부분은 나중에 한번 정리를 해봐야겠다는 생각이든다.
작동완료.
11. Undo / Redo 구현
이 부분 까지하면 아마 SwiftData와 관련된 파트는 다 끝나는 것 같다.
Undo를 사용하려면 기본적으로 UndoManager가 필요한데, UndoManager를 설정하는데는 2가지 방식이 존재한다.
1. ModelContext 사용
나처럼 ViewModel에서 관리를 할때 사용을 하면 될 것 같다.
1
2
3
4
5
6
7
8
9
10
init() {
do {
self.modelContainer = try ModelContainer(for: MarkedBookModel.self)
self.modelContext = modelContainer.mainContext
self.modelContext.undoManager = UndoManager()
fetchRequest()
} catch {
fatalError()
}
}
이렇게 UndoManger를 설정해주면 된다.
2. ModelContainer 사용
ModelContainer를 사용할때는 Docs에도 나와있지만 처음에 Container를 ViewModel이 아닌 View에서 만들고 이때 isUndoEnabled
을 사용하여 만들어 주게 된다.
지금은 이전과 같이 ViewModel에서 사용하므로 해당방식은 사용을 하지 못했다.
1
2
3
4
5
6
7
8
9
@main
struct SwiftDataAnimalsApp: App {
var body: some Scene {
WindowGroup() {
ContentView()
}
.modelContainer(for: AnimalCategory.self, isUndoEnabled: true)
}
}
해당 코드는 Docs에서 발췌.
다시 돌아와서 Toolbar에 추가를 해보도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button {
print("undoTapped")
} label: {
Image(systemName: "arrow.uturn.backward")
}
Button {
print("redoTapped")
} label: {
Image(systemName: "arrow.uturn.forward")
}
}
}
함수구현을 하면 된다.
UndoManager는 사진과 같이 Default가 nil이다.
함수구현은 간단하다.
1
2
3
4
5
6
7
8
9
10
11
// Undo
func undoAction() {
modelContext.undoManager!.undo()
saveContext()
}
// Redo
func redoAction() {
modelContext.undoManager!.redo()
saveContext()
}
두개가 계속 버튼이 활성화가 되면 안되기에 canUndo / canRedo
를 사용하여 버튼을 Deactive 해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button {
viewModel.undoAction()
} label: {
Image(systemName: "arrow.uturn.backward")
}
.disabled(viewModel.modelContext.undoManager!.canUndo == false)
Button {
viewModel.redoAction()
} label: {
Image(systemName: "arrow.uturn.forward")
}
.disabled(viewModel.modelContext.undoManager!.canRedo == false)
}
}
canUndo에 대한 내용만 올려본다.
초기 실행했을때는
이렇게 둘다 사용이 불가
단발성이 아니라서
이렇게도 된다.
상황은 삭제하고 해리포터에 대한 책을 2권 추가한것이다.
CoreData도 된다고하니 나중에 해보면 될것같다.
DB도 같이 확인을 해봤는데 같이 연동이 되는것을 확인했다. (사진은 첨부하지않는다.)