Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is UIAccessibility.post(notification: .announcement, argument: "arg") not announced in voice over?

When using Voice Over in iOS, calling UIAccessibility.post(notification:argument:) to announce a field error doesn't actually announce the error.

I have a submit button and, when focusing the button, voice over reads the button title as you would expect. When pressing the button, voice over reads the title again. When the submit button is pressed, I am doing some validation and, when there is a field error, I am trying to announce it by calling:

if UIAccessibility.isVoiceOverRunning {
    UIAccessibility.post(notification: .announcement, argument: "my field error")
}

Interestingly enough, if I stop on a breakpoint in the debugger the announcement happens. When I don't stop on a breakpoint, the announcement doesn't happen.

The notification is posting on the main thread and, if is like NotificationCenter.default, I assume that it is handled on the same thread it was posted on. I have tried to dispatch the call to the main queue, even though it is already on the main thread, and that doesn't seem to work either.

The only thing that I can think is that the notification is posted and observed before voice over is finished reading the submit button title and the announcement notification won't interrupt the current voice over.

I would really appreciate any help on this.

like image 761
brandenesmith Avatar asked Apr 04 '19 18:04

brandenesmith


6 Answers

This is an admittedly hacky solution, but I was able to prevent the system announcement from pre-empting my own by dispatching to the main thread with a slight delay:

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  UIAccessibility.post(notification: .announcement, argument: "<Your text>")
}
like image 73
tedrothrock Avatar answered Nov 14 '22 13:11

tedrothrock


Your problem may happen because the system needs to take over during the field error appears and, in this case, any customed VoiceOver notification is cancelled.🀯

I wrote an answer about problems with queueing multiple VoiceOver notifications that may help you to understand your current situation.πŸ€“

Your notification works with a breakpoint because you're delaying it and the system works during this time : there's no overlap between your notification and the system work.

A simple solution may be to implement a short delay before sending your notification but the delay depends on the speech rate that's why this is only a temporary workaround. πŸ™„

Your retry mechanism is smart and could be improved inside a loop of few retries in case of many system takeovers. πŸ‘

like image 39
XLE_22 Avatar answered Nov 14 '22 14:11

XLE_22


Another work around is to use .screenChanged instead and pass the error label, as:

UIAccessibility.post(notification: .screenChanged, argument: errorLabel)
like image 5
Amr Avatar answered Nov 14 '22 13:11

Amr


I was facing the same issue, so I picked up @brandenesmith idea of the notification queue and wrote a little helper class.

class AccessibilityAnnouncementQueue {
    
    static let shard = AccessibilityAnnouncementQueue()
    
    private var queue: [String] = []

    private init() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(announcementFinished(_:)),
                                               name: UIAccessibility.announcementDidFinishNotification,
                                               object: nil)
    }
    
    func post(announcement: String) {
        guard UIAccessibility.isVoiceOverRunning else { return }
        
        queue.append(announcement)
        postNotification(announcement)
    }
    
    
    private func postNotification(_ message: String) {
        let attrMessage: NSAttributedString = NSAttributedString(string: message, attributes: [.accessibilitySpeechQueueAnnouncement: true])
        UIAccessibility.post(notification: .announcement, argument: attrMessage)
    }
    
    @objc private func announcementFinished(_ sender: Notification) {
        guard
            let userInfo = sender.userInfo,
            let firstQueueItem = queue.first,
            let announcement = userInfo[UIAccessibility.announcementStringValueUserInfoKey] as? String,
            let success = userInfo[UIAccessibility.announcementWasSuccessfulUserInfoKey] as? Bool,
            firstQueueItem == announcement
        else { return }
        
        if success {
            queue.removeFirst()
        } else {
            postNotification(firstQueueItem)
        }
    }
    
}
like image 4
Stefan Wieland Avatar answered Nov 14 '22 13:11

Stefan Wieland


I am able to get this to work using a retry mechanism where I register as an observer of the UIAccessibility.announcementDidFinishNotification and then pull the announcement and success status out of the userInfo dictionary.

If the success status is false and the announcement is the same as the one I just sent, I post the notification again. This happens on repeat until the announcement was successful.

There are obviously multiple problems with this approach including having to de-register, what happens if another object manages to post the same announcement (this shouldn't ever happen in practice but in theory it could), having to keep track of the last announcement sent, etc.

The code would look like:

private var _errors: [String] = []
private var _lastAnnouncement: String = ""

init() {
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(announcementFinished(_:)),
        name: UIAccessibility.announcementDidFinishNotification,
        object: nil
    )
}

func showErrors() {
    if !_errors.isEmpty {
        view.errorLabel.text = _errors.first!
        view.errorLabel.isHidden = false

        if UIAccessibility.isVoiceOverRunning {
            _lastAnnouncement = _errors.first!
            UIAccessibility.post(notification: .announcement, argument: _errors.first!)
        }
    } else {
        view.errorLabel.text = ""
        view.errorLabel.isHidden = true
    }
}

@objc func announcementFinished(_ sender: Notification) {
    guard let announcement = sender.userInfo![UIAccessibility.announcementStringValueUserInfoKey] as? String else { return }
    guard let success = sender.userInfo![UIAccessibility.announcementWasSuccessfulUserInfoKey] as? Bool else { return }

    if !success && announcement == _lastAnnouncement {
        _lastAnnouncement = _errors.first!
        UIAccessibility.post(notification: .announcement, argument: _errors.first!)
    }
}

The problem is that this retry mechanism will always be used because the first call to UIAccessibility.post(notification: .announcement, argument: _errors.first!) always (unless I am stopped on a breakpoint). I still don't know why the first post always fails.

like image 2
brandenesmith Avatar answered Nov 14 '22 14:11

brandenesmith


If somebody uses RxSwift, probably following solution will be more suitable:

extension UIAccessibility {
    static func announce(_ message: String) -> Completable {
        guard !message.isEmpty else { return .empty() }
        return Completable.create { subscriber in
            let postAnnouncement = {
                DispatchQueue.main.async {
                    UIAccessibility.post(notification: .announcement, argument: message)
                }
            }
            
            postAnnouncement()
            
            let observable = NotificationCenter.default.rx.notification(UIAccessibility.announcementDidFinishNotification)
            return observable.subscribe(onNext: { notification in
                guard let userInfo = notification.userInfo,
                      let announcement = userInfo[UIAccessibility.announcementStringValueUserInfoKey] as? String,
                      announcement == message,
                      let success = userInfo[UIAccessibility.announcementWasSuccessfulUserInfoKey] as? Bool else { return }
                success ? subscriber(.completed) : postAnnouncement()
            })
        }
    }
}
like image 1
YOG Avatar answered Nov 14 '22 14:11

YOG