~13 min · universal-ios, swiftui, interaction, consistency, menus
Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Swipe is the worst UI, in my view. I hate it in Mail too. Tap it and get edit and delete, and that's that. Why should I have to swipe?"
The Feature Nobody Could Find
The travel journal shipped editing and deleting a capture behind .swipeActions, the idiomatic iOS gesture. The owner installed the build, used it, and asked whether there was any way to edit or delete a capture at all, and then, once it was pointed out, whether the iOS app was meant to be view-only. The feature was on his phone and did not exist for him. A swipe advertises nothing: there is no mark on the row that says it can be swiped, so discovery depends on already knowing.
One Person Uses All of These Apps
His second point turned a preference into a family rule. The travel journal, the crumb journal, the training app and native Pippa are separate sessions' work, and each session would reasonably pick its own gesture: a swipe in one, a long press in another, a tap in a third. From inside one app every choice looks fine. For the one person who uses all of them, it is a gesture to relearn per app. So interaction consistency is a correctness property here, and the ruling is short: a row's actions open on a tap, and there are no swipe actions in any family app. The general lesson is not about swipes. Any interaction pattern that will exist in more than one of these apps belongs in the family contract before the second app chooses it.
The Shape That Satisfies Both
Make the whole row the label of a Menu, so a tap anywhere on it opens the actions, and put a dimmed ellipsis at the trailing edge as the only hint needed. Two details make it work. A Text with .textSelection(.enabled) inside the row swallows the tap that should open the menu, so put Copy in the menu instead, which is where the desktop clients keep it anyway. And a List section header never receives a .contextMenu long press, while it does deliver taps to a control; the coding engine's phone app found that when project headers needed a remove action, and moved both directions onto tap menus for one gesture language.
Keep It True Across Apps
A rule that lives in one app's code is true in one app. The family keeps it in the shared iOS delivery document, beside the other non-negotiables, and a search for .swipeActions across the family's Swift sources is the kind of check that stays cheap and stays honest.
Code
A row that opens its actions on a tap, and a section header that does the same·swift
import SwiftUI
import UIKit
struct Crumb: Identifiable, Hashable, Sendable {
let id: String
var text: String
var place: String?
}
/// The whole row is the menu's label: a tap anywhere opens Edit, Copy and Delete.
struct CrumbRow: View {
let crumb: Crumb
let edit: () -> Void
let delete: () -> Void
var body: some View {
Menu {
Button("Edit", systemImage: "pencil", action: edit)
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = crumb.text }
Button("Delete", systemImage: "trash", role: .destructive, action: delete)
} label: {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(crumb.text) // no .textSelection: it would swallow the tap
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
if let place = crumb.place {
Text(place).font(.caption).foregroundStyle(.secondary)
}
}
Spacer(minLength: 8)
Image(systemName: "ellipsis").foregroundStyle(.tertiary) // the only hint needed
}
.contentShape(Rectangle())
}
}
}
struct JourneyList: View {
let journey: String
let crumbs: [Crumb]
var body: some View {
List {
Section {
ForEach(crumbs) { crumb in
CrumbRow(crumb: crumb, edit: {}, delete: {})
}
} header: {
// A section header ignores .contextMenu long-presses; a tap Menu works there too.
Menu {
Button("Rename Journey", systemImage: "pencil") {}
} label: {
Label(journey, systemImage: "chevron.down")
}
}
}
}
}
A sweep that keeps the ruling true in every app·bash
# A family-wide check is cheap, and it is the only kind that stays true across apps.
# Pass every app's Swift source folder. rg exits 0 on a match, 1 on none, and 2 on an error
# such as a folder that does not exist, so only 1 counts as clean.
(( $# > 0 )) || { echo "usage: sweep.sh <app source folder>..."; exit 2; }
for app in "$@"; do
rg -n '\.swipeActions' --glob '*.swift' "$app"
case $? in
0) echo "HARD_FAIL swipe actions found in $app"; exit 1 ;;
1) ;;
*) echo "HARD_FAIL could not search $app"; exit 1 ;;
esac
rg -n '\.textSelection\(\.enabled\)' --glob '*Row*.swift' "$app" \
&& echo "check: a selectable Text inside a tap row swallows the tap ($app)"
done
echo "rows open on a tap in $# app(s)"
Replace any swipe actions in SparkMobile with CrumbRow, and give a section header a tap menu. On a device or simulator, confirm a tap anywhere on the row opens the menu, then add .textSelection(.enabled) to the row's text and record what changes. Finally, add the sweep to your test script so a future .swipeActions fails the build, and run it with every app's Swift source folder.
Hint
.contentShape(Rectangle()) makes the empty space between the text and the ellipsis part of the tap target. rg exits 0 when it finds a match, 1 when it finds none and 2 when it cannot search, for example a folder that moved. Only 1 is clean, so a mistyped path fails instead of passing.
Progress
Progress is local-only — sign in to sync across devices.