Skip to content

SwipeActionsView: action buttons exposed in accessibility tree while off-screen #618

@RoyalPineapple

Description

@RoyalPineapple

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:

  1. accessibilityCustomActions on the ContentViewContainer — works correctly, proper VoiceOver pattern
  2. ❌ Physical DefaultSwipeActionButton instances 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

  1. Existing tests should still pass — this change only affects accessibility tree visibility, not layout or behavior.

  2. 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
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions