Detail: hold-heart-drag-up to bookmark privately
Created by: jedmund
Summary
The detail's floating heart gains a springy direct-manipulation gesture that exposes Pixiv's existing private-bookmark capability without a separate menu or settings screen.
Tap = toggle public bookmark (unchanged). Hold ~0.22s = a second glass button slides up above the heart with a bouncy spring; labels fade in to its left. Drag onto either button = it grows under the thumb, the other shrinks back. Release on a button commits that kind (public toggle, or private toggle); release off either button cancels cleanly.
Behavior details
-
HeartButtonStatenow tracks a three-caseBookmarkState(.unbookmarked/.publiclyBookmarked/.privatelyBookmarked) instead of a single bool. The public heart fills only when publicly bookmarked; the lock-heart fills only when privately bookmarked — never both. - Primary flips by state: privately-bookmarked posts show lock-heart as the resting bottom button with the "Bookmark privately" label; held drag reveals the public heart above it. Unbookmarked and publicly-bookmarked posts keep the public heart as primary.
- Tapping the primary toggles that kind off (if already set) or on. Dragging to the secondary always switches kinds — Pixiv's
.bookmarkAdd(restrict:)handles both the insert and the public↔ private transition via the same endpoint. - Labels render in
.calloutweight-medium with.primaryforeground for OS-appearance contrast. Held row: opacity 1.0 + scale 1.08. Other: opacity 0.8 + scale 1.0. Trailing-aligned, 12pt gap from buttons. -
.bookmarkDetail(illustID:)fires on detail open + each swipe-advance so the true restrict level comes back over the wire. A.privatelyBookmarkedresponse flips the primary to lock-heart with no bounce animation (not a user action, so no scale-pop). - Race-safe across swipe-paging:
heartDetailTaskis cancelled on advance; the apply handler bails if the user moved to a different illust while the fetch was in flight.
Animations
One shared bouncy spring (.spring(response: 0.32, dampingFraction: 0.6)) drives:
- The secondary button's slide-up + fade + scale on reveal.
- The active-button scale (1.12) + inactive back-to-1.0 on thumb crossover.
- The label opacity (0.8
↔ 1.0) + scale (1.0↔ 1.08) on the held row.
Layout
Heart host's size constraint bumped from 56×56 to 260×124 to fit the expanded stack + widest label. Non-interactive regions (labels, collapsed-secondary slot) are .allowsHitTesting(false) so the wider footprint doesn't absorb taps meant for the related-posts grid below. Heart button still pinned to bottom-right of safe area via the SwiftUI HStack's trailing-alignment.
Test plan
-
Tap heart on an unbookmarked post → public heart fills, normal behavior. -
Tap heart on a publicly-bookmarked post → unbookmarks. -
Hold heart ~1/4s → privately button slides up with spring, labels fade in. -
Hold + drag to private → privately button grows, heart shrinks, labels follow. Release → lock-heart fills red, public heart empties. Confirm on pixiv.net that the bookmark is private. -
Open detail on a post that was privately bookmarked outside this session → after .bookmarkDetailresolves, the primary button flips to lock-heart (filled) with no bounce animation. -
From privately-bookmarked, tap the lock-heart → unbookmarks. -
From privately-bookmarked, hold lock-heart + drag up to public heart → switches to public bookmark. Confirm on pixiv.net. -
Drag off either button + release → cancels cleanly, no API call, no state change. -
Dark + Light mode: glass chrome + label contrast both read correctly. -
Tap a related-posts cell in the lower-right of the detail → still pushes to that illust's detail (the wider heart host doesn't absorb the tap). -
Swipe-page the pedestal mid-hold → gesture resets cleanly on advance. -
Airplane mode: hold + drag to private → heart flips optimistically, rolls back to pre-tap state when the API errors out.