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: progress bar stale value, tap parallax, remove button animation

- Reset timer.progress = 0 in same state batch as currentStoryIndex
change so progress bars never show a stale fill on the new story
- Consolidate tap-through author nav to use transitionToAuthor (same
parallax path as swipe); replace pendingTransition guard in
canNavigate() with isDragging flag so tap never blocks during a
tap-triggered transition
- Remove headerButtonsVisible fade-in — flag/X/trash buttons stay
always visible like the profile row, no opacity flicker

+38 -53
+38 -53
Grain/Views/Stories/StoryViewer.swift
··· 88 88 @State private var authorHistory: [(authorIndex: Int, storyIndex: Int)] = [] 89 89 @State private var imagePrefetcher = ImagePrefetcher() 90 90 @State private var nextStoryFromTrailing = true 91 - @State private var headerButtonsVisible = true 91 + @State private var isDragging = false 92 92 93 93 init(authors: [GrainStoryAuthor], startAuthorDid: String? = nil, client: XRPCClient, onProfileTap: ((String) -> Void)? = nil, onDismiss: (() -> Void)? = nil) { 94 94 self.authors = authors ··· 170 170 timer.onComplete = { [self] in goToNext() } 171 171 timer.onQuarter = { [self] in markCurrentStoryViewed() } 172 172 await loadStoriesForCurrentAuthor() 173 - } 174 - .onChange(of: currentAuthorIndex) { 175 - headerButtonsVisible = false 176 - withAnimation(.easeIn(duration: 0.2).delay(0.05)) { 177 - headerButtonsVisible = true 178 - } 179 173 } 180 174 } 181 175 ··· 375 369 } 376 370 Spacer() 377 371 378 - Group { 379 - if let story { 380 - if story.creator.did == auth.userDID { 381 - Button { 382 - timer.stop() 383 - showDeleteConfirm = true 384 - } label: { 385 - Image(systemName: "trash") 386 - .foregroundStyle(.white) 387 - .frame(width: 36, height: 36) 388 - } 389 - } else { 390 - Button { 391 - timer.stop() 392 - reportStoryUri = story.uri 393 - reportStoryCid = story.cid 394 - showReportSheet = true 395 - } label: { 396 - Image(systemName: "flag") 397 - .foregroundStyle(.white) 398 - .frame(width: 36, height: 36) 399 - } 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) 400 392 } 401 393 } 402 - Button { close() } label: { 403 - Image(systemName: "xmark") 404 - .foregroundStyle(.white) 405 - .font(.body.weight(.semibold)) 406 - .frame(width: 36, height: 36) 407 - } 394 + } 395 + 396 + Button { close() } label: { 397 + Image(systemName: "xmark") 398 + .foregroundStyle(.white) 399 + .font(.body.weight(.semibold)) 400 + .frame(width: 36, height: 36) 408 401 } 409 - .opacity(headerButtonsVisible ? 1 : 0) 410 - .animation(.easeIn(duration: 0.2), value: headerButtonsVisible) 411 402 } 412 403 .padding(.horizontal, 16) 413 404 .padding(.vertical, 8) ··· 454 445 private func canNavigate() -> Bool { 455 446 !isLoadingStories && !stories.isEmpty 456 447 && !showReportSheet && !showDeleteConfirm 457 - && pendingTransition.authorIndex == nil 448 + && !isDragging 458 449 && Date().timeIntervalSince(lastNavTime) > 0.3 459 450 } 460 451 ··· 472 463 if currentStoryIndex < stories.count - 1 { 473 464 animateToStory(forward: true) { 474 465 currentStoryIndex += 1 466 + timer.progress = 0 475 467 imageLoaded = false 476 468 labelRevealed = false 477 469 showLocationCopied = false ··· 489 481 if currentStoryIndex > 0 { 490 482 animateToStory(forward: false) { 491 483 currentStoryIndex -= 1 484 + timer.progress = 0 492 485 imageLoaded = false 493 486 labelRevealed = false 494 487 showLocationCopied = false ··· 509 502 private func goToNextAuthor() { 510 503 if let pending = pendingTransition.authorIndex { 511 504 guard swipingForward else { cancelSwipe(); return } 505 + isDragging = false 512 506 authorHistory.append((authorIndex: currentAuthorIndex, storyIndex: currentStoryIndex)) 513 507 transitionToAuthor(pending, forward: true) 514 508 return ··· 517 511 close(); return 518 512 } 519 513 authorHistory.append((authorIndex: currentAuthorIndex, storyIndex: currentStoryIndex)) 520 - nextStoryFromTrailing = true 521 - currentAuthorIndex = next 522 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 523 - switchToCurrentAuthor() 524 - } 514 + transitionToAuthor(next, forward: true) 525 515 } 526 516 527 517 private func goToPreviousAuthor() { 528 518 if let pending = pendingTransition.authorIndex { 529 519 guard !swipingForward else { cancelSwipe(); return } 520 + isDragging = false 530 521 if let prev = authorHistory.popLast() { 531 522 transitionToAuthor(pending, forward: false, resumeIndex: prev.storyIndex) 532 523 } else { ··· 535 526 return 536 527 } 537 528 if let prev = authorHistory.popLast() { 538 - nextStoryFromTrailing = false 539 - currentAuthorIndex = prev.authorIndex 540 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 541 - switchToCurrentAuthor(resumeIndex: prev.storyIndex) 542 - } 529 + transitionToAuthor(prev.authorIndex, forward: false, resumeIndex: prev.storyIndex) 543 530 return 544 531 } 545 532 // No history — walk backward ignoring the reads filter 546 533 var i = currentAuthorIndex - 1 547 534 while i >= 0 { 548 535 if authors[i].profile.did != auth.userDID { 549 - nextStoryFromTrailing = false 550 - currentAuthorIndex = i 551 - withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { 552 - switchToCurrentAuthor() 553 - } 536 + transitionToAuthor(i, forward: false) 554 537 return 555 538 } 556 539 i -= 1 ··· 559 542 560 543 private func beginSwipe(forward: Bool) { 561 544 guard pendingTransition.authorIndex == nil else { return } 545 + isDragging = true 562 546 timer.stop() 563 547 swipingForward = forward 564 548 ··· 607 591 } 608 592 609 593 private func cancelSwipe() { 594 + isDragging = false 610 595 transitionTask?.cancel() 611 596 let resetOffset: CGFloat = swipingForward ? screenWidth : -screenWidth 612 597 withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) {