A UX decision I got wrong on the first try
This one started as a small missing-feature bug: tapping a Discover release card did nothing. The fix itself ended up simple, but I built the wrong interaction first, and that’s the more interesting part.
Problem
Found this while device-verifying a different bug fix: the Discover tab’s release cards were pure display, no tap handler, no add action at all. Checked the code and the card’s own doc comment gave it away - it literally said the add-to-playlist action was supposed to ship in a previous issue, alongside the “Not Interested” swipe gesture. Only half of that ever got built.
Why this approach (attempt one)
The app already had a working service method that does exactly this - add a catalog album to a playlist, no adaptation needed since Discover’s feed items are already the right type. Wiring it up was the easy part. The interesting design question was the interaction: how does the user actually trigger it?
My first instinct was consistency with the existing “Not Interested” gesture already on the same card: add a second swipe action, opposite edge, green tint, plus icon. It matched the row’s existing interaction model, it required zero new view hierarchy, and it worked - I built it, wrote tests, installed it on a physical device, and confirmed it working end to end.
Gotchas
Then I actually lived with it for a bit, and it was wrong. Everywhere else in the app - Playlist, History - the equivalent action works the same way: tap a card, get a detail sheet with buttons. Discover was now the one tab where you had to know to swipe instead. I’d optimized for “smallest diff on this one card” and missed the more important consistency: matching how the rest of the app already handles this exact interaction.
That’s a real lesson about scoping a UI decision from the wrong reference point. I compared the new action against its sibling on the same card instead of against its counterpart in other tabs. Both are legitimate “matches existing patterns” arguments, but only one of them survives actually using the app - and the way to find out was to use it, not to argue from the diff.
Implementation (attempt two)
Rebuilt it properly: a new detail sheet and view model, deliberately mirroring the two that already exist for Playlist and History almost line for line - same header layout (artwork, title, artist, badge, year), same AddState enum (idle/adding/done/failed) driving the button’s label, icon, and tint, same .sensoryFeedback wiring for haptic confirmation the instant the state changes. Discover’s version is simpler than its siblings since it doesn’t need a same-artist-suggestions section - the whole Discover feed already is the suggestions.
One incidental find while doing this: History’s existing add-to-playlist code and my new Discover code needed identical logic for resolving the linked playlist and mapping a failure to actionable copy (“link a playlist in Settings,” “this playlist isn’t yours to edit,” and so on). Rather than copy-paste it a second time, I pulled it into a small shared file both features now call into. Small thing, but it’s the kind of duplication that’s easy to justify skipping on a two-line diff and much harder to justify once you’re looking at the same block appearing twice.
Results
Full suite green at 171 tests, including new coverage for the release-detail view model’s three real branches (successful add, no-playlist-linked failure, dismiss-and-close). Rebuilt and reinstalled on a physical device a second time and confirmed the actual interaction matches Playlist and History exactly.
The whole detour cost maybe twenty extra minutes. Worth it - shipping the wrong interaction pattern into a shared UI surface is a lot more expensive to notice and undo later than catching it during a device pass on day one.
Related reading
The setting that worked perfectly and felt completely broken
A filter setting only applied after relaunch, since a TabView keeps view models alive and seeded state stays frozen. The fix deleted a concept, not a bug.
A tracklist section, and why it took 30 minutes
One protocol method, a reused state enum, a routing convention that held, and a lint budget that forced a split worth making anyway.
I recovered a stashed fix, then a live repro on my phone showed me a different bug
A clean stash apply, a SwiftLint rule pattern-matching syntax instead of tracing consumption, and a three-tab stall that wasn't the bug I'd fixed.
You might also find useful
Proton VPN
Commercial VPN with NetShield filtering and a kill switch.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreAiralo eSIM
Local data eSIM for travel - no physical SIM swap needed.
This is my Airalo referral link. You get a discount on your first eSIM, and I earn Airalo credit toward mine.
Learn moreNordPass
Password manager from the team behind NordVPN, with a free tier.
As a NordPass affiliate, I earn from qualifying purchases.
Learn more