Skip to content

Commit

Permalink
Add VoiceOver support
Browse files Browse the repository at this point in the history
  • Loading branch information
wtmoose committed Mar 12, 2017
1 parent f493300 commit 8448eac
Show file tree
Hide file tree
Showing 16 changed files with 349 additions and 171 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Change Log
All notable changes to this project will be documented in this file.

## [3.3.0](https://github.com/SwiftKickMobile/SwiftMessages/releases/tag/3.3.0)

### Features
* Add proper support for VoiceOver. See the [Accessibility section](README.md#accessibility) of the readme.

## [3.2.1](https://github.com/SwiftKickMobile/SwiftMessages/releases/tag/3.2.1)

### Bug Fixes
Expand Down
2 changes: 2 additions & 0 deletions Demo/Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Demo/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = it.swiftkick.Demo;
Expand All @@ -360,6 +361,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
DEVELOPMENT_TEAM = "";
INFOPLIST_FILE = Demo/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = it.swiftkick.Demo;
Expand Down
4 changes: 2 additions & 2 deletions Demo/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- SwiftMessages (3.1.5)
- SwiftMessages (3.3.0)

DEPENDENCIES:
- SwiftMessages (from `../`)
Expand All @@ -9,7 +9,7 @@ EXTERNAL SOURCES:
:path: "../"

SPEC CHECKSUMS:
SwiftMessages: e183a3541844cf450dc0983034bbfe7cc22dcaa7
SwiftMessages: 1e6f8140374c014befafcbf3149da86b323b0575

PODFILE CHECKSUM: 6431c980c9207084d738b6ba87b2101dd9eb5097

Expand Down
4 changes: 2 additions & 2 deletions Demo/Pods/Local Podspecs/SwiftMessages.podspec.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Demo/Pods/Manifest.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

282 changes: 145 additions & 137 deletions Demo/Pods/Pods.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Demo/Pods/Target Support Files/SwiftMessages/Info.plist

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ config.duration = .forever
SwiftMessages.show(config: config, view: view)
````

### Accessibility

SwiftMessages provides excellent VoiceOver support out-of-the-box.

* The title and body of the message are combined into a single announcement when the message is shown. The `MessageView.accessibilityPrefix` property can be set to prepend additional clarifying text to the announcement.

Sometimes, a message may contain important visual cues that aren't captured in the title or body. For example, a message may rely on a yellow background to convey a warning rather than having the word "warning" in the title or body. In this case, it might be helpful to set `MessageView.accessibilityPrefix = "warning"`.

* If the message is shown with a dim view using `config.dimMode`, elements below the dim view are not focusable until the message is hidden. If `config.dimMode.interactive == true`, the dim view itself will be focusable and read out "dismiss" followed by "button". The former text can be customized by setting the `config.dimModeAccessibilityLabel` property.

See the `AccessibleMessage` protocol for implementing proper accessibility support in custom views.

### Customization

`MessageView` provides the following UI elements, exposed as public, optional `@IBOutlets`:
Expand Down
4 changes: 2 additions & 2 deletions SwiftMessages.podspec
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
Pod::Spec.new do |spec|
spec.name = 'SwiftMessages'
spec.version = '3.2.1'
spec.version = '3.3.0'
spec.license = { :type => 'MIT' }
spec.homepage = 'https://github.com/SwiftKickMobile/SwiftMessages'
spec.authors = { 'Timothy Moose' => '[email protected]' }
spec.summary = 'A very flexible message bar for iOS written in Swift.'
spec.source = {:git => 'https://github.com/SwiftKickMobile/SwiftMessages.git', :tag => '3.2.1'}
spec.source = {:git => 'https://github.com/SwiftKickMobile/SwiftMessages.git', :tag => '3.3.0'}
spec.platform = :ios, '8.0'
spec.ios.deployment_target = '8.0'
spec.source_files = 'SwiftMessages/**/*.swift'
Expand Down
8 changes: 8 additions & 0 deletions SwiftMessages.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
objects = {

/* Begin PBXBuildFile section */
22E01F641E74EC8B00ACE19A /* MaskingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E01F631E74EC8B00ACE19A /* MaskingView.swift */; };
22E307FF1E74C5B100E35893 /* AccessibleMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E307FE1E74C5B100E35893 /* AccessibleMessage.swift */; };
86589D471D64B6E40041676C /* BaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86589D461D64B6E40041676C /* BaseView.swift */; };
86589D911D692B1C0041676C /* TabView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 86589D901D692B1B0041676C /* TabView.xib */; };
867BED211D622793005212E3 /* BackgroundViewable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867BED201D622793005212E3 /* BackgroundViewable.swift */; };
Expand Down Expand Up @@ -44,6 +46,8 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
22E01F631E74EC8B00ACE19A /* MaskingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MaskingView.swift; sourceTree = "<group>"; };
22E307FE1E74C5B100E35893 /* AccessibleMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccessibleMessage.swift; sourceTree = "<group>"; };
862C0C6A1D58E93300D06168 /* SwiftMessages.podspec */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; fileEncoding = 4; path = SwiftMessages.podspec; sourceTree = SOURCE_ROOT; };
862C0CB01D5911C100D06168 /* NSBundle+Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSBundle+Utils.swift"; sourceTree = "<group>"; };
862C0CD91D5A397F00D06168 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Resources/Images.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -114,6 +118,7 @@
867BED201D622793005212E3 /* BackgroundViewable.swift */,
864495551D4F7C390056EB2A /* Identifiable.swift */,
86AAF81D1D5549680031EE32 /* MarginAdjustable.swift */,
22E307FE1E74C5B100E35893 /* AccessibleMessage.swift */,
86AAF82A1D580DD70031EE32 /* Error.swift */,
86DBE0031D75BE800071E51D /* Array+Utils.swift */,
);
Expand Down Expand Up @@ -143,6 +148,7 @@
children = (
867E21931D4D50BB00594A41 /* Presenter.swift */,
86AAF8171D54F0650031EE32 /* PassthroughView.swift */,
22E01F631E74EC8B00ACE19A /* MaskingView.swift */,
86AAF8191D54F0850031EE32 /* PassthroughWindow.swift */,
8644955C1D4FAF7C0056EB2A /* WindowViewController.swift */,
86AAF81B1D551FE60031EE32 /* UIViewController+Utils.swift */,
Expand Down Expand Up @@ -305,7 +311,9 @@
86BBA9061D5E040C00FE8F16 /* Identifiable.swift in Sources */,
86BBA9011D5E040600FE8F16 /* PassthroughWindow.swift in Sources */,
86BBA9031D5E040600FE8F16 /* UIViewController+Utils.swift in Sources */,
22E01F641E74EC8B00ACE19A /* MaskingView.swift in Sources */,
86BBA9001D5E040600FE8F16 /* PassthroughView.swift in Sources */,
22E307FF1E74C5B100E35893 /* AccessibleMessage.swift in Sources */,
86BBA9041D5E040600FE8F16 /* NSBundle+Utils.swift in Sources */,
86BBA8FD1D5E03F800FE8F16 /* SwiftMessages.swift in Sources */,
86BBA9021D5E040600FE8F16 /* WindowViewController.swift in Sources */,
Expand Down
20 changes: 20 additions & 0 deletions SwiftMessages/AccessibleMessage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// AccessibleMessage.swift
// SwiftMessages
//
// Created by Timothy Moose on 3/11/17.
// Copyright © 2017 SwiftKick Mobile. All rights reserved.
//

import Foundation

/**
Message views that `AccessibleMessage`, as `MessageView` does will
have proper accessibility behavior when displaying messages.
`MessageView` implements this protocol.
*/
public protocol AccessibleMessage {
var accessibilityMessage: String? { get }
var accessibilityElement: NSObject? { get }
var additonalAccessibilityElements: [NSObject]? { get }
}
28 changes: 28 additions & 0 deletions SwiftMessages/MaskingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// MaskingView.swift
// SwiftMessages
//
// Created by Timothy Moose on 3/11/17.
// Copyright © 2017 SwiftKick Mobile. All rights reserved.
//

import UIKit


class MaskingView: PassthroughView {

var accessibleElements: [NSObject] = []

override func accessibilityElementCount() -> Int {
return accessibleElements.count
}

override func accessibilityElement(at index: Int) -> Any? {
return accessibleElements[index]
}

override func index(ofAccessibilityElement element: Any) -> Int {
guard let object = element as? NSObject else { return 0 }
return accessibleElements.index(of: object) ?? 0
}
}
30 changes: 29 additions & 1 deletion SwiftMessages/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import UIKit

/*
*/
open class MessageView: BaseView, Identifiable {
open class MessageView: BaseView, Identifiable, AccessibleMessage {

/*
MARK: - Button tap handler
Expand Down Expand Up @@ -81,6 +81,33 @@ open class MessageView: BaseView, Identifiable {
}

private var customId: String?

/*
MARK: - AccessibleMessage
*/

/**
An optional prefix for the `accessibilityMessage` that can
be used to futher clarify the message for VoiceOver. For example,
the view's background color or icon might convey that a message is
a warning, in which case one may specify the value "warning".
*/
private var accessibilityPrefix: String?

open var accessibilityMessage: String? {
let components = [accessibilityPrefix, titleLabel?.text, bodyLabel?.text].flatMap { $0 }
guard components.count > 0 else { return nil }
return components.joined(separator: ", ")
}

public var accessibilityElement: NSObject? {
return backgroundView
}

open var additonalAccessibilityElements: [NSObject]? {
if let button = button { return [button] }
return nil
}
}

/*
Expand Down Expand Up @@ -345,3 +372,4 @@ extension MessageView {
iconLabel?.isHidden = iconLabel?.text == nil
}
}

87 changes: 64 additions & 23 deletions SwiftMessages/Presenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class Presenter: NSObject, UIGestureRecognizerDelegate {
let config: SwiftMessages.Config
let view: UIView
weak var delegate: PresenterDelegate?
let maskingView = PassthroughView()
let maskingView = MaskingView()
var presentationContext = PresentationContext.viewController(Weak<UIViewController>(value: nil))
let panRecognizer: UIPanGestureRecognizer
var translationConstraint: NSLayoutConstraint! = nil
Expand Down Expand Up @@ -109,10 +109,27 @@ class Presenter: NSObject, UIGestureRecognizerDelegate {
showAnimation() { completed in
completion(completed)
if completed {
if self.config.dimMode.modal {
self.showAccessibilityFocus()
} else {
self.showAccessibilityAnnouncement()
}
self.config.eventListeners.forEach { $0(.didShow) }
}
}
}

private func showAccessibilityAnnouncement() {
guard let accessibleMessage = view as? AccessibleMessage,
let message = accessibleMessage.accessibilityMessage else { return }
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, message)
}

private func showAccessibilityFocus() {
guard let accessibleMessage = view as? AccessibleMessage,
let focus = accessibleMessage.accessibilityElement ?? accessibleMessage.additonalAccessibilityElements?.first else { return }
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, focus)
}

func getPresentationContext() throws -> PresentationContext {

Expand Down Expand Up @@ -207,39 +224,63 @@ class Presenter: NSObject, UIGestureRecognizerDelegate {
if config.interactiveHide {
view.addGestureRecognizer(panRecognizer)
}
do {

func setupInteractive(_ interactive: Bool) {
if interactive {
maskingView.tappedHander = { [weak self] in
guard let strongSelf = self else { return }
strongSelf.interactivelyHidden = true
strongSelf.delegate?.hide(presenter: strongSelf)
}
} else {
// There's no action to take, but the presence of
// a tap handler prevents interaction with underlying views.
maskingView.tappedHander = { }
installInteractive()
installAccessibility()
}

private func installInteractive() {
if config.dimMode.interactive {
maskingView.tappedHander = { [weak self] in
guard let strongSelf = self else { return }
strongSelf.interactivelyHidden = true
strongSelf.delegate?.hide(presenter: strongSelf)
}
} else {
// There's no action to take, but the presence of
// a tap handler prevents interaction with underlying views.
maskingView.tappedHander = { }
}
}

func installAccessibility() {
var elements: [NSObject] = []
if let accessibleMessage = view as? AccessibleMessage {
if let message = accessibleMessage.accessibilityMessage {
let element = accessibleMessage.accessibilityElement ?? view
element.isAccessibilityElement = true
if element.accessibilityLabel == nil {
element.accessibilityLabel = message
}
maskingView.accessibilityViewIsModal = true
elements.append(element)
}
switch config.dimMode {
case .none:
break
case .gray(let interactive):
setupInteractive(interactive)
case .color(_, let interactive):
setupInteractive(interactive)
if let additional = accessibleMessage.additonalAccessibilityElements {
elements += additional
}
}
if config.dimMode.interactive {
let dismissView = UIView(frame: maskingView.bounds)
dismissView.translatesAutoresizingMaskIntoConstraints = true
dismissView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
maskingView.addSubview(dismissView)
maskingView.sendSubview(toBack: dismissView)
dismissView.isUserInteractionEnabled = false
dismissView.isAccessibilityElement = true
dismissView.accessibilityLabel = config.dimModeAccessibilityLabel
dismissView.accessibilityTraits = UIAccessibilityTraitButton
elements.append(dismissView)
}
if config.dimMode.modal {
maskingView.accessibilityViewIsModal = true
}
maskingView.accessibleElements = elements
}

private var becomeKeyWindow: Bool {
if config.becomeKeyWindow == .some(true) { return true }
switch config.dimMode {
case .gray, .color:
// Should become key window in modal presentation style
// for proper voice over handling.
// for proper VoiceOver handling.
return true
case .none:
return false
Expand Down
Loading

0 comments on commit 8448eac

Please sign in to comment.