Merge pull request #1117 from kiwix/1116-ios-library-scroll-no-viewmodifier-fix

Fix library scroll issue - part 1
This commit is contained in:
Kelson 2025-02-18 23:52:37 +01:00 committed by GitHub
commit c5c76cb14f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 215 additions and 97 deletions

View File

@ -0,0 +1,36 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
struct ArticleActions: View {
let zimFileID: UUID
var body: some View {
AsyncButton {
guard let url = await ZimFileService.shared.getMainPageURL(zimFileID: zimFileID) else { return }
NotificationCenter.openURL(url, inNewTab: true)
} label: {
Label(LocalString.library_zim_file_context_main_page_label, systemImage: "house")
}
AsyncButton {
guard let url = await ZimFileService.shared.getRandomPageURL(zimFileID: zimFileID) else { return }
NotificationCenter.openURL(url, inNewTab: true)
} label: {
Label(LocalString.library_zim_file_context_random_label, systemImage: "die.face.5")
}
}
}

View File

@ -0,0 +1,35 @@
// This file is part of Kiwix for iOS & macOS.
//
// Kiwix is free software; you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// any later version.
//
// Kiwix is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
import UniformTypeIdentifiers
struct CopyPasteMenu: View {
let downloadURL: URL
var body: some View {
Button {
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(downloadURL.absoluteString, forType: .string)
#elseif os(iOS)
UIPasteboard.general.setValue(downloadURL.absoluteString, forPasteboardType: UTType.url.identifier)
#endif
} label: {
Label(LocalString.library_zim_file_context_copy_url, systemImage: "doc.on.doc")
}
}
}

View File

@ -112,70 +112,47 @@ struct LibraryZimFileDetailSidePanel: ViewModifier {
/// On macOS, converts the modified view to a Button that modifies the currently selected zim file
/// On iOS, converts the modified view to a NavigationLink that goes to the zim file detail.
struct LibraryZimFileContext: ViewModifier {
struct LibraryZimFileContext<Content: View>: View {
@EnvironmentObject private var viewModel: LibraryViewModel
@EnvironmentObject private var navigation: NavigationViewModel
let zimFile: ZimFile
let dismiss: (() -> Void)? // iOS only
init(zimFile: ZimFile, dismiss: (() -> Void)?) {
private let content: Content
private let zimFile: ZimFile
/// iOS only
private let dismiss: (() -> Void)?
init(
@ViewBuilder content: () -> Content,
zimFile: ZimFile,
dismiss: (() -> Void)? = nil
) {
self.content = content()
self.zimFile = zimFile
self.dismiss = dismiss
}
func body(content: Content) -> some View {
var body: some View {
Group {
#if os(macOS)
#if os(macOS)
Button {
viewModel.selectedZimFile = zimFile
} label: {
content
}.buttonStyle(.plain)
#elseif os(iOS)
#elseif os(iOS)
NavigationLink {
ZimFileDetail(zimFile: zimFile, dismissParent: dismiss)
} label: {
content
}
#endif
#endif
}.contextMenu {
if zimFile.fileURLBookmark != nil, !zimFile.isMissing {
Section { articleActions }
Section { ArticleActions(zimFileID: zimFile.fileID) }
}
Section { supplementaryActions }
}
}
@ViewBuilder
var articleActions: some View {
AsyncButton {
guard let url = await ZimFileService.shared.getMainPageURL(zimFileID: zimFile.fileID) else { return }
NotificationCenter.openURL(url, inNewTab: true)
} label: {
Label(LocalString.library_zim_file_context_main_page_label, systemImage: "house")
}
AsyncButton {
guard let url = await ZimFileService.shared.getRandomPageURL(zimFileID: zimFile.fileID) else { return }
NotificationCenter.openURL(url, inNewTab: true)
} label: {
Label(LocalString.library_zim_file_context_random_label, systemImage: "die.face.5")
}
}
@ViewBuilder
var supplementaryActions: some View {
if let downloadURL = zimFile.downloadURL {
Button {
#if os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(downloadURL.absoluteString, forType: .string)
#elseif os(iOS)
UIPasteboard.general.setValue(downloadURL.absoluteString, forPasteboardType: UTType.url.identifier)
#endif
} label: {
Label(LocalString.library_zim_file_context_copy_url, systemImage: "doc.on.doc")
if let downloadURL = zimFile.downloadURL {
Section { CopyPasteMenu(downloadURL: downloadURL) }
}
}
}
}

View File

@ -139,14 +139,18 @@ private struct CategoryGrid: View {
ForEach(sections) { section in
if sections.count <= 1 {
ForEach(section) { zimFile in
ZimFileCell(zimFile, prominent: .size)
.modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss))
LibraryZimFileContext(
content: { ZimFileCell(zimFile, prominent: .size) },
zimFile: zimFile,
dismiss: dismiss)
}
} else {
Section {
ForEach(section) { zimFile in
ZimFileCell(zimFile, prominent: .size)
.modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss))
LibraryZimFileContext(
content: { ZimFileCell(zimFile, prominent: .size) },
zimFile: zimFile,
dismiss: dismiss)
}
} header: {
SectionHeader(
@ -244,8 +248,10 @@ private struct CategoryList: View {
}
} else {
List(zimFiles, id: \.self, selection: $viewModel.selectedZimFile) { zimFile in
ZimFileRow(zimFile)
.modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss))
LibraryZimFileContext(
content: { ZimFileRow(zimFile) },
zimFile: zimFile,
dismiss: dismiss)
}
#if os(macOS)
.listStyle(.inset)

View File

@ -35,10 +35,11 @@ struct ZimFilesDownloads: View {
alignment: .leading,
spacing: 12
) {
ForEach(downloadTasks) { downloadTask in
if let zimFile = downloadTask.zimFile {
DownloadTaskCell(zimFile).modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss))
}
ForEach(downloadTasks.compactMap(\.zimFile)) { zimFile in
LibraryZimFileContext(
content: { DownloadTaskCell(zimFile) },
zimFile: zimFile,
dismiss: dismiss)
}
}
.modifier(GridCommon())

View File

@ -14,26 +14,93 @@
// along with Kiwix; If not, see https://www.gnu.org/licenses/.
import SwiftUI
import Defaults
private final class ViewModel: ObservableObject {
@Published private(set) var zimFiles: [ZimFile] = []
private var languageCodes = Set<String>()
private var searchText: String = ""
private let sortDescriptors = [
NSSortDescriptor(keyPath: \ZimFile.created, ascending: false),
NSSortDescriptor(keyPath: \ZimFile.name, ascending: true),
NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)
]
func update(languageCodes: Set<String>) {
guard languageCodes != self.languageCodes else { return }
self.languageCodes = languageCodes
Task {
await update()
}
}
func update(searchText: String) {
guard searchText != self.searchText else { return }
self.searchText = searchText
Task {
await update()
}
}
func update() async {
let searchText = self.searchText
let languageCodes = self.languageCodes
let newZimFiles: [ZimFile] = await withCheckedContinuation { continuation in
Database.shared.performBackgroundTask { context in
let predicate: NSPredicate = Self.buildPredicate(
searchText: searchText,
languageCodes: languageCodes
)
if let results = try? context.fetch(
ZimFile.fetchRequest(
predicate: predicate,
sortDescriptors: self.sortDescriptors
)
) {
continuation.resume(returning: results)
} else {
continuation.resume(returning: [])
}
}
}
await MainActor.run {
withAnimation(.easeInOut) {
self.zimFiles = newZimFiles
}
}
}
private static func buildPredicate(searchText: String, languageCodes: Set<String>) -> NSPredicate {
var predicates = [
NSPredicate(format: "languageCode IN %@", languageCodes),
NSPredicate(format: "requiresServiceWorkers == false")
]
if let aMonthAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) {
predicates.append(NSPredicate(format: "created > %@", aMonthAgo as CVarArg))
}
if !searchText.isEmpty {
predicates.append(
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "name CONTAINS[cd] %@", searchText),
NSPredicate(format: "fileDescription CONTAINS[cd] %@", searchText)
])
)
}
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
/// A grid of zim files that are newly available.
struct ZimFilesNew: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject var viewModel: LibraryViewModel
@EnvironmentObject var library: LibraryViewModel
@Default(.libraryLanguageCodes) private var languageCodes
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \ZimFile.created, ascending: false),
NSSortDescriptor(keyPath: \ZimFile.name, ascending: true),
NSSortDescriptor(keyPath: \ZimFile.size, ascending: false)
],
animation: .easeInOut
) private var zimFiles: FetchedResults<ZimFile>
@StateObject private var viewModel = ViewModel()
@State private var searchText = ""
private var filterPredicate: NSPredicate {
ZimFilesNew.buildPredicate(searchText: searchText)
}
let dismiss: (() -> Void)? // iOS only
var body: some View {
@ -42,9 +109,14 @@ struct ZimFilesNew: View {
alignment: .leading,
spacing: 12
) {
ForEach(zimFiles.filter { filterPredicate.evaluate(with: $0) }) { zimFile in
ZimFileCell(zimFile, prominent: .name)
.modifier(LibraryZimFileContext(zimFile: zimFile, dismiss: dismiss))
ForEach(viewModel.zimFiles, id: \.fileID) { zimFile in
LibraryZimFileContext(
content: {
ZimFileCell(zimFile, prominent: .name)
},
zimFile: zimFile,
dismiss: dismiss)
.transition(AnyTransition.opacity)
}
}
.modifier(GridCommon())
@ -52,11 +124,19 @@ struct ZimFilesNew: View {
.navigationTitle(NavigationItem.new.name)
.searchable(text: $searchText)
.onAppear {
viewModel.start(isUserInitiated: false)
viewModel.update(searchText: searchText)
viewModel.update(languageCodes: languageCodes)
library.start(isUserInitiated: false)
}
.onChange(of: searchText) { newSearchText in
viewModel.update(searchText: newSearchText)
}
.onChange(of: languageCodes) { newLanguageCodes in
viewModel.update(languageCodes: newLanguageCodes)
}
.overlay {
if zimFiles.isEmpty {
switch viewModel.state {
if viewModel.zimFiles.isEmpty {
switch library.state {
case .inProgress:
Message(text: LocalString.zim_file_catalog_fetching_message)
case .error:
@ -80,14 +160,14 @@ struct ZimFilesNew: View {
}
#endif
ToolbarItem {
if viewModel.state == .inProgress {
if library.state == .inProgress {
ProgressView()
#if os(macOS)
.scaleEffect(0.5)
#endif
} else {
Button {
viewModel.start(isUserInitiated: true)
library.start(isUserInitiated: true)
} label: {
Label(LocalString.zim_file_new_button_refresh,
systemImage: "arrow.triangle.2.circlepath.circle")
@ -96,25 +176,6 @@ struct ZimFilesNew: View {
}
}
}
private static func buildPredicate(searchText: String) -> NSPredicate {
var predicates = [
NSPredicate(format: "languageCode IN %@", Defaults[.libraryLanguageCodes]),
NSPredicate(format: "requiresServiceWorkers == false")
]
if let aMonthAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) {
predicates.append(NSPredicate(format: "created > %@", aMonthAgo as CVarArg))
}
if !searchText.isEmpty {
predicates.append(
NSCompoundPredicate(orPredicateWithSubpredicates: [
NSPredicate(format: "name CONTAINS[cd] %@", searchText),
NSPredicate(format: "fileDescription CONTAINS[cd] %@", searchText)
])
)
}
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
}
@available(macOS 13.0, iOS 16.0, *)

View File

@ -35,8 +35,10 @@ struct ZimFilesOpened: View {
spacing: 12
) {
ForEach(zimFiles) { zimFile in
ZimFileCell(zimFile, prominent: .name).modifier(LibraryZimFileContext(zimFile: zimFile,
dismiss: self.dismiss))
LibraryZimFileContext(
content: { ZimFileCell(zimFile, prominent: .name) },
zimFile: zimFile,
dismiss: dismiss)
}
}
.modifier(GridCommon(edges: .all))