diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Accessibility.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Accessibility.swift new file mode 100644 index 0000000..144bc56 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Accessibility.swift @@ -0,0 +1,68 @@ +// +// Accessibility.swift +// AndroidSwiftUICore +// +// Accessibility describes a view to assistive technology rather than changing +// how it looks, so every one of these folds into Compose semantics: a label +// becomes the content description, a value becomes the state description, +// traits become a role, and hiding clears the subtree's semantics outright. +// + +/// Roles that change how assistive technology announces a view. +public struct AccessibilityTraits: OptionSet, Sendable { + + public let rawValue: Int + public init(rawValue: Int) { self.rawValue = rawValue } + + public static let isButton = AccessibilityTraits(rawValue: 1 << 0) + public static let isHeader = AccessibilityTraits(rawValue: 1 << 1) + public static let isSelected = AccessibilityTraits(rawValue: 1 << 2) + public static let isImage = AccessibilityTraits(rawValue: 1 << 3) + + /// The names the interpreter maps to Compose semantics. + internal var names: [String] { + var names: [String] = [] + if contains(.isButton) { names.append("button") } + if contains(.isHeader) { names.append("header") } + if contains(.isSelected) { names.append("selected") } + if contains(.isImage) { names.append("image") } + return names + } +} + +public struct _AccessibilityModifier: RenderModifier { + let kind: String + let args: [String: PropValue] + public var _modifierNode: ModifierNode { ModifierNode(kind: kind, args: args) } +} + +public extension View { + + /// The description assistive technology reads for this view. + func accessibilityLabel(_ label: S) -> ModifiedContent { + modifier(_AccessibilityModifier(kind: "accessibilityLabel", args: ["text": .string(String(label))])) + } + + /// The view's current value, announced after its label. + func accessibilityValue(_ value: S) -> ModifiedContent { + modifier(_AccessibilityModifier(kind: "accessibilityValue", args: ["text": .string(String(value))])) + } + + /// Removes the view, and everything inside it, from the accessibility tree. + func accessibilityHidden(_ hidden: Bool) -> ModifiedContent { + modifier(_AccessibilityModifier(kind: "accessibilityHidden", args: ["value": .bool(hidden)])) + } + + func accessibilityAddTraits(_ traits: AccessibilityTraits) -> ModifiedContent { + modifier(_AccessibilityModifier( + kind: "accessibilityAddTraits", + args: ["traits": .array(traits.names.map { .string($0) })] + )) + } + + /// A stable handle for UI tests. Never announced. + func accessibilityIdentifier(_ identifier: S) -> ModifiedContent { + modifier(_AccessibilityModifier(kind: "accessibilityIdentifier", args: ["id": .string(String(identifier))])) + } + +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 0562225..c884ff3 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -737,3 +737,60 @@ struct PreferenceTests { #expect(outer == 70) // the inner reduction reached the outer one } } + +@Suite("Accessibility") +struct AccessibilityTests { + + private func arg(_ node: RenderNode, _ kind: String, _ key: String) -> PropValue? { + node.modifiers.first { $0.kind == kind }?.args[key] + } + + @Test("Label, value, and identifier emit their text") + func describes() { + let node = ViewHost( + Text("7") + .accessibilityLabel("Unread messages") + .accessibilityValue("7 items") + .accessibilityIdentifier("inbox-count") + ).evaluate() + #expect(arg(node, "accessibilityLabel", "text") == .string("Unread messages")) + #expect(arg(node, "accessibilityValue", "text") == .string("7 items")) + #expect(arg(node, "accessibilityIdentifier", "id") == .string("inbox-count")) + } + + @Test("Hiding is explicit in both directions") + func hidden() { + let hidden = ViewHost(Text("decorative").accessibilityHidden(true)).evaluate() + #expect(arg(hidden, "accessibilityHidden", "value") == .bool(true)) + // false must still emit — it has to be able to override an inherited hide + let shown = ViewHost(Text("visible").accessibilityHidden(false)).evaluate() + #expect(arg(shown, "accessibilityHidden", "value") == .bool(false)) + } + + @Test("Traits emit as a set of names") + func traits() { + let node = ViewHost( + Text("Chapter").accessibilityAddTraits([.isHeader, .isButton]) + ).evaluate() + guard case .array(let names)? = arg(node, "accessibilityAddTraits", "traits") else { + Issue.record("missing traits"); return + } + #expect(names.contains(.string("header"))) + #expect(names.contains(.string("button"))) + #expect(!names.contains(.string("selected"))) + } + + + @Test("Accessibility describes without disturbing the view") + func nonVisual() { + // the node type, props, and children must be identical with and without + // a description — these modifiers only add semantics + let plain = ViewHost(VStack { Text("hello") }).evaluate() + let described = ViewHost( + VStack { Text("hello") }.accessibilityLabel("greeting") + ).evaluate() + #expect(plain.type == described.type) + #expect(plain.children.count == described.children.count) + #expect(firstTextString(plain) == firstTextString(described)) + } +} diff --git a/Demo/App.swiftpm/Sources/AccessibilityPlaygrounds.swift b/Demo/App.swiftpm/Sources/AccessibilityPlaygrounds.swift new file mode 100644 index 0000000..da9c439 --- /dev/null +++ b/Demo/App.swiftpm/Sources/AccessibilityPlaygrounds.swift @@ -0,0 +1,47 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct AccessibilityPlayground: View { + + @State private var unread = 7 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("accessibilityLabel") { + // reads as "Unread messages", not "7" + Text("7") + .accessibilityLabel("Unread messages") + } + Example("accessibilityValue") { + Text("Volume") + .accessibilityLabel("Volume") + .accessibilityValue("30 percent") + } + Example("accessibilityHidden") { + VStack(alignment: .leading, spacing: 8) { + Text("kept in the tree") + Text("decorative-flourish") + .accessibilityHidden(true) + } + } + Example("accessibilityAddTraits") { + VStack(alignment: .leading, spacing: 8) { + Text("Chapter One") + .accessibilityAddTraits(.isHeader) + Text("Tap to continue") + .accessibilityAddTraits(.isButton) + } + } + Example("accessibilityIdentifier") { + Text("Checkout") + .accessibilityIdentifier("checkout-button") + } + } + } + .navigationTitle("Accessibility") + } +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 1c194f5..9efe9d1 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -61,6 +61,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())), CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())), CatalogEntry(id: "alert", title: "Alert", screen: AnyCatalogScreen(AlertPlayground())), + CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())), CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())), CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())), CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())), diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index 3b62890..01dbdfe 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -149,6 +149,15 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.platform.testTag import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.ImageBitmap @@ -1448,6 +1457,44 @@ internal fun ViewNode.composeModifiers(): Modifier { } } + // Accessibility folds into Compose semantics rather than changing + // any visual property. + "accessibilityLabel" -> { + val text = entry.args.string("text") + if (text == null) modifier else modifier.semantics { contentDescription = text } + } + + "accessibilityValue" -> { + val text = entry.args.string("text") + if (text == null) modifier else modifier.semantics { stateDescription = text } + } + + // clearAndSetSemantics drops this node AND its subtree from the + // tree, which is what hiding has to mean for a container. + "accessibilityHidden" -> { + val hidden = entry.args.string("value") == "true" + if (hidden) modifier.clearAndSetSemantics { } else modifier + } + + "accessibilityAddTraits" -> { + val traits = (entry.args["traits"] as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content } + .orEmpty() + modifier.semantics { + if (traits.contains("button")) role = Role.Button + if (traits.contains("image")) role = Role.Image + if (traits.contains("header")) heading() + if (traits.contains("selected")) selected = true + } + } + + // A test handle, never announced. + "accessibilityIdentifier" -> { + val id = entry.args.string("id") + if (id == null) modifier else modifier.testTag(id) + } + + // Dim disabled content; controls also drop interactivity via their // own `enabled` parameter (e.g. Button). "disabled" -> { @@ -1476,6 +1523,8 @@ private val KNOWN_MODIFIER_KINDS = setOf( "tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem", "transition", "focused", "longPress", "drag", "contentMode", "progressViewStyle", "buttonStyle", "pickerStyle", "toggleStyle", "textFieldStyle", + "accessibilityLabel", "accessibilityValue", "accessibilityHidden", + "accessibilityAddTraits", "accessibilityIdentifier", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded