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: smooth drag-to-dismiss for story viewer with prefetch

Use UIKit UIPanGestureRecognizer for buttery smooth drag-to-dismiss,
bypassing SwiftUI's rendering pipeline. Fade-out dismiss animation
for both drag and auto-advance. Prefetch next author's stories for
instant transitions between authors.

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

+285 -48
+198
Grain/Views/Stories/DragToDismiss.swift
··· 1 + import SwiftUI 2 + 3 + /// Shared handle so StoryViewer can trigger a fade-dismiss programmatically 4 + /// (e.g. when all stories finish) using the same UIKit path as the drag gesture. 5 + @MainActor 6 + final class FadeDismissHandle { 7 + fileprivate weak var targetView: UIView? 8 + fileprivate var performDismiss: (() -> Void)? 9 + 10 + func fadeDismiss() { 11 + guard let view = targetView else { 12 + performDismiss?() 13 + return 14 + } 15 + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut, animations: { 16 + view.alpha = 0 17 + view.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) 18 + }) { _ in 19 + if let vc = view.findViewController(), 20 + let presented = vc.presentedViewController ?? (vc.isBeingPresented ? vc : nil) { 21 + presented.dismiss(animated: false) { 22 + view.alpha = 1 23 + view.transform = .identity 24 + self.performDismiss?() 25 + } 26 + } else { 27 + view.isHidden = true 28 + self.performDismiss?() 29 + } 30 + } 31 + } 32 + } 33 + 34 + /// Installs a UIKit UIPanGestureRecognizer on the hosting view controller's view 35 + /// and applies CGAffineTransform directly — bypassing SwiftUI's rendering pipeline 36 + /// for buttery smooth drag-to-dismiss. 37 + struct DragToDismissInstaller: UIViewRepresentable { 38 + let handle: FadeDismissHandle 39 + let onDismiss: () -> Void 40 + let onDragStart: () -> Void 41 + let onDragCancel: () -> Void 42 + let onSwipeLeft: () -> Void 43 + let onSwipeRight: () -> Void 44 + 45 + func makeUIView(context: Context) -> UIView { 46 + let view = UIView(frame: .zero) 47 + view.backgroundColor = .clear 48 + view.isUserInteractionEnabled = false 49 + context.coordinator.anchorView = view 50 + DispatchQueue.main.async { 51 + context.coordinator.installGestureIfNeeded() 52 + } 53 + return view 54 + } 55 + 56 + func updateUIView(_ uiView: UIView, context: Context) { 57 + context.coordinator.onDismiss = onDismiss 58 + context.coordinator.onDragStart = onDragStart 59 + context.coordinator.onDragCancel = onDragCancel 60 + context.coordinator.onSwipeLeft = onSwipeLeft 61 + context.coordinator.onSwipeRight = onSwipeRight 62 + handle.performDismiss = onDismiss 63 + } 64 + 65 + func makeCoordinator() -> Coordinator { 66 + Coordinator( 67 + handle: handle, 68 + onDismiss: onDismiss, 69 + onDragStart: onDragStart, 70 + onDragCancel: onDragCancel, 71 + onSwipeLeft: onSwipeLeft, 72 + onSwipeRight: onSwipeRight 73 + ) 74 + } 75 + 76 + private enum DragDirection { 77 + case none, vertical, horizontal 78 + } 79 + 80 + final class Coordinator: NSObject, UIGestureRecognizerDelegate { 81 + let handle: FadeDismissHandle 82 + var onDismiss: () -> Void 83 + var onDragStart: () -> Void 84 + var onDragCancel: () -> Void 85 + var onSwipeLeft: () -> Void 86 + var onSwipeRight: () -> Void 87 + 88 + weak var anchorView: UIView? 89 + private weak var targetView: UIView? 90 + private var panGesture: UIPanGestureRecognizer? 91 + private var direction: DragDirection = .none 92 + 93 + init(handle: FadeDismissHandle, onDismiss: @escaping () -> Void, onDragStart: @escaping () -> Void, onDragCancel: @escaping () -> Void, onSwipeLeft: @escaping () -> Void, onSwipeRight: @escaping () -> Void) { 94 + self.handle = handle 95 + self.onDismiss = onDismiss 96 + self.onDragStart = onDragStart 97 + self.onDragCancel = onDragCancel 98 + self.onSwipeLeft = onSwipeLeft 99 + self.onSwipeRight = onSwipeRight 100 + } 101 + 102 + func installGestureIfNeeded() { 103 + guard panGesture == nil, let anchor = anchorView else { return } 104 + guard let vc = anchor.findViewController() else { return } 105 + let target = vc.view! 106 + targetView = target 107 + handle.targetView = target 108 + handle.performDismiss = onDismiss 109 + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) 110 + pan.delegate = self 111 + target.addGestureRecognizer(pan) 112 + panGesture = pan 113 + } 114 + 115 + // Allow SwiftUI tap gestures to work simultaneously 116 + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { 117 + return !(other is UIPanGestureRecognizer) 118 + } 119 + 120 + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { 121 + guard let view = targetView else { return } 122 + let translation = gesture.translation(in: view.superview ?? view) 123 + let velocity = gesture.velocity(in: view.superview ?? view) 124 + 125 + switch gesture.state { 126 + case .began: 127 + direction = .none 128 + 129 + case .changed: 130 + // Lock direction after enough movement 131 + if direction == .none { 132 + let absX = abs(translation.x) 133 + let absY = abs(translation.y) 134 + if absX > 15 || absY > 15 { 135 + if absY > absX && translation.y > 0 { 136 + direction = .vertical 137 + onDragStart() 138 + } else if absX > absY { 139 + direction = .horizontal 140 + } 141 + } 142 + } 143 + 144 + if direction == .vertical { 145 + let ty = max(translation.y, 0) 146 + let progress = min(ty / 300, 1) 147 + let scale = 1 - progress * 0.1 148 + view.transform = CGAffineTransform(translationX: 0, y: ty) 149 + .scaledBy(x: scale, y: scale) 150 + view.layer.cornerRadius = progress * 24 151 + view.clipsToBounds = true 152 + } 153 + 154 + case .ended, .cancelled: 155 + if direction == .vertical { 156 + let ty = max(translation.y, 0) 157 + 158 + let dismissThreshold = view.bounds.height * 0.35 159 + if ty > dismissThreshold || velocity.y > 1200 { 160 + handle.fadeDismiss() 161 + } else { 162 + // Spring back 163 + UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: [.allowUserInteraction]) { 164 + view.transform = .identity 165 + view.layer.cornerRadius = 0 166 + view.clipsToBounds = false 167 + } 168 + onDragCancel() 169 + } 170 + } else if direction == .horizontal { 171 + if translation.x < -80 || velocity.x < -500 { 172 + onSwipeLeft() 173 + } else if translation.x > 80 || velocity.x > 500 { 174 + onSwipeRight() 175 + } 176 + } 177 + 178 + direction = .none 179 + 180 + default: 181 + break 182 + } 183 + } 184 + } 185 + } 186 + 187 + private extension UIView { 188 + func findViewController() -> UIViewController? { 189 + var responder: UIResponder? = self 190 + while let next = responder?.next { 191 + if let vc = next as? UIViewController { 192 + return vc 193 + } 194 + responder = next 195 + } 196 + return nil 197 + } 198 + }
+87 -48
Grain/Views/Stories/StoryViewer.swift
··· 64 64 @State private var reportStoryCid = "" 65 65 @State private var lastNavTime: Date = .distantPast 66 66 @State private var labelRevealed = false 67 + @State private var fadeDismissHandle = FadeDismissHandle() 68 + @State private var prefetchedStories: [String: [GrainStory]] = [:] 67 69 68 70 init(authors: [GrainStoryAuthor], startIndex: Int = 0, startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 69 71 self.authors = authors ··· 89 91 } 90 92 91 93 var body: some View { 94 + storyContent 95 + .background( 96 + DragToDismissInstaller( 97 + handle: fadeDismissHandle, 98 + onDismiss: { onDismiss?() }, 99 + onDragStart: { timer.stop() }, 100 + onDragCancel: { 101 + let lr = storyLabelResult 102 + if lr.action == .none || lr.action == .badge { timer.start() } 103 + }, 104 + onSwipeLeft: { goToNextAuthor() }, 105 + onSwipeRight: { goToPreviousAuthor() } 106 + ) 107 + ) 108 + .statusBarHidden() 109 + .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 110 + Button("Delete", role: .destructive) { 111 + if let story = currentStory { 112 + Task { await deleteStory(story) } 113 + } 114 + } 115 + Button("Cancel", role: .cancel) { 116 + timer.start() 117 + } 118 + } 119 + .fullScreenCover(isPresented: $showReportSheet) { 120 + ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 121 + .environment(auth) 122 + } 123 + .onChange(of: showReportSheet) { 124 + if !showReportSheet { 125 + timer.start() 126 + } 127 + } 128 + .task { 129 + timer.onComplete = { [self] in goToNext() } 130 + timer.onHalfway = { [self] in markCurrentStoryViewed() } 131 + await loadStoriesForCurrentAuthor() 132 + } 133 + } 134 + 135 + @ViewBuilder 136 + private var storyContent: some View { 92 137 ZStack { 93 138 Color.black.ignoresSafeArea() 94 139 ··· 153 198 .frame(maxWidth: .infinity) 154 199 } 155 200 } 156 - .simultaneousGesture( 157 - DragGesture(minimumDistance: 80) 158 - .onEnded { value in 159 - if value.translation.width < -80 { 160 - goToNextAuthor() 161 - } else if value.translation.width > 80 { 162 - goToPreviousAuthor() 163 - } 164 - } 165 - ) 166 201 } 167 202 .allowsHitTesting(!showReportSheet && !showDeleteConfirm && (labelRevealed || storyLabelResult.action == .none || storyLabelResult.action == .badge)) 168 203 169 - // Header overlay (on top of tap zones) 204 + // Header overlay 170 205 VStack(spacing: 0) { 171 206 StoryProgressBars(timer: timer, stories: stories, currentStoryIndex: currentStoryIndex) 172 207 .padding(.horizontal) 173 208 .padding(.top, 8) 174 209 175 - // Creator info + actions 176 210 HStack(alignment: .center) { 177 211 Button { 178 212 close() ··· 227 261 Spacer() 228 262 .allowsHitTesting(false) 229 263 230 - // Location pill 231 264 if let locationText = storyLocationText(story) { 232 265 HStack { 233 266 HStack(spacing: 4) { ··· 250 283 .tint(.white) 251 284 } 252 285 } 253 - .statusBarHidden() 254 - .confirmationDialog("Delete this story?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { 255 - Button("Delete", role: .destructive) { 256 - if let story = currentStory { 257 - Task { await deleteStory(story) } 258 - } 259 - } 260 - Button("Cancel", role: .cancel) { 261 - timer.start() 262 - } 263 - } 264 - .fullScreenCover(isPresented: $showReportSheet) { 265 - ReportView(client: client, subjectUri: reportStoryUri, subjectCid: reportStoryCid) 266 - .environment(auth) 267 - } 268 - .onChange(of: showReportSheet) { 269 - if !showReportSheet { 270 - timer.start() 271 - } 272 - } 273 - .task { 274 - timer.onComplete = { [self] in goToNext() } 275 - timer.onHalfway = { [self] in markCurrentStoryViewed() } 276 - await loadStoriesForCurrentAuthor() 277 - } 278 286 } 279 287 280 288 // MARK: - Navigation 281 289 282 290 private func close() { 283 291 timer.stop() 284 - onDismiss?() 292 + fadeDismissHandle.fadeDismiss() 285 293 } 286 294 287 295 private func goToNext() { ··· 320 328 private func goToNextAuthor() { 321 329 if currentAuthorIndex < authors.count - 1 { 322 330 currentAuthorIndex += 1 323 - currentStoryIndex = 0 324 - stories = [] 325 - isLoadingStories = true 326 - timer.stop() 327 - Task { await loadStoriesForCurrentAuthor() } 331 + switchToCurrentAuthor() 328 332 } else { 329 333 close() 330 334 } ··· 333 337 private func goToPreviousAuthor() { 334 338 if currentAuthorIndex > 0 { 335 339 currentAuthorIndex -= 1 340 + switchToCurrentAuthor() 341 + } 342 + } 343 + 344 + private func switchToCurrentAuthor() { 345 + timer.stop() 346 + let did = authors[currentAuthorIndex].profile.did 347 + if let cached = prefetchedStories.removeValue(forKey: did) { 348 + stories = cached 349 + currentStoryIndex = viewedStories.firstUnviewedIndex(in: cached) 350 + labelRevealed = false 351 + isLoadingStories = false 352 + let lr = storyLabelResult 353 + if lr.action == .none || lr.action == .badge { timer.start() } 354 + prefetchAdjacentAuthors() 355 + } else { 336 356 currentStoryIndex = 0 337 357 stories = [] 338 358 isLoadingStories = true 339 - timer.stop() 340 359 Task { await loadStoriesForCurrentAuthor() } 341 360 } 342 361 } ··· 350 369 timer.stop() 351 370 352 371 do { 353 - let response = try await client.getStories(actor: did, auth: auth.authContext()) 354 - stories = response.stories 355 - currentStoryIndex = viewedStories.firstUnviewedIndex(in: response.stories) 372 + let fetched: [GrainStory] 373 + if let cached = prefetchedStories.removeValue(forKey: did) { 374 + fetched = cached 375 + } else { 376 + fetched = try await client.getStories(actor: did, auth: auth.authContext()).stories 377 + } 378 + stories = fetched 379 + currentStoryIndex = viewedStories.firstUnviewedIndex(in: fetched) 356 380 labelRevealed = false 357 381 let lr = storyLabelResult 358 382 if lr.action == .none || lr.action == .badge { ··· 362 386 stories = [] 363 387 } 364 388 isLoadingStories = false 389 + 390 + // Prefetch next author's stories 391 + prefetchAdjacentAuthors() 392 + } 393 + 394 + private func prefetchAdjacentAuthors() { 395 + let nextIndex = currentAuthorIndex + 1 396 + guard nextIndex < authors.count else { return } 397 + let did = authors[nextIndex].profile.did 398 + guard prefetchedStories[did] == nil else { return } 399 + Task { 400 + if let response = try? await client.getStories(actor: did, auth: auth.authContext()) { 401 + prefetchedStories[did] = response.stories 402 + } 403 + } 365 404 } 366 405 367 406 private func markCurrentStoryViewed() {