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 strip artifact and avatar animation in story parallax transition

- Full crossfade on parallax faces (opacity 0→1 / 1→0) so the outgoing
pane is invisible by the time it exits, removing the edge strip
- AvatarView gains `animated: Bool` + `lastUIImage` persistence: when URL
changes, shows previous image instead of gray fallback, preventing the
1-frame gray flash on author transitions
- pendingFaceView avatar uses animated:false so NukeUI snap is instant
- pendingFaceView story image uses synchronous ImagePipeline cache lookup;
prefetched thumbs render immediately rather than waiting for async dispatch,
closing the swipe-vs-tap timing gap

+114 -83
+17 -2
Grain/Views/Components/AvatarView.swift
··· 4 4 struct AvatarView: View { 5 5 let url: String? 6 6 var size: CGFloat = 32 7 + /// Set to false to suppress all NukeUI transitions — use when the avatar is inside 8 + /// an animated parent (e.g. story parallax pane) so it snaps atomically. 9 + var animated: Bool = true 10 + 11 + /// Retains the last successfully loaded image so URL changes don't flash gray. 12 + @State private var lastUIImage: UIImage? 7 13 8 14 var body: some View { 9 15 if let url, let imageURL = URL(string: url) { 10 16 LazyImage(url: imageURL) { state in 11 - if let image = state.image { 12 - image.resizable() 17 + if let uiImage = state.imageContainer?.image { 18 + Image(uiImage: uiImage) 19 + .resizable() 20 + .transition(animated ? .opacity : .identity) 21 + .onAppear { lastUIImage = uiImage } 22 + } else if let prev = lastUIImage { 23 + // Show previous image while new URL loads — no gray flash 24 + Image(uiImage: prev) 25 + .resizable() 26 + .transition(.identity) 13 27 } else { 14 28 fallback 29 + .transition(animated ? .opacity : .identity) 15 30 } 16 31 } 17 32 .frame(width: size, height: size)
+97 -81
Grain/Views/Stories/StoryViewer.swift
··· 84 84 @State private var pendingTransition = PendingAuthorTransition() 85 85 @State private var faceOffsets = FaceOffsets() 86 86 @State private var swipingForward = true 87 - @State private var transitionTask: Task<Void, Never>? 87 + @State private var transitionGeneration = 0 88 88 @State private var authorHistory: [(authorIndex: Int, storyIndex: Int)] = [] 89 89 @State private var imagePrefetcher = ImagePrefetcher() 90 90 @State private var nextStoryFromTrailing = true ··· 121 121 if let pendingIdx = pendingTransition.authorIndex { 122 122 pendingFaceView(authorIdx: pendingIdx) 123 123 .offset(x: faceOffsets.pending) 124 - .scaleEffect(0.95 + swipeAmount * 0.05) 125 - .opacity(0.65 + Double(swipeAmount) * 0.35) 124 + .scaleEffect(0.92 + swipeAmount * 0.08) 125 + .opacity(Double(swipeAmount)) 126 126 } 127 127 storyContent 128 128 .offset(x: faceOffsets.current) 129 - .scaleEffect(1 - swipeAmount * 0.05) 130 - .opacity(1 - Double(swipeAmount) * 0.35) 129 + .scaleEffect(1 - swipeAmount * 0.08) 130 + .opacity(1 - Double(swipeAmount)) 131 131 } 132 + .clipped() 132 133 .background( 133 134 DragToDismissInstaller( 134 135 handle: fadeDismissHandle, ··· 193 194 ZStack { 194 195 Color.black.ignoresSafeArea() 195 196 if let story { 196 - LazyImage(url: URL(string: story.thumb)) { state in 197 - if let img = state.image { 198 - img.resizable() 199 - .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 200 - .frame(maxWidth: .infinity) 197 + let thumbURL = URL(string: story.thumb) 198 + let cached = thumbURL.flatMap { 199 + ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: $0))?.image 200 + } 201 + if let img = cached { 202 + Image(uiImage: img) 203 + .resizable() 204 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 205 + .frame(maxWidth: .infinity) 206 + } else { 207 + LazyImage(url: thumbURL) { state in 208 + if let img = state.image { 209 + img.resizable() 210 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 211 + .frame(maxWidth: .infinity) 212 + .transition(.identity) 213 + } 201 214 } 202 215 } 203 216 } else { ··· 217 230 .padding(.horizontal) 218 231 .padding(.top, 8) 219 232 HStack(alignment: .center, spacing: 8) { 220 - AvatarView(url: authors[authorIdx].profile.avatar, size: 32) 233 + AvatarView(url: authors[authorIdx].profile.avatar, size: 32, animated: false) 221 234 VStack(alignment: .leading, spacing: 0) { 222 235 Text(story?.creator.displayName ?? story?.creator.handle ?? authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 223 236 .font(.subheadline.bold()) ··· 229 242 } 230 243 } 231 244 Spacer() 232 - // Placeholder slots to match storyContent's button layout height 233 - Color.clear.frame(width: 36, height: 36) 234 - Color.clear.frame(width: 36, height: 36) 245 + if authors[authorIdx].profile.did == auth.userDID { 246 + Image(systemName: "trash") 247 + .foregroundStyle(.white) 248 + .frame(width: 36, height: 36) 249 + } else { 250 + Image(systemName: "flag") 251 + .foregroundStyle(.white) 252 + .frame(width: 36, height: 36) 253 + } 254 + Image(systemName: "xmark") 255 + .foregroundStyle(.white) 256 + .font(.body.weight(.semibold)) 257 + .frame(width: 36, height: 36) 235 258 } 236 259 .padding(.horizontal, 16) 237 260 .padding(.vertical, 8) ··· 354 377 } 355 378 } label: { 356 379 HStack(alignment: .center, spacing: 8) { 357 - AvatarView(url: story?.creator.avatar ?? author.avatar, size: 32) 380 + AvatarView(url: story?.creator.avatar ?? author.avatar, size: 32, animated: false) 358 381 VStack(alignment: .leading, spacing: 0) { 359 382 Text(story?.creator.displayName ?? story?.creator.handle ?? author.displayName ?? author.handle) 360 383 .font(.subheadline.bold()) ··· 369 392 } 370 393 Spacer() 371 394 372 - if let story { 373 - if story.creator.did == auth.userDID { 374 - Button { 375 - timer.stop() 376 - showDeleteConfirm = true 377 - } label: { 378 - Image(systemName: "trash") 379 - .foregroundStyle(.white) 380 - .frame(width: 36, height: 36) 381 - } 382 - } else { 383 - Button { 384 - timer.stop() 385 - reportStoryUri = story.uri 386 - reportStoryCid = story.cid 387 - showReportSheet = true 388 - } label: { 389 - Image(systemName: "flag") 390 - .foregroundStyle(.white) 391 - .frame(width: 36, height: 36) 392 - } 395 + if author.did == auth.userDID { 396 + Button { 397 + guard let story else { return } 398 + timer.stop() 399 + showDeleteConfirm = true 400 + } label: { 401 + Image(systemName: "trash") 402 + .foregroundStyle(.white) 403 + .frame(width: 36, height: 36) 404 + } 405 + } else { 406 + Button { 407 + guard let story else { return } 408 + timer.stop() 409 + reportStoryUri = story.uri 410 + reportStoryCid = story.cid 411 + showReportSheet = true 412 + } label: { 413 + Image(systemName: "flag") 414 + .foregroundStyle(.white) 415 + .frame(width: 36, height: 36) 393 416 } 394 417 } 395 418 ··· 461 484 timer.stop() 462 485 lastNavTime = Date() 463 486 if currentStoryIndex < stories.count - 1 { 464 - animateToStory(forward: true) { 465 - currentStoryIndex += 1 466 - timer.progress = 0 467 - imageLoaded = false 468 - labelRevealed = false 469 - showLocationCopied = false 470 - } 487 + timer.progress = 0 488 + currentStoryIndex += 1 489 + imageLoaded = false 490 + labelRevealed = false 491 + showLocationCopied = false 471 492 prefetchStoryImages() 472 493 } else { 473 494 goToNextAuthor() ··· 479 500 timer.stop() 480 501 lastNavTime = Date() 481 502 if currentStoryIndex > 0 { 482 - animateToStory(forward: false) { 483 - currentStoryIndex -= 1 484 - timer.progress = 0 485 - imageLoaded = false 486 - labelRevealed = false 487 - showLocationCopied = false 488 - } 503 + timer.progress = 0 504 + currentStoryIndex -= 1 505 + imageLoaded = false 506 + labelRevealed = false 507 + showLocationCopied = false 489 508 prefetchStoryImages() 490 509 } else { 491 510 goToPreviousAuthor() 492 - } 493 - } 494 - 495 - private func animateToStory(forward: Bool, _ action: () -> Void) { 496 - nextStoryFromTrailing = forward 497 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 498 - action() 499 511 } 500 512 } 501 513 ··· 592 604 593 605 private func cancelSwipe() { 594 606 isDragging = false 595 - transitionTask?.cancel() 607 + transitionGeneration += 1 608 + let gen = transitionGeneration 596 609 let resetOffset: CGFloat = swipingForward ? screenWidth : -screenWidth 597 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 610 + withAnimation(.spring(response: 0.28, dampingFraction: 0.88), completionCriteria: .removed) { 598 611 faceOffsets.current = 0 599 612 faceOffsets.pending = resetOffset 600 - } 601 - transitionTask = Task { @MainActor in 602 - try? await Task.sleep(for: .milliseconds(400)) 603 - guard !Task.isCancelled else { return } 604 - pendingTransition = PendingAuthorTransition() 605 - startTimerIfSafe() 613 + } completion: { 614 + guard self.transitionGeneration == gen else { return } 615 + withTransaction(Transaction(animation: nil)) { 616 + self.pendingTransition = PendingAuthorTransition() 617 + self.faceOffsets = FaceOffsets() 618 + } 619 + self.startTimerIfSafe() 606 620 } 607 621 } 608 622 609 623 private func transitionToAuthor(_ index: Int, forward: Bool, resumeIndex: Int? = nil) { 610 624 timer.stop() 611 - transitionTask?.cancel() 625 + transitionGeneration += 1 626 + let gen = transitionGeneration 612 627 613 628 // Set up pending face if not already done by beginSwipe 614 629 if pendingTransition.authorIndex != index { ··· 622 637 faceOffsets.current = 0 623 638 } 624 639 let targetCurrentOffset: CGFloat = forward ? -screenWidth : screenWidth 625 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 640 + withAnimation(.spring(response: 0.28, dampingFraction: 0.88), completionCriteria: .removed) { 626 641 faceOffsets.current = targetCurrentOffset 627 642 faceOffsets.pending = 0 628 - } 629 - transitionTask = Task { @MainActor in 630 - try? await Task.sleep(for: .milliseconds(400)) 631 - guard !Task.isCancelled else { return } 632 - let storiesToPresent = pendingTransition.stories 633 - currentAuthorIndex = index 634 - timer.progress = 0 // next story bar starts at zero, never at a stale non-zero value 635 - if !storiesToPresent.isEmpty { 636 - presentStories(storiesToPresent, resumeIndex: resumeIndex) 637 - } else { 638 - switchToCurrentAuthor(resumeIndex: resumeIndex) 643 + } completion: { 644 + guard self.transitionGeneration == gen else { return } 645 + withTransaction(Transaction(animation: nil)) { 646 + let storiesToPresent = self.pendingTransition.stories 647 + self.currentAuthorIndex = index 648 + self.timer.progress = 0 649 + if !storiesToPresent.isEmpty { 650 + self.presentStories(storiesToPresent, resumeIndex: resumeIndex) 651 + } else { 652 + self.switchToCurrentAuthor(resumeIndex: resumeIndex) 653 + } 654 + self.pendingTransition = PendingAuthorTransition() 655 + self.faceOffsets = FaceOffsets() 639 656 } 640 - pendingTransition = PendingAuthorTransition() 641 - faceOffsets = FaceOffsets() 642 657 } 643 658 } 644 659 ··· 823 838 .fill(Color.white.opacity(0.3)) 824 839 Capsule() 825 840 .fill(Color.white) 826 - .frame(width: barWidth(for: index, totalWidth: geo.size.width)) 841 + .frame(width: max(0, barWidth(for: index, totalWidth: geo.size.width))) 827 842 } 828 843 .frame(height: 2) 844 + .transaction { $0.animation = nil } 829 845 } 830 846 } 831 847 }