포스트

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))
                }
            }
        }
        
    }

CleanShot 2024-11-26 at 00 34 54

이렇게 구성을 하였다.

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을 공유해야 한다는 것이다.

Nov-26-2024 01-29-15

작동완료.

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 타입의 값을 반환하며, 이를 호출하면 화면이 닫힌다.

주의 사항

  1. 뷰 계층 내에서만 동작:
    • dismisssheet, popover, 또는 NavigationStack 내에서만 동작한다. 해당 구조 외에서는 아무런 동작도 수행하지 않는다.
  2. 읽기 전용:
    • @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)
            }

이런식으로 의존성 주입을 하고있다.

이때 의존성 주입을받은 자식뷰에서 같은 방식으로 그 자식뷰의 자식뷰로 의존성주입이 가능하다.

작동하면

simulator_screenshot_E3E886AA-EE28-4657-B5A1-6DCE668C3F17

이런식으로 담기는게 확인이 된다.

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: ", ")

이렇게 수정을 해준다.

실행을하니 기존에 모델링되어있던 데이터와 충돌된 에러가 발생.

기존에 데이터가 있는데 함부로 모델링을 바꿔서 생긴것으로 추정.

아무래도 로컬데이터를 지워야할것으로 보인다.

CleanShot 2024-11-26 at 10 02 45

관련된 로컬데이터 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를 안하다보니 배열이 처음에는 []라서 데이터가 담긴 것.

Nov-26-2024 12-27-04

확인 완료.

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이 되면서 데이터가 넘어가지않는걸 확인했다.

Nov-26-2024 11-42-31

갑자기 잘되던게 안된다?

하지만 MainView에서 전달을 했는데 왜 안되는걸까 라는 생각이 들었다.

그래서 var book: Document?이렇게 바꿔주었더니 해결

Nov-26-2024 11-56-56

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())
}

simulator_screenshot_A76A41F7-143E-4C7E-9E14-B22234000D2C

완료.

이젠 전체삭제를 하면 되는데 이건 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를 중첩해서 사용하지는 않는듯 하다.

그렇게하니 버튼 자체가 먹통이 되는 문제가 발생했다.

simulator_screenshot_B85BC630-AE32-4997-940C-081CDE9E99DE

9. UI다듬기

현재 NavigationTitle이 버튼 아래에 존재하므로 이부분을 수정하고

또한

simulator_screenshot_C3E839CC-C6E8-46FE-8FF2-5EFCE69E2D42

이렇게 List가 좀 떨어져서 보여지는걸 다듬어 본다.

NavigationView Docs를 보려는데 Deprecated 예정이라고 한다. 그래서 NavigationStack Docs를 보고 Navigation부분을 다듬어 본다.

NavigationView를 지우고 VStack이 끝나는곳에 modifier를 달아주면 된다.

1
2
3
4
5
.navigationTitle("담은 책")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
    .... 생략 ....
}

이때 DisplayMode inline을 하지않게되면 위 사진과 같이 나오게 된다.

simulator_screenshot_6A9C707E-E7C6-402C-8329-0890D7058988

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("책이 리스트에 추가되었습니다."))
        }
    }

이렇게 해주었다.

Nov-26-2024 14-26-22

확인완료

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?

그런데 이때

CleanShot 2024-11-26 at 14 46 19

이 에러가 계속 발생했다.

위의 코드로 바꾸면서 문제를 해결을 하긴 했는데,

처음에는 var selectedBook: MarkedBookModel? 이렇게 했었는데 위와같은 에러가 발생.

아무래도 그대로 가져오면서 생긴 문제로 판단.

하지만 궁금해진게 단지 책을 담는거고 이것자체만으로는 ui상태변화랑 관련이 있는걸까

이전에도 @State를 붙여서 해결을 하긴했는데, 갑자기 궁금해진다.

우선 해당에러가 뜨는 이유는 SwiftUI View 구조가 Struct로 이루어지기 때문이다.

검색을 해보니 참고글이 있어 확인해보니 역시나 @State를 사용한다. 이전에는 아무렇지 않게 그냥 사용을 했었는데 이런 내용이 있었다.

이부분은 나중에 한번 정리를 해봐야겠다는 생각이든다.

작동완료.

Nov-26-2024 14-42-54

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")
        }
    }
}

함수구현을 하면 된다.

CleanShot 2024-11-26 at 16 32 21

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)
    }
}

CleanShot 2024-11-26 at 16 36 40

canUndo에 대한 내용만 올려본다.

초기 실행했을때는

simulator_screenshot_CACBDCB9-D36C-44B4-862D-5540DA3915F2

이렇게 둘다 사용이 불가

Nov-26-2024 16-38-58

단발성이 아니라서

이렇게도 된다.

상황은 삭제하고 해리포터에 대한 책을 2권 추가한것이다.

CoreData도 된다고하니 나중에 해보면 될것같다.

Nov-26-2024 16-40-22

DB도 같이 확인을 해봤는데 같이 연동이 되는것을 확인했다. (사진은 첨부하지않는다.)

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.