iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: reliable gallery scroll position and grid thumbnail caching

Replace fragile scrollPosition/viewAligned approach with ScrollViewReader
+ scrollTo for bulletproof initial gallery positioning. Hide content until
scroll lands to eliminate transition flash. Add sync Nuke cache reads for
profile grid thumbnails to prevent grey flash on back navigation. Clip
horizontal tab pages to prevent tap bleed between galleries/favorites grids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+128 -122
+77 -103
Grain/Views/Profile/ProfileGalleryFeedView.swift
··· 12 12 13 13 struct ProfileGalleryFeedView: View { 14 14 @Environment(AuthManager.self) private var auth 15 - @Environment(\.dismiss) private var dismiss 16 15 @Bindable var viewModel: ProfileDetailViewModel 17 16 let client: XRPCClient 18 17 let did: String 19 18 let initialUri: String 20 19 let source: ProfileGalleryFeedSource 21 20 22 - @State private var didExpand = false 23 - @State private var scrollAnchor: String? 21 + @State private var didScroll = false 24 22 @State private var selectedProfileDid: String? 25 23 @State private var selectedHashtag: String? 26 24 @State private var selectedLocation: LocationDestination? ··· 43 41 self.did = did 44 42 self.initialUri = initialUri 45 43 self.source = source 46 - _scrollAnchor = State(initialValue: initialUri) 47 44 } 48 45 49 46 private var items: [GrainGallery] { ··· 67 64 } 68 65 } 69 66 70 - private var tappedIndex: Int { 71 - items.firstIndex(where: { $0.uri == initialUri }) ?? 0 72 - } 73 - 74 - private var renderStartIndex: Int { 75 - didExpand ? 0 : tappedIndex 76 - } 77 - 78 67 private func loadMore() async { 79 68 switch source { 80 69 case .galleries: ··· 107 96 } 108 97 109 98 var body: some View { 110 - ScrollView { 111 - LazyVStack(spacing: 0) { 112 - ForEach(Array(itemsBinding.enumerated()), id: \.element.id) { index, $gallery in 113 - if index >= renderStartIndex { 99 + ScrollViewReader { proxy in 100 + ScrollView { 101 + LazyVStack(spacing: 0) { 102 + ForEach(Array(itemsBinding.enumerated()), id: \.element.id) { index, $gallery in 114 103 galleryCard(gallery: $gallery, index: index) 115 104 .id(gallery.uri) 116 105 } 117 - } 118 106 119 - if viewModel.isLoading, hasMore { 120 - ProgressView() 121 - .padding() 107 + if viewModel.isLoading, hasMore { 108 + ProgressView() 109 + .padding() 110 + } 122 111 } 123 112 } 124 - .scrollTargetLayout() 125 - } 126 - .scrollPosition(id: $scrollAnchor, anchor: .top) 127 - .scrollTargetBehavior(.viewAligned) 128 - .contentMargins(.top, 24, for: .scrollContent) 129 - .gesture( 130 - // Rightward swipe anywhere outside the carousel pops the nav. 131 - // Using exclusive .gesture (not simultaneous) so the child TabView 132 - // in GalleryCardView claims swipes inside the image area first; 133 - // ours only fires for touches that miss the carousel. 134 - DragGesture(minimumDistance: 30) 135 - .onEnded { value in 136 - let dx = value.translation.width 137 - let dy = value.translation.height 138 - let predicted = value.predictedEndTranslation.width 139 - if dx > 80, abs(dy) < 60, predicted > 120 { 140 - dismiss() 141 - } 113 + .opacity(didScroll ? 1 : 0) 114 + .onAppear { 115 + DispatchQueue.main.async { 116 + proxy.scrollTo(initialUri, anchor: .top) 117 + didScroll = true 142 118 } 143 - ) 144 - .environment(zoomState) 145 - .modifier(ImageZoomOverlay(zoomState: zoomState)) 146 - .navigationBarTitleDisplayMode(.inline) 147 - .navigationDestination(item: $selectedProfileDid) { did in 148 - ProfileView(client: client, did: did) 149 - } 150 - .navigationDestination(item: $selectedHashtag) { tag in 151 - HashtagFeedView(client: client, tag: tag) 152 - } 153 - .navigationDestination(item: $selectedLocation) { loc in 154 - LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 155 - } 156 - .fullScreenCover(item: $cardStoryAuthor) { author in 157 - StoryViewer( 158 - authors: [author], 159 - client: client, 160 - onProfileTap: { did in 161 - cardStoryAuthor = nil 162 - selectedProfileDid = did 163 - }, 164 - onDismiss: { cardStoryAuthor = nil } 165 - ) 166 - .environment(auth) 167 - } 168 - .sheet(isPresented: Binding( 169 - get: { commentSheetUri != nil }, 170 - set: { if !$0 { commentSheetUri = nil } } 171 - )) { 172 - if let uri = commentSheetUri { 173 - CommentSheetView( 119 + } 120 + .environment(zoomState) 121 + .modifier(ImageZoomOverlay(zoomState: zoomState)) 122 + .navigationBarTitleDisplayMode(.inline) 123 + .navigationDestination(item: $selectedProfileDid) { did in 124 + ProfileView(client: client, did: did) 125 + } 126 + .navigationDestination(item: $selectedHashtag) { tag in 127 + HashtagFeedView(client: client, tag: tag) 128 + } 129 + .navigationDestination(item: $selectedLocation) { loc in 130 + LocationFeedView(client: client, h3Index: loc.h3Index, locationName: loc.name) 131 + } 132 + .fullScreenCover(item: $cardStoryAuthor) { author in 133 + StoryViewer( 134 + authors: [author], 174 135 client: client, 175 - galleryUri: uri, 176 - onDismiss: { commentSheetUri = nil }, 177 136 onProfileTap: { did in 178 - commentSheetUri = nil 137 + cardStoryAuthor = nil 179 138 selectedProfileDid = did 180 139 }, 181 - onHashtagTap: { tag in 182 - commentSheetUri = nil 183 - selectedHashtag = tag 184 - }, 185 - onStoryTap: { author in 186 - commentSheetUri = nil 187 - cardStoryAuthor = author 188 - }, 189 - onCommentCountChanged: { count in 190 - updateCommentCount(uri: uri, count: count) 191 - } 140 + onDismiss: { cardStoryAuthor = nil } 192 141 ) 142 + .environment(auth) 193 143 } 194 - } 195 - .sheet(item: $reportGallery) { gallery in 196 - ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 197 - } 198 - .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 199 - Button("Delete", role: .destructive) { 200 - if let uri = deleteGalleryUri { 201 - Task { 202 - guard let authContext = await auth.authContext() else { return } 203 - let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 204 - try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 205 - removeAfterDelete(uri: uri) 144 + .sheet(isPresented: Binding( 145 + get: { commentSheetUri != nil }, 146 + set: { if !$0 { commentSheetUri = nil } } 147 + )) { 148 + if let uri = commentSheetUri { 149 + CommentSheetView( 150 + client: client, 151 + galleryUri: uri, 152 + onDismiss: { commentSheetUri = nil }, 153 + onProfileTap: { did in 154 + commentSheetUri = nil 155 + selectedProfileDid = did 156 + }, 157 + onHashtagTap: { tag in 158 + commentSheetUri = nil 159 + selectedHashtag = tag 160 + }, 161 + onStoryTap: { author in 162 + commentSheetUri = nil 163 + cardStoryAuthor = author 164 + }, 165 + onCommentCountChanged: { count in 166 + updateCommentCount(uri: uri, count: count) 167 + } 168 + ) 169 + } 170 + } 171 + .sheet(item: $reportGallery) { gallery in 172 + ReportView(client: client, subjectUri: gallery.uri, subjectCid: gallery.cid) 173 + } 174 + .alert("Delete Gallery?", isPresented: $showDeleteConfirmation) { 175 + Button("Delete", role: .destructive) { 176 + if let uri = deleteGalleryUri { 177 + Task { 178 + guard let authContext = await auth.authContext() else { return } 179 + let rkey = uri.split(separator: "/").last.map(String.init) ?? "" 180 + try? await client.deleteRecord(collection: "social.grain.gallery", rkey: rkey, auth: authContext) 181 + removeAfterDelete(uri: uri) 182 + } 183 + deleteGalleryUri = nil 206 184 } 207 - deleteGalleryUri = nil 208 185 } 186 + Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 187 + } message: { 188 + Text("This will permanently delete this gallery and all its photos.") 209 189 } 210 - Button("Cancel", role: .cancel) { deleteGalleryUri = nil } 211 - } message: { 212 - Text("This will permanently delete this gallery and all its photos.") 213 - } 190 + } // ScrollViewReader 214 191 } 215 192 216 193 @ViewBuilder ··· 229 206 onDelete: isOwner ? { showDeleteConfirmation = true; deleteGalleryUri = g.uri } : nil 230 207 ) 231 208 .onAppear { 232 - if g.uri == initialUri, !didExpand { 233 - didExpand = true 234 - } 235 209 if index == items.count - 1 { 236 210 Task { await loadMore() } 237 211 }
+51 -19
Grain/Views/Profile/ProfileView.swift
··· 640 640 HStack(alignment: .top, spacing: 0) { 641 641 galleriesGrid 642 642 .containerRelativeFrame(.horizontal) 643 + .contentShape(Rectangle()) 644 + .clipped() 643 645 .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 644 646 tabHeights[.grid] = h 645 647 } 646 648 .id(ProfileViewMode.grid) 647 649 favoritesGrid 648 650 .containerRelativeFrame(.horizontal) 651 + .contentShape(Rectangle()) 652 + .clipped() 649 653 .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 650 654 tabHeights[.favorites] = h 651 655 } 652 656 .id(ProfileViewMode.favorites) 653 657 storyArchiveGrid 654 658 .containerRelativeFrame(.horizontal) 659 + .contentShape(Rectangle()) 660 + .clipped() 655 661 .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { h in 656 662 tabHeights[.stories] = h 657 663 } ··· 715 721 .aspectRatio(3.0 / 4.0, contentMode: .fit) 716 722 .overlay { 717 723 if let photo = gallery.items?.first { 718 - LazyImage(url: URL(string: photo.thumb)) { state in 719 - if let image = state.image { 720 - image 721 - .resizable() 722 - .scaledToFill() 723 - } else { 724 - Rectangle().fill(.quaternary) 725 - } 726 - } 724 + ProfileGridThumbnail(urlString: photo.thumb) 727 725 } 728 726 } 729 727 .clipped() ··· 865 863 Color.clear 866 864 .aspectRatio(3.0 / 4.0, contentMode: .fit) 867 865 .overlay { 868 - if let photo = gallery.items?.first, let url = URL(string: photo.thumb) { 869 - LazyImage(url: url) { state in 870 - if let image = state.image { 871 - image 872 - .resizable() 873 - .scaledToFill() 874 - } else { 875 - Rectangle().fill(.quaternary) 876 - } 877 - } 866 + if let photo = gallery.items?.first { 867 + ProfileGridThumbnail(urlString: photo.thumb) 878 868 } else { 879 869 Rectangle().fill(.quaternary) 880 870 } ··· 1145 1135 private struct FavoriteThumbProbe { 1146 1136 let uri: String 1147 1137 let result: FavoriteThumbProbeResult 1138 + } 1139 + 1140 + // MARK: - Profile Grid Thumbnail (sync cache read to avoid flash) 1141 + 1142 + private struct ProfileGridThumbnail: View { 1143 + let urlString: String 1144 + @State private var asyncImage: UIImage? 1145 + 1146 + private var imageURL: URL? { 1147 + URL(string: urlString) 1148 + } 1149 + 1150 + private var resolvedImage: UIImage? { 1151 + if let imageURL, 1152 + let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image 1153 + { 1154 + return cached 1155 + } 1156 + return asyncImage 1157 + } 1158 + 1159 + var body: some View { 1160 + if let image = resolvedImage { 1161 + Image(uiImage: image) 1162 + .resizable() 1163 + .scaledToFill() 1164 + } else { 1165 + Rectangle().fill(.quaternary) 1166 + .onAppear { loadIfNeeded() } 1167 + } 1168 + } 1169 + 1170 + private func loadIfNeeded() { 1171 + guard let imageURL, asyncImage == nil else { return } 1172 + let request = ImageRequest(url: imageURL) 1173 + if ImagePipeline.shared.cache.cachedImage(for: request) != nil { return } 1174 + Task { 1175 + if let image = try? await ImagePipeline.shared.image(for: request) { 1176 + asyncImage = image 1177 + } 1178 + } 1179 + } 1148 1180 } 1149 1181 1150 1182 #Preview {