accessibility
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
accessibilityReduceTransparencyfallback
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
.accessibility3size - Touch targets remain ⥠44pt at all sizes
Reduce Motion
- All
withAnimationcalls gated onaccessibilityReduceMotion - 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