accessibility

📁 makgunay/claude-swift-skills 📅 13 days ago
4
总安装量
3
周安装量
#51479
全站排名
安装命令
npx skills add https://github.com/makgunay/claude-swift-skills --skill accessibility

Agent 安装分布

opencode 3
claude-code 3
codex 3
cursor 3
gemini-cli 2
github-copilot 2

Skill 文档

Accessibility Patterns

Concrete implementations for VoiceOver, Dynamic Type, Reduce Motion, keyboard navigation, and color contrast. These patterns apply to both macOS and iOS SwiftUI apps.

Critical Rules

  • ❌ Never rely on color alone to convey information — always pair with icons or text
  • ❌ Never use glass effects without a solid fallback for Reduce Transparency
  • ❌ Never ship animations without Reduce Motion handling
  • ❌ Never skip VoiceOver labels on interactive elements
  • ✅ Every interactive element must have an accessibility label
  • ✅ Every state change must be announced to VoiceOver
  • ✅ Every animation must respect accessibilityReduceMotion
  • ✅ Every glass effect must have a accessibilityReduceTransparency fallback

Adaptive Glass Effect

Glass effects become unreadable when Reduce Transparency is enabled. Always provide a solid fallback.

struct AdaptiveGlassModifier: ViewModifier {
    @Environment(\.accessibilityReduceTransparency) var reduceTransparency

    let style: GlassEffectStyle
    let shape: AnyShape

    func body(content: Content) -> some View {
        if reduceTransparency {
            content
                .background(Color(.systemBackground))
                .clipShape(shape)
        } else {
            content
                .glassEffect(style, in: shape)
        }
    }
}

extension View {
    func adaptiveGlass(
        _ style: GlassEffectStyle = .regular,
        in shape: some Shape = .rect
    ) -> some View {
        modifier(AdaptiveGlassModifier(style: style, shape: AnyShape(shape)))
    }
}

// Usage
Text("Accessible Glass")
    .padding()
    .adaptiveGlass(.regular, in: .rect(cornerRadius: 12))

Motion-Safe Animations

ViewModifier Approach

struct MotionSafeAnimation<V: Equatable>: ViewModifier {
    @Environment(\.accessibilityReduceMotion) var reduceMotion

    let animation: Animation
    let value: V

    func body(content: Content) -> some View {
        content.animation(reduceMotion ? .none : animation, value: value)
    }
}

extension View {
    func motionSafeAnimation<V: Equatable>(
        _ animation: Animation = .default,
        value: V
    ) -> some View {
        modifier(MotionSafeAnimation(animation: animation, value: value))
    }
}

Conditional withAnimation

struct MorphingCard: View {
    @State private var isExpanded = false
    @Environment(\.accessibilityReduceMotion) var reduceMotion

    var body: some View {
        VStack { /* content */ }
        .onChange(of: isExpanded) { _, newValue in
            if reduceMotion {
                // Instant change, no animation
            } else {
                withAnimation(.spring(response: 0.3)) {
                    // Animated change
                }
            }
        }
    }
}

VoiceOver Support

Accessible Labels

struct PromptRow: View {
    let prompt: Prompt

    var body: some View {
        HStack {
            Image(systemName: prompt.icon)
            VStack(alignment: .leading) {
                Text(prompt.title)
                Text(prompt.preview)
                    .font(.caption)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(prompt.title), \(prompt.category?.name ?? "uncategorized")")
        .accessibilityHint("Double tap to copy prompt")
        .accessibilityAddTraits(.isButton)
    }
}

Custom Actions

Expose multiple actions without requiring complex gesture discovery.

struct PromptCard: View {
    let prompt: Prompt
    var onCopy: () -> Void
    var onEdit: () -> Void
    var onDelete: () -> Void

    var body: some View {
        VStack { /* card content */ }
            .accessibilityElement(children: .combine)
            .accessibilityLabel(prompt.title)
            .accessibilityActions {
                Button("Copy to clipboard") { onCopy() }
                Button("Edit") { onEdit() }
                Button("Delete", role: .destructive) { onDelete() }
            }
    }
}

Rotor Support

Let VoiceOver users navigate by category or filter.

struct PromptListView: View {
    let prompts: [Prompt]
    @State private var favorites: Set<UUID> = []

    var body: some View {
        List(prompts) { prompt in
            PromptRow(prompt: prompt)
        }
        .accessibilityRotor("Favorites") {
            ForEach(prompts.filter { favorites.contains($0.id) }) { prompt in
                AccessibilityRotorEntry(prompt.title, id: prompt.id)
            }
        }
        .accessibilityRotor("Categories") {
            ForEach(Category.all) { category in
                AccessibilityRotorEntry(category.name, id: category.id)
            }
        }
    }
}

Announce State Changes

Always announce clipboard operations and other invisible state changes.

func copyPrompt(_ prompt: Prompt) {
    ClipboardService.shared.copy(prompt.content)

    #if os(iOS)
    UIAccessibility.post(
        notification: .announcement,
        argument: "Copied \(prompt.title) to clipboard"
    )
    #else
    NSAccessibility.post(
        element: NSApp.mainWindow as Any,
        notification: .announcementRequested,
        userInfo: [.announcement: "Copied \(prompt.title) to clipboard"]
    )
    #endif
}

Dynamic Type

Scaled Metrics

struct PromptDetailView: View {
    let prompt: Prompt
    @ScaledMetric(relativeTo: .body) var iconSize = 24
    @ScaledMetric(relativeTo: .title) var headerPadding = 16

    var body: some View {
        VStack(alignment: .leading, spacing: headerPadding) {
            HStack {
                Image(systemName: prompt.icon)
                    .font(.system(size: iconSize))
                Text(prompt.title)
                    .font(.title)
            }
            Text(prompt.content)
                .font(.body)
        }
        .padding()
    }
}

Adaptive Grid Layouts

Reduce columns as text size increases to prevent truncation.

struct AdaptivePromptGrid: View {
    let prompts: [Prompt]
    @Environment(\.dynamicTypeSize) var dynamicTypeSize

    var columns: [GridItem] {
        if dynamicTypeSize >= .accessibility1 {
            return [GridItem(.flexible())]                                    // 1 column
        } else if dynamicTypeSize >= .xxxLarge {
            return [GridItem(.flexible()), GridItem(.flexible())]             // 2 columns
        } else {
            return Array(repeating: GridItem(.flexible()), count: 3)          // 3 columns
        }
    }

    var body: some View {
        LazyVGrid(columns: columns, spacing: 16) {
            ForEach(prompts) { prompt in
                PromptCard(prompt: prompt)
            }
        }
    }
}

Keyboard Navigation (macOS)

Focus Management with FocusState

struct QuickAccessView: View {
    @FocusState private var focusedPrompt: UUID?
    let prompts: [Prompt]

    var body: some View {
        VStack {
            ForEach(prompts) { prompt in
                PromptRow(prompt: prompt)
                    .focused($focusedPrompt, equals: prompt.id)
            }
        }
        .onKeyPress(.upArrow) {
            moveFocus(direction: -1)
            return .handled
        }
        .onKeyPress(.downArrow) {
            moveFocus(direction: 1)
            return .handled
        }
        .onKeyPress(.return) {
            if let id = focusedPrompt { selectPrompt(id: id) }
            return .handled
        }
    }

    func moveFocus(direction: Int) {
        guard let current = focusedPrompt,
              let index = prompts.firstIndex(where: { $0.id == current }) else {
            focusedPrompt = prompts.first?.id
            return
        }
        let newIndex = index + direction
        if prompts.indices.contains(newIndex) {
            focusedPrompt = prompts[newIndex].id
        }
    }
}

Commands Menu with Keyboard Shortcuts

struct PromptManagerCommands: Commands {
    var body: some Commands {
        CommandGroup(after: .newItem) {
            Button("New Prompt") { }
                .keyboardShortcut("n", modifiers: .command)
            Button("New Category") { }
                .keyboardShortcut("n", modifiers: [.command, .shift])
        }

        CommandMenu("Prompts") {
            Button("Copy Selected") { }
                .keyboardShortcut("c", modifiers: [.command, .shift])
            Button("Quick Access") { }
                .keyboardShortcut(" ", modifiers: [.command, .shift])
        }
    }
}

Color Contrast & Color Blindness

High Contrast Mode

struct ContrastAwareText: View {
    let text: String
    @Environment(\.colorSchemeContrast) var contrast

    var body: some View {
        Text(text)
            .foregroundStyle(contrast == .increased ? .primary : .secondary)
    }
}

Color-Blind Safe Status Indicators

Always pair color with icon and optional text label.

struct StatusIndicator: View {
    let status: Status
    @Environment(\.accessibilityDifferentiateWithoutColor) var differentiateWithoutColor

    enum Status { case success, warning, error }

    var body: some View {
        HStack {
            Image(systemName: iconName)
                .foregroundStyle(color)

            if differentiateWithoutColor {
                Text(statusText)
                    .font(.caption)
            }
        }
    }

    var iconName: String {
        switch status {
        case .success: "checkmark.circle.fill"
        case .warning: "exclamationmark.triangle.fill"
        case .error: "xmark.circle.fill"
        }
    }

    var color: Color {
        switch status {
        case .success: .green
        case .warning: .yellow
        case .error: .red
        }
    }

    var statusText: String {
        switch status {
        case .success: "Success"
        case .warning: "Warning"
        case .error: "Error"
        }
    }
}

System Preferences Detection

Observable object that monitors all accessibility settings at runtime.

class AccessibilitySettings: ObservableObject {
    @Published var reduceTransparency: Bool
    @Published var reduceMotion: Bool
    @Published var differentiateWithoutColor: Bool
    @Published var isVoiceOverRunning: Bool

    init() {
        #if os(macOS)
        reduceTransparency = NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency
        reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
        differentiateWithoutColor = NSWorkspace.shared.accessibilityDisplayShouldDifferentiateWithoutColor
        isVoiceOverRunning = NSWorkspace.shared.isVoiceOverEnabled
        #else
        reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
        reduceMotion = UIAccessibility.isReduceMotionEnabled
        differentiateWithoutColor = UIAccessibility.shouldDifferentiateWithoutColor
        isVoiceOverRunning = UIAccessibility.isVoiceOverRunning
        #endif
        observeChanges()
    }

    private func observeChanges() {
        #if os(iOS)
        NotificationCenter.default.addObserver(
            forName: UIAccessibility.reduceTransparencyStatusDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.reduceTransparency = UIAccessibility.isReduceTransparencyEnabled
        }
        NotificationCenter.default.addObserver(
            forName: UIAccessibility.reduceMotionStatusDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            self?.reduceMotion = UIAccessibility.isReduceMotionEnabled
        }
        #endif
    }
}

Testing Checklist

VoiceOver

  • All interactive elements have .accessibilityLabel
  • Custom actions available where context menus exist
  • Logical reading order (test by swiping through elements)
  • Announcements for state changes (copy, delete, save)
  • Rotors configured for key navigation paths

Dynamic Type

  • All text scales via system fonts (no hardcoded sizes)
  • Layout adapts at accessibility sizes (grid → single column)
  • No truncation at .accessibility3 size
  • Touch targets remain ≥ 44pt at all sizes

Reduce Motion

  • All withAnimation calls gated on accessibilityReduceMotion
  • Glass morphing effects simplified or removed
  • No essential information conveyed only through animation
  • Transitions fall back to opacity-only

Reduce Transparency

  • Glass effects have solid background fallback
  • Sufficient contrast maintained without blur
  • Readability preserved on all backgrounds

Keyboard (macOS)

  • All features accessible via keyboard alone
  • Logical tab/focus order
  • Visible focus indicators
  • Standard shortcuts work (⌘N, ⌘F, ⌘,)
  • Custom shortcuts registered in Commands menu