Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<S: StringProtocol>(_ label: S) -> ModifiedContent<Self, _AccessibilityModifier> {
modifier(_AccessibilityModifier(kind: "accessibilityLabel", args: ["text": .string(String(label))]))
}

/// The view's current value, announced after its label.
func accessibilityValue<S: StringProtocol>(_ value: S) -> ModifiedContent<Self, _AccessibilityModifier> {
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<Self, _AccessibilityModifier> {
modifier(_AccessibilityModifier(kind: "accessibilityHidden", args: ["value": .bool(hidden)]))
}

func accessibilityAddTraits(_ traits: AccessibilityTraits) -> ModifiedContent<Self, _AccessibilityModifier> {
modifier(_AccessibilityModifier(
kind: "accessibilityAddTraits",
args: ["traits": .array(traits.names.map { .string($0) })]
))
}

/// A stable handle for UI tests. Never announced.
func accessibilityIdentifier<S: StringProtocol>(_ identifier: S) -> ModifiedContent<Self, _AccessibilityModifier> {
modifier(_AccessibilityModifier(kind: "accessibilityIdentifier", args: ["id": .string(String(identifier))]))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
47 changes: 47 additions & 0 deletions Demo/App.swiftpm/Sources/AccessibilityPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
49 changes: 49 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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" -> {
Expand Down Expand Up @@ -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
Expand Down
Loading