Skip to content

Commit

Permalink
Merge tag '4.3.0' into maxg/add-priority-to-flow
Browse files Browse the repository at this point in the history
* tag '4.3.0':
  Prepare version 4.3.0 (#513)
  Add passThroughTouches to BlueprintView, PassthroughView (#511)
  Bump checkout and upload-artifacts (#512)
  Bump rexml from 3.2.8 to 3.3.6
  Unnest Box.CornerStyle (#508)
  Prepare 4.2.1 (#507)
  Make public the UIBezierPath helper using Box.CornerStyle
  Prepare 4.2.0 (#505)
  Label and AttributedLabel now support accessibilityValue (#504)
  Prepare 4.1.2 Release (#503)
  Replace newlines in a11y labels with spaces (#502)
  Fix string index out of bounds crash. (#501)
  bumping version to 4.1.0 (#499)
  AttributedLabel Link Accessibility (#459)
  • Loading branch information
maxg-square committed Nov 3, 2024
2 parents 53c4a54 + d1fe857 commit c291cf4
Show file tree
Hide file tree
Showing 17 changed files with 380 additions and 98 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Read env
run: cat .github/workflows/env.properties >> $GITHUB_ENV
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Read env
run: cat .github/workflows/env.properties >> $GITHUB_ENV
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
installation_required: false

steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4

- name: Read env
run: cat .github/workflows/env.properties >> $GITHUB_ENV
Expand All @@ -56,7 +56,7 @@ jobs:
git ls-files -mo BlueprintUICommonControls/Tests/Sources/Resources/ReferenceImages | xargs tar -cvf snapshot_changes_${{ matrix.sdk }}.tar
- name: Archive snapshot changes
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: snapshot_changes_${{ matrix.sdk }}
Expand All @@ -69,7 +69,7 @@ jobs:
runs-on: macos-13-xlarge

steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4

- name: Read env
run: cat .github/workflows/env.properties >> $GITHUB_ENV
Expand Down
27 changes: 25 additions & 2 deletions BlueprintUI/Sources/BlueprintView/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@ public final class BlueprintView: UIView {
/// Provides performance metrics about the duration of layouts, updates, etc.
public weak var metricsDelegate: BlueprintViewMetricsDelegate? = nil

/// Defaults to `false`. If enabled, Blueprint will pass through any touches
/// not recieved by an element to the view hierarchy behind the `BlueprintView`.
public var passThroughTouches: Bool = false {
didSet {
if oldValue != passThroughTouches {
setNeedsViewHierarchyUpdate()
}
}
}

private var isVisible: Bool = false {
didSet {
switch (oldValue, isVisible) {
Expand All @@ -141,7 +151,7 @@ public final class BlueprintView: UIView {

rootController = NativeViewController(
node: NativeViewNode(
content: UIView.describe { _ in },
content: PassthroughView.describe { _ in },
// Because no layout update occurs here, passing an empty environment is fine;
// the correct environment will be passed during update.
environment: .empty,
Expand Down Expand Up @@ -327,6 +337,17 @@ public final class BlueprintView: UIView {
setNeedsViewHierarchyUpdate()
}

/// Ignore any touches on this view and (pass through) by returning nil if the default `hitTest` implementation returns this view.
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)

if passThroughTouches {
return result == self ? nil : result
} else {
return result
}
}

/// Clears any sizing caches, invalidates the `intrinsicContentSize` of the
/// view, and marks the view as needing a layout.
private func setNeedsViewHierarchyUpdate() {
Expand Down Expand Up @@ -391,7 +412,9 @@ public final class BlueprintView: UIView {
rootController.view.frame = bounds

var rootNode = NativeViewNode(
content: UIView.describe { _ in },
content: PassthroughView.describe { [weak self] config in
config[\.passThroughTouches] = self?.passThroughTouches ?? false
},
environment: environment,
layoutAttributes: LayoutAttributes(frame: rootFrame),
children: viewNodes
Expand Down
17 changes: 17 additions & 0 deletions BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import UIKit

extension Environment {
private enum LinkAccesibilityLabelKey: EnvironmentKey {
static var defaultValue: String? {
UIImage(systemName: "link")?.accessibilityLabel
}
}

/// The localised accessibility label elements should use when handling links.
///
/// Defaults to `UIImage(systemName: "link")?.accessibilityLabel`.
public var linkAccessibilityLabel: String? {
get { self[LinkAccesibilityLabelKey.self] }
set { self[LinkAccesibilityLabelKey.self] = newValue }
}
}
12 changes: 9 additions & 3 deletions BlueprintUI/Sources/Internal/PassthroughView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ import UIKit
CATransformLayer.self
}

/// Ignore any touches on this view and (pass through) by returning nil if the
/// default `hitTest` implementation returns this view.
public var passThroughTouches: Bool = true

/// Ignore any touches on this view and (pass through) by returning nil if the default `hitTest` implementation returns this view.
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
return result == self ? nil : result

if passThroughTouches {
return result == self ? nil : result
} else {
return result
}
}
}
8 changes: 4 additions & 4 deletions BlueprintUICommonControls/Sources/AccessibilityElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public struct AccessibilityElement: Element {
public var identifier: String?
public var traits: Set<Trait>
public var accessibilityFrameSize: CGSize?
public var accessibilityFrameCornerStyle: Box.CornerStyle
public var accessibilityFrameCornerStyle: CornerStyle
public var wrappedElement: Element

/// Used to provide custom behaviour when activated by voiceover. This will override the default behavior of issuing a tap event at the accessibility activation point.
Expand All @@ -34,7 +34,7 @@ public struct AccessibilityElement: Element {
hint: String? = nil,
identifier: String? = nil,
accessibilityFrameSize: CGSize? = nil,
accessibilityFrameCornerStyle: Box.CornerStyle = .square,
accessibilityFrameCornerStyle: CornerStyle = .square,
customActions: [AccessibilityElement.CustomAction] = [],
customContent: [AccessibilityElement.CustomContent] = [],
wrapping element: Element,
Expand Down Expand Up @@ -92,7 +92,7 @@ public struct AccessibilityElement: Element {

private final class AccessibilityView: UIView, AXCustomContentProvider {
var accessibilityFrameSize: CGSize?
var accessibilityFrameCornerStyle: Box.CornerStyle = .square
var accessibilityFrameCornerStyle: CornerStyle = .square
var accessibilityCustomContent: [AXCustomContent]! = [] // The exclamation `!` is in the protodol definition and required.

var increment: (() -> Void)?
Expand Down Expand Up @@ -175,7 +175,7 @@ extension Element {
hint: String? = nil,
identifier: String? = nil,
accessibilityFrameSize: CGSize? = nil,
accessibilityFrameCornerStyle: Box.CornerStyle = .square,
accessibilityFrameCornerStyle: CornerStyle = .square,
customActions: [AccessibilityElement.CustomAction] = [],
customContent: [AccessibilityElement.CustomContent] = []
) -> AccessibilityElement {
Expand Down
89 changes: 83 additions & 6 deletions BlueprintUICommonControls/Sources/AttributedLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ public struct AttributedLabel: Element, Hashable {
/// A set of accessibility traits that should be applied to the label, these will be merged with any existing traits.
public var accessibilityTraits: Set<AccessibilityElement.Trait>?

/// A localized string that represents the current value of the accessibility element.
///
/// The value is a localized string that contains the current value of an element.
/// For example, the value of a slider might be 9.5 or 35% and the value of a text field is the text it contains.
public var accessibilityValue: String?

/// A localized string that describes the result of performing an action on the element, when the result is non-obvious.
public var accessibilityHint: String?

Expand Down Expand Up @@ -249,13 +255,14 @@ extension AttributedLabel {

if !isMeasuring {
updateFontFitting(with: model)
}

isAccessibilityElement = model.isAccessibilityElement
accessibilityHint = model.accessibilityHint
updateAccessibilityTraits(with: model)
accessibilityCustomActions = model.accessibilityCustomActions.map { action in
UIAccessibilityCustomAction(name: action.name) { _ in action.onActivation() }
isAccessibilityElement = model.isAccessibilityElement
accessibilityHint = model.accessibilityHint
accessibilityValue = model.accessibilityValue
updateAccessibilityTraits(with: model)
accessibilityCustomActions = model.accessibilityCustomActions.map { action in
UIAccessibilityCustomAction(name: action.name) { _ in action.onActivation() }
}
}

urlHandler = environment.urlHandler
Expand All @@ -265,6 +272,11 @@ extension AttributedLabel {
if previousAttributedText != attributedText {
links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText)
accessibilityLinks = accessibilityLinks(for: links, in: model.attributedText)
accessibilityLabel = accessibilityLabel(
with: links,
in: model.attributedText.string,
linkAccessibilityLabel: environment.linkAccessibilityLabel
)
}

if let shadow = model.shadow {
Expand Down Expand Up @@ -537,6 +549,66 @@ extension AttributedLabel {
link: link
)
}


}

internal func accessibilityLabel(with links: [Link], in string: String, linkAccessibilityLabel: String?) -> String {
// When reading an attributed string that contains the `.link` attribute VoiceOver will announce "link" when it encounters the applied range. This is important because it informs the user about the context and position of the linked text within the greater string. This can be partocularly important when a string contains multiple links with the same linked text but different link destinations.

// UILabel is extremely insistant about how the `.link` attribute should be styled going so far as to apply its own preferences above any other provided attributes. In order to allow custom link styling we replace any instances of the `.link` attribute with a `labelLink.` attribute (see `NSAttributedString.normalizingForView(with:)`. This allows us to track the location of links while still providing our own custom styling. Unfortunately this means that voiceover doesnt recognize our links as links and consequently they are not announced to the user.

// Ideally we'd be able to enumerate our links, insert the `.link` attribute back and then set the resulting string as the `accessibilityAttributedString` but unfortunately that doesnt seem to work. Apple's [docs](https://developer.apple.com/documentation/objectivec/nsobject/2865944-accessibilityattributedlabel) indicate that this property is intended "for the inclusion of language attributes in the string to control pronunciation or accents" and doesnt seem to notice any included `.link` attributes.

// Insert the word "link" after each link in the label. This mirrors the VoiceOver behavior when encountering a `.link` attribute.

guard let localizedLinkString = linkAccessibilityLabel,
!links.isEmpty
else {
// We need to replace all newlines with " "
return string.removingNewlines
}
var label = string
// Wrap the word in [brackets] to indicate that it is a tag distinct from the content string. This is transparent to voiceover but should be helpful when the accessibility label is printed e.g. in the accessibility inspector.

// The use of square brackets is arbitrary but was chosen because:
// • Voiceover doesn't read the [] characters, but does realize the contained word is distinct from the preceding word.
// • Square brackets aren't often used in prose, unlike parenthesis. They're unlikely to be confused with the actual content.
// • They look like markdown.

let insertionString = "[\(localizedLinkString)]"
// Insert from the end of the string to keep indices stable.
let reversed = links.sorted { $0.range.location > $1.range.location }
for link in reversed {
// Extract substring from NSString to align with NSRange provided by the link.
let nsstring = string as NSString
guard link.range.location >= 0,
link.range.length >= 0,
link.range.location + link.range.length <= nsstring.length
else {
continue
}
let substring = nsstring.substring(with: link.range)

// Generate swift range from substring
guard let swiftRange = string.range(of: substring) else {
continue
}
let insertionPoint = swiftRange.upperBound

let insertionEnd = label.index(
insertionPoint,
offsetBy: insertionString.count,
limitedBy: label.endIndex
)
if insertionEnd != nil && label[insertionPoint..<(insertionEnd ?? insertionPoint)] == insertionString {
continue
}
label.insert(contentsOf: insertionString, at: insertionPoint)
}

// We need to replace all newlines with " "
return label.removingNewlines
}

func applyLinkColors(activeLinks: [Link] = []) {
Expand Down Expand Up @@ -790,3 +862,8 @@ extension NSTextCheckingResult.CheckingType {
}
}

extension String {
fileprivate var removingNewlines: String {
components(separatedBy: .newlines).filter { !$0.isEmpty }.joined(separator: " ")
}
}
65 changes: 1 addition & 64 deletions BlueprintUICommonControls/Sources/Box.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,70 +129,7 @@ public struct Box: Element {

extension Box {

public enum CornerStyle: Equatable {
case square
case capsule
case rounded(radius: CGFloat, corners: Corners = .all)

public struct Corners: OptionSet, Equatable {
public let rawValue: UInt8

public init(rawValue: UInt8) {
self.rawValue = rawValue
}

public static let topLeft = Corners(rawValue: 1)
public static let topRight = Corners(rawValue: 1 << 1)
public static let bottomLeft = Corners(rawValue: 1 << 2)
public static let bottomRight = Corners(rawValue: 1 << 3)

public static let all: Corners = [.topLeft, .topRight, .bottomLeft, .bottomRight]
public static let top: Corners = [.topRight, .topLeft]
public static let left: Corners = [.topLeft, .bottomLeft]
public static let bottom: Corners = [.bottomLeft, .bottomRight]
public static let right: Corners = [.topRight, .bottomRight]

var toCACornerMask: CACornerMask {
var mask: CACornerMask = []
if contains(.topLeft) {
mask.update(with: .layerMinXMinYCorner)
}

if contains(.topRight) {
mask.update(with: .layerMaxXMinYCorner)
}

if contains(.bottomLeft) {
mask.update(with: .layerMinXMaxYCorner)
}

if contains(.bottomRight) {
mask.update(with: .layerMaxXMaxYCorner)
}
return mask
}

var toUIRectCorner: UIRectCorner {
var rectCorner: UIRectCorner = []
if contains(.topLeft) {
rectCorner.update(with: .topLeft)
}

if contains(.topRight) {
rectCorner.update(with: .topRight)
}

if contains(.bottomLeft) {
rectCorner.update(with: .bottomLeft)
}

if contains(.bottomRight) {
rectCorner.update(with: .bottomRight)
}
return rectCorner
}
}
}
public typealias CornerStyle = BlueprintUICommonControls.CornerStyle

/// Specifies the curve style when showing rounded corners on a `Box`.
public enum CornerCurve: Equatable {
Expand Down
Loading

0 comments on commit c291cf4

Please sign in to comment.