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.

feat: interactive parallax author transition with mid-swipe cancel

Replaces the two-phase opacity/slide author transition with an
interactive parallax effect driven by the live pan gesture.

- DragToDismiss: add onHorizontalDragStart/onSwipeDragging/onHorizontalDragCancel
callbacks; eliminate horizontalDragStartFired (redundant with direction
state machine)
- StoryViewer: render pending author face in ZStack during swipe; current
face slides at 1x, incoming at 0.65x for depth parallax
- Pending face shows correct story index (mirrors presentStories logic),
correct bar count, and bars at 0% for next story
- Timer progress reset to 0 on commit so next story bars never inherit
stale non-zero progress
- Task cancellation replaces generation counter for rapid-swipe safety
- Consolidate 5 @State vars into PendingAuthorTransition + FaceOffsets structs

+267 -57
+25 -2
Grain/Views/Stories/DragToDismiss.swift
··· 42 42 let onDragCancel: () -> Void 43 43 let onSwipeLeft: () -> Void 44 44 let onSwipeRight: () -> Void 45 + var onHorizontalDragStart: ((Bool) -> Void)? // true = swiping left (forward) 46 + var onSwipeDragging: ((CGFloat) -> Void)? // raw translation.x during drag 47 + var onHorizontalDragCancel: (() -> Void)? 45 48 46 49 func makeUIView(context: Context) -> UIView { 47 50 let view = UIView(frame: .zero) ··· 60 63 context.coordinator.onDragCancel = onDragCancel 61 64 context.coordinator.onSwipeLeft = onSwipeLeft 62 65 context.coordinator.onSwipeRight = onSwipeRight 66 + context.coordinator.onHorizontalDragStart = onHorizontalDragStart 67 + context.coordinator.onSwipeDragging = onSwipeDragging 68 + context.coordinator.onHorizontalDragCancel = onHorizontalDragCancel 63 69 handle.performDismiss = onDismiss 64 70 } 65 71 ··· 70 76 onDragStart: onDragStart, 71 77 onDragCancel: onDragCancel, 72 78 onSwipeLeft: onSwipeLeft, 73 - onSwipeRight: onSwipeRight 79 + onSwipeRight: onSwipeRight, 80 + onHorizontalDragStart: onHorizontalDragStart, 81 + onSwipeDragging: onSwipeDragging, 82 + onHorizontalDragCancel: onHorizontalDragCancel 74 83 ) 75 84 } 76 85 ··· 85 94 var onDragCancel: () -> Void 86 95 var onSwipeLeft: () -> Void 87 96 var onSwipeRight: () -> Void 97 + var onHorizontalDragStart: ((Bool) -> Void)? 98 + var onSwipeDragging: ((CGFloat) -> Void)? 99 + var onHorizontalDragCancel: (() -> Void)? 88 100 89 101 weak var anchorView: UIView? 90 102 private weak var targetView: UIView? ··· 97 109 onDragStart: @escaping () -> Void, 98 110 onDragCancel: @escaping () -> Void, 99 111 onSwipeLeft: @escaping () -> Void, 100 - onSwipeRight: @escaping () -> Void 112 + onSwipeRight: @escaping () -> Void, 113 + onHorizontalDragStart: ((Bool) -> Void)? = nil, 114 + onSwipeDragging: ((CGFloat) -> Void)? = nil, 115 + onHorizontalDragCancel: (() -> Void)? = nil 101 116 ) { 102 117 self.handle = handle 103 118 self.onDismiss = onDismiss ··· 105 120 self.onDragCancel = onDragCancel 106 121 self.onSwipeLeft = onSwipeLeft 107 122 self.onSwipeRight = onSwipeRight 123 + self.onHorizontalDragStart = onHorizontalDragStart 124 + self.onSwipeDragging = onSwipeDragging 125 + self.onHorizontalDragCancel = onHorizontalDragCancel 108 126 } 109 127 110 128 func installGestureIfNeeded() { ··· 145 163 onDragStart() 146 164 } else if absX > absY { 147 165 direction = .horizontal 166 + onHorizontalDragStart?(translation.x < 0) 148 167 } 149 168 } 150 169 } ··· 157 176 .scaledBy(x: scale, y: scale) 158 177 view.layer.cornerRadius = progress * 24 159 178 view.clipsToBounds = true 179 + } else if direction == .horizontal { 180 + onSwipeDragging?(translation.x) 160 181 } 161 182 162 183 case .ended, .cancelled: ··· 203 224 onSwipeLeft() 204 225 } else if translation.x > 80 || velocity.x > 500 { 205 226 onSwipeRight() 227 + } else { 228 + onHorizontalDragCancel?() 206 229 } 207 230 } 208 231
+242 -55
Grain/Views/Stories/StoryViewer.swift
··· 46 46 private var quarterFired = false 47 47 } 48 48 49 + private struct PendingAuthorTransition { 50 + var authorIndex: Int? 51 + var stories: [GrainStory] = [] 52 + var storyIndex: Int = 0 53 + } 54 + 55 + private struct FaceOffsets { 56 + var current: CGFloat = 0 57 + var pending: CGFloat = 0 58 + } 59 + 49 60 struct StoryViewer: View { 50 61 @Environment(AuthManager.self) private var auth 51 62 @Environment(LabelDefinitionsCache.self) private var labelDefsCache ··· 70 81 @State private var fadeDismissHandle = FadeDismissHandle() 71 82 @State private var prefetchedStories: [String: [GrainStory]] = [:] 72 83 @State private var unreadOnly = false 73 - @State private var authorTransition: CGFloat = 1.0 74 - @State private var slideOffset: CGFloat = 0 84 + @State private var pendingTransition = PendingAuthorTransition() 85 + @State private var faceOffsets = FaceOffsets() 86 + @State private var swipingForward = true 87 + @State private var transitionTask: Task<Void, Never>? 75 88 @State private var authorHistory: [(authorIndex: Int, storyIndex: Int)] = [] 76 89 @State private var imagePrefetcher = ImagePrefetcher() 77 90 @State private var nextStoryFromTrailing = true ··· 94 107 resolveLabels(currentStory?.labels, definitions: labelDefsCache.definitions) 95 108 } 96 109 110 + private var screenWidth: CGFloat { 111 + (UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first?.screen.bounds.width) ?? 390 112 + } 113 + 114 + private var swipeAmount: CGFloat { 115 + min(abs(faceOffsets.current) / screenWidth, 1) 116 + } 117 + 97 118 var body: some View { 98 - storyContent 99 - .offset(x: slideOffset) 100 - .scaleEffect(0.85 + 0.15 * authorTransition) 101 - .opacity(0.3 + 0.7 * Double(authorTransition)) 102 - .background( 103 - DragToDismissInstaller( 104 - handle: fadeDismissHandle, 105 - onDismiss: { onDismiss?() }, 106 - onDragStart: { timer.stop() }, 107 - onDragCancel: { startTimerIfSafe() }, 108 - onSwipeLeft: { goToNextAuthor() }, 109 - onSwipeRight: { goToPreviousAuthor() } 110 - ) 119 + ZStack { 120 + if let pendingIdx = pendingTransition.authorIndex { 121 + pendingFaceView(authorIdx: pendingIdx) 122 + .offset(x: faceOffsets.pending) 123 + .scaleEffect(0.95 + swipeAmount * 0.05) 124 + .opacity(0.65 + Double(swipeAmount) * 0.35) 125 + } 126 + storyContent 127 + .offset(x: faceOffsets.current) 128 + .scaleEffect(1 - swipeAmount * 0.05) 129 + .opacity(1 - Double(swipeAmount) * 0.35) 130 + } 131 + .background( 132 + DragToDismissInstaller( 133 + handle: fadeDismissHandle, 134 + onDismiss: { onDismiss?() }, 135 + onDragStart: { timer.stop() }, 136 + onDragCancel: { startTimerIfSafe() }, 137 + onSwipeLeft: { goToNextAuthor() }, 138 + onSwipeRight: { goToPreviousAuthor() }, 139 + onHorizontalDragStart: { forward in beginSwipe(forward: forward) }, 140 + onSwipeDragging: { tx in updateSwipeDrag(tx) }, 141 + onHorizontalDragCancel: { cancelSwipe() } 111 142 ) 112 - .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 113 - Button("Delete", role: .destructive) { 114 - if let story = currentStory { 115 - Task { await deleteStory(story) } 116 - } 143 + ) 144 + .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 145 + Button("Delete", role: .destructive) { 146 + if let story = currentStory { 147 + Task { await deleteStory(story) } 117 148 } 118 - Button("Cancel", role: .cancel) { 119 - timer.start() 120 - } 149 + } 150 + Button("Cancel", role: .cancel) { 151 + timer.start() 121 152 } 122 - .fullScreenCover(isPresented: $showReportSheet) { 123 - ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 124 - .environment(auth) 153 + } 154 + .fullScreenCover(isPresented: $showReportSheet) { 155 + ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 156 + .environment(auth) 157 + } 158 + .onChange(of: showReportSheet) { 159 + if !showReportSheet { 160 + timer.start() 125 161 } 126 - .onChange(of: showReportSheet) { 127 - if !showReportSheet { 128 - timer.start() 162 + } 163 + .task { 164 + guard !isPreview else { return } 165 + let startAuthor = authors[currentAuthorIndex] 166 + let isOwn = startAuthor.profile.did == auth.userDID 167 + let hasUnreads = !viewedStories.hasViewedAll(authorDid: startAuthor.profile.did, latestAt: startAuthor.latestAt) 168 + unreadOnly = isOwn || hasUnreads 169 + timer.onComplete = { [self] in goToNext() } 170 + timer.onQuarter = { [self] in markCurrentStoryViewed() } 171 + await loadStoriesForCurrentAuthor() 172 + } 173 + } 174 + 175 + /// Mirrors the logic in presentStories so pendingTransition.storyIndex matches what will be committed. 176 + private func resolvedStoryIndex(for stories: [GrainStory], resumeIndex: Int? = nil) -> Int { 177 + if let resume = resumeIndex { 178 + return min(resume, max(stories.count - 1, 0)) 179 + } 180 + let isOwn = stories.first?.creator.did == auth.userDID 181 + return (unreadOnly && isOwn) ? 0 : viewedStories.firstUnviewedIndex(in: stories) 182 + } 183 + 184 + @ViewBuilder 185 + private func pendingFaceView(authorIdx: Int) -> some View { 186 + let story = pendingTransition.stories.indices.contains(pendingTransition.storyIndex) 187 + ? pendingTransition.stories[pendingTransition.storyIndex] 188 + : pendingTransition.stories.first 189 + let barCount = pendingTransition.stories.isEmpty 190 + ? max(authors[authorIdx].storyCount, 1) 191 + : pendingTransition.stories.count 192 + ZStack { 193 + Color.black.ignoresSafeArea() 194 + if let story { 195 + LazyImage(url: URL(string: story.thumb)) { state in 196 + if let img = state.image { 197 + img.resizable() 198 + .aspectRatio(story.aspectRatio.ratio, contentMode: .fit) 199 + .frame(maxWidth: .infinity) 200 + } 129 201 } 202 + } else { 203 + ProgressView().tint(.white) 130 204 } 131 - .task { 132 - guard !isPreview else { return } 133 - let startAuthor = authors[currentAuthorIndex] 134 - let isOwn = startAuthor.profile.did == auth.userDID 135 - let hasUnreads = !viewedStories.hasViewedAll(authorDid: startAuthor.profile.did, latestAt: startAuthor.latestAt) 136 - unreadOnly = isOwn || hasUnreads 137 - timer.onComplete = { [self] in goToNext() } 138 - timer.onQuarter = { [self] in markCurrentStoryViewed() } 139 - await loadStoriesForCurrentAuthor() 205 + VStack(spacing: 0) { 206 + HStack(spacing: 4) { 207 + ForEach(0 ..< barCount, id: \.self) { i in 208 + GeometryReader { geo in 209 + Capsule().fill(Color.white.opacity(0.3)) 210 + Capsule().fill(Color.white) 211 + .frame(width: i < pendingTransition.storyIndex ? geo.size.width : 0) 212 + } 213 + .frame(height: 2) 214 + } 215 + } 216 + .padding(.horizontal) 217 + .padding(.top, 8) 218 + HStack(spacing: 8) { 219 + AvatarView(url: authors[authorIdx].profile.avatar, size: 32) 220 + Text(authors[authorIdx].profile.displayName ?? authors[authorIdx].profile.handle) 221 + .font(.subheadline.bold()) 222 + .foregroundStyle(.white) 223 + Spacer() 224 + } 225 + .padding(.horizontal, 16) 226 + .padding(.vertical, 8) 227 + Spacer() 140 228 } 229 + } 141 230 } 142 231 143 232 private var storyContent: some View { ··· 327 416 private func canNavigate() -> Bool { 328 417 !isLoadingStories && !stories.isEmpty 329 418 && !showReportSheet && !showDeleteConfirm 419 + && pendingTransition.authorIndex == nil 330 420 && Date().timeIntervalSince(lastNavTime) > 0.3 331 421 } 332 422 ··· 379 469 } 380 470 381 471 private func goToNextAuthor() { 382 - if let next = findAuthorIndex(from: currentAuthorIndex, forward: true) { 472 + if let pending = pendingTransition.authorIndex { 473 + guard swipingForward else { cancelSwipe(); return } 383 474 authorHistory.append((authorIndex: currentAuthorIndex, storyIndex: currentStoryIndex)) 384 - transitionToAuthor(next, direction: 1) 385 - } else { 386 - close() 475 + transitionToAuthor(pending, forward: true) 476 + return 477 + } 478 + guard let next = findAuthorIndex(from: currentAuthorIndex, forward: true) else { 479 + close(); return 387 480 } 481 + authorHistory.append((authorIndex: currentAuthorIndex, storyIndex: currentStoryIndex)) 482 + transitionToAuthor(next, forward: true) 388 483 } 389 484 390 485 private func goToPreviousAuthor() { 486 + if let pending = pendingTransition.authorIndex { 487 + guard !swipingForward else { cancelSwipe(); return } 488 + if let prev = authorHistory.popLast() { 489 + transitionToAuthor(pending, forward: false, resumeIndex: prev.storyIndex) 490 + } else { 491 + transitionToAuthor(pending, forward: false) 492 + } 493 + return 494 + } 391 495 if let prev = authorHistory.popLast() { 392 - transitionToAuthor(prev.authorIndex, direction: -1, resumeIndex: prev.storyIndex) 496 + transitionToAuthor(prev.authorIndex, forward: false, resumeIndex: prev.storyIndex) 393 497 return 394 498 } 395 - // No history (entered mid-strip in reads mode) — walk backward ignoring the reads filter 499 + // No history — walk backward ignoring the reads filter 396 500 var i = currentAuthorIndex - 1 397 501 while i >= 0 { 398 502 if authors[i].profile.did != auth.userDID { 399 - transitionToAuthor(i, direction: -1) 503 + transitionToAuthor(i, forward: false) 400 504 return 401 505 } 402 506 i -= 1 403 507 } 404 508 } 405 509 406 - private func transitionToAuthor(_ index: Int, direction: CGFloat, resumeIndex: Int? = nil) { 510 + private func beginSwipe(forward: Bool) { 511 + guard pendingTransition.authorIndex == nil else { return } 407 512 timer.stop() 408 - withAnimation(.easeIn(duration: 0.15)) { 409 - authorTransition = 0 410 - slideOffset = -80 * direction 513 + swipingForward = forward 514 + 515 + let resumeForBackward: Int? = forward ? nil : authorHistory.last?.storyIndex 516 + 517 + let targetIdx: Int? 518 + if forward { 519 + targetIdx = findAuthorIndex(from: currentAuthorIndex, forward: true) 520 + } else { 521 + if let prev = authorHistory.last { 522 + targetIdx = prev.authorIndex 523 + } else { 524 + var i = currentAuthorIndex - 1 525 + var found: Int? 526 + while i >= 0 { 527 + if authors[i].profile.did != auth.userDID { found = i; break } 528 + i -= 1 529 + } 530 + targetIdx = found 531 + } 411 532 } 412 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { 533 + guard let idx = targetIdx else { return } 534 + pendingTransition.authorIndex = idx 535 + let did = authors[idx].profile.did 536 + if let cached = prefetchedStories[did] { 537 + pendingTransition.stories = cached 538 + pendingTransition.storyIndex = resolvedStoryIndex(for: cached, resumeIndex: resumeForBackward) 539 + } else { 540 + pendingTransition.storyIndex = resumeForBackward ?? 0 541 + Task { 542 + if let response = try? await client.getStories(actor: did, auth: auth.authContext()) { 543 + pendingTransition.stories = response.stories 544 + pendingTransition.storyIndex = resolvedStoryIndex(for: response.stories, resumeIndex: resumeForBackward) 545 + } 546 + } 547 + } 548 + faceOffsets.pending = forward ? screenWidth : -screenWidth 549 + } 550 + 551 + private func updateSwipeDrag(_ tx: CGFloat) { 552 + guard pendingTransition.authorIndex != nil else { return } 553 + let clamped = max(-screenWidth, min(screenWidth, tx)) 554 + faceOffsets.current = clamped 555 + let origin: CGFloat = swipingForward ? screenWidth : -screenWidth 556 + faceOffsets.pending = origin + clamped * 0.65 557 + } 558 + 559 + private func cancelSwipe() { 560 + transitionTask?.cancel() 561 + let resetOffset: CGFloat = swipingForward ? screenWidth : -screenWidth 562 + withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 563 + faceOffsets.current = 0 564 + faceOffsets.pending = resetOffset 565 + } 566 + transitionTask = Task { @MainActor in 567 + try? await Task.sleep(for: .milliseconds(400)) 568 + guard !Task.isCancelled else { return } 569 + pendingTransition = PendingAuthorTransition() 570 + startTimerIfSafe() 571 + } 572 + } 573 + 574 + private func transitionToAuthor(_ index: Int, forward: Bool, resumeIndex: Int? = nil) { 575 + timer.stop() 576 + transitionTask?.cancel() 577 + 578 + // Set up pending face if not already done by beginSwipe 579 + if pendingTransition.authorIndex != index { 580 + swipingForward = forward 581 + pendingTransition.authorIndex = index 582 + let did = authors[index].profile.did 583 + let cached = prefetchedStories[did] ?? [] 584 + pendingTransition.stories = cached 585 + pendingTransition.storyIndex = resolvedStoryIndex(for: cached, resumeIndex: resumeIndex) 586 + faceOffsets.pending = forward ? screenWidth : -screenWidth 587 + faceOffsets.current = 0 588 + } 589 + let targetCurrentOffset: CGFloat = forward ? -screenWidth : screenWidth 590 + withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 591 + faceOffsets.current = targetCurrentOffset 592 + faceOffsets.pending = 0 593 + } 594 + transitionTask = Task { @MainActor in 595 + try? await Task.sleep(for: .milliseconds(400)) 596 + guard !Task.isCancelled else { return } 597 + let storiesToPresent = pendingTransition.stories 413 598 currentAuthorIndex = index 414 - switchToCurrentAuthor(resumeIndex: resumeIndex) 415 - slideOffset = 80 * direction 416 - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 417 - authorTransition = 1 418 - slideOffset = 0 599 + timer.progress = 0 // next story bar starts at zero, never at a stale non-zero value 600 + if !storiesToPresent.isEmpty { 601 + presentStories(storiesToPresent, resumeIndex: resumeIndex) 602 + } else { 603 + switchToCurrentAuthor(resumeIndex: resumeIndex) 419 604 } 605 + pendingTransition = PendingAuthorTransition() 606 + faceOffsets = FaceOffsets() 420 607 } 421 608 } 422 609