Skip to content

Gallery hero zoom animation with interactive drag-to-dismiss (#14) - #41

Merged
vanities merged 2 commits into
masterfrom
gallery-zoom-transition
Aug 12, 2026
Merged

Gallery hero zoom animation with interactive drag-to-dismiss (#14)#41
vanities merged 2 commits into
masterfrom
gallery-zoom-transition

Conversation

@vanities

@vanities vanities commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Present the gallery as a fullScreenCover using iOS 18's native zoom navigation transition (.matchedTransitionSource + .navigationTransition(.zoom)) instead of a sheet
  • The system now drives the thumbnail→fullscreen hero expansion and Photos-style interactive drag-to-dismiss, replacing the flaky scroll-bounce detection from the earlier WIP (WIP: Gallery expanding animation (#14) #39)
  • Dismissing zooms back to whichever media you're currently viewing (the thread auto-scrolls to keep its thumbnail on screen)
  • Adds an X close button (hidden while pinch-zoomed); interactive dismiss is disabled while zoomed into media or scrubbing video
  • RepliesView gets its own transition-source context so thread and replies thumbnails never register duplicate source IDs
  • Test fixtures with intentional zero-width spaces are now explicit \u{200B} escapes so the swiftlint invisible_character autocorrect build phase can't strip them

Testing

  • Built and ran on iPhone 17 Pro simulator; zoom-in, paging, and drag-to-dismiss verified by hand
  • Unit tests pass (2/2)
  • SwiftLint clean on changed files

Closes #14. Supersedes #39.

https://claude.ai/code/session_01BzkuYBpheMbQn6EdRPT4wg

Summary by CodeRabbit

  • New Features
    • Added smoother zoom transitions when opening media from thread thumbnails.
    • Gallery viewing now opens in a full-screen experience with a dedicated close button.
  • Bug Fixes
    • Improved gallery dismissal behavior while media is zoomed or video playback is being scrubbed.
    • Close controls are hidden when zooming to prevent accidental dismissal.
  • Accessibility
    • Added an accessibility identifier for the gallery close button.

Present the gallery as a fullScreenCover with iOS 18's native zoom
navigation transition instead of a sheet. Thumbnails register as
matchedTransitionSource so the system drives the thumbnail->fullscreen
expansion and Photos-style interactive drag-to-dismiss, replacing the
scroll-bounce detection approach from the earlier WIP. Dismissing zooms
back to whichever media is currently shown. Adds a close button and
disables interactive dismiss while pinch-zoomed or scrubbing video.

Claude-Session: https://claude.ai/code/session_01BzkuYBpheMbQn6EdRPT4wg
The swiftlint build phase's invisible_character autocorrect was
stripping the literal U+200B characters these URL-parser tests
deliberately contain. Explicit \u{200B} escapes keep the fixtures
intact and lint-clean.

Claude-Session: https://claude.ai/code/session_01BzkuYBpheMbQn6EdRPT4wg
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The gallery now uses shared namespace zoom transitions, full-screen presentation, and an in-gallery close button. Reply context and transition-source environment values support thumbnail ownership. URL tests use explicit zero-width-space escapes.

Changes

Gallery transition and presentation

Layer / File(s) Summary
Shared transition state and thumbnail wiring
swiftchan/Models/PresentationState.swift, swiftchan/Views/Boards/Catalog/Thread/*
The presentation environment now carries gallery namespace and reply context. Thread and reply views provide this context. Post thumbnails register conditional transition sources.
Full-screen gallery and dismissal controls
swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift, swiftchan/Views/Media/Gallery/GalleryView.swift, swiftchan/Services/AccessibilityIdentifiers.swift
ThreadView presents GalleryView full screen with a zoom transition. GalleryView uses a custom close button and disables interactive dismissal while media is zoomed or being scrubbed.

Test URL encoding

Layer / File(s) Summary
Explicit zero-width-space test encoding
swiftchanTests/swiftchanTests.swift
URL test strings use explicit \u{200B} escape sequences.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PostView
  participant ThreadView
  participant GalleryView
  PostView->>ThreadView: select media and present gallery
  ThreadView->>GalleryView: present full-screen gallery with shared namespace
  GalleryView->>GalleryView: disable dismissal while zoomed or seeking
  GalleryView->>ThreadView: dismiss through close button
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the gallery zoom animation and interactive drag-to-dismiss changes.
Linked Issues check ✅ Passed The PR implements the gallery expanding animation requested by issue #14 through native iOS 18 zoom navigation transitions.
Out of Scope Changes check ✅ Passed The changes support the gallery transition objective, including dismissal behavior, accessibility, context handling, and related test fixture updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gallery-zoom-transition

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@swiftchan/Views/Boards/Catalog/Thread/PostView.swift`:
- Around line 55-61: Update the gallery source activation around
galleryTransitionSource in PostView so the obscured ThreadView or RepliesView
list source is inactive while the pushed post-detail destination is visible.
Track and use the active gallery-source owner rather than relying only on
inRepliesContext and presentationState.presentingReplies, ensuring only the
current context registers mediaIndex.

In `@swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift`:
- Around line 131-135: Add a ScrollViewReader around the reply grid in
RepliesView and, when presentationState.presentingReplies is true, scroll to the
reply corresponding to presentationState.galleryIndex before GalleryView
dismissal so its thumbnail is rendered as the zoom source. Reuse the existing
reply-to-media mapping and scrollToPost behavior where applicable, without
changing the non-replies flow.

In `@swiftchan/Views/Media/Gallery/GalleryView.swift`:
- Around line 120-133: Add .allowsHitTesting(!isZoomed) and
.accessibilityHidden(isZoomed) to the closeButton view so it is neither
interactive nor exposed to accessibility when zoomed, while preserving its
existing opacity and animation behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f4e7d76-b841-4755-b3c8-5c7f1730bc71

📥 Commits

Reviewing files that changed from the base of the PR and between 4946110 and 5daeb43.

📒 Files selected for processing (7)
  • swiftchan/Models/PresentationState.swift
  • swiftchan/Services/AccessibilityIdentifiers.swift
  • swiftchan/Views/Boards/Catalog/Thread/PostView.swift
  • swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift
  • swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
  • swiftchan/Views/Media/Gallery/GalleryView.swift
  • swiftchanTests/swiftchanTests.swift

Comment on lines +55 to +61
.galleryTransitionSource(
id: mediaIndex,
namespace: galleryNamespace,
// Only one context may own a source id: the thread
// list normally, RepliesView while it is pushed.
isActive: inRepliesContext == presentationState.presentingReplies
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file="swiftchan/Views/Boards/Catalog/Thread/PostView.swift"
printf '%s\n' '--- target file ---'
sed -n '1,180p' "$file"

printf '%s\n' '--- related declarations and usages ---'
rg -n -C 4 \
  'inRepliesContext|presentingReplies|galleryTransitionSource|galleryNamespace|ThreadView|RepliesView' \
  swiftchan/Views

Repository: vanities/swiftchan

Length of output: 35102


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(PostView|ThreadView|RepliesView|Gallery|Catalog)([^/]*\.swift)$'

printf '%s\n' '--- exact environment declarations and presentation state ---'
rg -n -C 8 \
  'EnvironmentKey|inRepliesContext|presentingReplies|presentationState|navigationDestination|sheet|fullScreenCover|NavigationStack|NavigationLink' \
  swiftchan -g '*.swift'

Repository: vanities/swiftchan

Length of output: 49487


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

terms = (
    "inRepliesContext",
    "presentingReplies",
    "galleryTransitionSource",
    "galleryNamespace",
)
for path in Path("swiftchan").rglob("*.swift"):
    text = path.read_text(errors="replace")
    if any(term in text for term in terms):
        print(f"\n--- {path} ---")
        for i, line in enumerate(text.splitlines(), 1):
            if any(term in line for term in terms):
                lo, hi = max(1, i - 8), min(len(text.splitlines()), i + 12)
                lines = text.splitlines()
                for n in range(lo, hi + 1):
                    print(f"{n}: {lines[n-1]}")
                print()
PY

Repository: vanities/swiftchan

Length of output: 18627


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- ThreadView hierarchy ---'
sed -n '45,145p' swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
sed -n '225,245p' swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift

printf '%s\n' '--- RepliesView hierarchy ---'
sed -n '20,45p' swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift

printf '%s\n' '--- deterministic source-activation model ---'
python3 - <<'PY'
cases = [
    ("thread list", False, False),
    ("ThreadView PostView destination", False, False),
    ("RepliesView list", True, True),
    ("RepliesView PostView destination", True, True),
]
for name, in_replies, presenting_replies in cases:
    active = in_replies == presenting_replies
    print(f"{name}: inRepliesContext={in_replies}, "
          f"presentingReplies={presenting_replies}, isActive={active}")

groups = {}
for name, in_replies, presenting_replies in cases:
    if in_replies == presenting_replies:
        groups.setdefault((in_replies, presenting_replies), []).append(name)
for state, owners in groups.items():
    if len(owners) > 1:
        print(f"DUPLICATE-CONTEXT: {state} -> {', '.join(owners)}")
PY

Repository: vanities/swiftchan

Length of output: 7284


Deactivate the obscured list source for pushed PostView destinations.

Both ThreadView and RepliesView register the same mediaIndex as their pushed PostView because the destination inherits the list’s context values. Track the active gallery-source owner and deactivate the obscured list source while a post-detail destination is visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Boards/Catalog/Thread/PostView.swift` around lines 55 - 61,
Update the gallery source activation around galleryTransitionSource in PostView
so the obscured ThreadView or RepliesView list source is inactive while the
pushed post-detail destination is visible. Track and use the active
gallery-source owner rather than relying only on inRepliesContext and
presentationState.presentingReplies, ensuring only the current context registers
mediaIndex.

Comment on lines +131 to +135
// Zoom back to whichever media the user is on;
// scrollToPost keeps its thumbnail on screen.
.navigationTransition(
.zoom(sourceID: presentationState.galleryIndex, in: galleryNamespace)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(ThreadView|GalleryView|RepliesView)\.swift$'
printf '%s\n' '--- ThreadView outline ---'
ast-grep outline swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift
printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 5 'galleryIndex|scrollToPost|presentingReplies|fullScreenCover|navigationTransition|RepliesView|GalleryView' swiftchan/Views/Boards/Catalog/Thread
printf '%s\n' '--- line range under review ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '90,165p'

Repository: vanities/swiftchan

Length of output: 18137


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- ThreadView body and helpers ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '1,115p'
cat -n swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift | sed -n '390,465p'

printf '%s\n' '--- RepliesView ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift

printf '%s\n' '--- GalleryView structure and paging ---'
ast-grep outline swiftchan/Views/Media/Gallery/GalleryView.swift
rg -n -C 8 'galleryIndex|TabView|selection|presentingGallery|dismiss|onDisappear|onChange' swiftchan/Views/Media/Gallery/GalleryView.swift

printf '%s\n' '--- PostView media source and reply context ---'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '1,90p'
cat -n swiftchan/Views/Boards/Catalog/Thread/PostView.swift | sed -n '145,190p'

printf '%s\n' '--- presentation state declarations and media mappings ---'
rg -n -C 5 'class PresentationState|struct PresentationState|galleryIndex|presentingIndex|postMediaMapping|media:' swiftchan

Repository: vanities/swiftchan

Length of output: 47609


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- transition environment definitions ---'
cat -n swiftchan/Models/PresentationState.swift
rg -n -C 8 'galleryTransitionSource|inRepliesContext|galleryNamespace' swiftchan

printf '%s\n' '--- complete GalleryView state update path ---'
cat -n swiftchan/Views/Media/Gallery/GalleryView.swift | sed -n '130,205p'

printf '%s\n' '--- relevant navigation and cover modifiers ---'
rg -n -C 12 'NavigationStack|NavigationSplitView|navigationDestination|fullScreenCover|presentingReplies|presentingGallery' swiftchan/Views/Boards/Catalog/Thread swiftchan/Models

printf '%s\n' '--- repository changes near the reviewed code ---'
git diff --stat
git diff -- swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift swiftchan/Views/Boards/Catalog/Thread/PostView.swift

Repository: vanities/swiftchan

Length of output: 39697


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path

thread = Path("swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift").read_text()
replies = Path("swiftchan/Views/Boards/Catalog/Thread/RepliesView.swift").read_text()
post = Path("swiftchan/Views/Boards/Catalog/Thread/PostView.swift").read_text()
gallery = Path("swiftchan/Views/Media/Gallery/GalleryView.swift").read_text()

checks = {
    "gallery transition uses galleryIndex": ".zoom(sourceID: presentationState.galleryIndex" in thread,
    "gallery paging updates galleryIndex": "state.galleryIndex = index" in gallery,
    "thread scroll is disabled in replies": "if !presentationState.presentingReplies && !showReply" in thread,
    "replies view has a ScrollView": "ScrollView(.vertical" in replies,
    "replies view lacks a ScrollViewReader": "ScrollViewReader" not in replies,
    "replies view lacks galleryIndex change handling": ".onChange(of: presentationState.galleryIndex)" not in replies,
    "reply context owns the active source": "inRepliesContext == presentationState.presentingReplies" in post,
}

for name, result in checks.items():
    print(f"{'PASS' if result else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: vanities/swiftchan

Length of output: 461


Keep the active reply thumbnail visible before dismissal.

When presentationState.presentingReplies is true, ThreadView does not call scrollToPost. RepliesView does not scroll when GalleryView changes presentationState.galleryIndex. If the user pages to media whose reply is outside the visible LazyVGrid range, the zoom transition has no rendered source.

Add a ScrollViewReader to RepliesView and scroll the reply that maps to the active media before dismissal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Boards/Catalog/Thread/ThreadView.swift` around lines 131 -
135, Add a ScrollViewReader around the reply grid in RepliesView and, when
presentationState.presentingReplies is true, scroll to the reply corresponding
to presentationState.galleryIndex before GalleryView dismissal so its thumbnail
is rendered as the zoom source. Reuse the existing reply-to-media mapping and
scrollToPost behavior where applicable, without changing the non-replies flow.

Comment on lines +120 to +133
private var closeButton: some View {
Button {
state.presentingGallery = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white)
.shadow(radius: 4)
}
.padding(16)
.opacity(isZoomed ? 0 : 1)
.animation(.easeInOut(duration: 0.15), value: isZoomed)
.accessibilityIdentifier(AccessibilityIdentifiers.galleryCloseButton)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Disable the hidden close button.

When isZoomed is true, .opacity(0) hides the button but keeps its hit area and accessibility element active. Add .allowsHitTesting(!isZoomed) and .accessibilityHidden(isZoomed).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@swiftchan/Views/Media/Gallery/GalleryView.swift` around lines 120 - 133, Add
.allowsHitTesting(!isZoomed) and .accessibilityHidden(isZoomed) to the
closeButton view so it is neither interactive nor exposed to accessibility when
zoomed, while preserving its existing opacity and animation behavior.

@vanities
vanities merged commit 6554771 into master Aug 12, 2026
1 of 2 checks passed
@vanities
vanities deleted the gallery-zoom-transition branch August 12, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gallery expanding animation

1 participant