포스트

BookStore (3)

1. CoreData

1. Modeling

그냥 Document의 내용 그대로 하면 될것같다.

최근 본내역도 클릭했을때 DetailView로 연동할지말지는 고민을 해봐야할것같다.

CleanShot 2024-11-26 at 19 04 51

이렇게 만들어준다.

여기서도 주의할 점이라면 authors가 배열이 아닌 String 이라는 것에 초점을 둬야한다.

CoreData의 경우 이전에도 언급했지만

CleanShot 2024-11-26 at 19 06 01

이걸통해서 만들게되면 코드로도 모델링이 가능하다.

2. CoreData ViewModel 구성

MarkedList에는 SwiftData를 썼으니 최근본 내역에서는 CoreData를 사용해보려 한다.

검색을하고난뒤 도서를 탭했을때 탭한 도서에 대해서 보여주는것이기 때문에,

Create와 Read만 손보면 되는데, 최근본 내역을 초기화하는 기능도 있으면 좋을듯 해서 DeleteAll 이렇게 3개의 기능을 필요로 하면 될듯 하다.

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
@Published var recentBooks: [RecentBook] = []

private let container: NSPersistentContainer
private let context: NSManagedObjectContext

init() {
    self.container = NSPersistentContainer(name: "RecentBook") // Wrong
    self.context = container.viewContext
    fetchRequest()
}

func fetchRequest() {
    let request = NSFetchRequest<RecentBook>(entityName: "RecentBook")
    do {
        recentBooks = try context.fetch(request)
        saveRecent()
    } catch {
        fatalError()
    }
}

func saveRecent() {
    do {
        try context.save()
    } catch {
        fatalError()
    }
}

func addRecent(object: Document) {
    let item = RecentBook() // Wrong
    item.authors = object.authors.joined(separator: ", ")
    item.contents = object.contents
    item.price = Int64(object.price)
    item.title = object.title
    item.publisher = object.publisher
    item.status = object.status
    item.thumbnail = object.thumbnail
    item.url = object.url
    context.insert(item)
    saveRecent()
}

func checkDuplicate(object: Document) -> Bool {
    if recentBooks.contains(where: { $0.title == object.title }) {
        return true
    } else {
        return false
    }
}

우선은 생각나는대로 이렇게 구성해보았다. 에러가 날수도 있기에 실행하면서 관련 에러를 수정할 예정

2. 의존성 주입

1
2
3
4
5
6
7
8
9
// contentview
@StateObject var recentViewModel = RecentViewModel()

MainView()
    .environmentObject(apiViewModel)
    .environmentObject(markViewModel)
    .environmentObject(recentViewModel)
// mainview
@EnvironmentObject var recentViewModel: RecentViewModel    

이렇게 해주었다.

3. TapEvent 설정

여기부분에서 탭을했을때에 대해서 설정을 해야하는데,

1
2
3
4
5
6
7
8
9
10
11
12
List {
    ForEach(apiViewModel.books) { book in
        NavigationLink {
            DetailView(isFromMain: true, book: book)
                .environmentObject(markViewModel)
        } label: {
            ResultListCell(title: book.title,
                            author: book.authors.joined(separator: ", "),
                            price: book.price)
        }
    }
}

UIKit을 didSelectRowAt이라는 함수가 있었기에 가능했지만 이건 그렇지 않아서

1
2
3
.onTapGesture {
    recentViewModel.addRecent(object: book)
}

이걸 사용한다.

우선 작동하는지만 테스트

로컬로 확인을 해보면 될듯

실행하니 다음과 같은 에러가 발생

1
2
CoreData: error: Illegal attempt to save to a file that was never opened. "This NSPersistentStoreCoordinator has no persistent stores (unknown).  It cannot perform a save operation.". No last error recorded.
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'This NSPersistentStoreCoordinator has no persistent stores (unknown).  It cannot perform a save operation.

아무래도 Container 부분이 잘못된듯 하다.

Container관련 Docs를 보니

1
2
3
4
5
container.loadPersistentStores { _, error in
    if let error {
        fatalError("Failed to load persistent stores: \(error.localizedDescription)")
    }
}

이부분이 빠져서 생긴 문제.

그리고 실행하니 이번엔 아래와 같은 에러가 뜬다.

1
CoreData: error: CoreData: error: Failed to call designated initializer on NSManagedObject class 'RecentBook' 

let item = RecentBook(context: context) 여기부분이 그냥 RecentBook()이라서 생긴 에러로 보인다.

실행하고 테스트를 해보니

CleanShot 2024-11-26 at 19 53 47

들어오는게 확인이 되었지만 문제가 발생

아무래도 tapGesture와 NavigationLink 클릭하면서 겹치면서 화면전환도 잘 안되고 그냥 가볍게 화면을 터치하는것만으로도 계속해서 데이터가 쌓인다.

onTapGesture 대신 DetailView로 화면이 전환될때 onAppear애서 넘기는게 더 나을것으로 판단이 되어 바꿔본다.

4. onAppear 수정

1
2
3
4
5
.onAppear {
    if let book = book {
        recentViewModel.addRecent(object: book)
    }
}

탭을했는데 화면이 넘어가지 않는다.

무슨 문제가 있는듯하다.

BreakPoint찍어서 확인을 해보는데 book자체는 잘 전달이 된다.

생각해보니 위에 너무 많은 중복 데이터가 저장되어 DB reset을 내가 직접 했는데 저장을 하지않아서 생긴 문제같다.

저장을하니 원래대로 작동이 된다.

5. Fetch 갯수 정하기

Docs에 나와있는듯 해서 적용해보려고 한다.

그전에 클릭한 시간대로 정렬도 해야하고해서 Attribute를 하나 더 추가해준다.

등록시간은 TimeSince1970이걸 사용할것이다.

addRecent 함수에

item.addedTime = Date().timeIntervalSince1970 이걸 추가해준다.

CleanShot 2024-11-26 at 22 03 16

당연하지만 값이 클수록 최근에 등록이 된것이다.

1
2
3
4
5
6
let request: NSFetchRequest = {
    let request = RecentBook.fetchRequest()
    request.fetchLimit = 10
    request.sortDescriptors = [NSSortDescriptor(keyPath: \RecentBook.addedTime, ascending: false)]
    return request
}()

이렇게 Computed Property로 request를 만들어준다.

print로 배열을 출력해보니

1
2
3
addedTime = "1732627053.482013"
addedTime = "1732626235.837399"
addedTime = "1732626159.66943"

등록한 순서대로 정렬이 되어있음을 알 수 있다. (나머지는 다 날렸다.)

6. GridView 구성하기

이젠 Grid를 통해서 최근 본 책을 구현할건데

1
2
3
4
5
6
7
8
9
10
11
12
VStack {
    AsyncImage(url: URL(string: imageURL)) { image in
        image
            .resizable()
    } placeholder: {
        Image(systemName: "photo.artframe")
    }
    .frame(width: 120, height: 120)
    Text(title)
        .font(.system(size: 15))
}
.border(Color.black, width: 1)

CleanShot 2024-11-26 at 22 43 17

이렇게 구현을 해보았다.

Grid는 GridItem이라는것이 필요하다.

1
2
3
4
5
6
7
8
9
10
ScrollView(.horizontal) {
    let gridItem = [GridItem()]
    LazyHGrid(rows: gridItem) {
        ForEach(recentViewModel.recentBooks) { book in
            GridView(imageURL: book.thumbnail ?? "", title: book.title ?? "")
        }
    }
}
.scrollIndicators(.automatic)
.frame(height: UIScreen.main.bounds.width * 0.4)

ScrollView 갑자기 기억이 안나서 Docs를 참고했다.

LazyHGridDocs

GridItemDocs

실행을 하면

Nov-26-2024 23-11-22

이렇게 구현이 된다.

두번째 책이 추가가 안된건, 이미 한번 봤었던 책이기 때문, 일단은 중복처리를 해둔거긴한데 나중에 다시 중복쪽만 제거해주면 된다.

CleanShot 2024-11-27 at 01 27 43

실제로 10번째가 세이노의 가르침인데 이게 현재 보여지는 마지막 책이다.

7. 보완

1. Search text 없을때 List 초기화

1
2
3
4
5
6
7
8
9
10
11
.searchable(text: $searchText)
.onSubmit(of: .search) {
    Task {
        await  apiViewModel.request(searchText: searchText)
    }
}
.onChange(of: searchText) { _, _ in
    if searchText.isEmpty {
        apiViewModel.books.removeAll()
    }
}

이렇게 onChange를 적용하되 isEmpty를 통해 값이 없을때만 지워준다.

Nov-27-2024 01-44-30

2. GridView -> DetailView

1
2
3
4
5
6
7
ForEach(recentViewModel.recentBooks) { book in
    NavigationLink {
        DetailView(isFromMain: false, book: book)
    } label: {
        GridView(imageURL: book.thumbnail ?? "", title: book.title ?? "")
    }
}

이렇게 Grid에도 넣었는데 book이 문제가 된다.

DetailView에 들어가는 book이 전부 타입이 다르기때문.

enum을 통해서 DetailView 부분을 조금 수정해보려고 하였으나 Type이 전부 달라서 고민을 하던 중

함수를 하나 만들어서 구현하기로 결정

1
2
3
4
5
func convert(object: RecentBook) -> MarkedBookModel {
    let item = MarkedBookModel(authors: object.authors ?? "", contents: object.contents ?? "", price: Int(object.price), publisher: object.publisher ?? "", status: object.status ?? "", thumbnail: object.thumbnail ?? "", title: object.title ?? "", url: object.url ?? "")
    
    return item
}

이렇게 conversion용 함수를 만들어 준다.

그리고 다시

1
2
3
4
5
NavigationLink {
    let item = convert(object: book)
    DetailView(isFromMain: false, markedBook: item)
        .environmentObject(markViewModel)
        .environmentObject(recentViewModel)

이렇게 item을 넣어주게 되면.

Nov-27-2024 10-53-13

적용이 되는걸 알 수 있다.

3. 최근 본 내역 전체 삭제

마지막으로 최근 본 내역도 리셋을 하면 좋을듯 해서 간단하게 구현해본다.

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
HStack {
    Spacer()
    if !recentViewModel.recentBooks.isEmpty {
        Button {
            isDelete = true
        } label: {
            Text("최근 본 내역 삭제")
                .foregroundStyle(.red)
        }
        .alert(isPresented: $isDelete) {
            Alert(title: Text("삭제"), message: Text("최근 본 내역을 삭제하시겠습니까?"), primaryButton: .destructive(Text("삭제")) {
                recentViewModel.deleteAll()
            }, secondaryButton: .cancel(Text("취소")))
        }
    }
}

func deleteAll() {
    let request: NSFetchRequest<NSFetchRequestResult> = RecentBook.fetchRequest()
    let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
    
    do {
        try context.execute(deleteRequest)
        saveRecent()
    } catch {
        fatalError()
    }
}

이렇게 버튼을 만들어 주고 최근 본 책이 있을때만 즉 CoreData에 item이 있을때만 보이게 한다.

즉 앱을 처음에 설치하거나, 리셋을 하고난 이후의 상황에서는 해당 버튼이 보이지 않게 처리했다.

실행하면

Nov-27-2024 11-23-02

잘 되는걸 알 수 있다.

끝.

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