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: eliminate ghost overlay and loading flash on story author transition

Two related issues were causing visible flicker when swiping between
story authors:

1. The fading storyContent stayed in the view tree as an opacity-0
layer through the spring's settle window (~200ms past visual
completion), so any modifier composed on top of it (overlays,
backgrounds) would render against an invisible-but-present layer
and "pop off" abruptly when the commit cleared pendingTransition.
Drop storyContent from the tree once it's fully faded with
`if swipeAmount < 1`, and mark its insertion/removal as
.transition(.identity) so SwiftUI snaps instead of fading.

2. The fullsize-loading placeholder rendered the blurred thumb via a
nested LazyImage, which takes a frame to publish even from Nuke's
memory cache — producing a 1-frame background flash on every
transition where the next story's fullsize wasn't already loaded.
Hit the cache synchronously and render via plain Image(uiImage:),
matching the pattern pendingFaceView already uses; fall back to
LazyImage only if the cache truly misses.

The cache miss itself is the underlying cause of the rare residual
flash, and stems from storyPrefetchRequests only enqueueing fullsize
URLs. Mirror feedPrefetchRequests' policy and prefetch every
reachable thumb at high priority — they're tiny and the placeholder
relies on them being cache-hot. Tests updated to use containment
assertions where exact-count assertions previously gated the high
tier.

+132 -44
+21 -5
Grain/Utilities/ImagePrefetchPlanning.swift
··· 113 113 // MARK: - Stories 114 114 115 115 /// Prefetch story images with priority queue: 116 - /// 1. Next 2 stories of current author — high 117 - /// 2. First story of next author — high 118 - /// 3. Rest of current author's stack — normal 119 - /// 4. Next 2 stories of author-after-next — normal 120 - /// 5. First story of next 2 authors beyond — low 116 + /// - Thumbs (all reachable authors) — high (tiny payload, used by the 117 + /// StoryViewer's fullsize-loading placeholder; needs to be cache-hot to 118 + /// avoid a 1-frame flash on transition) 119 + /// 1. Next 2 stories of current author — fullsize at high 120 + /// 2. First story of next author — fullsize at high 121 + /// 3. Rest of current author's stack — fullsize at normal 122 + /// 4. Next 2 stories of author-after-next — fullsize at normal 123 + /// 5. First story of next 2 authors beyond — fullsize at low 121 124 static func storyPrefetchRequests( 122 125 currentStories: [(thumb: String, fullsize: String)], 123 126 currentStoryIndex: Int, ··· 129 132 var high: [ImageRequest] = [] 130 133 var normal: [ImageRequest] = [] 131 134 var low: [ImageRequest] = [] 135 + 136 + // Thumbs are tiny — prefetch every reachable thumb at high priority so 137 + // the StoryViewer's fullsize-loading placeholder always hits the 138 + // synchronous Nuke cache (mirrors `feedPrefetchRequests`' policy). 139 + let allThumbStrings: [String] = currentStories.map(\.thumb) 140 + + (nextAuthorStories?.map(\.thumb) ?? []) 141 + + (secondNextAuthorStories?.map(\.thumb) ?? []) 142 + + [thirdNextFirstStory?.thumb, fourthNextFirstStory?.thumb].compactMap(\.self) 143 + for thumbStr in allThumbStrings { 144 + if let url = URL(string: thumbStr) { 145 + high.append(ImageRequest(url: url, priority: .high)) 146 + } 147 + } 132 148 133 149 // 1. Next 2 stories of current author — high 134 150 let storyEnd = min(currentStoryIndex + 3, currentStories.count)
+64 -24
Grain/Views/Stories/StoryViewer.swift
··· 121 121 .scaleEffect(0.92 + swipeAmount * 0.08) 122 122 .opacity(Double(swipeAmount)) 123 123 } 124 - storyContent 125 - .offset(x: faceOffsets.current) 126 - .scaleEffect(1 - swipeAmount * 0.08) 127 - .opacity(1 - Double(swipeAmount)) 124 + // Drop storyContent from the tree once it's fully faded so the spring's 125 + // settle window (~200ms past visual completion) doesn't keep an invisible 126 + // layer alive across the commit — otherwise the stale subtree pops off 127 + // visibly at commit time. 128 + if swipeAmount < 1 { 129 + storyContent 130 + .offset(x: faceOffsets.current) 131 + .scaleEffect(1 - swipeAmount * 0.08) 132 + .opacity(1 - Double(swipeAmount)) 133 + .transition(.identity) 134 + } 128 135 } 129 136 .clipped() 130 137 .background( ··· 276 283 } 277 284 } 278 285 286 + /// Synchronous Nuke cache lookup so the avatar swaps atomically at commit. 287 + /// Falls back to AvatarView(animated:false) whose lastUIImage shows the prior 288 + /// avatar during any mid-animation cache miss — visually identical, no type-switch artifact. 289 + @ViewBuilder 290 + private func storyAvatarView(url: String?) -> some View { 291 + if let urlStr = url, 292 + let imageURL = URL(string: urlStr), 293 + let img = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image 294 + { 295 + Image(uiImage: img) 296 + .resizable() 297 + .frame(width: 32, height: 32) 298 + .clipShape(Circle()) 299 + } else { 300 + AvatarView(url: url, size: 32, animated: false) 301 + } 302 + } 303 + 279 304 private var storyContent: some View { 280 305 ZStack { 281 306 Color.black.ignoresSafeArea() ··· 303 328 } 304 329 } else { 305 330 ZStack { 306 - LazyImage(url: URL(string: story.thumb)) { thumbState in 307 - if let thumb = thumbState.image { 308 - thumb 309 - .resizable() 310 - .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 311 - .blur(radius: 20) 312 - .clipped() 331 + // Hit Nuke's memory cache synchronously so the blurred 332 + // thumb is in frame 1 — LazyImage takes a frame to 333 + // publish even from cache, causing a 1-frame flash 334 + // right after a story transition. 335 + if let thumbURL = URL(string: story.thumb), 336 + let cachedThumb = ImagePipeline.shared.cache 337 + .cachedImage(for: ImageRequest(url: thumbURL))?.image 338 + { 339 + Image(uiImage: cachedThumb) 340 + .resizable() 341 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 342 + .blur(radius: 20) 343 + .clipped() 344 + } else { 345 + LazyImage(url: URL(string: story.thumb)) { thumbState in 346 + if let thumb = thumbState.image { 347 + thumb 348 + .resizable() 349 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 350 + .blur(radius: 20) 351 + .clipped() 352 + } 313 353 } 314 354 } 315 355 ProgressView() ··· 368 408 } 369 409 } label: { 370 410 HStack(alignment: .center, spacing: 8) { 371 - AvatarView(url: story?.creator.avatar ?? author.avatar, size: 32, animated: false) 411 + storyAvatarView(url: story?.creator.avatar ?? author.avatar) 372 412 VStack(alignment: .leading, spacing: 0) { 373 413 Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 374 414 .font(.subheadline.bold()) ··· 599 639 faceOffsets.current = 0 600 640 faceOffsets.pending = resetOffset 601 641 } completion: { 602 - guard self.transitionGeneration == gen else { return } 642 + guard transitionGeneration == gen else { return } 603 643 withTransaction(Transaction(animation: nil)) { 604 - self.pendingTransition = PendingAuthorTransition() 605 - self.faceOffsets = FaceOffsets() 644 + pendingTransition = PendingAuthorTransition() 645 + faceOffsets = FaceOffsets() 606 646 } 607 - self.startTimerIfSafe() 647 + startTimerIfSafe() 608 648 } 609 649 } 610 650 ··· 629 669 faceOffsets.current = targetCurrentOffset 630 670 faceOffsets.pending = 0 631 671 } completion: { 632 - guard self.transitionGeneration == gen else { return } 672 + guard transitionGeneration == gen else { return } 633 673 withTransaction(Transaction(animation: nil)) { 634 - let storiesToPresent = self.pendingTransition.stories 635 - self.currentAuthorIndex = index 636 - self.timer.progress = 0 674 + let storiesToPresent = pendingTransition.stories 675 + currentAuthorIndex = index 676 + timer.progress = 0 637 677 if !storiesToPresent.isEmpty { 638 - self.presentStories(storiesToPresent, resumeIndex: resumeIndex) 678 + presentStories(storiesToPresent, resumeIndex: resumeIndex) 639 679 } else { 640 - self.switchToCurrentAuthor(resumeIndex: resumeIndex) 680 + switchToCurrentAuthor(resumeIndex: resumeIndex) 641 681 } 642 - self.pendingTransition = PendingAuthorTransition() 643 - self.faceOffsets = FaceOffsets() 682 + pendingTransition = PendingAuthorTransition() 683 + faceOffsets = FaceOffsets() 644 684 } 645 685 } 646 686 }
+47 -15
GrainTests/ImagePrefetchPlanningTests.swift
··· 162 162 fourthNextFirstStory: nil 163 163 ) 164 164 165 - // High: stories 1, 2 of current + first of next author 166 - XCTAssertEqual(urls(from: result.high), [ 167 - "https://cdn.example.com/full/1.jpg", 168 - "https://cdn.example.com/full/2.jpg", 169 - "https://cdn.example.com/full/0.jpg", // next author's first 170 - ]) 165 + let highUrls = Set(urls(from: result.high)) 166 + 167 + // High fullsizes: stories 1, 2 of current + first of next author 168 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg")) 169 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/2.jpg")) 170 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg")) 171 + 172 + // High thumbs: every reachable thumb (current + next author) 173 + for i in 0 ..< 5 { 174 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/\(i).jpg")) 175 + } 171 176 172 - // Normal: stories 3, 4 of current (rest of stack) 177 + // Normal: stories 3, 4 of current (rest of stack — fullsize only) 173 178 XCTAssertEqual(urls(from: result.normal), [ 174 179 "https://cdn.example.com/full/3.jpg", 175 180 "https://cdn.example.com/full/4.jpg", ··· 189 194 fourthNextFirstStory: nil 190 195 ) 191 196 192 - // High: only next author's first (no more current stories to prefetch) 193 - XCTAssertEqual(urls(from: result.high), ["https://cdn.example.com/full/0.jpg"]) 197 + let highUrls = Set(urls(from: result.high)) 198 + 199 + // High fullsize: only next author's first (no more current stories) 200 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/0.jpg")) 201 + XCTAssertFalse(highUrls.contains("https://cdn.example.com/full/1.jpg")) 202 + 203 + // High thumbs: current + next author thumbs 204 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg")) 205 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg")) 194 206 } 195 207 196 208 func testStory_noNextAuthorData_skipsThatTier() { ··· 205 217 fourthNextFirstStory: nil 206 218 ) 207 219 208 - // High: only current author stories 1, 2 209 - XCTAssertEqual(urls(from: result.high).count, 2) 220 + let highUrls = Set(urls(from: result.high)) 221 + 222 + // High fullsize: only current stories 1, 2 (no next author tier) 223 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg")) 224 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/2.jpg")) 225 + // High thumbs: only current author's thumbs 226 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg")) 227 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg")) 228 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/2.jpg")) 229 + 210 230 XCTAssertTrue(result.low.isEmpty) 211 231 } 212 232 ··· 227 247 fourthNextFirstStory: fourth 228 248 ) 229 249 230 - // High: current story 1 + next author first 231 - XCTAssertEqual(urls(from: result.high).count, 2) 232 - // Normal: second-next author stories 0, 1 250 + let highUrls = Set(urls(from: result.high)) 251 + 252 + // High fullsizes: current story 1 + next author first 253 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/full/1.jpg")) 254 + XCTAssertTrue(highUrls.contains("https://next/f0")) 255 + // High thumbs: every reachable thumb at every tier 256 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/0.jpg")) 257 + XCTAssertTrue(highUrls.contains("https://cdn.example.com/thumb/1.jpg")) 258 + XCTAssertTrue(highUrls.contains("https://next/t0")) 259 + XCTAssertTrue(highUrls.contains("https://second/t0")) 260 + XCTAssertTrue(highUrls.contains("https://second/t1")) 261 + XCTAssertTrue(highUrls.contains("https://third/t0")) 262 + XCTAssertTrue(highUrls.contains("https://fourth/t0")) 263 + 264 + // Normal: second-next author stories 0, 1 (fullsize only) 233 265 XCTAssertEqual(urls(from: result.normal).count, 2) 234 - // Low: third + fourth author firsts 266 + // Low: third + fourth author firsts (fullsize only) 235 267 XCTAssertEqual(urls(from: result.low).count, 2) 236 268 } 237 269