From db8f5aff1c6edef9019c188dd4a7cf8e2e36e756 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 16 Apr 2025 12:30:13 +0300 Subject: [PATCH 01/19] [UPUP-1077]: add mock data --- Example/Example.xcodeproj/project.pbxproj | 66 ++++++++- .../Mock/MockPostExampleModel&PI=0&PS=10.json | 65 +++++++++ .../Mock/MockPostExampleModel&PI=1&PS=10.json | 65 +++++++++ .../Mock/MockPostExampleModel&PI=2&PS=10.json | 65 +++++++++ .../Mock/MockPostExampleModel&PI=3&PS=10.json | 65 +++++++++ Example/Sources/Model/PostExampleModel.swift | 26 ++++ .../{ => Repository}/IntsRepository.swift | 0 .../Sources/Repository/PostRepository.swift | 59 ++++++++ Example/Sources/{ => View}/ContentView.swift | 0 .../Sources/{ => View}/ListStateViews.swift | 0 .../{ => View}/ListWithSectionsView.swift | 0 .../{ => View}/ListWithoutSectionView.swift | 0 Sources/PagingList/PageRequestService.swift | 133 ++++++++++++++++++ 13 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json create mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json create mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json create mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json create mode 100644 Example/Sources/Model/PostExampleModel.swift rename Example/Sources/{ => Repository}/IntsRepository.swift (100%) create mode 100644 Example/Sources/Repository/PostRepository.swift rename Example/Sources/{ => View}/ContentView.swift (100%) rename Example/Sources/{ => View}/ListStateViews.swift (100%) rename Example/Sources/{ => View}/ListWithSectionsView.swift (100%) rename Example/Sources/{ => View}/ListWithoutSectionView.swift (100%) create mode 100644 Sources/PagingList/PageRequestService.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 6645194..cb17742 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770A72DAF92E50043935A /* PostExampleModel.swift */; }; + 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770AA2DAFA1C00043935A /* PostRepository.swift */; }; + 084770C62DAFA4440043935A /* MockPostExampleModel&PI=0&PS=10.json in Sources */ = {isa = PBXBuildFile; fileRef = 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */; }; + 084770C92DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */; }; + 084770CD2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; + 084770CF2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */; }; 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */; }; 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */; }; @@ -19,6 +25,12 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 084770A72DAF92E50043935A /* PostExampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExampleModel.swift; sourceTree = ""; }; + 084770AA2DAFA1C00043935A /* PostRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepository.swift; sourceTree = ""; }; + 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=0&PS=10.json"; sourceTree = ""; }; + 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=1&PS=10.json"; sourceTree = ""; }; + 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=2&PS=10.json"; sourceTree = ""; }; + 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=3&PS=10.json"; sourceTree = ""; }; 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithSectionsView.swift; sourceTree = ""; }; 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateViews.swift; sourceTree = ""; }; 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithoutSectionView.swift; sourceTree = ""; }; @@ -43,6 +55,45 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 084770A92DAFA1B30043935A /* Repository */ = { + isa = PBXGroup; + children = ( + E10FA355297A68830031ED65 /* IntsRepository.swift */, + 084770AA2DAFA1C00043935A /* PostRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + 084770C72DAFA5250043935A /* Mock */ = { + isa = PBXGroup; + children = ( + 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */, + 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */, + 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */, + 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */, + ); + path = Mock; + sourceTree = ""; + }; + 084770D02DAFA7BB0043935A /* View */ = { + isa = PBXGroup; + children = ( + 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, + E1FDB14B29770604003CC2A5 /* ContentView.swift */, + 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, + 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */, + ); + path = View; + sourceTree = ""; + }; + 084770D12DAFA7D60043935A /* Model */ = { + isa = PBXGroup; + children = ( + 084770A72DAF92E50043935A /* PostExampleModel.swift */, + ); + path = Model; + sourceTree = ""; + }; E10FA35129770CBE0031ED65 /* Packages */ = { isa = PBXGroup; children = ( @@ -72,12 +123,11 @@ E1FDB14829770604003CC2A5 /* Sources */ = { isa = PBXGroup; children = ( + 084770D12DAFA7D60043935A /* Model */, + 084770D02DAFA7BB0043935A /* View */, + 084770C72DAFA5250043935A /* Mock */, + 084770A92DAFA1B30043935A /* Repository */, E1FDB14929770604003CC2A5 /* ExampleApp.swift */, - E1FDB14B29770604003CC2A5 /* ContentView.swift */, - 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, - E10FA355297A68830031ED65 /* IntsRepository.swift */, - 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */, - 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, E1FDB14D29770605003CC2A5 /* Assets.xcassets */, E1FDB14F29770605003CC2A5 /* Preview Content */, ); @@ -163,6 +213,9 @@ files = ( E1FDB15129770605003CC2A5 /* Preview Assets.xcassets in Resources */, E1FDB14E29770605003CC2A5 /* Assets.xcassets in Resources */, + 084770C92DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */, + 084770CD2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */, + 084770CF2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -195,10 +248,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */, E10FA356297A68830031ED65 /* IntsRepository.swift in Sources */, 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */, E1FDB14C29770604003CC2A5 /* ContentView.swift in Sources */, + 084770C62DAFA4440043935A /* MockPostExampleModel&PI=0&PS=10.json in Sources */, 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */, + 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */, 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */, E1FDB14A29770604003CC2A5 /* ExampleApp.swift in Sources */, ); diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json new file mode 100644 index 0000000..b11b2f8 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "123e4567-e89b-12d3-a456-426614174000", + "title": "Post 1", + "description": "Got first post", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "223e4567-e89b-12d3-a456-426614174001", + "title": "Post 2", + "description": "Excited for the second post!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "323e4567-e89b-12d3-a456-426614174002", + "title": "Post 3", + "description": "Third post vibes", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "423e4567-e89b-12d3-a456-426614174003", + "title": "Post 4", + "description": "Another day, another post", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "523e4567-e89b-12d3-a456-426614174004", + "title": "Post 5", + "description": "Loving this journey", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "623e4567-e89b-12d3-a456-426614174005", + "title": "Post 6", + "description": "Halfway through!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "723e4567-e89b-12d3-a456-426614174006", + "title": "Post 7", + "description": "Lucky number seven", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "823e4567-e89b-12d3-a456-426614174007", + "title": "Post 8", + "description": "Keeping it real", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "923e4567-e89b-12d3-a456-426614174008", + "title": "Post 9", + "description": "Almost at double digits", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "a23e4567-e89b-12d3-a456-426614174009", + "title": "Post 10", + "description": "Made it to ten!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json new file mode 100644 index 0000000..cf8bdb9 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "b23e4567-e89b-12d3-a456-426614174010", + "title": "Post 11", + "description": "Eleven is my favorite", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "c23e4567-e89b-12d3-a456-426614174011", + "title": "Post 12", + "description": "Twelve posts and counting", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "d23e4567-e89b-12d3-a456-426614174012", + "title": "Post 13", + "description": "Lucky thirteen", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "e23e4567-e89b-12d3-a456-426614174013", + "title": "Post 14", + "description": "Fourteen feels great", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "f23e4567-e89b-12d3-a456-426614174014", + "title": "Post 15", + "description": "Halfway to thirty!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "g23e4567-e89b-12d3-a456-426614174015", + "title": "Post 16", + "description": "Sweet sixteen", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "h23e4567-e89b-12d3-a456-426614174016", + "title": "Post 17", + "description": "Seventeen and thriving", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "i23e4567-e89b-12d3-a456-426614174017", + "title": "Post 18", + "description": "Eighteen posts strong", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "j23e4567-e89b-12d3-a456-426614174018", + "title": "Post 19", + "description": "Nineteen and counting", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "k23e4567-e89b-12d3-a456-426614174019", + "title": "Post 20", + "description": "Twenty posts milestone!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json new file mode 100644 index 0000000..0af9f48 --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "l23e4567-e89b-12d3-a456-426614174020", + "title": "Post 21", + "description": "Beyond twenty, let's go!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "m23e4567-e89b-12d3-a456-426614174021", + "title": "Post 22", + "description": "Twenty-two and feeling new", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "n23e4567-e89b-12d3-a456-426614174022", + "title": "Post 23", + "description": "Twenty-three, what's in store?", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "o23e4567-e89b-12d3-a456-426614174023", + "title": "Post 24", + "description": "Twenty-four, let's soar", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "p23e4567-e89b-12d3-a456-426614174024", + "title": "Post 25", + "description": "Quarter century of posts!", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "q23e4567-e89b-12d3-a456-426614174025", + "title": "Post 26", + "description": "Twenty-six, mixing it up", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "r23e4567-e89b-12d3-a456-426614174026", + "title": "Post 27", + "description": "Twenty-seven, keep it revvin'", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "s23e4567-e89b-12d3-a456-426614174027", + "title": "Post 28", + "description": "Twenty-eight, feeling great", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "t23e4567-e89b-12d3-a456-426614174028", + "title": "Post 29", + "description": "Twenty-nine, on cloud nine", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "u23e4567-e89b-12d3-a456-426614174029", + "title": "Post 30", + "description": "Thirty posts, who's counting?", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": true +} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json new file mode 100644 index 0000000..246144a --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "v23e4567-e89b-12d3-a456-426614174030", + "title": "Post 31", + "description": "Thirty-one, having fun", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "w23e4567-e89b-12d3-a456-426614174031", + "title": "Post 32", + "description": "Thirty-two, something new", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "x23e4567-e89b-12d3-a456-426614174032", + "title": "Post 33", + "description": "Thirty-three, living free", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "y23e4567-e89b-12d3-a456-426614174033", + "title": "Post 34", + "description": "Thirty-four, explore more", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "z23e4567-e89b-12d3-a456-426614174034", + "title": "Post 35", + "description": "Thirty-five, feeling alive", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "aa3e4567-e89b-12d3-a456-426614174035", + "title": "Post 36", + "description": "Thirty-six, full of tricks", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "bb3e4567-e89b-12d3-a456-426614174036", + "title": "Post 37", + "description": "Thirty-seven, post heaven", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "cc3e4567-e89b-12d3-a456-426614174037", + "title": "Post 38", + "description": "Thirty-eight, can't wait", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "dd3e4567-e89b-12d3-a456-426614174038", + "title": "Post 39", + "description": "Thirty-nine, feeling fine", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "ee3e4567-e89b-12d3-a456-426614174039", + "title": "Post 40", + "description": "Forty posts, the most!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": false +} diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift new file mode 100644 index 0000000..694af75 --- /dev/null +++ b/Example/Sources/Model/PostExampleModel.swift @@ -0,0 +1,26 @@ +// +// PostExampleModel.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation +import PagingList + +struct PostExampleModel: PaginatedResponse { + let items: [Post] + let hasMore: Bool? + + enum CodingKeys: String, CodingKey { + case items = "posts" + case hasMore + } +} + +struct Post: Codable, Identifiable { + let id: UUID + let title: String + let description: String + let imageUrl: URL? +} diff --git a/Example/Sources/IntsRepository.swift b/Example/Sources/Repository/IntsRepository.swift similarity index 100% rename from Example/Sources/IntsRepository.swift rename to Example/Sources/Repository/IntsRepository.swift diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift new file mode 100644 index 0000000..0dc75b3 --- /dev/null +++ b/Example/Sources/Repository/PostRepository.swift @@ -0,0 +1,59 @@ +// +// PostRepository.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation + +enum PostRepositoryError: Swift.Error { + case undefined +} + +extension PostRepositoryError: LocalizedError { + var errorDescription: String? { + switch self { + case .undefined: + return "Ooops:(" + } + } +} + +class PostRepository { + private enum Constants { + static let delayInNanoseconds: UInt64 = 3_000_000_000 + } + + func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { + await Task { + try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) + }.value + + if let postExampleModel = getPostExampleData(pageIndex: page, pageSize: pageSize), Bool.random() { + return postExampleModel + } else { + throw PostRepositoryError.undefined + } + } + + private func getPostExampleData(pageIndex: Int, pageSize: Int) -> PostExampleModel? { + var mockFileName = "MockPostExampleModel" + let mockExtension = "json" + + mockFileName = "\(mockFileName)&PI=\(pageIndex)&PS=\(pageSize)" + + guard let mockFileUrl = Bundle.main.url(forResource: mockFileName, withExtension: mockExtension) else { + return nil + } + + do { + let data = try Data(contentsOf: mockFileUrl) + let posts = try JSONDecoder().decode(PostExampleModel.self, from: data) + + return posts + } catch { + return nil + } + } +} diff --git a/Example/Sources/ContentView.swift b/Example/Sources/View/ContentView.swift similarity index 100% rename from Example/Sources/ContentView.swift rename to Example/Sources/View/ContentView.swift diff --git a/Example/Sources/ListStateViews.swift b/Example/Sources/View/ListStateViews.swift similarity index 100% rename from Example/Sources/ListStateViews.swift rename to Example/Sources/View/ListStateViews.swift diff --git a/Example/Sources/ListWithSectionsView.swift b/Example/Sources/View/ListWithSectionsView.swift similarity index 100% rename from Example/Sources/ListWithSectionsView.swift rename to Example/Sources/View/ListWithSectionsView.swift diff --git a/Example/Sources/ListWithoutSectionView.swift b/Example/Sources/View/ListWithoutSectionView.swift similarity index 100% rename from Example/Sources/ListWithoutSectionView.swift rename to Example/Sources/View/ListWithoutSectionView.swift diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift new file mode 100644 index 0000000..0f577ea --- /dev/null +++ b/Sources/PagingList/PageRequestService.swift @@ -0,0 +1,133 @@ +import Foundation + +// Модель используется для ответов от сервера, которые возвращают данные по страницам +public protocol PaginatedResponse: Codable { + associatedtype T: Codable + var items: [T] { get } + var hasMore: Bool? { get } // Опционально для API с метаданными +} + +// Модель используется в билдере запросов, наследующих PaginatedResponse +public struct PageRequestModel { + let page: Int + let pageSize: Int + let completion: (Result) -> Void +} + +// При использовании сервиса необходимо чтоб тип items в PaginatedResponse соответствовал +// типу DataModel +final class PaginationService: ObservableObject { + @Published public var pagingState: PagingListState = .fullscreenLoading + @Published public var items: [DataModel] = [] + public private(set) var canLoadMore: Bool = true + + private var startPage: Int + private var currentPage: Int + private var isRequestInProcess = false + + private var requestBuilder: ((PageRequestModel) -> Void)? + private var resultHandler: ((Result<[DataModel], Error>) -> Void)? + + public init(startPage: Int = 1) { + self.startPage = startPage + self.currentPage = startPage + } + + public func request( + pageSize: Int, + isFirst: Bool, + group: DispatchGroup? = nil, + requestBuilder: @escaping (PageRequestModel) -> Void, + resultHandler: @escaping (Result<[DataModel], Error>) -> Void + ) { + guard isRequestInProcess == false else { + return + } + + guard canLoadMore else { + pagingState = .items + return + } + + isRequestInProcess = true + group?.enter() + + self.requestBuilder = requestBuilder + self.resultHandler = resultHandler + + let page = isFirst ? 1 : currentPage + + let pageRequestModel = PageRequestModel( + page: page, + pageSize: pageSize + ) { [weak self] result in + self?.isRequestInProcess = false + + switch result { + case .success(let model): + guard let self, let items = model.items as? [DataModel] else { + return + } + + self.currentPage += items.count == .zero ? .zero : 1 + self.pagingState = .items + self.canLoadMore = items.count == pageSize + + if isFirst { + self.items = items + } else { + self.items.append(contentsOf: items) + } + + resultHandler(.success(items)) + case .failure(let error): + if isFirst { + self?.pagingState = .fullscreenError(error) + } else { + self?.pagingState = .pagingError(error) + } + + resultHandler(.failure(error)) + } + + group?.leave() + } + + requestBuilder(pageRequestModel) + } + + public func reload(group: DispatchGroup? = nil) { + // NOTE: This property is necessary to receive new elements on reload, if they exist. + let roundingSize = items.count % 10 + let additionalSize = 10 - (roundingSize == .zero ? 10 : roundingSize) + let pageSize = items.count + additionalSize + + group?.enter() + + let pageRequestModel = PageRequestModel( + page: startPage, + pageSize: pageSize + ) { [weak self] result in + switch result { + case .success(let model): + guard let self, let items = model.items as? [DataModel] else { + return + } + + self.pagingState = .items + self.items = items + self.canLoadMore = items.count == pageSize + + self.resultHandler?(.success(self.items)) + case .failure(let error): + self?.pagingState = .fullscreenError(error) + + self?.resultHandler?(.failure(error)) + } + + group?.leave() + } + + requestBuilder?(pageRequestModel) + } +} From 2eff498b01c652f2d0df7750db1d4ebc77acd931 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 16 Apr 2025 15:41:25 +0300 Subject: [PATCH 02/19] [UPUP-1077]: mock update --- Example/Example.xcodeproj/project.pbxproj | 22 +++---- .../Mock/MockPostExampleModel&PI=0&PS=10.json | 65 ------------------- .../Mock/MockPostExampleModel&PI=1&PS=10.json | 60 ++++++++--------- .../Mock/MockPostExampleModel&PI=2&PS=10.json | 60 ++++++++--------- .../Mock/MockPostExampleModel&PI=3&PS=10.json | 62 +++++++++--------- .../Mock/MockPostExampleModel&PI=4&PS=10.json | 65 +++++++++++++++++++ 6 files changed, 167 insertions(+), 167 deletions(-) delete mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json create mode 100644 Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index cb17742..2373a1f 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -9,10 +9,10 @@ /* Begin PBXBuildFile section */ 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770A72DAF92E50043935A /* PostExampleModel.swift */; }; 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770AA2DAFA1C00043935A /* PostRepository.swift */; }; - 084770C62DAFA4440043935A /* MockPostExampleModel&PI=0&PS=10.json in Sources */ = {isa = PBXBuildFile; fileRef = 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */; }; - 084770C92DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */; }; - 084770CD2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; - 084770CF2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; + 084770DC2DAFD9C50043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */; }; + 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; + 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; + 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */; }; 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */; }; 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */; }; 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */; }; @@ -27,10 +27,10 @@ /* Begin PBXFileReference section */ 084770A72DAF92E50043935A /* PostExampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExampleModel.swift; sourceTree = ""; }; 084770AA2DAFA1C00043935A /* PostRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepository.swift; sourceTree = ""; }; - 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=0&PS=10.json"; sourceTree = ""; }; 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=1&PS=10.json"; sourceTree = ""; }; 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=2&PS=10.json"; sourceTree = ""; }; 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=3&PS=10.json"; sourceTree = ""; }; + 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=4&PS=10.json"; sourceTree = ""; }; 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithSectionsView.swift; sourceTree = ""; }; 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateViews.swift; sourceTree = ""; }; 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithoutSectionView.swift; sourceTree = ""; }; @@ -67,7 +67,7 @@ 084770C72DAFA5250043935A /* Mock */ = { isa = PBXGroup; children = ( - 084770C52DAFA4400043935A /* MockPostExampleModel&PI=0&PS=10.json */, + 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */, 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */, 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */, 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */, @@ -123,9 +123,9 @@ E1FDB14829770604003CC2A5 /* Sources */ = { isa = PBXGroup; children = ( + 084770C72DAFA5250043935A /* Mock */, 084770D12DAFA7D60043935A /* Model */, 084770D02DAFA7BB0043935A /* View */, - 084770C72DAFA5250043935A /* Mock */, 084770A92DAFA1B30043935A /* Repository */, E1FDB14929770604003CC2A5 /* ExampleApp.swift */, E1FDB14D29770605003CC2A5 /* Assets.xcassets */, @@ -211,11 +211,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 084770DC2DAFD9C50043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */, + 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */, + 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */, + 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */, E1FDB15129770605003CC2A5 /* Preview Assets.xcassets in Resources */, E1FDB14E29770605003CC2A5 /* Assets.xcassets in Resources */, - 084770C92DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */, - 084770CD2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */, - 084770CF2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -252,7 +253,6 @@ E10FA356297A68830031ED65 /* IntsRepository.swift in Sources */, 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */, E1FDB14C29770604003CC2A5 /* ContentView.swift in Sources */, - 084770C62DAFA4440043935A /* MockPostExampleModel&PI=0&PS=10.json in Sources */, 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */, 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */, 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */, diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json deleted file mode 100644 index b11b2f8..0000000 --- a/Example/Sources/Mock/MockPostExampleModel&PI=0&PS=10.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "posts": [ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "title": "Post 1", - "description": "Got first post", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "223e4567-e89b-12d3-a456-426614174001", - "title": "Post 2", - "description": "Excited for the second post!", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "323e4567-e89b-12d3-a456-426614174002", - "title": "Post 3", - "description": "Third post vibes", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "423e4567-e89b-12d3-a456-426614174003", - "title": "Post 4", - "description": "Another day, another post", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "523e4567-e89b-12d3-a456-426614174004", - "title": "Post 5", - "description": "Loving this journey", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "623e4567-e89b-12d3-a456-426614174005", - "title": "Post 6", - "description": "Halfway through!", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "723e4567-e89b-12d3-a456-426614174006", - "title": "Post 7", - "description": "Lucky number seven", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "823e4567-e89b-12d3-a456-426614174007", - "title": "Post 8", - "description": "Keeping it real", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "923e4567-e89b-12d3-a456-426614174008", - "title": "Post 9", - "description": "Almost at double digits", - "imageUrl": "https://picsum.photos/200" - }, - { - "id": "a23e4567-e89b-12d3-a456-426614174009", - "title": "Post 10", - "description": "Made it to ten!", - "imageUrl": "https://picsum.photos/200" - } - ], - "hasMore": true -} diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json index cf8bdb9..b11b2f8 100644 --- a/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json +++ b/Example/Sources/Mock/MockPostExampleModel&PI=1&PS=10.json @@ -1,63 +1,63 @@ { "posts": [ { - "id": "b23e4567-e89b-12d3-a456-426614174010", - "title": "Post 11", - "description": "Eleven is my favorite", + "id": "123e4567-e89b-12d3-a456-426614174000", + "title": "Post 1", + "description": "Got first post", "imageUrl": "https://picsum.photos/200" }, { - "id": "c23e4567-e89b-12d3-a456-426614174011", - "title": "Post 12", - "description": "Twelve posts and counting", + "id": "223e4567-e89b-12d3-a456-426614174001", + "title": "Post 2", + "description": "Excited for the second post!", "imageUrl": "https://picsum.photos/200" }, { - "id": "d23e4567-e89b-12d3-a456-426614174012", - "title": "Post 13", - "description": "Lucky thirteen", + "id": "323e4567-e89b-12d3-a456-426614174002", + "title": "Post 3", + "description": "Third post vibes", "imageUrl": "https://picsum.photos/200" }, { - "id": "e23e4567-e89b-12d3-a456-426614174013", - "title": "Post 14", - "description": "Fourteen feels great", + "id": "423e4567-e89b-12d3-a456-426614174003", + "title": "Post 4", + "description": "Another day, another post", "imageUrl": "https://picsum.photos/200" }, { - "id": "f23e4567-e89b-12d3-a456-426614174014", - "title": "Post 15", - "description": "Halfway to thirty!", + "id": "523e4567-e89b-12d3-a456-426614174004", + "title": "Post 5", + "description": "Loving this journey", "imageUrl": "https://picsum.photos/200" }, { - "id": "g23e4567-e89b-12d3-a456-426614174015", - "title": "Post 16", - "description": "Sweet sixteen", + "id": "623e4567-e89b-12d3-a456-426614174005", + "title": "Post 6", + "description": "Halfway through!", "imageUrl": "https://picsum.photos/200" }, { - "id": "h23e4567-e89b-12d3-a456-426614174016", - "title": "Post 17", - "description": "Seventeen and thriving", + "id": "723e4567-e89b-12d3-a456-426614174006", + "title": "Post 7", + "description": "Lucky number seven", "imageUrl": "https://picsum.photos/200" }, { - "id": "i23e4567-e89b-12d3-a456-426614174017", - "title": "Post 18", - "description": "Eighteen posts strong", + "id": "823e4567-e89b-12d3-a456-426614174007", + "title": "Post 8", + "description": "Keeping it real", "imageUrl": "https://picsum.photos/200" }, { - "id": "j23e4567-e89b-12d3-a456-426614174018", - "title": "Post 19", - "description": "Nineteen and counting", + "id": "923e4567-e89b-12d3-a456-426614174008", + "title": "Post 9", + "description": "Almost at double digits", "imageUrl": "https://picsum.photos/200" }, { - "id": "k23e4567-e89b-12d3-a456-426614174019", - "title": "Post 20", - "description": "Twenty posts milestone!", + "id": "a23e4567-e89b-12d3-a456-426614174009", + "title": "Post 10", + "description": "Made it to ten!", "imageUrl": "https://picsum.photos/200" } ], diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json index 0af9f48..cf8bdb9 100644 --- a/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json +++ b/Example/Sources/Mock/MockPostExampleModel&PI=2&PS=10.json @@ -1,63 +1,63 @@ { "posts": [ { - "id": "l23e4567-e89b-12d3-a456-426614174020", - "title": "Post 21", - "description": "Beyond twenty, let's go!", + "id": "b23e4567-e89b-12d3-a456-426614174010", + "title": "Post 11", + "description": "Eleven is my favorite", "imageUrl": "https://picsum.photos/200" }, { - "id": "m23e4567-e89b-12d3-a456-426614174021", - "title": "Post 22", - "description": "Twenty-two and feeling new", + "id": "c23e4567-e89b-12d3-a456-426614174011", + "title": "Post 12", + "description": "Twelve posts and counting", "imageUrl": "https://picsum.photos/200" }, { - "id": "n23e4567-e89b-12d3-a456-426614174022", - "title": "Post 23", - "description": "Twenty-three, what's in store?", + "id": "d23e4567-e89b-12d3-a456-426614174012", + "title": "Post 13", + "description": "Lucky thirteen", "imageUrl": "https://picsum.photos/200" }, { - "id": "o23e4567-e89b-12d3-a456-426614174023", - "title": "Post 24", - "description": "Twenty-four, let's soar", + "id": "e23e4567-e89b-12d3-a456-426614174013", + "title": "Post 14", + "description": "Fourteen feels great", "imageUrl": "https://picsum.photos/200" }, { - "id": "p23e4567-e89b-12d3-a456-426614174024", - "title": "Post 25", - "description": "Quarter century of posts!", + "id": "f23e4567-e89b-12d3-a456-426614174014", + "title": "Post 15", + "description": "Halfway to thirty!", "imageUrl": "https://picsum.photos/200" }, { - "id": "q23e4567-e89b-12d3-a456-426614174025", - "title": "Post 26", - "description": "Twenty-six, mixing it up", + "id": "g23e4567-e89b-12d3-a456-426614174015", + "title": "Post 16", + "description": "Sweet sixteen", "imageUrl": "https://picsum.photos/200" }, { - "id": "r23e4567-e89b-12d3-a456-426614174026", - "title": "Post 27", - "description": "Twenty-seven, keep it revvin'", + "id": "h23e4567-e89b-12d3-a456-426614174016", + "title": "Post 17", + "description": "Seventeen and thriving", "imageUrl": "https://picsum.photos/200" }, { - "id": "s23e4567-e89b-12d3-a456-426614174027", - "title": "Post 28", - "description": "Twenty-eight, feeling great", + "id": "i23e4567-e89b-12d3-a456-426614174017", + "title": "Post 18", + "description": "Eighteen posts strong", "imageUrl": "https://picsum.photos/200" }, { - "id": "t23e4567-e89b-12d3-a456-426614174028", - "title": "Post 29", - "description": "Twenty-nine, on cloud nine", + "id": "j23e4567-e89b-12d3-a456-426614174018", + "title": "Post 19", + "description": "Nineteen and counting", "imageUrl": "https://picsum.photos/200" }, { - "id": "u23e4567-e89b-12d3-a456-426614174029", - "title": "Post 30", - "description": "Thirty posts, who's counting?", + "id": "k23e4567-e89b-12d3-a456-426614174019", + "title": "Post 20", + "description": "Twenty posts milestone!", "imageUrl": "https://picsum.photos/200" } ], diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json index 246144a..0af9f48 100644 --- a/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json +++ b/Example/Sources/Mock/MockPostExampleModel&PI=3&PS=10.json @@ -1,65 +1,65 @@ { "posts": [ { - "id": "v23e4567-e89b-12d3-a456-426614174030", - "title": "Post 31", - "description": "Thirty-one, having fun", + "id": "l23e4567-e89b-12d3-a456-426614174020", + "title": "Post 21", + "description": "Beyond twenty, let's go!", "imageUrl": "https://picsum.photos/200" }, { - "id": "w23e4567-e89b-12d3-a456-426614174031", - "title": "Post 32", - "description": "Thirty-two, something new", + "id": "m23e4567-e89b-12d3-a456-426614174021", + "title": "Post 22", + "description": "Twenty-two and feeling new", "imageUrl": "https://picsum.photos/200" }, { - "id": "x23e4567-e89b-12d3-a456-426614174032", - "title": "Post 33", - "description": "Thirty-three, living free", + "id": "n23e4567-e89b-12d3-a456-426614174022", + "title": "Post 23", + "description": "Twenty-three, what's in store?", "imageUrl": "https://picsum.photos/200" }, { - "id": "y23e4567-e89b-12d3-a456-426614174033", - "title": "Post 34", - "description": "Thirty-four, explore more", + "id": "o23e4567-e89b-12d3-a456-426614174023", + "title": "Post 24", + "description": "Twenty-four, let's soar", "imageUrl": "https://picsum.photos/200" }, { - "id": "z23e4567-e89b-12d3-a456-426614174034", - "title": "Post 35", - "description": "Thirty-five, feeling alive", + "id": "p23e4567-e89b-12d3-a456-426614174024", + "title": "Post 25", + "description": "Quarter century of posts!", "imageUrl": "https://picsum.photos/200" }, { - "id": "aa3e4567-e89b-12d3-a456-426614174035", - "title": "Post 36", - "description": "Thirty-six, full of tricks", + "id": "q23e4567-e89b-12d3-a456-426614174025", + "title": "Post 26", + "description": "Twenty-six, mixing it up", "imageUrl": "https://picsum.photos/200" }, { - "id": "bb3e4567-e89b-12d3-a456-426614174036", - "title": "Post 37", - "description": "Thirty-seven, post heaven", + "id": "r23e4567-e89b-12d3-a456-426614174026", + "title": "Post 27", + "description": "Twenty-seven, keep it revvin'", "imageUrl": "https://picsum.photos/200" }, { - "id": "cc3e4567-e89b-12d3-a456-426614174037", - "title": "Post 38", - "description": "Thirty-eight, can't wait", + "id": "s23e4567-e89b-12d3-a456-426614174027", + "title": "Post 28", + "description": "Twenty-eight, feeling great", "imageUrl": "https://picsum.photos/200" }, { - "id": "dd3e4567-e89b-12d3-a456-426614174038", - "title": "Post 39", - "description": "Thirty-nine, feeling fine", + "id": "t23e4567-e89b-12d3-a456-426614174028", + "title": "Post 29", + "description": "Twenty-nine, on cloud nine", "imageUrl": "https://picsum.photos/200" }, { - "id": "ee3e4567-e89b-12d3-a456-426614174039", - "title": "Post 40", - "description": "Forty posts, the most!", + "id": "u23e4567-e89b-12d3-a456-426614174029", + "title": "Post 30", + "description": "Thirty posts, who's counting?", "imageUrl": "https://picsum.photos/200" } ], - "hasMore": false + "hasMore": true } diff --git a/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json b/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json new file mode 100644 index 0000000..246144a --- /dev/null +++ b/Example/Sources/Mock/MockPostExampleModel&PI=4&PS=10.json @@ -0,0 +1,65 @@ +{ + "posts": [ + { + "id": "v23e4567-e89b-12d3-a456-426614174030", + "title": "Post 31", + "description": "Thirty-one, having fun", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "w23e4567-e89b-12d3-a456-426614174031", + "title": "Post 32", + "description": "Thirty-two, something new", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "x23e4567-e89b-12d3-a456-426614174032", + "title": "Post 33", + "description": "Thirty-three, living free", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "y23e4567-e89b-12d3-a456-426614174033", + "title": "Post 34", + "description": "Thirty-four, explore more", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "z23e4567-e89b-12d3-a456-426614174034", + "title": "Post 35", + "description": "Thirty-five, feeling alive", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "aa3e4567-e89b-12d3-a456-426614174035", + "title": "Post 36", + "description": "Thirty-six, full of tricks", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "bb3e4567-e89b-12d3-a456-426614174036", + "title": "Post 37", + "description": "Thirty-seven, post heaven", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "cc3e4567-e89b-12d3-a456-426614174037", + "title": "Post 38", + "description": "Thirty-eight, can't wait", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "dd3e4567-e89b-12d3-a456-426614174038", + "title": "Post 39", + "description": "Thirty-nine, feeling fine", + "imageUrl": "https://picsum.photos/200" + }, + { + "id": "ee3e4567-e89b-12d3-a456-426614174039", + "title": "Post 40", + "description": "Forty posts, the most!", + "imageUrl": "https://picsum.photos/200" + } + ], + "hasMore": false +} From 5e48c76ca739b2afa86dbe77469feec6c50a5ff3 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 16 Apr 2025 17:03:08 +0300 Subject: [PATCH 03/19] [UPUP-1077]: add demo ListWithPageRequestService module --- Example/Example.xcodeproj/project.pbxproj | 8 ++ .../Sources/Repository/PostRepository.swift | 24 ++-- Example/Sources/View/ContentView.swift | 12 ++ .../View/ListWithPageRequestServiceView.swift | 106 ++++++++++++++++++ .../ListWithPageRequestServiceViewModel.swift | 50 +++++++++ Sources/PagingList/PageRequestService.swift | 8 +- 6 files changed, 195 insertions(+), 13 deletions(-) create mode 100644 Example/Sources/View/ListWithPageRequestServiceView.swift create mode 100644 Example/Sources/View/ListWithPageRequestServiceViewModel.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 2373a1f..4813641 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */; }; + 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */; }; + 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */; }; 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */; }; 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */; }; 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */; }; @@ -31,6 +33,8 @@ 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=2&PS=10.json"; sourceTree = ""; }; 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=3&PS=10.json"; sourceTree = ""; }; 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=4&PS=10.json"; sourceTree = ""; }; + 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceView.swift; sourceTree = ""; }; + 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceViewModel.swift; sourceTree = ""; }; 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithSectionsView.swift; sourceTree = ""; }; 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateViews.swift; sourceTree = ""; }; 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithoutSectionView.swift; sourceTree = ""; }; @@ -78,6 +82,8 @@ 084770D02DAFA7BB0043935A /* View */ = { isa = PBXGroup; children = ( + 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */, + 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */, 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, E1FDB14B29770604003CC2A5 /* ContentView.swift */, 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, @@ -255,8 +261,10 @@ E1FDB14C29770604003CC2A5 /* ContentView.swift in Sources */, 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */, 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */, + 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */, 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */, E1FDB14A29770604003CC2A5 /* ExampleApp.swift in Sources */, + 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index 0dc75b3..df47bcc 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -25,15 +25,21 @@ class PostRepository { static let delayInNanoseconds: UInt64 = 3_000_000_000 } - func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { - await Task { - try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) - }.value - - if let postExampleModel = getPostExampleData(pageIndex: page, pageSize: pageSize), Bool.random() { - return postExampleModel - } else { - throw PostRepositoryError.undefined + func getPosts( + page: Int, + pageSize: Int, + completion: @escaping (Result) -> Void + ) { + Task { + await Task { + try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) + }.value + + if let postExampleModel = getPostExampleData(pageIndex: page, pageSize: pageSize) { + completion(.success(postExampleModel)) + } else { + completion(.failure(PostRepositoryError.undefined)) + } } } diff --git a/Example/Sources/View/ContentView.swift b/Example/Sources/View/ContentView.swift index bab672c..bc4c56e 100644 --- a/Example/Sources/View/ContentView.swift +++ b/Example/Sources/View/ContentView.swift @@ -11,6 +11,7 @@ import PagingList enum PagingListType: Equatable, Hashable, Identifiable { case listWithSection case listWithoutSection + case listWithPageRequestService var id: Self { self } } @@ -38,6 +39,15 @@ struct ContentView: View { } .background(.gray) .cornerRadius(15) + + Button { + navigationPath.append(.listWithPageRequestService) + } label: { + Text("Tap to go list with PageRequestService") + .padding(20) + } + .background(.gray) + .cornerRadius(15) } .navigationDestination(for: PagingListType.self) { type in switch type { @@ -45,6 +55,8 @@ struct ContentView: View { ListWithSectionsView() case .listWithoutSection: ListWithoutSectionView() + case .listWithPageRequestService: + ListWithPageRequestServiceView() } } } diff --git a/Example/Sources/View/ListWithPageRequestServiceView.swift b/Example/Sources/View/ListWithPageRequestServiceView.swift new file mode 100644 index 0000000..a69a1b3 --- /dev/null +++ b/Example/Sources/View/ListWithPageRequestServiceView.swift @@ -0,0 +1,106 @@ +// +// ListWithPageRequestService.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import SwiftUI +import PagingList + +struct ListWithPageRequestServiceView: View { + private enum Constants { + static let requestLimit = 10 + } + + @State private var loadedPagesCount = 0 + @State private var items = [Int]() + @State private var pagingState: PagingListState = .items + + @ObservedObject private var viewModel = ListWithPageRequestServiceViewModel() + + private let repository = PostRepository() + + // swiftlint:disable vertical_parameter_alignment_on_call + var body: some View { + PagingList( + state: $viewModel.pageRequestService.pagingState, + items: viewModel.posts + ) { post in + PostView(post: post) + .listRowSeparator(.hidden) + } fullscreenEmptyView: { + FullscreenEmptyStateView() + .listRowSeparator(.hidden) + } fullscreenLoadingView: { + FullscreenLoadingStateView() + .listRowSeparator(.hidden) + } fullscreenErrorView: { error in + FullscreenErrorStateView(error: error) { + pagingState = .fullscreenLoading + viewModel.requestPosts(isFirst: true) + } + .listRowSeparator(.hidden) + } pagingDisabledView: { + PagingDisabledStateView() + .listRowSeparator(.hidden) + } pagingLoadingView: { + if viewModel.pageRequestService.canLoadMore { + PagingLoadingStateView() + .listRowSeparator(.hidden) + } + } pagingErrorView: { error in + PagingErrorStateView(error: error) { + viewModel.requestPosts(isFirst: false) + } + .listRowSeparator(.hidden) + } onPageRequest: { isFirst in + viewModel.requestPosts(isFirst: isFirst) + } onRefreshRequest: { + viewModel.requestPosts(isFirst: true) + } + .listStyle(.plain) + .onAppear { + if viewModel.pageRequestService.canLoadMore && viewModel.pageRequestService.pagingState == .fullscreenLoading { + viewModel.requestPosts(isFirst: true) + } + } + } + // swiftlint:enable vertical_parameter_alignment_on_call +} + +private struct PostView: View { + let post: Post + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(post.title) + .font(.title) + if let imageUrl = post.imageUrl, let image = downloadImage(url: imageUrl) { + Image(uiImage: image) + } + Text(post.description) + .font(.caption) + } + } + + private func downloadImage(url: URL) -> UIImage? { + var data: Data? + + Task { + let data = try? await URLSession.shared.data(from: url, delegate: nil).0 + } + + guard let data, let image = UIImage(data: data) else { + return nil + } + + return image + } +} + +struct ListWithPageRequestServiceView_Previews: PreviewProvider { + static var previews: some View { + ListWithPageRequestServiceView() + } +} diff --git a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift new file mode 100644 index 0000000..eec6feb --- /dev/null +++ b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift @@ -0,0 +1,50 @@ +// +// ListWithPageRequestServiceViewModel.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation +import PagingList + +final class ListWithPageRequestServiceViewModel: ObservableObject { + @Published var isLoading: Bool = false + @Published var posts: [Post] = [] + + var pageRequestService = PageRequestService() + + private let postRepository = PostRepository() + private var workItem: DispatchWorkItem? + + func requestPosts(isFirst: Bool) { + pageRequestService.request( + pageSize: 10, + isFirst: isFirst + ) { [weak self] requestModel in + self?.postRepository.getPosts( + page: requestModel.page, + pageSize: requestModel.pageSize, + completion: requestModel.completion + ) + } resultHandler: { [weak self] in + switch $0 { + case .success(let posts): + self?.posts = posts + case .failure(let error): + print(error.localizedDescription) + } + } + } + + private func reloadHistory(group: DispatchGroup? = nil, isNeedShowLoading: Bool) { + let group = group ?? DispatchGroup() + isLoading = isNeedShowLoading + + pageRequestService.reload(group: group) + + group.notify(queue: .main) { [weak self] in + self?.isLoading = false + } + } +} diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 0f577ea..38258fc 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -9,14 +9,14 @@ public protocol PaginatedResponse: Codable { // Модель используется в билдере запросов, наследующих PaginatedResponse public struct PageRequestModel { - let page: Int - let pageSize: Int - let completion: (Result) -> Void + public let page: Int + public let pageSize: Int + public let completion: (Result) -> Void } // При использовании сервиса необходимо чтоб тип items в PaginatedResponse соответствовал // типу DataModel -final class PaginationService: ObservableObject { +public final class PageRequestService: ObservableObject { @Published public var pagingState: PagingListState = .fullscreenLoading @Published public var items: [DataModel] = [] public private(set) var canLoadMore: Bool = true From 7b2c74a863413153287726c5e780e1ccbe99aaed Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 16 Apr 2025 18:06:17 +0300 Subject: [PATCH 04/19] [UPUP-1077]: add additional metadata --- Example/Example.xcodeproj/project.pbxproj | 2 +- Example/Sources/Model/PostExampleModel.swift | 7 ++++--- Example/Sources/Repository/PostRepository.swift | 12 +++++++----- .../View/ListWithPageRequestServiceView.swift | 17 ----------------- .../ListWithPageRequestServiceViewModel.swift | 2 +- Sources/PagingList/PageRequestService.swift | 11 ++++++++++- 6 files changed, 23 insertions(+), 28 deletions(-) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 4813641..ff4e277 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -71,10 +71,10 @@ 084770C72DAFA5250043935A /* Mock */ = { isa = PBXGroup; children = ( - 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */, 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */, 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */, 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */, + 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */, ); path = Mock; sourceTree = ""; diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift index 694af75..a8fb240 100644 --- a/Example/Sources/Model/PostExampleModel.swift +++ b/Example/Sources/Model/PostExampleModel.swift @@ -11,16 +11,17 @@ import PagingList struct PostExampleModel: PaginatedResponse { let items: [Post] let hasMore: Bool? + var totalPages: Int? + var currentPage: Int? enum CodingKeys: String, CodingKey { case items = "posts" - case hasMore + case hasMore, totalPages, currentPage } } struct Post: Codable, Identifiable { - let id: UUID + let id: String let title: String let description: String - let imageUrl: URL? } diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index df47bcc..9f160f4 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -35,20 +35,22 @@ class PostRepository { try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) }.value - if let postExampleModel = getPostExampleData(pageIndex: page, pageSize: pageSize) { - completion(.success(postExampleModel)) + if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { + DispatchQueue.main.async { + completion(.success(postExampleModel)) + } } else { completion(.failure(PostRepositoryError.undefined)) } } } - private func getPostExampleData(pageIndex: Int, pageSize: Int) -> PostExampleModel? { + private func getPostExampleData(page: Int, pageSize: Int) -> PostExampleModel? { var mockFileName = "MockPostExampleModel" let mockExtension = "json" - mockFileName = "\(mockFileName)&PI=\(pageIndex)&PS=\(pageSize)" - + mockFileName = "\(mockFileName)&PI=\(page)&PS=\(pageSize)" + print(mockFileName) guard let mockFileUrl = Bundle.main.url(forResource: mockFileName, withExtension: mockExtension) else { return nil } diff --git a/Example/Sources/View/ListWithPageRequestServiceView.swift b/Example/Sources/View/ListWithPageRequestServiceView.swift index a69a1b3..afea33c 100644 --- a/Example/Sources/View/ListWithPageRequestServiceView.swift +++ b/Example/Sources/View/ListWithPageRequestServiceView.swift @@ -76,27 +76,10 @@ private struct PostView: View { VStack(alignment: .leading, spacing: 8) { Text(post.title) .font(.title) - if let imageUrl = post.imageUrl, let image = downloadImage(url: imageUrl) { - Image(uiImage: image) - } Text(post.description) .font(.caption) } } - - private func downloadImage(url: URL) -> UIImage? { - var data: Data? - - Task { - let data = try? await URLSession.shared.data(from: url, delegate: nil).0 - } - - guard let data, let image = UIImage(data: data) else { - return nil - } - - return image - } } struct ListWithPageRequestServiceView_Previews: PreviewProvider { diff --git a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift index eec6feb..69a0958 100644 --- a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift +++ b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift @@ -30,7 +30,7 @@ final class ListWithPageRequestServiceViewModel: ObservableObject { } resultHandler: { [weak self] in switch $0 { case .success(let posts): - self?.posts = posts + self?.posts += posts case .failure(let error): print(error.localizedDescription) } diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 38258fc..f4094d4 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -5,6 +5,8 @@ public protocol PaginatedResponse: Codable { associatedtype T: Codable var items: [T] { get } var hasMore: Bool? { get } // Опционально для API с метаданными + var totalPages: Int? { get } + var currentPage: Int? { get } } // Модель используется в билдере запросов, наследующих PaginatedResponse @@ -71,7 +73,14 @@ public final class PageRequestService Date: Thu, 17 Apr 2025 11:31:29 +0300 Subject: [PATCH 05/19] [UPUP-1077]: add prefetch logic --- Sources/PagingList/PageRequestService.swift | 79 ++++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index f4094d4..ba017f9 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -22,10 +22,13 @@ public final class PageRequestService? // Для управления префетчингом + private let maxPrefetchPages: Int = 2 // Максимум 2 страницы вперед private var requestBuilder: ((PageRequestModel) -> Void)? private var resultHandler: ((Result<[DataModel], Error>) -> Void)? @@ -34,7 +37,7 @@ public final class PageRequestService 0 && self.canLoadMore && !Task.isCancelled { + try? await performPrefetch(pageSize: pageSize) + pagesToFetch -= 1 + } + } + } + + private func performPrefetch(pageSize: Int) async throws { + guard isRequestInProcess == false, canLoadMore else { + return + } + + isRequestInProcess = true + + let pageRequestModel = PageRequestModel( + page: currentPage, + pageSize: pageSize + ) { [weak self] result in + self?.isRequestInProcess = false + + switch result { + case .success(let model): + guard let self, let items = model.items as? [DataModel] else { + return + } + + self.currentPage += items.count == 0 ? 0 : 1 + self.prefetchedPages += 1 + + if let hasMore = model.hasMore { + self.canLoadMore = hasMore + } else if let totalPages = model.totalPages, let currentPage = model.currentPage { + self.canLoadMore = currentPage < totalPages + } else { + self.canLoadMore = items.count == pageSize + } + + // Синхронное обновление на главном потоке + DispatchQueue.main.async { + self.items.append(contentsOf: items) + self.resultHandler?(.success(items)) + } + case .failure: + // Игнорируем ошибки префетчинга + break + } + } + + requestBuilder?(pageRequestModel) + } + + public func stopPrefetching() { + prefetchTask?.cancel() + prefetchTask = nil + isRequestInProcess = false + } } From c5e62b70b8e1a319cd41ebeb3e52675ef209b3c7 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Thu, 17 Apr 2025 12:10:17 +0300 Subject: [PATCH 06/19] [UPUP-1077]: add notification for prefetch stop --- Sources/PagingList/PageRequestService.swift | 23 +++++++++++++++------ Sources/PagingList/PagingList.swift | 15 +++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index ba017f9..1e98f1f 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -27,8 +27,8 @@ public final class PageRequestService? // Для управления префетчингом - private let maxPrefetchPages: Int = 2 // Максимум 2 страницы вперед + private var prefetchTask: Task? + private let maxPrefetchPages: Int = 2 private var requestBuilder: ((PageRequestModel) -> Void)? private var resultHandler: ((Result<[DataModel], Error>) -> Void)? @@ -36,6 +36,19 @@ public final class PageRequestService Date: Thu, 17 Apr 2025 15:59:54 +0300 Subject: [PATCH 07/19] [UPUP-1077]: update PageRequestService with PageRequestState actor --- Example/Sources/Model/PostExampleModel.swift | 2 +- .../Sources/Repository/PostRepository.swift | 24 +- .../View/ListWithPageRequestServiceView.swift | 14 +- .../ListWithPageRequestServiceViewModel.swift | 99 ++++-- Sources/PagingList/PageRequestService.swift | 316 ++++++++---------- 5 files changed, 222 insertions(+), 233 deletions(-) diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift index a8fb240..30a3c87 100644 --- a/Example/Sources/Model/PostExampleModel.swift +++ b/Example/Sources/Model/PostExampleModel.swift @@ -20,7 +20,7 @@ struct PostExampleModel: PaginatedResponse { } } -struct Post: Codable, Identifiable { +struct Post: Codable, Identifiable, Sendable { let id: String let title: String let description: String diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index 9f160f4..5fb28c8 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -25,23 +25,13 @@ class PostRepository { static let delayInNanoseconds: UInt64 = 3_000_000_000 } - func getPosts( - page: Int, - pageSize: Int, - completion: @escaping (Result) -> Void - ) { - Task { - await Task { - try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) - }.value - - if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { - DispatchQueue.main.async { - completion(.success(postExampleModel)) - } - } else { - completion(.failure(PostRepositoryError.undefined)) - } + func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { + try await Task.sleep(nanoseconds: 1_000_000_000) + + if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { + return postExampleModel + } else { + throw PostRepositoryError.undefined } } diff --git a/Example/Sources/View/ListWithPageRequestServiceView.swift b/Example/Sources/View/ListWithPageRequestServiceView.swift index afea33c..e3a82a9 100644 --- a/Example/Sources/View/ListWithPageRequestServiceView.swift +++ b/Example/Sources/View/ListWithPageRequestServiceView.swift @@ -9,14 +9,6 @@ import SwiftUI import PagingList struct ListWithPageRequestServiceView: View { - private enum Constants { - static let requestLimit = 10 - } - - @State private var loadedPagesCount = 0 - @State private var items = [Int]() - @State private var pagingState: PagingListState = .items - @ObservedObject private var viewModel = ListWithPageRequestServiceViewModel() private let repository = PostRepository() @@ -37,7 +29,7 @@ struct ListWithPageRequestServiceView: View { .listRowSeparator(.hidden) } fullscreenErrorView: { error in FullscreenErrorStateView(error: error) { - pagingState = .fullscreenLoading + viewModel.state = .fullscreenLoading viewModel.requestPosts(isFirst: true) } .listRowSeparator(.hidden) @@ -45,7 +37,7 @@ struct ListWithPageRequestServiceView: View { PagingDisabledStateView() .listRowSeparator(.hidden) } pagingLoadingView: { - if viewModel.pageRequestService.canLoadMore { + if viewModel.canLoadMore { PagingLoadingStateView() .listRowSeparator(.hidden) } @@ -61,7 +53,7 @@ struct ListWithPageRequestServiceView: View { } .listStyle(.plain) .onAppear { - if viewModel.pageRequestService.canLoadMore && viewModel.pageRequestService.pagingState == .fullscreenLoading { + if viewModel.canLoadMore && viewModel.state == .fullscreenLoading { viewModel.requestPosts(isFirst: true) } } diff --git a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift index 69a0958..3ceff7e 100644 --- a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift +++ b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift @@ -9,42 +9,91 @@ import Foundation import PagingList final class ListWithPageRequestServiceViewModel: ObservableObject { - @Published var isLoading: Bool = false + private enum Constants { + static let requestLimit = 10 + } + @Published var posts: [Post] = [] + @Published var state: PagingListState = .fullscreenLoading + @Published var canLoadMore: Bool = true - var pageRequestService = PageRequestService() + var pageRequestService: PageRequestService private let postRepository = PostRepository() private var workItem: DispatchWorkItem? + init() { + pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) + pageRequestService.$items + .receive(on: DispatchQueue.main) // Ensure updates on main thread + .assign(to: &$posts) // Assign directly to @Published property + + // Subscribe to pagingState publisher + pageRequestService.$pagingState + .receive(on: DispatchQueue.main) // Ensure updates on main thread + .assign(to: &$state) + } + func requestPosts(isFirst: Bool) { - pageRequestService.request( - pageSize: 10, - isFirst: isFirst - ) { [weak self] requestModel in - self?.postRepository.getPosts( - page: requestModel.page, - pageSize: requestModel.pageSize, - completion: requestModel.completion - ) - } resultHandler: { [weak self] in - switch $0 { - case .success(let posts): - self?.posts += posts - case .failure(let error): - print(error.localizedDescription) + Task { + do { + try await pageRequestService.request(pageSize: 10, isFirst: isFirst) + let canLoadMore = await pageRequestService.getCanLoadMore() + await MainActor.run { + self.posts = self.pageRequestService.items + self.canLoadMore = canLoadMore + } + } catch { + let canLoadMore = await pageRequestService.getCanLoadMore() + self.canLoadMore = canLoadMore } } } - private func reloadHistory(group: DispatchGroup? = nil, isNeedShowLoading: Bool) { - let group = group ?? DispatchGroup() - isLoading = isNeedShowLoading - - pageRequestService.reload(group: group) - - group.notify(queue: .main) { [weak self] in - self?.isLoading = false + // func requestPosts(isFirst: Bool) { + // pageRequestService.request( + // pageSize: 10, + // isFirst: isFirst + // ) { [weak self] requestModel in + // self?.postRepository.getPosts( + // page: requestModel.page, + // pageSize: requestModel.pageSize, + // completion: requestModel.completion + // ) + // } resultHandler: { [weak self] in + // switch $0 { + // case .success(let posts): + // self?.posts += posts + // case .failure(let error): + // print(error.localizedDescription) + // } + // } + // } + func reload() { + Task { + do { + try await pageRequestService.reload(pageSize: 10) + let canLoadMore = await pageRequestService.getCanLoadMore() + await MainActor.run { + self.posts = self.pageRequestService.items + self.canLoadMore = canLoadMore + } + } catch { + let canLoadMore = await pageRequestService.getCanLoadMore() + await MainActor.run { + self.canLoadMore = canLoadMore + } + } } } + // private func reloadHistory(group: DispatchGroup? = nil, isNeedShowLoading: Bool) { + // let group = group ?? DispatchGroup() + // isLoading = isNeedShowLoading + // + // pageRequestService.reload(group: group) + // + // group.notify(queue: .main) { [weak self] in + // self?.isLoading = false + // } + // } } diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 1e98f1f..6d450aa 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,5 +1,53 @@ import Foundation +actor PageRequestState { + let startPage: Int + let maxPrefetchPages: Int + var prefetchedPages: Int = 0 + + private(set) var currentPage: Int + private(set) var canLoadMore: Bool = true + + + private var isRequestInProcess: Bool = false + + init(startPage: Int, maxPrefetchPages: Int) { + self.startPage = startPage + self.currentPage = startPage + self.maxPrefetchPages = maxPrefetchPages + } + + func startRequest() -> Bool { + guard !isRequestInProcess else { return false } + isRequestInProcess = true + return true + } + + func endRequest() { + isRequestInProcess = false + } + + func incrementPrefetchedPages() { + prefetchedPages += 1 + } + + func resetPrefetchedPages() { + prefetchedPages = 0 + } + + func incrementPage() { + currentPage += 1 + } + + func setCanLoadMore(_ value: Bool) { + canLoadMore = value + } + + func getCanPrefetchMore() -> Bool { + return prefetchedPages < maxPrefetchPages + } +} + // Модель используется для ответов от сервера, которые возвращают данные по страницам public protocol PaginatedResponse: Codable { associatedtype T: Codable @@ -18,209 +66,119 @@ public struct PageRequestModel { // При использовании сервиса необходимо чтоб тип items в PaginatedResponse соответствовал // типу DataModel -public final class PageRequestService: ObservableObject { +public final class PageRequestService: ObservableObject { @Published public var pagingState: PagingListState = .fullscreenLoading @Published public var items: [DataModel] = [] - public private(set) var canLoadMore: Bool = true - public private(set) var prefetchedPages: Int = 0 - private var startPage: Int - private var currentPage: Int - private var isRequestInProcess = false + private let state: PageRequestState private var prefetchTask: Task? private let maxPrefetchPages: Int = 2 - - private var requestBuilder: ((PageRequestModel) -> Void)? - private var resultHandler: ((Result<[DataModel], Error>) -> Void)? - - public init(startPage: Int = 1) { - self.startPage = startPage - self.currentPage = startPage - - // Подписываемся на уведомление для остановки префетчинга при закрытии экрана с PagingList - NotificationCenter.default.addObserver( - forName: .stopPrefetching, - object: nil, - queue: .main - ) { [weak self] _ in - self?.stopPrefetching() - } - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - public func request( - pageSize: Int, - isFirst: Bool, - group: DispatchGroup? = nil, - requestBuilder: @escaping (PageRequestModel) -> Void, - resultHandler: @escaping (Result<[DataModel], Error>) -> Void + private let fetchPage: (Int, Int) async throws -> ResponseModel + + public init( + startPage: Int = 1, + fetchPage: @escaping (Int, Int) async throws -> ResponseModel ) { - guard isRequestInProcess == false else { - return - } - - guard canLoadMore else { - pagingState = .items - return - } - - isRequestInProcess = true - group?.enter() - - self.requestBuilder = requestBuilder - self.resultHandler = resultHandler - - let page = isFirst ? 1 : currentPage - - let pageRequestModel = PageRequestModel( - page: page, - pageSize: pageSize - ) { [weak self] result in - self?.isRequestInProcess = false + self.state = PageRequestState(startPage: startPage, maxPrefetchPages: 2) + self.fetchPage = fetchPage + } + + public func request(pageSize: Int, isFirst: Bool) async throws { + guard await state.startRequest() else { throw PagingError.requestInProgress } + defer { Task { await state.endRequest() } } + + let page = isFirst ? state.startPage : await state.currentPage + do { + let model = try await fetchPage(page, pageSize) + guard let modelItems = model.items as? [DataModel] else { + throw PagingError.invalidResponse + } + + await state.incrementPage() - switch result { - case .success(let model): - guard let self, let items = model.items as? [DataModel] else { - return - } - - currentPage += items.count == .zero ? .zero : 1 - pagingState = .items - - if let hasMore = model.hasMore { - canLoadMore = hasMore - } else if let totalPages = model.totalPages, let currentPage = model.currentPage { - canLoadMore = currentPage < totalPages - } else { - canLoadMore = items.count == pageSize - } - - if isFirst { - self.items = items - prefetchedPages = 0 - } else { - self.items.append(contentsOf: items) - } - - resultHandler(.success(items)) - - if canLoadMore && isFirst == false && prefetchedPages < maxPrefetchPages { - prefetchNextPages(pageSize: pageSize) - } - case .failure(let error): + await MainActor.run { if isFirst { - self?.pagingState = .fullscreenError(error) + self.items = modelItems } else { - self?.pagingState = .pagingError(error) + self.items.append(contentsOf: modelItems) } - - resultHandler(.failure(error)) + self.pagingState = .items + self.objectWillChange.send() } + + await updateCanLoadMore(items: modelItems, pageSize: pageSize, model: model) + + let canPrefetchMore = await state.getCanPrefetchMore() - group?.leave() + if !isFirst && canPrefetchMore { + await prefetchNextPages(pageSize: pageSize) + } + } catch { + await MainActor.run { + self.pagingState = isFirst ? .fullscreenError(error) : .pagingError(error) + } + throw error } - - requestBuilder(pageRequestModel) + } + + public func reload(pageSize: Int) async throws { + await state.resetPrefetchedPages() + try await request(pageSize: pageSize, isFirst: true) } - public func reload(group: DispatchGroup? = nil) { - // NOTE: This property is necessary to receive new elements on reload, if they exist. - let roundingSize = items.count % 10 - let additionalSize = 10 - (roundingSize == .zero ? 10 : roundingSize) - let pageSize = items.count + additionalSize - - group?.enter() - - let pageRequestModel = PageRequestModel( - page: startPage, - pageSize: pageSize - ) { [weak self] result in - switch result { - case .success(let model): - guard let self, let items = model.items as? [DataModel] else { - return - } - - self.pagingState = .items - self.items = items - self.canLoadMore = items.count == pageSize - - self.resultHandler?(.success(self.items)) - case .failure(let error): - self?.pagingState = .fullscreenError(error) - - self?.resultHandler?(.failure(error)) - } - - group?.leave() - } - - requestBuilder?(pageRequestModel) + public func stopPrefetching() { + prefetchTask?.cancel() + prefetchTask = nil + Task { await state.endRequest() } } - private func prefetchNextPages(pageSize: Int) { + public func getCanLoadMore() async -> Bool { + await state.canLoadMore + } + + private func prefetchNextPages(pageSize: Int) async { prefetchTask?.cancel() - prefetchTask = Task { [weak self] in guard let self else { return } - - var pagesToFetch = self.maxPrefetchPages - self.prefetchedPages - while pagesToFetch > 0 && self.canLoadMore && !Task.isCancelled { - try? await performPrefetch(pageSize: pageSize) - pagesToFetch -= 1 + guard await state.prefetchedPages < maxPrefetchPages else { return } + do { + let newItems = try await performPrefetch(pageSize: pageSize) + await MainActor.run { + self.items.append(contentsOf: newItems) + self.objectWillChange.send() + } + await state.incrementPrefetchedPages() + await updateCanLoadMore(items: newItems, pageSize: pageSize) + } catch { + print("Ошибка предварительной загрузки: \(error)") } } } - - private func performPrefetch(pageSize: Int) async throws { - guard isRequestInProcess == false, canLoadMore else { - return - } - - isRequestInProcess = true - - let pageRequestModel = PageRequestModel( - page: currentPage, - pageSize: pageSize - ) { [weak self] result in - self?.isRequestInProcess = false - - switch result { - case .success(let model): - guard let self, let items = model.items as? [DataModel] else { - return - } - - self.currentPage += items.count == 0 ? 0 : 1 - self.prefetchedPages += 1 - - if let hasMore = model.hasMore { - self.canLoadMore = hasMore - } else if let totalPages = model.totalPages, let currentPage = model.currentPage { - self.canLoadMore = currentPage < totalPages - } else { - self.canLoadMore = items.count == pageSize - } - - // Синхронное обновление на главном потоке - Task { @MainActor in - self.items.append(contentsOf: items) - self.resultHandler?(.success(items)) - } - case .failure: - break - } + + private func performPrefetch(pageSize: Int) async throws -> [DataModel] { + guard await state.startRequest() else { throw PagingError.requestInProgress } + defer { Task { await state.endRequest() } } + + let model = try await fetchPage(await state.currentPage, pageSize) + guard let modelItems = model.items as? [DataModel] else { + throw PagingError.invalidResponse } - - requestBuilder?(pageRequestModel) + await state.incrementPage() + return modelItems } - - public func stopPrefetching() { - prefetchTask?.cancel() - prefetchTask = nil - isRequestInProcess = false + + private func updateCanLoadMore(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) async { + if let hasMore = model?.hasMore { + await state.setCanLoadMore(hasMore) + } else if let totalPages = model?.totalPages, let currentPage = model?.currentPage { + await state.setCanLoadMore(currentPage < totalPages) + } else { + await state.setCanLoadMore(items.count == pageSize) + } } } + +enum PagingError: Error { + case requestInProgress + case invalidResponse +} From ef15b73558205c71fd754ec195eea36906d4b0d8 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Thu, 17 Apr 2025 16:27:34 +0300 Subject: [PATCH 08/19] [UPUP-1077]: PageRequestService update --- .../Sources/Repository/PostRepository.swift | 2 +- .../View/ListWithPageRequestServiceView.swift | 2 +- .../ListWithPageRequestServiceViewModel.swift | 68 ++++------ Sources/PagingList/PageRequestService.swift | 119 +++++++++--------- Sources/PagingList/PagingListState.swift | 2 +- 5 files changed, 86 insertions(+), 107 deletions(-) diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index 5fb28c8..bc2712b 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -20,7 +20,7 @@ extension PostRepositoryError: LocalizedError { } } -class PostRepository { +final class PostRepository: Sendable { private enum Constants { static let delayInNanoseconds: UInt64 = 3_000_000_000 } diff --git a/Example/Sources/View/ListWithPageRequestServiceView.swift b/Example/Sources/View/ListWithPageRequestServiceView.swift index e3a82a9..f92c40e 100644 --- a/Example/Sources/View/ListWithPageRequestServiceView.swift +++ b/Example/Sources/View/ListWithPageRequestServiceView.swift @@ -16,7 +16,7 @@ struct ListWithPageRequestServiceView: View { // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( - state: $viewModel.pageRequestService.pagingState, + state: $viewModel.state, items: viewModel.posts ) { post in PostView(post: post) diff --git a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift index 3ceff7e..b540916 100644 --- a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift +++ b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift @@ -8,6 +8,7 @@ import Foundation import PagingList +@MainActor final class ListWithPageRequestServiceViewModel: ObservableObject { private enum Constants { static let requestLimit = 10 @@ -17,83 +18,58 @@ final class ListWithPageRequestServiceViewModel: ObservableObject { @Published var state: PagingListState = .fullscreenLoading @Published var canLoadMore: Bool = true - var pageRequestService: PageRequestService + let pageRequestService: PageRequestService - private let postRepository = PostRepository() - private var workItem: DispatchWorkItem? + private let postRepository: PostRepository - init() { + init(postRepository: PostRepository = PostRepository()) { + self.postRepository = postRepository pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) - pageRequestService.$items - .receive(on: DispatchQueue.main) // Ensure updates on main thread - .assign(to: &$posts) // Assign directly to @Published property - - // Subscribe to pagingState publisher - pageRequestService.$pagingState - .receive(on: DispatchQueue.main) // Ensure updates on main thread - .assign(to: &$state) } func requestPosts(isFirst: Bool) { Task { do { - try await pageRequestService.request(pageSize: 10, isFirst: isFirst) + try await pageRequestService.request(pageSize: Constants.requestLimit, isFirst: isFirst) + let items = await pageRequestService.getItems() + let pagingState = await pageRequestService.getPagingState() let canLoadMore = await pageRequestService.getCanLoadMore() await MainActor.run { - self.posts = self.pageRequestService.items + self.posts = items + self.state = pagingState self.canLoadMore = canLoadMore } } catch { + let pagingState = await pageRequestService.getPagingState() let canLoadMore = await pageRequestService.getCanLoadMore() - self.canLoadMore = canLoadMore + await MainActor.run { + self.state = pagingState + self.canLoadMore = canLoadMore + } } } } - // func requestPosts(isFirst: Bool) { - // pageRequestService.request( - // pageSize: 10, - // isFirst: isFirst - // ) { [weak self] requestModel in - // self?.postRepository.getPosts( - // page: requestModel.page, - // pageSize: requestModel.pageSize, - // completion: requestModel.completion - // ) - // } resultHandler: { [weak self] in - // switch $0 { - // case .success(let posts): - // self?.posts += posts - // case .failure(let error): - // print(error.localizedDescription) - // } - // } - // } func reload() { Task { do { - try await pageRequestService.reload(pageSize: 10) + try await pageRequestService.reload(pageSize: Constants.requestLimit) + let items = await pageRequestService.getItems() + let pagingState = await pageRequestService.getPagingState() let canLoadMore = await pageRequestService.getCanLoadMore() await MainActor.run { - self.posts = self.pageRequestService.items + self.posts = items + self.state = pagingState self.canLoadMore = canLoadMore } } catch { + let pagingState = await pageRequestService.getPagingState() let canLoadMore = await pageRequestService.getCanLoadMore() await MainActor.run { + self.state = pagingState self.canLoadMore = canLoadMore } } } } - // private func reloadHistory(group: DispatchGroup? = nil, isNeedShowLoading: Bool) { - // let group = group ?? DispatchGroup() - // isLoading = isNeedShowLoading - // - // pageRequestService.reload(group: group) - // - // group.notify(queue: .main) { [weak self] in - // self?.isLoading = false - // } - // } } diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 6d450aa..6341b57 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,15 +1,17 @@ import Foundation -actor PageRequestState { +actor PageRequestState { let startPage: Int let maxPrefetchPages: Int var prefetchedPages: Int = 0 private(set) var currentPage: Int private(set) var canLoadMore: Bool = true - + private(set) var items: [DataModel] = [] + private(set) var pagingState: PagingListState = .fullscreenLoading private var isRequestInProcess: Bool = false + private var prefetchTask: Task? init(startPage: Int, maxPrefetchPages: Int) { self.startPage = startPage @@ -43,41 +45,47 @@ actor PageRequestState { canLoadMore = value } + func setItems(_ newItems: [DataModel], isFirst: Bool) { + if isFirst { + items = newItems + } else { + items.append(contentsOf: newItems) + } + } + + func setPagingState(_ state: PagingListState) { + pagingState = state + } + func getCanPrefetchMore() -> Bool { return prefetchedPages < maxPrefetchPages } + + func setPrefetchTask(_ task: Task?) { + prefetchTask = task + } + + func cancelPrefetchTask() { + prefetchTask?.cancel() + prefetchTask = nil + } } -// Модель используется для ответов от сервера, которые возвращают данные по страницам -public protocol PaginatedResponse: Codable { - associatedtype T: Codable +public protocol PaginatedResponse: Codable, Sendable { + associatedtype T: Codable, Sendable var items: [T] { get } - var hasMore: Bool? { get } // Опционально для API с метаданными + var hasMore: Bool? { get } var totalPages: Int? { get } var currentPage: Int? { get } } -// Модель используется в билдере запросов, наследующих PaginatedResponse -public struct PageRequestModel { - public let page: Int - public let pageSize: Int - public let completion: (Result) -> Void -} - -// При использовании сервиса необходимо чтоб тип items в PaginatedResponse соответствовал -// типу DataModel -public final class PageRequestService: ObservableObject { - @Published public var pagingState: PagingListState = .fullscreenLoading - @Published public var items: [DataModel] = [] - +public final class PageRequestService: Sendable { private let state: PageRequestState - private var prefetchTask: Task? - private let maxPrefetchPages: Int = 2 - private let fetchPage: (Int, Int) async throws -> ResponseModel + private let fetchPage: @Sendable (Int, Int) async throws -> ResponseModel public init( startPage: Int = 1, - fetchPage: @escaping (Int, Int) async throws -> ResponseModel + fetchPage: @escaping @Sendable (Int, Int) async throws -> ResponseModel ) { self.state = PageRequestState(startPage: startPage, maxPrefetchPages: 2) self.fetchPage = fetchPage @@ -95,28 +103,16 @@ public final class PageRequestService Bool { - await state.canLoadMore - } private func prefetchNextPages(pageSize: Int) async { - prefetchTask?.cancel() - prefetchTask = Task { [weak self] in + await state.cancelPrefetchTask() + let task = Task { [weak self] in guard let self else { return } - guard await state.prefetchedPages < maxPrefetchPages else { return } + guard await state.getCanPrefetchMore() else { return } do { let newItems = try await performPrefetch(pageSize: pageSize) - await MainActor.run { - self.items.append(contentsOf: newItems) - self.objectWillChange.send() - } + await state.setItems(newItems, isFirst: false) await state.incrementPrefetchedPages() await updateCanLoadMore(items: newItems, pageSize: pageSize) } catch { print("Ошибка предварительной загрузки: \(error)") } } + await state.setPrefetchTask(task) } private func performPrefetch(pageSize: Int) async throws -> [DataModel] { @@ -168,13 +152,32 @@ public final class PageRequestService Bool { + await state.canLoadMore + } + + public func getItems() async -> [DataModel] { + await state.items + } + + public func getPagingState() async -> PagingListState { + await state.pagingState } } diff --git a/Sources/PagingList/PagingListState.swift b/Sources/PagingList/PagingListState.swift index 65563c6..dabf93a 100644 --- a/Sources/PagingList/PagingListState.swift +++ b/Sources/PagingList/PagingListState.swift @@ -1,6 +1,6 @@ import Foundation -public enum PagingListState { +public enum PagingListState: Sendable { // Paging disabled. case disabled // Cells are visible. No loading next items here. From 09cd6cbd7d953160a50b09da334db3b951d95f5b Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Thu, 17 Apr 2025 16:43:57 +0300 Subject: [PATCH 09/19] [UPUP-1077]: quick fixes --- Example/Sources/Repository/IntsRepository.swift | 2 +- Example/Sources/Repository/PostRepository.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Example/Sources/Repository/IntsRepository.swift b/Example/Sources/Repository/IntsRepository.swift index 0079e19..5f09840 100644 --- a/Example/Sources/Repository/IntsRepository.swift +++ b/Example/Sources/Repository/IntsRepository.swift @@ -39,6 +39,6 @@ class IntsRepository { } } -extension Int: Identifiable { +extension Int: @retroactive Identifiable { public var id: Int { self } } diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index bc2712b..a70fd3c 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -25,8 +25,9 @@ final class PostRepository: Sendable { static let delayInNanoseconds: UInt64 = 3_000_000_000 } + @Sendable func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { - try await Task.sleep(nanoseconds: 1_000_000_000) + try await Task.sleep(nanoseconds: Constants.delayInNanoseconds) if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { return postExampleModel From ee26f5195c073cefa96cdb70adbbaeb082b507bc Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Thu, 17 Apr 2025 17:19:58 +0300 Subject: [PATCH 10/19] [UPUP-1077]: fix Ints repository for Swift 6 --- Example/Sources/Repository/IntsRepository.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Example/Sources/Repository/IntsRepository.swift b/Example/Sources/Repository/IntsRepository.swift index 5f09840..5d875b4 100644 --- a/Example/Sources/Repository/IntsRepository.swift +++ b/Example/Sources/Repository/IntsRepository.swift @@ -20,11 +20,12 @@ extension IntsRepositoryError: LocalizedError { } } -class IntsRepository { +final class IntsRepository: Sendable { private enum Constants { static let delayInNanoseconds: UInt64 = 3_000_000_000 } + @Sendable func getItems(limit: Int, offset: Int) async throws -> [Int] { await Task { try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) From c7fc5fc56db15013fd828083c17aef2dbf0f05e4 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Thu, 17 Apr 2025 18:47:39 +0300 Subject: [PATCH 11/19] [UPUP-1077]: pagingState for PageRequetService update --- Sources/PagingList/PageRequestService.swift | 93 ++++++++++++++++----- 1 file changed, 74 insertions(+), 19 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 6341b57..6c4aa4b 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -9,7 +9,10 @@ actor PageRequestState { private(set) var canLoadMore: Bool = true private(set) var items: [DataModel] = [] private(set) var pagingState: PagingListState = .fullscreenLoading - + private var prefetchedItems: [DataModel] = [] + private var prefetchedPage: Int? // Номер страницы для префетч-данных + private var isPrefetchPending: Bool = false // Флаг для ожидания префетча + private var isRequestInProcess: Bool = false private var prefetchTask: Task? @@ -35,6 +38,9 @@ actor PageRequestState { func resetPrefetchedPages() { prefetchedPages = 0 + prefetchedItems = [] + prefetchedPage = nil + isPrefetchPending = false } func incrementPage() { @@ -69,6 +75,30 @@ actor PageRequestState { prefetchTask?.cancel() prefetchTask = nil } + + func hasPrefetchedItems(forPage page: Int) -> Bool { + return !prefetchedItems.isEmpty && prefetchedPage == page + } + + func getPrefetchedItems() -> [DataModel] { + let result = prefetchedItems + prefetchedItems = [] + prefetchedPage = nil + return result + } + + func setPrefetchedItems(_ items: [DataModel], forPage page: Int) { + prefetchedItems = items + prefetchedPage = page + } + + func setPrefetchPending(_ value: Bool) { + isPrefetchPending = value + } + + func getIsPrefetchPending() -> Bool { + return isPrefetchPending + } } public protocol PaginatedResponse: Codable, Sendable { @@ -95,21 +125,41 @@ public final class PageRequestService [DataModel] { + private func performPrefetch(pageSize: Int, forPage page: Int) async throws -> ResponseModel { guard await state.startRequest() else { throw PagingError.requestInProgress } defer { Task { await state.endRequest() } } - let model = try await fetchPage(await state.currentPage, pageSize) - guard let modelItems = model.items as? [DataModel] else { - throw PagingError.invalidResponse - } - await state.incrementPage() - return modelItems + return try await fetchPage(page, pageSize) } private func updateCanLoadMore(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) async { @@ -166,6 +220,7 @@ public final class PageRequestService Bool { From 4ca29c050d3cbd6d51675be06ea13ee25e92d87a Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Fri, 18 Apr 2025 02:24:42 +0300 Subject: [PATCH 12/19] [UPUP-1077]: update prefetch logic --- Sources/PagingList/PageRequestService.swift | 98 ++++++++++++++------- 1 file changed, 66 insertions(+), 32 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 6c4aa4b..67764f2 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,29 +1,32 @@ import Foundation -actor PageRequestState { +actor PageRequestState { let startPage: Int - let maxPrefetchPages: Int + let prefetchTreshold: Int var prefetchedPages: Int = 0 + var maxPrefetchedPage: Int? private(set) var currentPage: Int private(set) var canLoadMore: Bool = true private(set) var items: [DataModel] = [] private(set) var pagingState: PagingListState = .fullscreenLoading - private var prefetchedItems: [DataModel] = [] - private var prefetchedPage: Int? // Номер страницы для префетч-данных + private var prefetchedItems: [Int: ResponseModel] = [:] private var isPrefetchPending: Bool = false // Флаг для ожидания префетча private var isRequestInProcess: Bool = false private var prefetchTask: Task? - init(startPage: Int, maxPrefetchPages: Int) { + init(startPage: Int, prefetchTreshold: Int) { self.startPage = startPage self.currentPage = startPage - self.maxPrefetchPages = maxPrefetchPages + self.prefetchTreshold = prefetchTreshold } func startRequest() -> Bool { - guard !isRequestInProcess else { return false } + guard isRequestInProcess == false else { + return false + } + isRequestInProcess = true return true } @@ -38,8 +41,7 @@ actor PageRequestState { func resetPrefetchedPages() { prefetchedPages = 0 - prefetchedItems = [] - prefetchedPage = nil + prefetchedItems = [:] isPrefetchPending = false } @@ -64,7 +66,19 @@ actor PageRequestState { } func getCanPrefetchMore() -> Bool { - return prefetchedPages < maxPrefetchPages + return prefetchedPages < prefetchTreshold + } + + func getPrefetchPages() -> Int { + return prefetchedPages + } + + func getMaxPrefetchedPage() -> Int? { + return maxPrefetchedPage + } + + func setMaxPrefetchedPage(_ value: Int) { + maxPrefetchedPage = value } func setPrefetchTask(_ task: Task?) { @@ -77,19 +91,19 @@ actor PageRequestState { } func hasPrefetchedItems(forPage page: Int) -> Bool { - return !prefetchedItems.isEmpty && prefetchedPage == page + return prefetchedItems.isEmpty == false && prefetchedItems.keys.contains(page) } - func getPrefetchedItems() -> [DataModel] { - let result = prefetchedItems - prefetchedItems = [] - prefetchedPage = nil + func getPrefetchedItems(for page: Int) -> ResponseModel? { + let result = prefetchedItems[page] + prefetchedItems.removeValue(forKey: page) + prefetchedPages -= 1 + return result } - func setPrefetchedItems(_ items: [DataModel], forPage page: Int) { - prefetchedItems = items - prefetchedPage = page + func setPrefetchedItems(_ items: ResponseModel, forPage page: Int) { + prefetchedItems[page] = items } func setPrefetchPending(_ value: Bool) { @@ -110,14 +124,14 @@ public protocol PaginatedResponse: Codable, Sendable { } public final class PageRequestService: Sendable { - private let state: PageRequestState + private let state: PageRequestState private let fetchPage: @Sendable (Int, Int) async throws -> ResponseModel public init( startPage: Int = 1, fetchPage: @escaping @Sendable (Int, Int) async throws -> ResponseModel ) { - self.state = PageRequestState(startPage: startPage, maxPrefetchPages: 2) + self.state = PageRequestState(startPage: startPage, prefetchTreshold: 2) self.fetchPage = fetchPage } @@ -138,7 +152,16 @@ public final class PageRequestService Date: Fri, 18 Apr 2025 11:47:24 +0300 Subject: [PATCH 13/19] [UPUP-1077]: PageRequestService refactor --- Example/Example.xcodeproj/project.pbxproj | 18 +- .../Sources/{View => UI}/ContentView.swift | 0 .../Sources/{View => UI}/ListStateViews.swift | 0 .../ListWithPageRequestServiceView.swift | 6 +- .../ListWithPageRequestServiceViewModel.swift | 48 ++++ .../{View => UI}/ListWithSectionsView.swift | 0 .../{View => UI}/ListWithoutSectionView.swift | 0 .../ListWithPageRequestServiceViewModel.swift | 75 ----- Sources/PagingList/PageRequestService.swift | 265 ++++++------------ Sources/PagingList/PageRequestState.swift | 101 +++++++ 10 files changed, 255 insertions(+), 258 deletions(-) rename Example/Sources/{View => UI}/ContentView.swift (100%) rename Example/Sources/{View => UI}/ListStateViews.swift (100%) rename Example/Sources/{View => UI/ListWithPageRequestService}/ListWithPageRequestServiceView.swift (92%) create mode 100644 Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift rename Example/Sources/{View => UI}/ListWithSectionsView.swift (100%) rename Example/Sources/{View => UI}/ListWithoutSectionView.swift (100%) delete mode 100644 Example/Sources/View/ListWithPageRequestServiceViewModel.swift create mode 100644 Sources/PagingList/PageRequestState.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index ff4e277..e7af5bb 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -79,17 +79,16 @@ path = Mock; sourceTree = ""; }; - 084770D02DAFA7BB0043935A /* View */ = { + 084770D02DAFA7BB0043935A /* UI */ = { isa = PBXGroup; children = ( - 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */, - 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */, + 089B61F82DB2239C00059219 /* ListWithPageRequestService */, 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */, E1FDB14B29770604003CC2A5 /* ContentView.swift */, 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */, 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */, ); - path = View; + path = UI; sourceTree = ""; }; 084770D12DAFA7D60043935A /* Model */ = { @@ -100,6 +99,15 @@ path = Model; sourceTree = ""; }; + 089B61F82DB2239C00059219 /* ListWithPageRequestService */ = { + isa = PBXGroup; + children = ( + 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */, + 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */, + ); + path = ListWithPageRequestService; + sourceTree = ""; + }; E10FA35129770CBE0031ED65 /* Packages */ = { isa = PBXGroup; children = ( @@ -131,7 +139,7 @@ children = ( 084770C72DAFA5250043935A /* Mock */, 084770D12DAFA7D60043935A /* Model */, - 084770D02DAFA7BB0043935A /* View */, + 084770D02DAFA7BB0043935A /* UI */, 084770A92DAFA1B30043935A /* Repository */, E1FDB14929770604003CC2A5 /* ExampleApp.swift */, E1FDB14D29770605003CC2A5 /* Assets.xcassets */, diff --git a/Example/Sources/View/ContentView.swift b/Example/Sources/UI/ContentView.swift similarity index 100% rename from Example/Sources/View/ContentView.swift rename to Example/Sources/UI/ContentView.swift diff --git a/Example/Sources/View/ListStateViews.swift b/Example/Sources/UI/ListStateViews.swift similarity index 100% rename from Example/Sources/View/ListStateViews.swift rename to Example/Sources/UI/ListStateViews.swift diff --git a/Example/Sources/View/ListWithPageRequestServiceView.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift similarity index 92% rename from Example/Sources/View/ListWithPageRequestServiceView.swift rename to Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift index f92c40e..95fc019 100644 --- a/Example/Sources/View/ListWithPageRequestServiceView.swift +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift @@ -16,7 +16,7 @@ struct ListWithPageRequestServiceView: View { // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( - state: $viewModel.state, + state: $viewModel.pagingState, items: viewModel.posts ) { post in PostView(post: post) @@ -29,7 +29,7 @@ struct ListWithPageRequestServiceView: View { .listRowSeparator(.hidden) } fullscreenErrorView: { error in FullscreenErrorStateView(error: error) { - viewModel.state = .fullscreenLoading + viewModel.pagingState = .fullscreenLoading viewModel.requestPosts(isFirst: true) } .listRowSeparator(.hidden) @@ -53,7 +53,7 @@ struct ListWithPageRequestServiceView: View { } .listStyle(.plain) .onAppear { - if viewModel.canLoadMore && viewModel.state == .fullscreenLoading { + if viewModel.canLoadMore && viewModel.pagingState == .fullscreenLoading { viewModel.requestPosts(isFirst: true) } } diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift new file mode 100644 index 0000000..339d822 --- /dev/null +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift @@ -0,0 +1,48 @@ +// +// ListWithPageRequestServiceViewModel.swift +// Example +// +// Created by Maria Nesterova on 16.04.2025. +// + +import Foundation +import PagingList + +@MainActor +final class ListWithPageRequestServiceViewModel: ObservableObject { + private enum Constants { + static let requestLimit = 10 + } + + @Published var posts: [Post] = [] + @Published var pagingState: PagingListState = .fullscreenLoading + @Published var canLoadMore: Bool = true + + var pageRequestService: PageRequestService + + private let postRepository: PostRepository + + init(postRepository: PostRepository = PostRepository()) { + self.postRepository = postRepository + pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) + } + + func requestPosts(isFirst: Bool) { + Task { + do { + try await pageRequestService.request(pageSize: Constants.requestLimit, isFirst: isFirst) + let posts = await pageRequestService.getItems() + let pagingState = await pageRequestService.getPagingState() + let canLoadMore = await pageRequestService.getCanLoadMore() + self.posts = posts + self.pagingState = pagingState + self.canLoadMore = canLoadMore + } catch { + let pagingState = await pageRequestService.getPagingState() + let canLoadMore = await pageRequestService.getCanLoadMore() + self.pagingState = pagingState + self.canLoadMore = canLoadMore + } + } + } +} diff --git a/Example/Sources/View/ListWithSectionsView.swift b/Example/Sources/UI/ListWithSectionsView.swift similarity index 100% rename from Example/Sources/View/ListWithSectionsView.swift rename to Example/Sources/UI/ListWithSectionsView.swift diff --git a/Example/Sources/View/ListWithoutSectionView.swift b/Example/Sources/UI/ListWithoutSectionView.swift similarity index 100% rename from Example/Sources/View/ListWithoutSectionView.swift rename to Example/Sources/UI/ListWithoutSectionView.swift diff --git a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift b/Example/Sources/View/ListWithPageRequestServiceViewModel.swift deleted file mode 100644 index b540916..0000000 --- a/Example/Sources/View/ListWithPageRequestServiceViewModel.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ListWithPageRequestServiceViewModel.swift -// Example -// -// Created by Maria Nesterova on 16.04.2025. -// - -import Foundation -import PagingList - -@MainActor -final class ListWithPageRequestServiceViewModel: ObservableObject { - private enum Constants { - static let requestLimit = 10 - } - - @Published var posts: [Post] = [] - @Published var state: PagingListState = .fullscreenLoading - @Published var canLoadMore: Bool = true - - let pageRequestService: PageRequestService - - private let postRepository: PostRepository - - init(postRepository: PostRepository = PostRepository()) { - self.postRepository = postRepository - pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) - } - - func requestPosts(isFirst: Bool) { - Task { - do { - try await pageRequestService.request(pageSize: Constants.requestLimit, isFirst: isFirst) - let items = await pageRequestService.getItems() - let pagingState = await pageRequestService.getPagingState() - let canLoadMore = await pageRequestService.getCanLoadMore() - await MainActor.run { - self.posts = items - self.state = pagingState - self.canLoadMore = canLoadMore - } - } catch { - let pagingState = await pageRequestService.getPagingState() - let canLoadMore = await pageRequestService.getCanLoadMore() - await MainActor.run { - self.state = pagingState - self.canLoadMore = canLoadMore - } - } - } - } - - func reload() { - Task { - do { - try await pageRequestService.reload(pageSize: Constants.requestLimit) - let items = await pageRequestService.getItems() - let pagingState = await pageRequestService.getPagingState() - let canLoadMore = await pageRequestService.getCanLoadMore() - await MainActor.run { - self.posts = items - self.state = pagingState - self.canLoadMore = canLoadMore - } - } catch { - let pagingState = await pageRequestService.getPagingState() - let canLoadMore = await pageRequestService.getCanLoadMore() - await MainActor.run { - self.state = pagingState - self.canLoadMore = canLoadMore - } - } - } - } -} diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 67764f2..447a986 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,120 +1,5 @@ import Foundation -actor PageRequestState { - let startPage: Int - let prefetchTreshold: Int - var prefetchedPages: Int = 0 - var maxPrefetchedPage: Int? - - private(set) var currentPage: Int - private(set) var canLoadMore: Bool = true - private(set) var items: [DataModel] = [] - private(set) var pagingState: PagingListState = .fullscreenLoading - private var prefetchedItems: [Int: ResponseModel] = [:] - private var isPrefetchPending: Bool = false // Флаг для ожидания префетча - - private var isRequestInProcess: Bool = false - private var prefetchTask: Task? - - init(startPage: Int, prefetchTreshold: Int) { - self.startPage = startPage - self.currentPage = startPage - self.prefetchTreshold = prefetchTreshold - } - - func startRequest() -> Bool { - guard isRequestInProcess == false else { - return false - } - - isRequestInProcess = true - return true - } - - func endRequest() { - isRequestInProcess = false - } - - func incrementPrefetchedPages() { - prefetchedPages += 1 - } - - func resetPrefetchedPages() { - prefetchedPages = 0 - prefetchedItems = [:] - isPrefetchPending = false - } - - func incrementPage() { - currentPage += 1 - } - - func setCanLoadMore(_ value: Bool) { - canLoadMore = value - } - - func setItems(_ newItems: [DataModel], isFirst: Bool) { - if isFirst { - items = newItems - } else { - items.append(contentsOf: newItems) - } - } - - func setPagingState(_ state: PagingListState) { - pagingState = state - } - - func getCanPrefetchMore() -> Bool { - return prefetchedPages < prefetchTreshold - } - - func getPrefetchPages() -> Int { - return prefetchedPages - } - - func getMaxPrefetchedPage() -> Int? { - return maxPrefetchedPage - } - - func setMaxPrefetchedPage(_ value: Int) { - maxPrefetchedPage = value - } - - func setPrefetchTask(_ task: Task?) { - prefetchTask = task - } - - func cancelPrefetchTask() { - prefetchTask?.cancel() - prefetchTask = nil - } - - func hasPrefetchedItems(forPage page: Int) -> Bool { - return prefetchedItems.isEmpty == false && prefetchedItems.keys.contains(page) - } - - func getPrefetchedItems(for page: Int) -> ResponseModel? { - let result = prefetchedItems[page] - prefetchedItems.removeValue(forKey: page) - prefetchedPages -= 1 - - return result - } - - func setPrefetchedItems(_ items: ResponseModel, forPage page: Int) { - prefetchedItems[page] = items - } - - func setPrefetchPending(_ value: Bool) { - isPrefetchPending = value - } - - func getIsPrefetchPending() -> Bool { - return isPrefetchPending - } -} - public protocol PaginatedResponse: Codable, Sendable { associatedtype T: Codable, Sendable var items: [T] { get } @@ -125,49 +10,62 @@ public protocol PaginatedResponse: Codable, Sendable { public final class PageRequestService: Sendable { private let state: PageRequestState - private let fetchPage: @Sendable (Int, Int) async throws -> ResponseModel + private let fetchPage: @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel public init( startPage: Int = 1, - fetchPage: @escaping @Sendable (Int, Int) async throws -> ResponseModel + fetchPage: @escaping @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel ) { self.state = PageRequestState(startPage: startPage, prefetchTreshold: 2) self.fetchPage = fetchPage + + // Подписываемся на уведомление для остановки префетчинга при закрытии экрана с PagingList + NotificationCenter.default.addObserver( + forName: .stopPrefetching, + object: nil, + queue: .main + ) { [weak self] _ in + self?.stopPrefetching() + } } - + + deinit { + NotificationCenter.default.removeObserver(self) + } + public func request(pageSize: Int, isFirst: Bool) async throws { - guard await state.startRequest() else { throw PagingError.requestInProgress } + guard await state.startRequest() else { + throw PagingError.requestInProgress + } + defer { Task { await state.endRequest() } } - // Разделяем доступ к startPage (синхронный) и currentPage (асинхронный) - let page: Int - if isFirst { - page = state.startPage - } else { - page = await state.currentPage - } + let currentPage = await state.currentPage + let page = isFirst ? state.startPage : currentPage do { let modelItems: [DataModel] - let hasPrefetchedItems = await state.hasPrefetchedItems(forPage: page) - if isFirst == false && hasPrefetchedItems { + + if + isFirst == false, + let prefetchedResponse = await state.getPrefetchedResponse(for: page), + let items = prefetchedResponse.items as? [DataModel] + { // Используем префетч-данные, если они есть для текущей страницы - if let model = await state.getPrefetchedItems(for: page) { - if let items = model.items as? [DataModel] { - modelItems = items - await updateCanLoadMore(items: items, pageSize: pageSize, model: model) - } else { - modelItems = [] - } - } else { - modelItems = [] - } + modelItems = items + await updateCanLoadMore(items: items, pageSize: pageSize, model: prefetchedResponse) } else { // Выполняем запрос, если префетч-данных нет + if isFirst { + await state.resetPrefetchedPages() + } + let model = try await fetchPage(page, pageSize) + guard let items = model.items as? [DataModel] else { throw PagingError.invalidResponse } + modelItems = items await updateCanLoadMore(items: modelItems, pageSize: pageSize, model: model) } @@ -176,71 +74,66 @@ public final class PageRequestService ResponseModel { - guard await state.startRequest() else { throw PagingError.requestInProgress } - defer { Task { await state.endRequest() } } - - return try await fetchPage(page, pageSize) - } - private func updateCanLoadMore(items: [DataModel], pageSize: Int, model: ResponseModel? = nil) async { let canLoadMore: Bool + if let hasMore = model?.hasMore { canLoadMore = hasMore } else if let totalPages = model?.totalPages, let currentPage = model?.currentPage { @@ -248,13 +141,31 @@ public final class PageRequestService Bool { @@ -268,6 +179,10 @@ public final class PageRequestService PagingListState { await state.pagingState } + + public func setPagingState(pagingState: PagingListState) async { + await state.setPagingState(pagingState) + } } enum PagingError: Error { diff --git a/Sources/PagingList/PageRequestState.swift b/Sources/PagingList/PageRequestState.swift new file mode 100644 index 0000000..9cdca77 --- /dev/null +++ b/Sources/PagingList/PageRequestState.swift @@ -0,0 +1,101 @@ +actor PageRequestState { + let startPage: Int + var maxPrefetchedPage: Int? + var canPrefetchMore: Bool { return prefetchedPages < prefetchTreshold } + + private(set) var currentPage: Int + private(set) var canLoadMore: Bool = true + private(set) var canLoadMorePrefetch: Bool = true + private(set) var items: [DataModel] = [] + private(set) var pagingState: PagingListState = .fullscreenLoading + private(set) var isRequestInProcess: Bool = false + private(set) var isPrefetchPending: Bool = false + + private var prefetchedPages: Int = 0 + private var prefetchedResponse: [Int: ResponseModel] = [:] + private var prefetchTask: Task? + private let prefetchTreshold: Int + + init(startPage: Int, prefetchTreshold: Int) { + self.startPage = startPage + self.currentPage = startPage + self.prefetchTreshold = prefetchTreshold + } + + func startRequest() -> Bool { + guard isRequestInProcess == false else { + return false + } + + isRequestInProcess = true + + return isRequestInProcess + } + + func endRequest() { + isRequestInProcess = false + } + + func incrementPrefetchedPages() { + prefetchedPages += 1 + } + + func resetPrefetchedPages() { + prefetchedPages = 0 + prefetchedResponse = [:] + isPrefetchPending = false + } + + func incrementPage() { + currentPage += 1 + } + + func setCanLoadMore(_ value: Bool) { + canLoadMore = value + } + + func setCanLoadMorePrefetch(_ value: Bool) { + canLoadMorePrefetch = value + } + + func setItems(_ newItems: [DataModel], isFirst: Bool) { + if isFirst { + items.removeAll() + } + + items.append(contentsOf: newItems) + } + + func setPagingState(_ state: PagingListState) { + pagingState = state + } + + func setMaxPrefetchedPage(_ value: Int) { + maxPrefetchedPage = value + } + + func setPrefetchTask(_ task: Task?) { + prefetchTask = task + } + + func cancelPrefetchTask() { + prefetchTask?.cancel() + prefetchTask = nil + } + + func getPrefetchedResponse(for page: Int) -> ResponseModel? { + let result = prefetchedResponse[page] + prefetchedResponse.removeValue(forKey: page) + prefetchedPages -= 1 + + return result + } + + func setPrefetchedItems(_ items: ResponseModel?, forPage page: Int) { + prefetchedResponse[page] = items + } + + func setPrefetchPending(_ value: Bool) { + isPrefetchPending = value + } +} From be92afdfb4b2f678e3b37887c680a07e05b02b53 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Fri, 18 Apr 2025 17:35:10 +0300 Subject: [PATCH 14/19] [UPUP-1077]: add PageRequestState documentation --- Sources/PagingList/PageRequestService.swift | 76 ++++++++++----------- Sources/PagingList/PageRequestState.swift | 50 +++++++++++++- Sources/PagingList/PagingList.swift | 2 - 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 447a986..e386581 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,5 +1,10 @@ import Foundation +enum PagingError: Error { + case requestInProgress + case invalidResponse +} + public protocol PaginatedResponse: Codable, Sendable { associatedtype T: Codable, Sendable var items: [T] { get } @@ -81,6 +86,30 @@ public final class PageRequestService Bool { + await state.canLoadMore + } + + public func getItems() async -> [DataModel] { + await state.items + } + + public func getPagingState() async -> PagingListState { + await state.pagingState + } + + public func setPagingState(pagingState: PagingListState) async { + await state.setPagingState(pagingState) + } + private func prefetchIfNeeded(pageSize: Int) async { guard await state.isRequestInProcess else { return @@ -132,21 +161,19 @@ public final class PageRequestService Bool { let canLoadMore: Bool if let hasMore = model?.hasMore { @@ -157,35 +184,6 @@ public final class PageRequestService Bool { - await state.canLoadMore - } - - public func getItems() async -> [DataModel] { - await state.items - } - - public func getPagingState() async -> PagingListState { - await state.pagingState + return canLoadMore } - - public func setPagingState(pagingState: PagingListState) async { - await state.setPagingState(pagingState) - } -} - -enum PagingError: Error { - case requestInProgress - case invalidResponse } diff --git a/Sources/PagingList/PageRequestState.swift b/Sources/PagingList/PageRequestState.swift index 9cdca77..eaec9c7 100644 --- a/Sources/PagingList/PageRequestState.swift +++ b/Sources/PagingList/PageRequestState.swift @@ -1,14 +1,23 @@ +/// A thread-safe actor that manages the state of paginated requests, including current page, items, and prefetching logic. actor PageRequestState { let startPage: Int + /// The maximum page number that has been prefetched, if any. var maxPrefetchedPage: Int? + /// Indicates whether more pages can be prefetched based on the prefetch threshold. var canPrefetchMore: Bool { return prefetchedPages < prefetchTreshold } private(set) var currentPage: Int + /// Indicates whether more pages can be loaded, related to current page on the screen for requests to continue. private(set) var canLoadMore: Bool = true + /// Indicates whether more pages can be loaded, related to already prefetched pages. private(set) var canLoadMorePrefetch: Bool = true + /// The list of items loaded so far. private(set) var items: [DataModel] = [] + /// The current UI state of the paginated list (e.g., loading, error, items). private(set) var pagingState: PagingListState = .fullscreenLoading + /// Indicates whether a page request is currently in progress. private(set) var isRequestInProcess: Bool = false + /// Indicates whether a prefetch operation is pending. private(set) var isPrefetchPending: Bool = false private var prefetchedPages: Int = 0 @@ -16,12 +25,18 @@ actor PageRequestState? private let prefetchTreshold: Int + /// Initializes the state with a starting page and prefetch threshold. + /// - Parameters: + /// - startPage: The initial page number. + /// - prefetchTreshold: The maximum number of pages to prefetch ahead. init(startPage: Int, prefetchTreshold: Int) { self.startPage = startPage self.currentPage = startPage self.prefetchTreshold = prefetchTreshold } + /// Starts a new page request, preventing concurrent requests. + /// - Returns: `true` if the request was started, `false` if a request is already in progress. func startRequest() -> Bool { guard isRequestInProcess == false else { return false @@ -32,32 +47,44 @@ actor PageRequestState?) { prefetchTask = task } + /// Cancels the current prefetch task, if any. func cancelPrefetchTask() { prefetchTask?.cancel() prefetchTask = nil } - + + /// Retrieves and removes the prefetched response for a specific page. + /// - Parameter page: The page number to retrieve. + /// - Returns: The prefetched `ResponseModel` if available, or `nil`. func getPrefetchedResponse(for page: Int) -> ResponseModel? { let result = prefetchedResponse[page] + prefetchedResponse.removeValue(forKey: page) prefetchedPages -= 1 return result } - + + /// Stores a prefetched response for a specific page. + /// - Parameters: + /// - items: The `ResponseModel` to store, or `nil` to clear. + /// - page: The page number associated with the response. func setPrefetchedItems(_ items: ResponseModel?, forPage page: Int) { prefetchedResponse[page] = items } - + + /// Sets whether a prefetch operation is pending. + /// - Parameter value: `true` if prefetching is pending, `false` otherwise. func setPrefetchPending(_ value: Bool) { isPrefetchPending = value } diff --git a/Sources/PagingList/PagingList.swift b/Sources/PagingList/PagingList.swift index 41f691d..1cbbc5f 100644 --- a/Sources/PagingList/PagingList.swift +++ b/Sources/PagingList/PagingList.swift @@ -64,7 +64,6 @@ public struct PagingList< } .refreshable(action: requestOnRefresh) .onDisappear { - // Останавливаем префетчинг при исчезновении списка NotificationCenter.default.post(name: .stopPrefetching, object: nil) } } @@ -109,7 +108,6 @@ public struct PagingList< } } -// Уведомление для остановки префетчинга extension Notification.Name { static let stopPrefetching = Notification.Name("StopPrefetching") } From 66a6aa9a818161708e0b1f0fc2f3fff2c2d41375 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Fri, 18 Apr 2025 17:52:37 +0300 Subject: [PATCH 15/19] [UPUP-1077]: add documentation for PageRequestService --- .../Sources/Repository/PostRepository.swift | 2 +- Sources/PagingList/PageRequestService.swift | 42 ++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostRepository.swift index a70fd3c..439ea63 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostRepository.swift @@ -41,7 +41,7 @@ final class PostRepository: Sendable { let mockExtension = "json" mockFileName = "\(mockFileName)&PI=\(page)&PS=\(pageSize)" - print(mockFileName) + guard let mockFileUrl = Bundle.main.url(forResource: mockFileName, withExtension: mockExtension) else { return nil } diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index e386581..a4a26b2 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -1,30 +1,47 @@ import Foundation -enum PagingError: Error { +/// Errors that can occur during paginated requests. +public enum PagingError: Error { + /// A request is already in progress. case requestInProgress + /// The response data is invalid or cannot be cast to the expected type. case invalidResponse } +/// A protocol defining the structure of a paginated response, including items and optional pagination metadata. public protocol PaginatedResponse: Codable, Sendable { + /// The type of items contained in the response, conforming to Codable and Sendable. associatedtype T: Codable, Sendable + + /// The list of items for the current page. var items: [T] { get } + /// Indicates whether more pages are available. var hasMore: Bool? { get } + /// The total number of pages, if known. var totalPages: Int? { get } + /// The current page number, if known. var currentPage: Int? { get } } +/// A service for managing paginated data requests, including prefetching upcoming pages. public final class PageRequestService: Sendable { private let state: PageRequestState private let fetchPage: @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel + /// Initializes the service with a starting page and a closure for fetching pages. + /// - Parameters: + /// - startPage: The initial page number (default is 1). + /// - prefetchTreshold: The maximum number of pages to prefetch ahead (default is 1). + /// - fetchPage: A closure that fetches a page of data asynchronously, returning a `PaginatedResponse`. public init( startPage: Int = 1, + prefetchTreshold: Int = 1, fetchPage: @escaping @Sendable (_ page: Int, _ pageSize: Int) async throws -> ResponseModel ) { - self.state = PageRequestState(startPage: startPage, prefetchTreshold: 2) + self.state = PageRequestState(startPage: startPage, prefetchTreshold: prefetchTreshold) self.fetchPage = fetchPage - // Подписываемся на уведомление для остановки префетчинга при закрытии экрана с PagingList + // Subscribe to a notification to stop prefetching when the PagingList screen is dismissed. NotificationCenter.default.addObserver( forName: .stopPrefetching, object: nil, @@ -38,6 +55,10 @@ public final class PageRequestService Bool { await state.canLoadMore } + /// Retrieves the current list of loaded items, excluding prefetched. + /// - Returns: An array of `DataModel` items. public func getItems() async -> [DataModel] { await state.items } + /// Retrieves the current UI state of the paginated list. + /// - Returns: The current `PagingListState`. public func getPagingState() async -> PagingListState { await state.pagingState } + /// Sets the UI state of the paginated list. + /// - Parameter pagingState: The new `PagingListState` to apply. public func setPagingState(pagingState: PagingListState) async { await state.setPagingState(pagingState) } From 6aed071c34fc85ffee2a8360040a390ad9821d54 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Tue, 22 Apr 2025 18:46:04 +0300 Subject: [PATCH 16/19] [UPUP-1077]: fix comments --- Example/Example.xcodeproj/project.pbxproj | 28 ++++++++++++++++--- .../Sources/Error/IntsRepositoryError.swift | 21 ++++++++++++++ .../Sources/Error/PostsRepositoryError.swift | 21 ++++++++++++++ Example/Sources/Model/PostExampleModel.swift | 8 +----- Example/Sources/Model/PostModel.swift | 12 ++++++++ .../Sources/Repository/IntsRepository.swift | 14 ---------- ...Repository.swift => PostsRepository.swift} | 20 ++----------- Example/Sources/UI/ContentView.swift | 6 ++-- .../ListWithPageRequestServiceView.swift | 16 ++++------- .../ListWithPageRequestServiceViewModel.swift | 8 +++--- .../Extension/NotificationName+Ext.swift | 12 ++++++++ Sources/PagingList/PageRequestService.swift | 20 ++++++++++--- Sources/PagingList/PageRequestState.swift | 8 ++++++ Sources/PagingList/PagingList.swift | 4 --- Sources/PagingList/PagingListState.swift | 20 ++++++++----- 15 files changed, 144 insertions(+), 74 deletions(-) create mode 100644 Example/Sources/Error/IntsRepositoryError.swift create mode 100644 Example/Sources/Error/PostsRepositoryError.swift create mode 100644 Example/Sources/Model/PostModel.swift rename Example/Sources/Repository/{PostRepository.swift => PostsRepository.swift} (75%) create mode 100644 Sources/PagingList/Extension/NotificationName+Ext.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index e7af5bb..1014a46 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -8,13 +8,16 @@ /* Begin PBXBuildFile section */ 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770A72DAF92E50043935A /* PostExampleModel.swift */; }; - 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770AA2DAFA1C00043935A /* PostRepository.swift */; }; + 084770AB2DAFA1C80043935A /* PostsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770AA2DAFA1C00043935A /* PostsRepository.swift */; }; 084770DC2DAFD9C50043935A /* MockPostExampleModel&PI=1&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */; }; 084770DD2DAFD9C50043935A /* MockPostExampleModel&PI=2&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */; }; 084770DE2DAFD9C50043935A /* MockPostExampleModel&PI=3&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */; }; 084770E22DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json in Resources */ = {isa = PBXBuildFile; fileRef = 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */; }; 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */; }; 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */; }; + 08D497D82DB7E4DB008C31C3 /* PostModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497D72DB7E4D7008C31C3 /* PostModel.swift */; }; + 08D497DA2DB7EA87008C31C3 /* PostsRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */; }; + 08D497DD2DB7EAD4008C31C3 /* IntsRepositoryError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */; }; 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */; }; 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */; }; 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */; }; @@ -28,13 +31,16 @@ /* Begin PBXFileReference section */ 084770A72DAF92E50043935A /* PostExampleModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExampleModel.swift; sourceTree = ""; }; - 084770AA2DAFA1C00043935A /* PostRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostRepository.swift; sourceTree = ""; }; + 084770AA2DAFA1C00043935A /* PostsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsRepository.swift; sourceTree = ""; }; 084770C82DAFA6710043935A /* MockPostExampleModel&PI=1&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=1&PS=10.json"; sourceTree = ""; }; 084770CC2DAFA7060043935A /* MockPostExampleModel&PI=2&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=2&PS=10.json"; sourceTree = ""; }; 084770CE2DAFA76F0043935A /* MockPostExampleModel&PI=3&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=3&PS=10.json"; sourceTree = ""; }; 084770E12DAFDB7A0043935A /* MockPostExampleModel&PI=4&PS=10.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "MockPostExampleModel&PI=4&PS=10.json"; sourceTree = ""; }; 084770E32DAFE0F90043935A /* ListWithPageRequestServiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceView.swift; sourceTree = ""; }; 084770E52DAFE18B0043935A /* ListWithPageRequestServiceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithPageRequestServiceViewModel.swift; sourceTree = ""; }; + 08D497D72DB7E4D7008C31C3 /* PostModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostModel.swift; sourceTree = ""; }; + 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsRepositoryError.swift; sourceTree = ""; }; + 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntsRepositoryError.swift; sourceTree = ""; }; 733AAF702CECDECA00FAB5AF /* ListWithSectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithSectionsView.swift; sourceTree = ""; }; 733AAF722CECEC6500FAB5AF /* ListStateViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStateViews.swift; sourceTree = ""; }; 73FE54672CEF6DEC00B6C83E /* ListWithoutSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListWithoutSectionView.swift; sourceTree = ""; }; @@ -63,7 +69,7 @@ isa = PBXGroup; children = ( E10FA355297A68830031ED65 /* IntsRepository.swift */, - 084770AA2DAFA1C00043935A /* PostRepository.swift */, + 084770AA2DAFA1C00043935A /* PostsRepository.swift */, ); path = Repository; sourceTree = ""; @@ -94,6 +100,7 @@ 084770D12DAFA7D60043935A /* Model */ = { isa = PBXGroup; children = ( + 08D497D72DB7E4D7008C31C3 /* PostModel.swift */, 084770A72DAF92E50043935A /* PostExampleModel.swift */, ); path = Model; @@ -108,6 +115,15 @@ path = ListWithPageRequestService; sourceTree = ""; }; + 08D497DB2DB7EACD008C31C3 /* Error */ = { + isa = PBXGroup; + children = ( + 08D497DC2DB7EAD2008C31C3 /* IntsRepositoryError.swift */, + 08D497D92DB7EA78008C31C3 /* PostsRepositoryError.swift */, + ); + path = Error; + sourceTree = ""; + }; E10FA35129770CBE0031ED65 /* Packages */ = { isa = PBXGroup; children = ( @@ -137,6 +153,7 @@ E1FDB14829770604003CC2A5 /* Sources */ = { isa = PBXGroup; children = ( + 08D497DB2DB7EACD008C31C3 /* Error */, 084770C72DAFA5250043935A /* Mock */, 084770D12DAFA7D60043935A /* Model */, 084770D02DAFA7BB0043935A /* UI */, @@ -266,13 +283,16 @@ 084770A82DAF92F60043935A /* PostExampleModel.swift in Sources */, E10FA356297A68830031ED65 /* IntsRepository.swift in Sources */, 733AAF712CECDECA00FAB5AF /* ListWithSectionsView.swift in Sources */, + 08D497DD2DB7EAD4008C31C3 /* IntsRepositoryError.swift in Sources */, E1FDB14C29770604003CC2A5 /* ContentView.swift in Sources */, 73FE54682CEF6DEC00B6C83E /* ListWithoutSectionView.swift in Sources */, - 084770AB2DAFA1C80043935A /* PostRepository.swift in Sources */, + 084770AB2DAFA1C80043935A /* PostsRepository.swift in Sources */, 084770E42DAFE1090043935A /* ListWithPageRequestServiceView.swift in Sources */, 733AAF732CECEC6500FAB5AF /* ListStateViews.swift in Sources */, + 08D497DA2DB7EA87008C31C3 /* PostsRepositoryError.swift in Sources */, E1FDB14A29770604003CC2A5 /* ExampleApp.swift in Sources */, 084770E62DAFE1A10043935A /* ListWithPageRequestServiceViewModel.swift in Sources */, + 08D497D82DB7E4DB008C31C3 /* PostModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Example/Sources/Error/IntsRepositoryError.swift b/Example/Sources/Error/IntsRepositoryError.swift new file mode 100644 index 0000000..814b693 --- /dev/null +++ b/Example/Sources/Error/IntsRepositoryError.swift @@ -0,0 +1,21 @@ +// +// IntsRepositoryError.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +enum IntsRepositoryError: Swift.Error { + case undefined +} + +extension IntsRepositoryError: LocalizedError { + var errorDescription: String? { + switch self { + case .undefined: + return "Undefined error" + } + } +} diff --git a/Example/Sources/Error/PostsRepositoryError.swift b/Example/Sources/Error/PostsRepositoryError.swift new file mode 100644 index 0000000..8a0cdb6 --- /dev/null +++ b/Example/Sources/Error/PostsRepositoryError.swift @@ -0,0 +1,21 @@ +// +// PostsRepositoryError.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +enum PostsRepositoryError: Swift.Error { + case undefined +} + +extension PostsRepositoryError: LocalizedError { + var errorDescription: String? { + switch self { + case .undefined: + return "Undefined error" + } + } +} diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift index 30a3c87..72f537b 100644 --- a/Example/Sources/Model/PostExampleModel.swift +++ b/Example/Sources/Model/PostExampleModel.swift @@ -9,7 +9,7 @@ import Foundation import PagingList struct PostExampleModel: PaginatedResponse { - let items: [Post] + let items: [PostModel] let hasMore: Bool? var totalPages: Int? var currentPage: Int? @@ -19,9 +19,3 @@ struct PostExampleModel: PaginatedResponse { case hasMore, totalPages, currentPage } } - -struct Post: Codable, Identifiable, Sendable { - let id: String - let title: String - let description: String -} diff --git a/Example/Sources/Model/PostModel.swift b/Example/Sources/Model/PostModel.swift new file mode 100644 index 0000000..c6cc5ff --- /dev/null +++ b/Example/Sources/Model/PostModel.swift @@ -0,0 +1,12 @@ +// +// PostModel.swift +// Example +// +// Created by Maria Nesterova on 22.04.2025. +// + +struct PostModel: Codable, Identifiable, Sendable { + let id: String + let title: String + let description: String +} diff --git a/Example/Sources/Repository/IntsRepository.swift b/Example/Sources/Repository/IntsRepository.swift index 5d875b4..16f43e4 100644 --- a/Example/Sources/Repository/IntsRepository.swift +++ b/Example/Sources/Repository/IntsRepository.swift @@ -7,25 +7,11 @@ import Foundation -enum IntsRepositoryError: Swift.Error { - case undefined -} - -extension IntsRepositoryError: LocalizedError { - var errorDescription: String? { - switch self { - case .undefined: - return "Ooops:(" - } - } -} - final class IntsRepository: Sendable { private enum Constants { static let delayInNanoseconds: UInt64 = 3_000_000_000 } - @Sendable func getItems(limit: Int, offset: Int) async throws -> [Int] { await Task { try? await Task.sleep(nanoseconds: Constants.delayInNanoseconds) diff --git a/Example/Sources/Repository/PostRepository.swift b/Example/Sources/Repository/PostsRepository.swift similarity index 75% rename from Example/Sources/Repository/PostRepository.swift rename to Example/Sources/Repository/PostsRepository.swift index 439ea63..05dc0aa 100644 --- a/Example/Sources/Repository/PostRepository.swift +++ b/Example/Sources/Repository/PostsRepository.swift @@ -1,5 +1,5 @@ // -// PostRepository.swift +// PostsRepository.swift // Example // // Created by Maria Nesterova on 16.04.2025. @@ -7,32 +7,18 @@ import Foundation -enum PostRepositoryError: Swift.Error { - case undefined -} - -extension PostRepositoryError: LocalizedError { - var errorDescription: String? { - switch self { - case .undefined: - return "Ooops:(" - } - } -} - -final class PostRepository: Sendable { +final class PostsRepository: Sendable { private enum Constants { static let delayInNanoseconds: UInt64 = 3_000_000_000 } - @Sendable func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { try await Task.sleep(nanoseconds: Constants.delayInNanoseconds) if let postExampleModel = getPostExampleData(page: page, pageSize: pageSize) { return postExampleModel } else { - throw PostRepositoryError.undefined + throw PostsRepositoryError.undefined } } diff --git a/Example/Sources/UI/ContentView.swift b/Example/Sources/UI/ContentView.swift index bc4c56e..2d1c327 100644 --- a/Example/Sources/UI/ContentView.swift +++ b/Example/Sources/UI/ContentView.swift @@ -25,7 +25,7 @@ struct ContentView: View { Button { navigationPath.append(.listWithSection) } label: { - Text("Tap to go list with section") + Text("Tap to go to list with section") .padding(20) } .background(.gray) @@ -34,7 +34,7 @@ struct ContentView: View { Button { navigationPath.append(.listWithoutSection) } label: { - Text("Tap to go list without section") + Text("Tap to go to list without section") .padding(20) } .background(.gray) @@ -43,7 +43,7 @@ struct ContentView: View { Button { navigationPath.append(.listWithPageRequestService) } label: { - Text("Tap to go list with PageRequestService") + Text("Tap to go to list with PageRequestService") .padding(20) } .background(.gray) diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift index 95fc019..0f2ef74 100644 --- a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift @@ -9,9 +9,9 @@ import SwiftUI import PagingList struct ListWithPageRequestServiceView: View { - @ObservedObject private var viewModel = ListWithPageRequestServiceViewModel() + @StateObject private var viewModel = ListWithPageRequestServiceViewModel() - private let repository = PostRepository() + private let repository = PostsRepository() // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { @@ -53,16 +53,14 @@ struct ListWithPageRequestServiceView: View { } .listStyle(.plain) .onAppear { - if viewModel.canLoadMore && viewModel.pagingState == .fullscreenLoading { - viewModel.requestPosts(isFirst: true) - } + viewModel.requestPosts(isFirst: true) } } // swiftlint:enable vertical_parameter_alignment_on_call } private struct PostView: View { - let post: Post + let post: PostModel var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -74,8 +72,6 @@ private struct PostView: View { } } -struct ListWithPageRequestServiceView_Previews: PreviewProvider { - static var previews: some View { - ListWithPageRequestServiceView() - } +#Preview { + ListWithPageRequestServiceView() } diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift index 339d822..9c25cdb 100644 --- a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceViewModel.swift @@ -14,15 +14,15 @@ final class ListWithPageRequestServiceViewModel: ObservableObject { static let requestLimit = 10 } - @Published var posts: [Post] = [] + @Published var posts: [PostModel] = [] @Published var pagingState: PagingListState = .fullscreenLoading @Published var canLoadMore: Bool = true - var pageRequestService: PageRequestService + var pageRequestService: PageRequestService - private let postRepository: PostRepository + private let postRepository: PostsRepository - init(postRepository: PostRepository = PostRepository()) { + init(postRepository: PostsRepository = PostsRepository()) { self.postRepository = postRepository pageRequestService = PageRequestService(startPage: 1, fetchPage: postRepository.getPosts(page:pageSize:)) } diff --git a/Sources/PagingList/Extension/NotificationName+Ext.swift b/Sources/PagingList/Extension/NotificationName+Ext.swift new file mode 100644 index 0000000..a9914e5 --- /dev/null +++ b/Sources/PagingList/Extension/NotificationName+Ext.swift @@ -0,0 +1,12 @@ +// +// NotificationName+Ext.swift +// PagingList +// +// Created by Maria Nesterova on 22.04.2025. +// + +import Foundation + +extension Notification.Name { + static let stopPrefetching = Notification.Name("StopPrefetching") +} diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index a4a26b2..abc90c4 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -2,23 +2,29 @@ import Foundation /// Errors that can occur during paginated requests. public enum PagingError: Error { + /// A request is already in progress. case requestInProgress + /// The response data is invalid or cannot be cast to the expected type. case invalidResponse } /// A protocol defining the structure of a paginated response, including items and optional pagination metadata. public protocol PaginatedResponse: Codable, Sendable { + /// The type of items contained in the response, conforming to Codable and Sendable. associatedtype T: Codable, Sendable /// The list of items for the current page. var items: [T] { get } + /// Indicates whether more pages are available. var hasMore: Bool? { get } + /// The total number of pages, if known. var totalPages: Int? { get } + /// The current page number, if known. var currentPage: Int? { get } } @@ -64,8 +70,6 @@ public final class PageRequestService { let startPage: Int + /// The maximum page number that has been prefetched, if any. var maxPrefetchedPage: Int? + /// Indicates whether more pages can be prefetched based on the prefetch threshold. var canPrefetchMore: Bool { return prefetchedPages < prefetchTreshold } private(set) var currentPage: Int + /// Indicates whether more pages can be loaded, related to current page on the screen for requests to continue. private(set) var canLoadMore: Bool = true + /// Indicates whether more pages can be loaded, related to already prefetched pages. private(set) var canLoadMorePrefetch: Bool = true + /// The list of items loaded so far. private(set) var items: [DataModel] = [] + /// The current UI state of the paginated list (e.g., loading, error, items). private(set) var pagingState: PagingListState = .fullscreenLoading + /// Indicates whether a page request is currently in progress. private(set) var isRequestInProcess: Bool = false + /// Indicates whether a prefetch operation is pending. private(set) var isPrefetchPending: Bool = false diff --git a/Sources/PagingList/PagingList.swift b/Sources/PagingList/PagingList.swift index 1cbbc5f..370934f 100644 --- a/Sources/PagingList/PagingList.swift +++ b/Sources/PagingList/PagingList.swift @@ -107,7 +107,3 @@ public struct PagingList< await onRefreshRequest() } } - -extension Notification.Name { - static let stopPrefetching = Notification.Name("StopPrefetching") -} diff --git a/Sources/PagingList/PagingListState.swift b/Sources/PagingList/PagingListState.swift index dabf93a..a7fc1ca 100644 --- a/Sources/PagingList/PagingListState.swift +++ b/Sources/PagingList/PagingListState.swift @@ -1,19 +1,25 @@ import Foundation public enum PagingListState: Sendable { - // Paging disabled. + /// Paging disabled. case disabled - // Cells are visible. No loading next items here. + + /// Cells are visible. No loading next items here. case items - // Fullscreen initial data loading(first page). + + /// Fullscreen initial data loading(first page). case fullscreenLoading - // Fullscreen error on loading first page. + + /// Fullscreen error on loading first page. case fullscreenError(Error) - // Loading next page(> 1). Next page loading cell is visible here at the bottom fo the list. + + /// Loading next page(> 1). Next page loading cell is visible here at the bottom fo the list. case pagingLoading - // Error on next page loading. Next page error cell is visible here at the bottom of the list. + + /// Error on next page loading. Next page error cell is visible here at the bottom of the list. case pagingError(Error) - // Updating content with pull to refresh + + /// Updating content with pull to refresh case refresh } From 1da88670fe0ba416ab66db68d8d235cb2e32f83a Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 23 Apr 2025 11:47:39 +0300 Subject: [PATCH 17/19] [UPUP-1077]: update SwiftLint --- Example/Example.xcodeproj/project.pbxproj | 2 +- Example/Sources/Model/PostExampleModel.swift | 1 + .../Sources/Repository/PostsRepository.swift | 1 + .../ListWithPageRequestServiceView.swift | 2 - Example/Sources/UI/ListWithSectionsView.swift | 2 - .../Sources/UI/ListWithoutSectionView.swift | 4 +- Sources/PagingList/PageRequestService.swift | 4 +- Sources/PagingList/PageRequestState.swift | 2 +- swiftlint.yml | 100 +++++++++++++++--- 9 files changed, 91 insertions(+), 27 deletions(-) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 1014a46..c5b1e49 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -271,7 +271,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint >/dev/null; then\n swiftlint --path .. --config ../swiftlint.yml\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "export PATH=\"$HOME/.mint/bin:/opt/homebrew/bin:$PATH\"\nexpected_swiflint_version=\"0.58.2\"\n\nif (mint list | grep \"$expected_swiflint_version\"); then\n mint install realm/SwiftLint@\"$expected_swiflint_version\"\n swiftlint lint --config ../swiftlint.yml ..\nelse\n echo \"SwiftLint $expected_swiflint_version is not installed\"\n echo \"run the command in the terminal: mint install realm/SwiftLint@$expected_swiflint_version\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Example/Sources/Model/PostExampleModel.swift b/Example/Sources/Model/PostExampleModel.swift index 72f537b..e3ee9c0 100644 --- a/Example/Sources/Model/PostExampleModel.swift +++ b/Example/Sources/Model/PostExampleModel.swift @@ -10,6 +10,7 @@ import PagingList struct PostExampleModel: PaginatedResponse { let items: [PostModel] + // swiftlint:disable:next discouraged_optional_boolean let hasMore: Bool? var totalPages: Int? var currentPage: Int? diff --git a/Example/Sources/Repository/PostsRepository.swift b/Example/Sources/Repository/PostsRepository.swift index 05dc0aa..a6730cd 100644 --- a/Example/Sources/Repository/PostsRepository.swift +++ b/Example/Sources/Repository/PostsRepository.swift @@ -12,6 +12,7 @@ final class PostsRepository: Sendable { static let delayInNanoseconds: UInt64 = 3_000_000_000 } + @Sendable func getPosts(page: Int, pageSize: Int) async throws -> PostExampleModel { try await Task.sleep(nanoseconds: Constants.delayInNanoseconds) diff --git a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift index 0f2ef74..e884db7 100644 --- a/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift +++ b/Example/Sources/UI/ListWithPageRequestService/ListWithPageRequestServiceView.swift @@ -13,7 +13,6 @@ struct ListWithPageRequestServiceView: View { private let repository = PostsRepository() - // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( state: $viewModel.pagingState, @@ -56,7 +55,6 @@ struct ListWithPageRequestServiceView: View { viewModel.requestPosts(isFirst: true) } } - // swiftlint:enable vertical_parameter_alignment_on_call } private struct PostView: View { diff --git a/Example/Sources/UI/ListWithSectionsView.swift b/Example/Sources/UI/ListWithSectionsView.swift index 5094e64..041eefd 100644 --- a/Example/Sources/UI/ListWithSectionsView.swift +++ b/Example/Sources/UI/ListWithSectionsView.swift @@ -25,7 +25,6 @@ struct ListWithSectionsView: View { private let repository = IntsRepository() - // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( state: $pagingState, @@ -84,7 +83,6 @@ struct ListWithSectionsView: View { requestItems(isFirst: true) } } - // swiftlint:enable vertical_parameter_alignment_on_call // Sync method for first loading and pagination loading content. private func requestItems(isFirst: Bool) { diff --git a/Example/Sources/UI/ListWithoutSectionView.swift b/Example/Sources/UI/ListWithoutSectionView.swift index 478c17c..70509eb 100644 --- a/Example/Sources/UI/ListWithoutSectionView.swift +++ b/Example/Sources/UI/ListWithoutSectionView.swift @@ -14,12 +14,11 @@ struct ListWithoutSectionView: View { } @State private var loadedPagesCount = 0 - @State private var items = [Int]() + @State private var items: [Int] = [] @State private var pagingState: PagingListState = .items private let repository = IntsRepository() - // swiftlint:disable vertical_parameter_alignment_on_call var body: some View { PagingList( state: $pagingState, @@ -62,7 +61,6 @@ struct ListWithoutSectionView: View { requestItems(isFirst: true) } } - // swiftlint:enable vertical_parameter_alignment_on_call // Sync method for first loading and pagination loading content. private func requestItems(isFirst: Bool) { diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index abc90c4..7f9e139 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -2,7 +2,6 @@ import Foundation /// Errors that can occur during paginated requests. public enum PagingError: Error { - /// A request is already in progress. case requestInProgress @@ -12,15 +11,16 @@ public enum PagingError: Error { /// A protocol defining the structure of a paginated response, including items and optional pagination metadata. public protocol PaginatedResponse: Codable, Sendable { - /// The type of items contained in the response, conforming to Codable and Sendable. associatedtype T: Codable, Sendable /// The list of items for the current page. var items: [T] { get } + // swiftlint:disable discouraged_optional_boolean /// Indicates whether more pages are available. var hasMore: Bool? { get } + // swiftlint:enable discouraged_optional_boolean /// The total number of pages, if known. var totalPages: Int? { get } diff --git a/Sources/PagingList/PageRequestState.swift b/Sources/PagingList/PageRequestState.swift index 3717f6c..d673809 100644 --- a/Sources/PagingList/PageRequestState.swift +++ b/Sources/PagingList/PageRequestState.swift @@ -1,4 +1,4 @@ -/// A thread-safe actor that manages the state of paginated requests, including current page, items, and prefetching logic. +/// A thread-safe actor that manages state of paginated requests, including current page, items, and prefetching logic. actor PageRequestState { let startPage: Int diff --git a/swiftlint.yml b/swiftlint.yml index cc4e3c5..27324ed 100644 --- a/swiftlint.yml +++ b/swiftlint.yml @@ -4,13 +4,9 @@ disabled_rules: - type_body_length - notification_center_detachment - generic_type_name - + opt_in_rules: - - anyobject_protocol - array_init - - block_based_kvo - - class_delegate_protocol - - closing_brace - closure_body_length - closure_end_indentation - closure_spacing @@ -20,17 +16,14 @@ opt_in_rules: - contains_over_filter_is_empty - contains_over_first_not_nil - contains_over_range_nil_comparison - - compiler_protocol_init - - custom_rules + - convenience_type - discouraged_optional_boolean - discouraged_object_literal - - duplicate_imports - empty_collection_literal - empty_count - empty_string - empty_xctest_method - explicit_init - - explicit_self - fallthrough - fatal_error_message - first_where @@ -41,7 +34,6 @@ opt_in_rules: - joined_default_parameter - last_where - legacy_multiple - - legacy_random - literal_expression_end_indentation - lower_acl_than_parent - multiline_arguments @@ -49,6 +41,7 @@ opt_in_rules: - multiline_literal_brackets - multiline_parameters - nimble_operator + - non_overridable_class_declaration - nslocalizedstring_key - number_separator - object_literal @@ -60,6 +53,7 @@ opt_in_rules: - private_outlet - prefer_zero_over_explicit_init - prohibited_super_call + - prohibited_interface_builder - quick_discouraged_call - quick_discouraged_focused_test - quick_discouraged_pending_test @@ -73,8 +67,6 @@ opt_in_rules: - strong_iboutlet - toggle_bool - unneeded_parentheses_in_closure_argument - - unused_declaration - - unused_import - vertical_parameter_alignment_on_call - vertical_whitespace_closing_braces - xct_specific_matcher @@ -89,6 +81,15 @@ excluded: - Source/UI/Extensions/UIFont+extension.swift - Source/Resources/R.generated.swift +analyzer_rules: + - explicit_self + - unused_declaration + - unused_import + +opening_brace: + severity: warning + ignore_multiline_statement_conditions: true + force_cast: warning force_unwrapping: warning force_try: warning @@ -104,8 +105,8 @@ file_length: error: 500 closure_body_length: - warning: 30 - error: 30 + warning: 40 + error: 40 type_name: min_length: 1 @@ -131,7 +132,74 @@ nesting: type_level: warning: 4 error: 8 - -warning_threshold: 50 + +custom_rules: + # Избегаем многострочных комментариев /* ... */ + no_multiline_comments: + name: "No Multiline Comments" + regex: "/\\*[^*]*\\*+([^/*][^*]*\\*+)*/" + match_kinds: comment + message: "Use // instead of /* ... */ for comments." + severity: warning + + # Избегаем !, используем == false + no_negation_in_conditions: + name: "No Negation in Conditions" + regex: "(if|guard|while)\\s+([^\\{]*\\s+)?![a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)*\\s*([^\\{]*)\\s*(guard\\s+.*\\s+else\\s*)?\\{" + message: "Use '== false' instead of '!' in conditions (e.g., 'if name.isEmpty == false')." + severity: warning + + # Избегаем _Protocol_ в названиях протоколов + no_protocol_suffix: + name: "No 'protocol' in Protocol Names" + regex: "protocol\\s+[a-zA-Z0-9_]*?(?i)protocol(?-i)[a-zA-Z0-9_]*" + message: "Avoid using 'protocol' (in any case) in protocol names." + severity: warning + + # Избегаем configure/set в именах методов, используем setup + no_configure_or_set_in_methods: + name: "No 'configure' or 'set' in Method Names" + regex: "(func|private\\s+func)\\s+\\b(configure|set)\\b[a-zA-Z0-9_]*\\(" + excluded: + - ".*(?i)(service|provider).*" # Исключает файлы с Service или Provider в имени типа + message: "Use 'setup' instead of 'configure' or 'set' in method names." + severity: warning + + # Используем [] вместо [Type]() + no_type_init_for_arrays: + name: "No Type Init for Arrays" + regex: "(let|var)\\s+[a-zA-Z0-9_]+\\s*=\\s*\\[+[a-zA-Z0-9_]+(?:\\.[a-zA-Z0-9_]+)*\\]+\\(\\)" + message: "Use '[]' instead of '[Type]()' for array initialization." + severity: warning + + # Избегание unowned + no_unowned_in_closures: + name: "No Unowned in Closures" + regex: "\\[unowned\\s+self\\]" + message: "Use '[weak self]' instead of 'unowned' in closures." + severity: error + + # Свойства с get, set выделяем в приватные методы, если логика требует более одной строки + property_get_set_single_line: + name: "Property Get/Set Single Line" + regex: "^\\s*(var|let)\\s+[a-zA-Z0-9_]+\\s*:\\s*[a-zA-Z0-9_]+\\s*\\{\\s*(get|set)\\s*\\{[^}]*\\n[^}]*\\}" + message: "Properties with get/set should be single-line. Extract multi-line logic to a private method." + severity: warning + + # Свойства с willSet, didSet выделяем в приватные методы, если логика требует более одной строки + property_willset_didset_single_line: + name: "Property WillSet/DidSet Single Line" + regex: "(willSet|didSet)\\s*\\{[^}]*\\n[^}]*\\}" + message: "WillSet and DidSet should be single-line. Extract multi-line logic to a private method." + severity: warning + + # Избегаем эмодзи в коде + no_emoji: + name: "No Emoji" + regex: "[\\x{1F300}-\\x{1F5FF}\\x{1F600}-\\x{1F64F}\\x{1F680}-\\x{1F6FF}\\x{1F900}-\\x{1F9FF}]" + message: "Do not use emoji in code." + severity: warning + +warning_threshold: 1 reporter: "xcode" From 189ca6dcb9a56162b50aa5437e7df51fe4144d74 Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 23 Apr 2025 12:58:33 +0300 Subject: [PATCH 18/19] [UPUP-1077]: update refresh logic --- Sources/PagingList/PageRequestService.swift | 6 +----- Sources/PagingList/PageRequestState.swift | 8 +++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index 7f9e139..e7a5549 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -66,7 +66,7 @@ public final class PageRequestService Bool { + func startRequest(isFirst: Bool) -> Bool { guard isRequestInProcess == false else { return false } + if isFirst { + currentPage = startPage + cancelPrefetchTask() + resetPrefetchedPages() + } + isRequestInProcess = true return isRequestInProcess From 6fd4da9f674fdb2aa78a1b2203edc866a9ad27fa Mon Sep 17 00:00:00 2001 From: "maria.nesterova" Date: Wed, 23 Apr 2025 13:44:10 +0300 Subject: [PATCH 19/19] [UPUP-1077]: update request cancellation --- Sources/PagingList/PageRequestService.swift | 68 ++++++++++++--------- Sources/PagingList/PageRequestState.swift | 29 ++++++--- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/Sources/PagingList/PageRequestService.swift b/Sources/PagingList/PageRequestService.swift index e7a5549..03a003e 100644 --- a/Sources/PagingList/PageRequestService.swift +++ b/Sources/PagingList/PageRequestService.swift @@ -73,40 +73,49 @@ public final class PageRequestService? private var prefetchedPages: Int = 0 private var prefetchedResponse: [Int: ResponseModel] = [:] private var prefetchTask: Task? @@ -46,16 +47,19 @@ actor PageRequestState Bool { - guard isRequestInProcess == false else { - return false - } - if isFirst { + items.removeAll() + cancelRequestTask() + endRequest() currentPage = startPage cancelPrefetchTask() resetPrefetchedPages() } + guard isRequestInProcess == false else { + return false + } + isRequestInProcess = true return isRequestInProcess @@ -99,11 +103,7 @@ actor PageRequestState?) { + requestTask = task + } + /// Sets the task responsible for prefetching pages. /// - Parameter task: The `Task` to set, or `nil` to clear. func setPrefetchTask(_ task: Task?) { @@ -156,4 +162,9 @@ actor PageRequestState