summaryrefslogtreecommitdiff
path: root/Sora
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-07-24 18:10:43 +0200
committerFuwn <[email protected]>2025-07-24 18:10:43 +0200
commitf69eb3d8a7fb2d181f44376df53ff3765d12867e (patch)
tree06ab8f4218bf3d9d2f818a23c1dbbd3db0ebd3af /Sora
parentfeat: Development commit (diff)
downloadsora-testing-f69eb3d8a7fb2d181f44376df53ff3765d12867e.tar.xz
sora-testing-f69eb3d8a7fb2d181f44376df53ff3765d12867e.zip
feat: Development commit
Diffstat (limited to 'Sora')
-rw-r--r--Sora/Data/PostGridViewState/PostGridViewState.swift1
-rw-r--r--Sora/Views/Post/Grid/PostGridView.swift574
2 files changed, 315 insertions, 260 deletions
diff --git a/Sora/Data/PostGridViewState/PostGridViewState.swift b/Sora/Data/PostGridViewState/PostGridViewState.swift
index 266d05c..95e3396 100644
--- a/Sora/Data/PostGridViewState/PostGridViewState.swift
+++ b/Sora/Data/PostGridViewState/PostGridViewState.swift
@@ -4,5 +4,6 @@ struct PostGridViewState: Equatable {
var posts: [BooruPost] = []
var currentPage: Int = 1
var selectedPost: BooruPost?
+ var scrollPostID: String?
let createdAt = Date()
}
diff --git a/Sora/Views/Post/Grid/PostGridView.swift b/Sora/Views/Post/Grid/PostGridView.swift
index f247370..99c7966 100644
--- a/Sora/Views/Post/Grid/PostGridView.swift
+++ b/Sora/Views/Post/Grid/PostGridView.swift
@@ -15,6 +15,8 @@ struct PostGridView: View { // swiftlint:disable:this type_body_length
@State private var suppressNextSearchSubmit = false
@State private var searchTask: Task<Void, Never>?
@State private var suggestions: [BooruTag] = []
+ private static let topID = "PostGridScrollTop"
+ @State private var scrollID: String?
@Environment(\.isSearching)
private var isSearching
@@ -32,323 +34,366 @@ struct PostGridView: View { // swiftlint:disable:this type_body_length
.filter { settings.displayRatings.contains($0.rating) } ?? []
}
- var body: some View {
- ScrollViewReader { proxy in
- ScrollView {
- Group {
- if let error = manager.error {
- ContentUnavailableView(
- "Provider Error",
- systemImage: "exclamationmark.triangle.fill",
- description: Text(error.localizedDescription)
- )
- }
+ @ViewBuilder private var gridContent: some View {
+ if let error = manager.error {
+ ContentUnavailableView(
+ "Provider Error",
+ systemImage: "exclamationmark.triangle.fill",
+ description: Text(error.localizedDescription)
+ )
+ }
- if activePosts.isEmpty, manager.isLoading {
- let gridItems = Array(
- repeating: GridItem(.flexible()),
- count: settings.thumbnailGridColumns
- )
+ if activePosts.isEmpty, manager.isLoading {
+ placeholderGrid
+ } else {
+ gridView(columnCount: settings.thumbnailGridColumns)
+ }
+ }
- LazyVGrid(columns: gridItems) {
- ForEach(0..<(50 / settings.thumbnailGridColumns), id: \.self) { _ in
- PostGridThumbnailPlaceholderView()
- }
- }
- #if os(macOS)
- .padding(8)
- #else
- .padding(.horizontal)
- #endif
- .transition(.opacity)
- } else {
- let columnCount = settings.thumbnailGridColumns
+ @ViewBuilder private var placeholderGrid: some View {
+ let gridItems = Array(
+ repeating: GridItem(.flexible()),
+ count: settings.thumbnailGridColumns
+ )
- if settings.alternativeThumbnailGrid {
- let columnsData = (0..<columnCount).map { columnIndex in
- activePosts.enumerated().compactMap { index, post in
- index % columnCount == columnIndex ? post : nil
- }
- }
+ LazyVGrid(columns: gridItems) {
+ ForEach(0..<(50 / settings.thumbnailGridColumns), id: \.self) { _ in
+ PostGridThumbnailPlaceholderView()
+ }
+ }
+ #if os(macOS)
+ .padding(8)
+ #else
+ .padding(.horizontal)
+ #endif
+ .transition(.opacity)
+ }
- HStack(alignment: .top) {
- ForEach(0..<columnCount, id: \.self) { columnIndex in
- LazyVStack {
- ForEach(columnsData[columnIndex], id: \.id) { post in
- waterfallGridContent(post: post)
- .id(post.id)
- }
- }
- .transaction { $0.animation = nil }
- .scrollTargetLayout()
- }
- }
- #if os(macOS)
- .padding(8)
- #else
- .padding(.horizontal)
- #endif
- .transition(.opacity)
- } else {
- WaterfallGrid(activePosts, id: \.id) { post in
- waterfallGridContent(post: post)
- .id(post.id)
- }
- .gridStyle(columns: columnCount)
- .transaction { $0.animation = nil }
- .scrollTargetLayout()
- #if os(macOS)
- .padding(8)
- #else
- .padding(.horizontal)
- #endif
- .transition(.opacity)
+ @ViewBuilder
+ private func gridView(columnCount: Int) -> some View {
+ if settings.alternativeThumbnailGrid {
+ let columnsData = computeColumnsData(columnCount: columnCount)
+
+ HStack(alignment: .top) {
+ ForEach(0..<columnCount, id: \.self) { columnIndex in
+ LazyVStack {
+ ForEach(columnsData[columnIndex], id: \.id) { post in
+ waterfallGridContent(post: post)
+ .id(post.id)
}
}
+ .transaction { $0.animation = nil }
+ .scrollTargetLayout()
}
- .id(queryID)
- .transition(.opacity)
}
- #if os(iOS)
- .searchable(
- text: $manager.searchText,
- isPresented: $isSearchablePresented,
- placement: .navigationBarDrawer(displayMode: .automatic),
- prompt: "Tags"
- )
+ #if os(macOS)
+ .padding(8)
#else
- .searchable(
- text: $manager.searchText,
- isPresented: $isSearchablePresented,
- prompt: "Tags"
- )
+ .padding(.horizontal)
#endif
- .searchSuggestions {
- if settings.searchSuggestionsMode != .disabled && isSearchablePresented {
- SearchSuggestionsView(
- items: searchSuggestionsItems(),
- searchText: $manager.searchText,
- suppressNextSearchSubmit: $suppressNextSearchSubmit
- )
- }
+ .transition(.opacity)
+ } else {
+ WaterfallGrid(activePosts, id: \.id) { post in
+ waterfallGridContent(post: post)
+ .id(post.id)
+ }
+ .gridStyle(columns: columnCount)
+ .transaction { $0.animation = nil }
+ .scrollTargetLayout()
+ #if os(macOS)
+ .padding(8)
+ #else
+ .padding(.horizontal)
+ #endif
+ .transition(.opacity)
+ }
+ }
+
+ private func computeColumnsData(columnCount: Int) -> [[BooruPost]] {
+ (0..<columnCount).map { columnIndex in
+ activePosts.enumerated().compactMap { index, post in
+ index % columnCount == columnIndex ? post : nil
+ }
+ }
+ }
+
+ var body: some View {
+ ScrollView {
+ Color.clear
+ .frame(height: 0)
+ .id(Self.topID)
+ .scrollTargetLayout()
+
+ gridContent
+ .id(queryID)
+ .transition(.opacity)
+ }
+ .scrollPosition(id: $scrollID, anchor: .top)
+ .scrollTargetBehavior(.viewAligned)
+ #if os(iOS)
+ .searchable(
+ text: $manager.searchText,
+ isPresented: $isSearchablePresented,
+ placement: .navigationBarDrawer(displayMode: .automatic),
+ prompt: "Tags"
+ )
+ #else
+ .searchable(
+ text: $manager.searchText,
+ isPresented: $isSearchablePresented,
+ prompt: "Tags"
+ )
+ #endif
+ .searchSuggestions {
+ if settings.searchSuggestionsMode != .disabled && isSearchablePresented {
+ SearchSuggestionsView(
+ items: searchSuggestionsItems(),
+ searchText: $manager.searchText,
+ suppressNextSearchSubmit: $suppressNextSearchSubmit
+ )
}
- .onChange(of: manager.searchText) { _, newValue in
- if settings.searchSuggestionsMode == .tags {
- searchTask?.cancel()
+ }
+ .onChange(of: manager.searchText) { _, newValue in
+ if settings.searchSuggestionsMode == .tags {
+ searchTask?.cancel()
- searchTask = Task {
- try? await Task.sleep(nanoseconds: 300_000_000)
+ searchTask = Task {
+ try? await Task.sleep(nanoseconds: 300_000_000)
- guard !Task.isCancelled else { return }
+ guard !Task.isCancelled else { return }
- let searchTag = newValue.split(separator: " ").last.map(String.init) ?? ""
+ let searchTag = newValue.split(separator: " ").last.map(String.init) ?? ""
- if !searchTag.isEmpty {
- suggestions = await manager.searchTags(name: searchTag)
- } else {
- suggestions = []
- }
+ if !searchTag.isEmpty {
+ suggestions = await manager.searchTags(name: searchTag)
+ } else {
+ suggestions = []
}
}
}
- .onSubmit(of: .search) {
- if suppressNextSearchSubmit {
- suppressNextSearchSubmit = false
-
- return
- }
+ }
+ .onSubmit(of: .search) {
+ if suppressNextSearchSubmit {
+ suppressNextSearchSubmit = false
- Task(priority: .userInitiated) {
- await manager.performSearch(settings: settings)
- }
+ return
}
- .navigationDestination(for: BooruPost.self) { post in
- PostDetailsView(post: post)
+
+ Task(priority: .userInitiated) {
+ await manager.performSearch(settings: settings)
}
- .onChange(of: isSearchablePresented) { _, isPresented in
- if !isPresented, manager.searchText.isEmpty, !manager.isNavigatingHistory {
- Task(priority: .userInitiated) { await manager.performSearch() }
- }
+ }
+ .navigationDestination(for: BooruPost.self) { post in
+ PostDetailsView(post: post)
+ }
+ .onChange(of: isSearchablePresented) { _, isPresented in
+ if !isPresented, manager.searchText.isEmpty, !manager.isNavigatingHistory {
+ Task(priority: .userInitiated) { await manager.performSearch() }
}
- .onChange(of: manager.posts) { _, newPosts in
- if manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count {
- updateViewState(for: queryID, posts: newPosts)
- }
+ }
+ .onChange(of: manager.posts) { _, newPosts in
+ if manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count {
+ updateViewState(for: queryID, posts: newPosts)
}
- .onChange(of: manager.currentPage) { _, newPage in
- if manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count {
- let queryID = manager.searchHistory[manager.historyIndex].id
+ }
+ .onChange(of: manager.currentPage) { _, newPage in
+ if manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count {
+ let queryID = manager.searchHistory[manager.historyIndex].id
- updateViewState(for: queryID, currentPage: newPage)
- }
+ updateViewState(for: queryID, currentPage: newPage)
}
- .onChange(of: manager.selectedPost) { _, newPost in
- let queryID = manager.searchHistory.last { $0.tags == manager.tags }?.id ?? UUID()
+ }
+ .onChange(of: manager.selectedPost) { _, newPost in
+ let queryID = manager.searchHistory.last { $0.tags == manager.tags }?.id ?? UUID()
- updateViewState(for: queryID, selectedPost: newPost, resetSelectedPost: newPost == nil)
+ updateViewState(for: queryID, selectedPost: newPost, resetSelectedPost: newPost == nil)
+ }
+ .onChange(of: scrollID) { _, newValue in
+ guard manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count else {
+ return
}
- .onChange(of: manager.historyIndex) { _, newIndex in
- guard newIndex >= 0 && newIndex < manager.searchHistory.count else { return }
-
- let queryID = manager.searchHistory[newIndex].id
-
- if let state = viewStates[queryID] {
- manager.posts = state.posts
- manager.currentPage = state.currentPage
- manager.selectedPost = state.selectedPost
- } else {
- manager.posts = []
- manager.currentPage = 1
-
- Task(priority: .userInitiated) {
- await manager.fetchPosts(
- page: 1,
- tags: manager.searchHistory[newIndex].tags,
- replace: true
- )
- }
- }
- proxy.scrollTo(queryID, anchor: .top)
+ let queryID = manager.searchHistory[manager.historyIndex].id
+
+ updateViewState(for: queryID, scrollPostID: newValue)
+ }
+ .onChange(of: manager.historyIndex) { _, newIndex in
+ guard newIndex >= 0 && newIndex < manager.searchHistory.count else { return }
+
+ let queryID = manager.searchHistory[newIndex].id
+
+ if let state = viewStates[queryID] {
+ manager.posts = state.posts
+ manager.currentPage = state.currentPage
+ manager.selectedPost = state.selectedPost
+ } else {
+ manager.posts = []
+ manager.currentPage = 1
+
+ Task(priority: .userInitiated) {
+ await manager.fetchPosts(
+ page: 1,
+ tags: manager.searchHistory[newIndex].tags,
+ replace: true
+ )
+ }
}
- .toolbar {
- #if os(macOS)
- ToolbarItem {
- Button(action: {
- Task {
- await manager.fetchPosts(page: 1, tags: manager.tags, replace: true)
- }
- }) {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .disabled(manager.isLoading)
- }
- #endif
- #if !os(macOS)
- PlatformSpecificToolbarItem {
- Button(action: { Task { isSearchHistoryPresented.toggle() } }) {
- Label("Search History", systemImage: "clock.arrow.circlepath")
+ if let savedID = viewStates[queryID]?.scrollPostID {
+ scrollID = savedID
+ } else {
+ scrollID = Self.topID
+ }
+ }
+ .toolbar {
+ #if os(macOS)
+ ToolbarItem {
+ Button(action: {
+ Task {
+ await manager.fetchPosts(page: 1, tags: manager.tags, replace: true)
}
+ }) {
+ Label("Refresh", systemImage: "arrow.clockwise")
}
+ .disabled(manager.isLoading)
+ }
+ #endif
- if #available(iOS 26, *), manager.isLoading || manager.isNavigatingHistory {
- ToolbarItem(placement: .status) { ProgressView() }
+ #if !os(macOS)
+ PlatformSpecificToolbarItem {
+ Button(action: { Task { isSearchHistoryPresented.toggle() } }) {
+ Label("Search History", systemImage: "clock.arrow.circlepath")
}
- #endif
+ }
- PlatformSpecificToolbarItem {
- PostGridBookmarkButtonView()
- .disabled(manager.tags.isEmpty)
+ if #available(iOS 26, *), manager.isLoading || manager.isNavigatingHistory {
+ ToolbarItem(placement: .status) { ProgressView() }
}
+ #endif
- PlatformSpecificToolbarItem {
- Button(
- action: {
- Task(priority: .userInitiated) {
- await manager.loadNextPage()
- }
+ PlatformSpecificToolbarItem {
+ PostGridBookmarkButtonView()
+ .disabled(manager.tags.isEmpty)
+ }
+
+ PlatformSpecificToolbarItem {
+ Button(
+ action: {
+ Task(priority: .userInitiated) {
+ await manager.loadNextPage()
}
- ) {
- Label(
- "Manually Load Next Page",
- systemImage: "arrow.down.to.line"
- )
}
- .disabled(manager.isLoading)
+ ) {
+ Label(
+ "Manually Load Next Page",
+ systemImage: "arrow.down.to.line"
+ )
}
+ .disabled(manager.isLoading)
+ }
- #if !os(macOS)
- if #unavailable(iOS 26), manager.isLoading || manager.isNavigatingHistory {
- ToolbarItem(placement: .topBarTrailing) { ProgressView() }
- }
- #endif
-
- PlatformSpecificToolbarItem(placement: .navigation) {
- Menu {
- ForEach(
- Array(manager.searchHistory.enumerated().filter { $0.offset < manager.historyIndex }),
- id: \.offset
- ) { offset, query in
- Button(action: {
- manager.historyIndex = offset
- }) {
- Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " "))
- }
- }
- } label: {
- Label("Previous Search", systemImage: "chevron.left")
- } primaryAction: {
- withAnimation {
- manager.goBackInHistory()
+ #if !os(macOS)
+ if #unavailable(iOS 26), manager.isLoading || manager.isNavigatingHistory {
+ ToolbarItem(placement: .topBarTrailing) { ProgressView() }
+ }
+ #endif
+
+ PlatformSpecificToolbarItem(placement: .navigation) {
+ Menu {
+ ForEach(
+ Array(manager.searchHistory.enumerated().filter { $0.offset < manager.historyIndex }),
+ id: \.offset
+ ) { offset, query in
+ Button(action: {
+ manager.historyIndex = offset
+ }) {
+ Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " "))
}
}
- .disabled(!manager.canGoBackInHistory)
- .id("previousSearchMenu")
+ } label: {
+ Label("Previous Search", systemImage: "chevron.left")
+ } primaryAction: {
+ withAnimation {
+ manager.goBackInHistory()
+ }
}
+ .disabled(!manager.canGoBackInHistory)
+ .id("previousSearchMenu")
+ }
- PlatformSpecificToolbarItem(placement: .navigation) {
- Menu {
- ForEach(
- Array(manager.searchHistory.enumerated().filter { $0.offset > manager.historyIndex }),
- id: \.offset
- ) { offset, query in
- Button(action: {
- manager.historyIndex = offset
- }) {
- Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " "))
- }
- }
- } label: {
- Label("Next Search", systemImage: "chevron.right")
- } primaryAction: {
- withAnimation {
- manager.goForwardInHistory()
+ PlatformSpecificToolbarItem(placement: .navigation) {
+ Menu {
+ ForEach(
+ Array(manager.searchHistory.enumerated().filter { $0.offset > manager.historyIndex }),
+ id: \.offset
+ ) { offset, query in
+ Button(action: {
+ manager.historyIndex = offset
+ }) {
+ Text(query.tags.isEmpty ? "No Tags" : query.tags.joined(separator: " "))
}
}
- .disabled(!manager.canGoForwardInHistory)
- .id("nextSearchMenu")
+ } label: {
+ Label("Next Search", systemImage: "chevron.right")
+ } primaryAction: {
+ withAnimation {
+ manager.goForwardInHistory()
+ }
}
+ .disabled(!manager.canGoForwardInHistory)
+ .id("nextSearchMenu")
}
- .navigationTitle("Posts")
- .refreshable {
- manager.clearCachedPages()
+ }
+ .navigationTitle("Posts")
+ .refreshable {
+ manager.clearCachedPages()
- Task(priority: .userInitiated) {
- await manager.fetchPosts(page: 1, tags: manager.tags, replace: true)
- }
+ Task(priority: .userInitiated) {
+ await manager.fetchPosts(page: 1, tags: manager.tags, replace: true)
}
- .sheet(isPresented: $isSearchHistoryPresented) {
- PostGridSearchHistoryView(
- selectedTab: $selectedTab,
- isPresented: $isSearchHistoryPresented
- )
+ }
+ .sheet(isPresented: $isSearchHistoryPresented) {
+ PostGridSearchHistoryView(
+ selectedTab: $selectedTab,
+ isPresented: $isSearchHistoryPresented
+ )
+ }
+ .onAppear {
+ scrollID = viewStates[queryID]?.scrollPostID ?? Self.topID
+ }
+ .onDisappear {
+ guard manager.historyIndex >= 0 && manager.historyIndex < manager.searchHistory.count else {
+ return
}
- #if os(iOS)
- .gesture(
- DragGesture()
- .onEnded { value in
- if value.startLocation.x < 50 && value.translation.width > 100 {
- withAnimation {
- manager.goBackInHistory()
- }
-
- debugPrint("ContentView: Swipe left, \(manager.searchHistory)")
+
+ let queryID = manager.searchHistory[manager.historyIndex].id
+
+ updateViewState(for: queryID, scrollPostID: scrollID)
+ }
+ #if os(iOS)
+ .gesture(
+ DragGesture()
+ .onEnded { value in
+ if value.startLocation.x < 50 && value.translation.width > 100 {
+ withAnimation {
+ manager.goBackInHistory()
}
- if value.startLocation.x > (UIScreen.main.bounds.width - 50)
- && value.translation.width < -100
- {
- withAnimation {
- manager.goForwardInHistory()
- }
+ debugPrint("ContentView: Swipe left, \(manager.searchHistory)")
+ }
- debugPrint("ContentView: Swipe right, \(manager.searchHistory)")
+ if value.startLocation.x > (UIScreen.main.bounds.width - 50)
+ && value.translation.width < -100
+ {
+ withAnimation {
+ manager.goForwardInHistory()
}
+
+ debugPrint("ContentView: Swipe right, \(manager.searchHistory)")
}
- )
- #endif
- }
+ }
+ )
+ #endif
}
private func waterfallGridContent(post: BooruPost) -> some View {
@@ -383,6 +428,7 @@ struct PostGridView: View { // swiftlint:disable:this type_body_length
posts: [BooruPost] = [],
currentPage: Int? = nil,
selectedPost: BooruPost? = nil,
+ scrollPostID: String? = nil,
resetSelectedPost: Bool = false,
) {
let wasNewlyCreated = viewStates[queryID] == nil
@@ -398,6 +444,14 @@ struct PostGridView: View { // swiftlint:disable:this type_body_length
state.selectedPost = nil
}
+ if let scrollPostID {
+ state.scrollPostID = scrollPostID
+ }
+
+ if wasNewlyCreated && state.scrollPostID == nil {
+ state.scrollPostID = Self.topID
+ }
+
viewStates[queryID] = state
if wasNewlyCreated {