Grid improvements for infinite scroll

This commit is contained in:
Jackson Harper 2023-12-27 16:04:32 +08:00
parent f47e53f56b
commit ca511c3041
5 changed files with 171 additions and 121 deletions

View file

@ -137,7 +137,6 @@ import Views
// this can occur if there are non-contiguous items in our list causing older
// items to be synced back into those "holes" in the list
if let cursor = cursor, let currentCursor = Int(cursor) {
print("IDX: ", idx, " CURSOR: ", cursor, " CURRENT CURSOR: ", currentCursor)
if currentCursor > idx {
useCursor = currentCursor.description
}

View file

@ -62,11 +62,6 @@ struct LibraryItemGridCardNavigationLink: View {
@State private var scale = 1.0
@ObservedObject var item: Models.LibraryItem
let actionHandler: (GridCardAction) -> Void
@Binding var isContextMenuOpen: Bool
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
@ -83,19 +78,12 @@ struct LibraryItemGridCardNavigationLink: View {
isPDF: item.isPDF
)
}, label: {
GridCard(item: LibraryItemData.make(from: item), isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
GridCard(item: LibraryItemData.make(from: item))
}
)
.buttonStyle(.plain)
.aspectRatio(1.0, contentMode: .fill)
.background(
Color.secondarySystemGroupedBackground
.onTapGesture {
if isContextMenuOpen {
isContextMenuOpen = false
}
}
)
.background(Color.systemBackground)
.cornerRadius(6)
}
}

View file

@ -876,6 +876,10 @@ struct AnimatingCellHeight: AnimatableModifier {
FiltersHeader(viewModel: viewModel)
}
func menuItems(for item: Models.LibraryItem) -> some View {
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
}
var body: some View {
VStack(alignment: .leading) {
Color.systemBackground.frame(height: 1)
@ -896,39 +900,37 @@ struct AnimatingCellHeight: AnimatableModifier {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
if viewModel.showLoadingBar {
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: { _ in
})
ForEach(fakeLibraryItems(dataService: dataService), id: \.id) { item in
GridCard(item: item)
.aspectRatio(1.0, contentMode: .fill)
.background(
Color.secondarySystemGroupedBackground
.onTapGesture {
if isContextMenuOpen {
isContextMenuOpen = false
}
}
)
.background(Color.systemBackground)
.cornerRadius(6)
}.redacted(reason: .placeholder)
} else {
ForEach(viewModel.fetcher.items) { item in
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.id) { idx, item in
LibraryItemGridCardNavigationLink(
item: item,
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
isContextMenuOpen: $isContextMenuOpen,
viewModel: viewModel
)
.contextMenu {
menuItems(for: item)
}
.onAppear {
if idx >= viewModel.fetcher.items.count - 5 {
Task {
await viewModel.loadMore(dataService: dataService)
}
}
}
}
}
BottomView(viewModel: viewModel)
Spacer()
}
.frame(maxHeight: .infinity)
.padding()
.background(
GeometryReader {
Color(.systemGroupedBackground).preference(
Color(.systemBackground).preference(
key: ScrollViewOffsetPreferenceKey.self,
value: $0.frame(in: .global).origin.y
)
@ -942,11 +944,17 @@ struct AnimatingCellHeight: AnimatableModifier {
}
}
HStack {
Spacer()
BottomView(viewModel: viewModel).frame(maxWidth: 300)
Spacer()
}
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
LoadingSection()
}
}
.background(Color(.systemGroupedBackground))
.background(Color(.systemBackground))
Spacer()
}

View file

@ -8,11 +8,9 @@ import Views
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
let filterKey: String
@ObservedObject var fetcher: LibraryItemFetcher
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
let folderConfigs: [String: LibraryListConfig]
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: Models.LibraryItem?
@Published var itemUnderTitleEdit: Models.LibraryItem?
@Published var itemForHighlightsView: Models.LibraryItem?
@ -35,20 +33,19 @@ import Views
@Published var appliedSort = LinkedItemSort.newest.rawValue
@State var lastMoreFetched: Date?
@State var lastFiltersFetched: Date?
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@Published var appliedFilter: InternalFilter? {
didSet {
if let filterName = appliedFilter?.name {
if let filterName = appliedFilter?.name.lowercased() {
UserDefaults.standard.setValue(filterName, forKey: filterKey)
}
}
}
let folderConfigs: [String: LibraryListConfig]
init(filterKey: String, fetcher: LibraryItemFetcher, folderConfigs: [String: LibraryListConfig]) {
self.filterKey = filterKey
@ -84,9 +81,15 @@ import Views
}
func loadFilters(dataService: DataService) async {
let start = Date()
var hasLocalResults = false
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
if let lastFiltersFetched, lastFiltersFetched.timeIntervalSinceNow > -100 {
print("skipping fetching filters as last fetch was too recent: ", lastFiltersFetched)
return
}
// Load from disk
if let results = try? dataService.viewContext.fetch(fetchRequest) {
hasLocalResults = true
@ -94,13 +97,13 @@ import Views
}
let hasResults = hasLocalResults
Task.detached {
if let downloadedFilters = try? await dataService.filters() {
await self.updateFilters(newFilters: downloadedFilters)
} else if !hasResults {
await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters)
}
if let downloadedFilters = try? await dataService.filters() {
updateFilters(newFilters: downloadedFilters)
} else if !hasResults {
updateFilters(newFilters: InternalFilter.DefaultInboxFilters)
}
lastFiltersFetched = start
}
func loadMore(dataService: DataService, loadCursor: String? = nil) async {
@ -160,8 +163,6 @@ import Views
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
appliedFilter = newFilter
} else {
appliedFilter = filters.first(where: { availableFolders.contains($0.folder) })
}
}
@ -171,8 +172,8 @@ import Views
dataService: dataService,
filterState: filterState
)
objectWillChange.send()
}
objectWillChange.send()
}
func loadItems(dataService: DataService, isRefresh: Bool, forceRemote: Bool = false) async {

View file

@ -11,62 +11,12 @@ public enum GridCardAction {
}
public struct GridCard: View {
@Binding var isContextMenuOpen: Bool
let item: LibraryItemData
let actionHandler: (GridCardAction) -> Void
// let tapAction: () -> Void
public init(
item: LibraryItemData,
isContextMenuOpen: Binding<Bool>,
actionHandler: @escaping (GridCardAction) -> Void
item: LibraryItemData
) {
self.item = item
self._isContextMenuOpen = isContextMenuOpen
self.actionHandler = actionHandler
}
// Menu doesn't provide an API to observe it's open state
// so we have keep track of it's state manually
func tapHandler() {
if isContextMenuOpen {
isContextMenuOpen = false
}
}
func menuActionHandler(_ action: GridCardAction) {
isContextMenuOpen = false
actionHandler(action)
}
var contextMenuView: some View {
Group {
Button(
action: { menuActionHandler(.viewHighlights) },
label: { Label("Notebook", systemImage: "highlighter") }
)
Button(
action: { menuActionHandler(.editTitle) },
label: { Label("Edit Info", systemImage: "info.circle") }
)
Button(
action: { menuActionHandler(.editLabels) },
label: { Label(item.sortedLabels.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
)
Button(
action: { menuActionHandler(.toggleArchiveStatus) },
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: { menuActionHandler(.delete) },
label: { Label("Delete", systemImage: "trash") }
)
}
}
var imageBox: some View {
@ -159,6 +109,117 @@ public struct GridCard: View {
}
}
var readingSpeed: Int64 {
var result = UserDefaults.standard.integer(forKey: UserDefaultKey.userWordsPerMinute.rawValue)
if result <= 0 {
result = 235
}
return Int64(result)
}
var estimatedReadingTime: String {
if item.wordsCount > 0 {
let readLen = max(1, item.wordsCount / readingSpeed)
return "\(readLen) MIN READ • "
}
return ""
}
var readingProgress: String {
// If there is no wordsCount don't show progress because it will make no sense
if item.wordsCount > 0 {
return "\(String(format: "%d", Int(item.readingProgress)))%"
}
if item.isPDF {
// base estimated reading time on page count
return "\(String(format: "%d", Int(item.readingProgress)))%"
}
return ""
}
var hasMultipleInfoItems: Bool {
item.wordsCount > 0 || item.highlights?.first { ($0 as? Highlight)?.annotation != nil } != nil
}
var highlightsText: String {
if let highlights = item.highlights, highlights.count > 0 {
let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count)
if item.wordsCount > 0 || item.isPDF {
return "\(fmted)"
}
return fmted
}
return ""
}
var notesText: String {
let notes = item.highlights?.filter { item in
if let highlight = item as? Highlight {
return !(highlight.annotation ?? "").isEmpty
}
return false
}
if let notes = notes, notes.count > 0 {
let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count)
if hasMultipleInfoItems {
return "\(fmted)"
}
return fmted
}
return ""
}
var flairLabels: [FlairLabels] {
item.sortedLabels.compactMap { label in
if let name = label.name {
return FlairLabels(rawValue: name.lowercased())
}
return nil
}.sorted { $0.sortOrder < $1.sortOrder }
}
var isPartiallyRead: Bool {
Int(item.readingProgress) > 0
}
var nonFlairLabels: [LinkedItemLabel] {
item.sortedLabels.filter { label in
if let name = label.name, FlairLabels(rawValue: name.lowercased()) != nil {
return false
}
return true
}
}
var readInfo: some View {
HStack(alignment: .center, spacing: 5.0) {
ForEach(flairLabels, id: \.self) {
$0.icon
}
Text("\(estimatedReadingTime)")
.font(.caption2).fontWeight(.medium)
.foregroundColor(Color.themeLibraryItemSubtle)
+
Text("\(readingProgress)")
.font(.caption2).fontWeight(.medium)
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle)
+
Text("\(highlightsText)")
.font(.caption2).fontWeight(.medium)
.foregroundColor(Color.themeLibraryItemSubtle)
+
Text("\(notesText)")
.font(.caption2).fontWeight(.medium)
.foregroundColor(Color.themeLibraryItemSubtle)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
public var body: some View {
GeometryReader { geo in
VStack(alignment: .leading, spacing: 0) {
@ -167,17 +228,19 @@ public struct GridCard: View {
.frame(height: geo.size.height / 2.0)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.title)
.font(.appHeadline)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
}
readInfo
.dynamicTypeSize(.xSmall ... .medium)
.padding(.horizontal, 15)
Text(item.title)
.lineLimit(2)
.font(.appHeadline)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal, 15)
byLine
.padding(.horizontal, 15)
}
.frame(height: 30)
.padding(.horizontal, 10)
.padding(.bottom, 10)
.padding(.top, 10)
@ -186,30 +249,21 @@ public struct GridCard: View {
Text(item.descriptionText ?? item.title)
.font(.appSubheadline)
.foregroundColor(.appGrayTextContrast)
.lineLimit(3)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer()
}
.padding(.horizontal, 10)
.padding(.horizontal, 15)
// Category Labels
if item.hasLabels {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(item.sortedLabels, id: \.self) {
TextChip(feedItemLabel: $0)
}
Spacer()
}
.padding(.horizontal, 10)
}
if !nonFlairLabels.isEmpty {
LabelsFlowLayout(labels: nonFlairLabels)
.padding(.horizontal, 15)
}
}
.padding(.horizontal, 0)
.padding(.top, 0)
}
.contextMenu { contextMenuView }
}
}
}