SwipeActionsView: action buttons exposed in accessibility tree while off-screen
Nobody has claimed this yet.
- Dominant language
- Swift
- Stars
- 210
- Forks
- 35
- Avg merge
- 1h 34m
- Merged PRs (30d)
- 1
Description
Fix: Swipe-to-reveal action buttons exposed in accessibility tree while off-screen
Problem
When a Listable list item has swipe actions configured (leading or trailing), the DefaultSwipeActionButton instances are always present in the accessibility tree — even when the swipe is closed and the buttons are visually hidden (zero-width container). VoiceOver users navigate to invisible "Void", "Repeat", "Delete" buttons that they cannot activate because the buttons' activation points are beyond screen bounds.
How It Manifests
A VoiceOver user navigating a list hears:
"Bruschetta, $9.00" → "Void, button" → "Repeat, button" → "Next item..."
The "Void" and "Repeat" buttons are invisible — they're positioned off-screen at x = cellWidth (e.g., x=1196 on a 1180-wide screen). Attempting to activate them fails because the activation point is out of bounds. The user has no way to interact with these phantom elements.
Root Cause
SwipeActionsView positioning (SwipeActionsView.swift)
When the swipe state is .closed, the SwipeActionsView frame is set to zero width via bounds.divided(atDistance: 0, from: .maxXEdge).slice in ItemCell.ContentViewContainer.updateFrames(). The view is visually clipped, but its child DefaultSwipeActionButton instances remain as accessibility elements.
DefaultSwipeActionButton (line ~258 in ListableUI/Sources/Internal/SwipeActionsView.swift) is a UIButton subclass. UIButton is isAccessibilityElement = true by default. There is no code anywhere that hides these buttons from accessibility when the swipe is closed.
The correct accessibility path already exists
ItemCell.ContentViewContainer (in ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift) already sets accessibilityCustomActions on the container from the swipe actions (line ~325). These custom actions work correctly — they call performAccessibilityAction(_:) which triggers the swipe action's onTap handler and returns true. This is the proper VoiceOver interaction path.
So there are two competing accessibility paths:
- ✅
accessibilityCustomActionson theContentViewContainer— works correctly, proper VoiceOver pattern - ❌ Physical
DefaultSwipeActionButtoninstances in the view hierarchy — always visible to VoiceOver even when off-screen, activation fails
Fix
Hide the SwipeActionsView from the accessibility tree so VoiceOver only uses the custom actions path. The simplest fix:
Option A: Hide SwipeActionsView from accessibility (recommended)
In SwipeActionsView.swift, set accessibilityElementsHidden = true on the SwipeActionsView in its initializer:
// In SwipeActionsView.init (around line 68)
override init(frame: CGRect) {
// ... existing init code ...
// Hide swipe action buttons from VoiceOver.
// The ContentViewContainer already exposes these actions as
// accessibilityCustomActions, which is the correct VoiceOver path.
accessibilityElementsHidden = true
}
This permanently hides all child buttons from VoiceOver, relying entirely on the accessibilityCustomActions set by ContentViewContainer. This is the cleanest fix because:
- The custom actions path already works correctly
- The physical buttons are an implementation detail of the swipe gesture visual, not an accessibility interface
- No state tracking needed (don't need to toggle based on open/closed state)
Option B: Toggle based on swipe state (more complex, probably unnecessary)
If there's a reason VoiceOver should be able to interact with the physical buttons when the swipe is open (e.g., if the custom actions path doesn't cover all cases), toggle accessibilityElementsHidden based on swipe state:
In SwipeActionsView.apply(state:) (line ~226):
func apply(state newState: SwipeActionState) {
let priorState = state
state = newState
// Hide buttons from VoiceOver when swipe is closed
accessibilityElementsHidden = (newState == .closed)
// ... rest of existing code ...
}
And set the initial value in init:
accessibilityElementsHidden = true // starts closed
Relevant Files
All paths are relative to the Listable repo root:
| File | What it contains |
|---|---|
ListableUI/Sources/Internal/SwipeActionsView.swift |
SwipeActionsView class (line ~12) and DefaultSwipeActionButton class (line ~258). The buttons are created here and never hidden from accessibility. This is where the fix goes. |
ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift |
ContentViewContainer manages swipe registration, positioning, and accessibility custom actions. Lines ~318-328 set up accessibilityCustomActions from swipe actions — this already works correctly and is the proper VoiceOver path. |
How to Verify
-
Existing tests should still pass — this change only affects accessibility tree visibility, not layout or behavior.
-
Manual VoiceOver test:
- Create a list with items that have trailing swipe actions
- Navigate with VoiceOver through the list
- Before fix: VoiceOver visits invisible swipe action buttons between list items
- After fix: VoiceOver skips the physical buttons; swipe actions are available via the Actions rotor (custom actions) on the list item itself
-
Verify custom actions still work:
- Navigate to a list item with VoiceOver
- Use the Actions rotor (swipe up/down)
- The swipe action names (e.g., "Void", "Delete") should appear as custom actions
- Activating them should trigger the action
Context
Found during accessibility fuzzing of a POS app that uses Listable. Affected screens include cart item rows (Void/Repeat), customer directory rows (Delete), and any other list using Listable's swipe actions. The phantom buttons confuse VoiceOver users who hear button announcements for elements they can't see or interact with.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in ListableUI/Sources/Internal/SwipeActionsView.swift, reading SwipeActionsView initialization and its state handling, then compare it with the accessibilityCustomActions setup in ListableUI/Sources/Internal/ItemCell.ContentViewContainer.swift. Run the existing test suite, then verify with VoiceOver that closed swipe buttons are absent from navigation while the list item's custom actions still appear and activate correctly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- swift
- Domain
- accessibility, mobile
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 82/100