Skip to content

Commit 30ee274

Browse files
feat: Added support for accessibility large content viewer (#581)
This change adds support for the accessibility large content viewer. [Reference WWDC video](https://developer.apple.com/videos/play/wwdc2019/261/) [Apple doc](https://developer.apple.com/documentation/uikit/uilargecontentviewerinteraction) https://github.com/user-attachments/assets/bbf8548a-022d-455c-ac89-157ad8eb66ab
2 parents 729800a + 6951100 commit 30ee274

File tree

3 files changed

+310
-0
lines changed

3 files changed

+310
-0
lines changed
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import BlueprintUI
2+
import UIKit
3+
4+
extension Element {
5+
/// Adds large content viewer support to an individual element. Ensure that you use this in conjunction with accessibilityLargeContentViewerInteractionContainer().
6+
///
7+
/// Large content viewer allows users to see a larger version of content when they press and hold
8+
/// on small UI elements. This is particularly useful for users who are low vision.
9+
/// It must only be used if dynamic type is not an option for a given element; it must not be used as a substitute for dynamic type.
10+
/// It's triggered by a long press gesture and shows an enlarged version of the content in a special overlay.
11+
/// It's only available when accessibility system type sizes.
12+
///
13+
/// - Parameters:
14+
/// - title: The title to display in the large content viewer. Defaults to nil.
15+
/// - image: The image to display in the large content viewer. Defaults to nil.
16+
/// - scalesLargeContentImage: Whether the image should be scaled in the large content viewer. Defaults to false.
17+
/// - largeContentImageInsets: The insets to apply to the large content image. Defaults to zero insets.
18+
///
19+
public func accessibilityShowsLargeContentViewer(
20+
display: Accessibility.LargeContentViewerConfiguration.Display,
21+
scalesLargeContentImage: Bool = false,
22+
largeContentImageInsets: UIEdgeInsets = .zero,
23+
interactionEndedCallback: ((CGPoint) -> Void)? = nil
24+
) -> Element {
25+
Accessibility.LargeContentViewer(
26+
wrapping: self,
27+
configuration: .init(
28+
display: display,
29+
scalesLargeContentImage: scalesLargeContentImage,
30+
largeContentImageInsets: largeContentImageInsets,
31+
interactionEndedCallback: interactionEndedCallback
32+
)
33+
)
34+
}
35+
}
36+
37+
extension Accessibility {
38+
39+
/// Enables an element to opt-in to Large content viewer accessibility support. For a given
40+
/// element, add conformance to this protocol and provide the `largeContentViewerConfiguration` to
41+
/// automatically provide the large content viewer behavior without having to manually supply the arguments
42+
/// every time an instance of the element is defined.
43+
///
44+
/// Large content viewer allows users to see a larger version of content when they press and hold
45+
/// on small UI elements. This is particularly useful for users who are low vision.
46+
/// It must only be used if dynamic type is not an option for a given element; it must not be used as a substitute for dynamic type.
47+
/// It's triggered by a long press gesture and shows an enlarged version of the content in a special overlay.
48+
/// It's only available when accessibility system type sizes.
49+
///
50+
/// If your element can function as a large content viewer element, add conformance to this protocol to
51+
/// add large content viewer behavior via `accessibilityShowsLargeContentViewer()`.
52+
public protocol LargeContentViewerElement: Element {
53+
54+
/// Returns the large content viewer configuration for this element.
55+
var largeContentViewerConfiguration: LargeContentViewerConfiguration { get }
56+
}
57+
}
58+
59+
60+
extension Accessibility.LargeContentViewerElement {
61+
62+
/// Enables large content viewer for the provided element.
63+
public func accessibilityShowsLargeContentViewer() -> Element {
64+
Accessibility.LargeContentViewer(wrapping: self, configuration: largeContentViewerConfiguration)
65+
}
66+
}
67+
68+
extension Accessibility {
69+
/// Large content viewer allows users to see a larger version of content when they press and hold
70+
/// on small UI elements. This is particularly useful for users who have difficulty seeing small text or icons.
71+
public protocol LargeContentViewerItem: UIView {
72+
var largeContentViewerConfiguration: LargeContentViewerConfiguration { get }
73+
74+
func didEndInteraction(at location: CGPoint, root: UICoordinateSpace)
75+
}
76+
}
77+
78+
extension Accessibility {
79+
public struct LargeContentViewerConfiguration {
80+
81+
public enum Display: Equatable {
82+
case title(String, UIImage?)
83+
case image(UIImage)
84+
case none
85+
}
86+
87+
/// Title and/or image to display in the large content viewer.
88+
public var display: Display
89+
90+
/// Whether the image should be scaled in the large content viewer.
91+
public var scalesLargeContentImage: Bool
92+
93+
/// The insets to apply to the large content image.
94+
public var largeContentImageInsets: UIEdgeInsets
95+
96+
/// The callback to be called when the interaction ends on this item.
97+
/// The point (within the coordinate space of the element) at which the interaction ended is provided as the argument.
98+
public var interactionEndedCallback: ((CGPoint) -> Void)?
99+
100+
public init(
101+
display: Display,
102+
scalesLargeContentImage: Bool = false,
103+
largeContentImageInsets: UIEdgeInsets = .zero,
104+
interactionEndedCallback: ((CGPoint) -> Void)? = nil
105+
) {
106+
self.display = display
107+
self.scalesLargeContentImage = scalesLargeContentImage
108+
self.largeContentImageInsets = largeContentImageInsets
109+
self.interactionEndedCallback = interactionEndedCallback
110+
}
111+
}
112+
}
113+
114+
extension Accessibility {
115+
116+
public struct LargeContentViewer: Element {
117+
118+
var wrapping: Element
119+
120+
var configuration: LargeContentViewerConfiguration
121+
122+
public var content: ElementContent {
123+
ElementContent(child: wrapping)
124+
}
125+
126+
public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? {
127+
LargeContentViewerView.describe { config in
128+
config[\.largeContentViewerConfiguration] = configuration
129+
}
130+
}
131+
}
132+
}
133+
134+
extension Accessibility {
135+
136+
private final class LargeContentViewerView: UIView, LargeContentViewerItem {
137+
138+
var largeContentViewerConfiguration: LargeContentViewerConfiguration {
139+
didSet {
140+
updateLargeContentViewerItem()
141+
}
142+
}
143+
144+
override init(frame: CGRect) {
145+
largeContentViewerConfiguration = .init(display: .none)
146+
super.init(frame: frame)
147+
showsLargeContentViewer = false
148+
updateLargeContentViewerItem()
149+
}
150+
151+
required init?(coder: NSCoder) {
152+
largeContentViewerConfiguration = .init(display: .none)
153+
super.init(coder: coder)
154+
showsLargeContentViewer = false
155+
updateLargeContentViewerItem()
156+
}
157+
158+
private func updateLargeContentViewerItem() {
159+
scalesLargeContentImage = largeContentViewerConfiguration.scalesLargeContentImage
160+
largeContentImageInsets = largeContentViewerConfiguration.largeContentImageInsets
161+
162+
switch largeContentViewerConfiguration.display {
163+
case .title(let title, let image):
164+
showsLargeContentViewer = true
165+
largeContentTitle = title
166+
largeContentImage = image
167+
case .image(let image):
168+
showsLargeContentViewer = true
169+
largeContentTitle = nil
170+
largeContentImage = image
171+
case .none:
172+
showsLargeContentViewer = false
173+
largeContentTitle = nil
174+
largeContentImage = nil
175+
}
176+
}
177+
178+
func didEndInteraction(at location: CGPoint, root: UICoordinateSpace) {
179+
largeContentViewerConfiguration.interactionEndedCallback?(convert(location, from: root))
180+
}
181+
}
182+
}
183+
184+
// MARK: - Large content viewer container
185+
186+
extension Element {
187+
188+
/// Adds a large content viewer interaction container to the element.
189+
/// This is used to wrap elements that need to be able to show a large content viewer.
190+
/// Use this in conjunction with accessibilityShowsLargeContentViewer() on elements that need to show a large content viewer.
191+
/// Elements that are wrapped in this container will be able to show a large content viewer and allow a user to swipe through them with one finger
192+
/// and have the HUD update in real time.
193+
public func accessibilityLargeContentViewerInteractionContainer() -> Element {
194+
Accessibility.LargeContentViewerInteractionContainer(wrapping: self)
195+
}
196+
}
197+
198+
extension Accessibility {
199+
200+
public struct LargeContentViewerInteractionContainer: Element {
201+
202+
var wrapping: Element
203+
204+
public var content: ElementContent {
205+
ElementContent(child: wrapping)
206+
}
207+
208+
public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? {
209+
LargeContentViewerInteractionContainerView.describe { _ in }
210+
}
211+
}
212+
}
213+
214+
extension Accessibility {
215+
216+
private final class LargeContentViewerInteractionContainerView: UIView, UILargeContentViewerInteractionDelegate {
217+
218+
var largeContentViewerInteraction: UILargeContentViewerInteraction?
219+
220+
public override func didMoveToWindow() {
221+
super.didMoveToWindow()
222+
if window != nil {
223+
let largeContentViewerInteraction = UILargeContentViewerInteraction(delegate: self)
224+
addInteraction(largeContentViewerInteraction)
225+
self.largeContentViewerInteraction = largeContentViewerInteraction
226+
}
227+
}
228+
229+
// MARK: UILargeContentViewerInteractionDelegate
230+
231+
public func largeContentViewerInteraction(
232+
_ interaction: UILargeContentViewerInteraction,
233+
didEndOn item: (any UILargeContentViewerItem)?,
234+
at point: CGPoint
235+
) {
236+
if let largeContentItem = item as? Accessibility.LargeContentViewerItem {
237+
largeContentItem.didEndInteraction(at: point, root: self)
238+
}
239+
}
240+
}
241+
}
242+

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Added
1313

14+
- Added support for accessibility large content viewer.
15+
1416
### Removed
1517

1618
### Changed

SampleApp/Sources/AccessibilityViewController.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,72 @@ final class AccessibilityViewController: UIViewController {
7878
traits: [.staticText],
7979
userInputLabels: ["Short Input Label"]
8080
)
81+
Row {
82+
Button(
83+
wrapping: Label(text: "Large content item 1", configure: { label in
84+
label.color = .white
85+
})
86+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
87+
.box(background: .systemBlue)
88+
)
89+
.accessibilityShowsLargeContentViewer(
90+
display: .title("Large content item 1 display text", nil),
91+
interactionEndedCallback: { print("Interaction ended on item 1 at: \($0)") }
92+
)
93+
Button(
94+
wrapping: Label(text: "Large content item 2", configure: { label in
95+
label.color = .white
96+
})
97+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
98+
.box(background: .systemGreen)
99+
)
100+
.accessibilityShowsLargeContentViewer(
101+
display: .title("Large content item 2 display text", nil),
102+
interactionEndedCallback: { print("Interaction ended on item 2 at: \($0)") }
103+
)
104+
}.accessibilityLargeContentViewerInteractionContainer()
105+
Row {
106+
Button(
107+
wrapping: Label(text: "Large content item 3", configure: { label in
108+
label.color = .white
109+
})
110+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
111+
.box(background: .systemRed)
112+
)
113+
.accessibilityShowsLargeContentViewer(
114+
display: .title("Large content item 3 display text", nil),
115+
interactionEndedCallback: { print("Interaction ended on item 3 at: \($0)") }
116+
)
117+
Button(
118+
wrapping: Label(text: "Large content item 4", configure: { label in
119+
label.color = .white
120+
})
121+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
122+
.box(background: .systemYellow)
123+
)
124+
.accessibilityShowsLargeContentViewer(
125+
display: .title("Large content item 4 display text", nil),
126+
interactionEndedCallback: { print("Interaction ended on item 4 at: \($0)") }
127+
)
128+
}.accessibilityLargeContentViewerInteractionContainer()
129+
Row {
130+
Button(
131+
wrapping: Label(text: "Non large content item", configure: { label in
132+
label.color = .white
133+
})
134+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
135+
.box(background: .systemGray)
136+
)
137+
.accessibilityShowsLargeContentViewer(display: .none)
138+
Button(
139+
wrapping: Label(text: "Large content item 5", configure: { label in
140+
label.color = .white
141+
})
142+
.inset(by: .init(top: 8.0, left: 8.0, bottom: 8.0, right: 8.0))
143+
.box(background: .systemPurple)
144+
)
145+
.accessibilityShowsLargeContentViewer(display: .title("Large content item 5 display text", nil))
146+
}.accessibilityLargeContentViewerInteractionContainer()
81147

82148
}
83149
.accessibilityContainer()

0 commit comments

Comments
 (0)