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 story image flash and add alt text fade-in

Skip thumbnail placeholder entirely when fullsize is already in Nuke's
memory cache by checking the cache before creating LazyImage. Avoids a
SwiftUI view-identity swap (sync Image → LazyImage-delivered Image) that
caused a visible flash even when pixels were identical.

Also adds an asymmetric transition on the alt text overlay so it fades
in gently but dismisses at the existing speed.

+92 -52
+29 -27
Grain/Views/Components/GalleryCardView.swift
··· 259 259 ZStack(alignment: .bottom) { 260 260 TabView(selection: $currentPage) { 261 261 ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in 262 - ZoomableImage( 263 - url: photo.fullsize, 264 - thumbURL: photo.thumb, 265 - aspectRatio: photo.aspectRatio.ratio, 266 - onDoubleTap: { point in doubleTapLike(at: point) } 267 - ) 262 + ZStack { 263 + ZoomableImage( 264 + url: photo.fullsize, 265 + thumbURL: photo.thumb, 266 + aspectRatio: photo.aspectRatio.ratio, 267 + onDoubleTap: { point in doubleTapLike(at: point) } 268 + ) 269 + 270 + // Per-page alt text overlay — lives INSIDE the TabView page so 271 + // it translates with the photo during a swipe instead of 272 + // staying fixed while the photo moves underneath. Wrapping 273 + // grey + text in a single ZStack guarantees they share one 274 + // transition (no out-of-sync fade between them). 275 + if showingAlt, let alt = photo.alt, !alt.isEmpty { 276 + ZStack { 277 + Color.black.opacity(0.6) 278 + Text(alt) 279 + .font(.subheadline) 280 + .foregroundStyle(.white) 281 + .multilineTextAlignment(.center) 282 + .padding(20) 283 + } 284 + .transition(.asymmetric( 285 + insertion: .opacity.animation(.easeIn(duration: 0.35)), 286 + removal: .opacity 287 + )) 288 + .allowsHitTesting(false) 289 + } 290 + } 268 291 .tag(index) 269 292 } 270 293 } ··· 277 300 .allowsHitTesting(lr.action != .warnMedia || gallery.labelRevealed) 278 301 279 302 pageIndicator(photos: photos, hasPortrait: hasPortrait) 280 - altTextOverlay(photos: photos) 281 303 altButton(photos: photos) 282 304 283 305 // Double-tap heart animations ··· 340 362 } 341 363 } 342 364 .padding(.vertical, 8) 343 - } 344 - } 345 - 346 - @ViewBuilder 347 - private func altTextOverlay(photos: [GrainPhoto]) -> some View { 348 - if showingAlt, let alt = photos[currentPage].alt, !alt.isEmpty { 349 - Color.black.opacity(0.6) 350 - .frame(maxWidth: .infinity, maxHeight: .infinity) 351 - .onTapGesture { 352 - withAnimation(.easeInOut(duration: 0.2)) { 353 - showingAlt = false 354 - } 355 - } 356 - Text(alt) 357 - .font(.subheadline) 358 - .foregroundStyle(.white) 359 - .multilineTextAlignment(.center) 360 - .padding(20) 361 - .frame(maxWidth: .infinity, maxHeight: .infinity) 362 - .allowsHitTesting(false) 363 365 } 364 366 } 365 367
+63 -25
Grain/Views/Stories/StoryViewer.swift
··· 308 308 if let story = currentStory { 309 309 let lr = storyLabelResult 310 310 311 - // Story image 311 + // Story image — check memory cache before creating LazyImage so 312 + // we never get a two-state view swap (sync Image → LazyImage-delivered 313 + // Image) for the same pixel content, which causes a flash. 314 + let cachedFullsize: UIImage? = (lr.action != .hide || labelRevealed) 315 + ? URL(string: story.fullsize).flatMap { 316 + ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: $0))?.image 317 + } 318 + : nil 312 319 ZStack { 313 - LazyImage(request: { 314 - guard lr.action != .hide || labelRevealed, 315 - let url = URL(string: story.fullsize) else { return ImageRequest(url: nil) } 316 - return ImageRequest(url: url, priority: .veryHigh) 317 - }()) { state in 318 - if let image = state.image { 319 - image 320 + Group { 321 + if let cached = cachedFullsize { 322 + // DEBUG: blue = fullsize sync-pulled from memory cache (no LazyImage needed) 323 + Image(uiImage: cached) 320 324 .resizable() 321 325 .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 322 326 .frame(maxWidth: .infinity) 327 + // .overlay(alignment: .topLeading) { 328 + // Color.blue.opacity(0.35).ignoresSafeArea() 329 + // Text("fullsize · cache").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 330 + // } 323 331 .onAppear { 324 332 if !imageLoaded { 325 333 imageLoaded = true ··· 327 335 } 328 336 } 329 337 } else { 330 - ZStack { 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) 338 + LazyImage(request: { 339 + guard lr.action != .hide || labelRevealed, 340 + let url = URL(string: story.fullsize) else { return ImageRequest(url: nil) } 341 + return ImageRequest(url: url, priority: .veryHigh) 342 + }()) { state in 343 + if let image = state.image { 344 + image 340 345 .resizable() 341 346 .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 342 - .blur(radius: 20) 343 - .clipped() 347 + .frame(maxWidth: .infinity) 348 + // .overlay(alignment: .topLeading) { 349 + // // DEBUG: green = fullsize delivered by LazyImage (network/disk) 350 + // Color.green.opacity(0.35).ignoresSafeArea() 351 + // Text("fullsize · network").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 352 + // } 353 + .onAppear { 354 + if !imageLoaded { 355 + imageLoaded = true 356 + startTimerIfSafe() 357 + } 358 + } 344 359 } else { 345 - LazyImage(url: URL(string: story.thumb)) { thumbState in 346 - if let thumb = thumbState.image { 347 - thumb 360 + ZStack { 361 + if let thumbURL = URL(string: story.thumb), 362 + let cachedThumb = ImagePipeline.shared.cache 363 + .cachedImage(for: ImageRequest(url: thumbURL))?.image 364 + { 365 + Image(uiImage: cachedThumb) 348 366 .resizable() 349 367 .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 350 368 .blur(radius: 20) 351 369 .clipped() 370 + // .overlay(alignment: .topLeading) { 371 + // // DEBUG: yellow = thumb sync-pulled from memory cache 372 + // Color.yellow.opacity(0.35).ignoresSafeArea() 373 + // Text("thumb · cache").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 374 + // } 375 + } else { 376 + LazyImage(url: URL(string: story.thumb)) { thumbState in 377 + if let thumb = thumbState.image { 378 + thumb 379 + .resizable() 380 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 381 + .blur(radius: 20) 382 + .clipped() 383 + // .overlay(alignment: .topLeading) { 384 + // // DEBUG: red = thumb from network (cache miss) 385 + // Color.red.opacity(0.35).ignoresSafeArea() 386 + // Text("thumb · network").font(.caption2.bold()).padding(6).background(.black.opacity(0.5)).foregroundStyle(.white).padding(8) 387 + // } 388 + } 389 + } 352 390 } 391 + ProgressView() 392 + .tint(.white) 353 393 } 354 394 } 355 - ProgressView() 356 - .tint(.white) 357 395 } 358 396 } 359 397 }