Iago Salomon
Iago Salomon

Reputation: 57

SWIFT: Make voice over stop reading a truncated string

In a label I am attributing a large string and setting its .lineBreakMode = .buTrucatingTail, but when I do that and try to use VoiceOver on it, I ends up reading the whole string, not just what is in the screen, here is an example:

string.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
srring.lineBreakMode = .buTrucatingTail

This is what appears on screen:

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco...

But the voice over reads the whole string.

Does anyone know how to make it stop in the truncation three dots? Or how to set the accessibility label to what is on screen (because the text length changes depending on the device)?

Thanks in advance.

Upvotes: 3

Views: 1741

Answers (1)

XLE_22
XLE_22

Reputation: 5671

[...] when I do that and try to use VoiceOver on it, I ends up reading the whole string, not just what is in the screen [...] voice over reads the whole string.

As I said in my comment, the truncation is only for display.
VoiceOver will always read out the entire text of the string and it doesn't care of what's on screen.
It's exactly like the accessibilityLabel that may be different from what's displayed: here, the accessibilityLabel is the entire string content. 🤨

Does anyone know how to make it stop in the truncation three dots?

Your question arose my curiosity and I decided to look into this problem.
I found out a solution using TextKit whose basics are supposed to be known: if that's not the case ⟹ Apple doc 👍

⚠️ The main idea is to determine the index of the last visible character in order to extract the displayed substring and assign it to the accessibilityLabel in the end. ⚠️

Initial assumptions: use of a UILabel using the same lineBreakMode as the one defined in the question (byTruncationTail).
I wrote the entire following code in a playground and confirmed the results using a Xcode blank project to check it out.🤓

import Foundation
import UIKit

extension UILabel {
    var displayedLines: (number: Int?, lastIndex: Int?) {
    
        guard let text = text else {
            return (nil, nil)
        }
    
    let attributes: [NSAttributedString.Key: UIFont] = [.font:font]
    let attributedString = NSAttributedString(string: text,
                                              attributes: attributes)
    
    //Beginning of the TextKit basics...
    let textStorage = NSTextStorage(attributedString: attributedString)
    let layoutManager = NSLayoutManager()
    textStorage.addLayoutManager(layoutManager)
    
    let textContainerSize = CGSize(width: frame.size.width,
                                   height: CGFloat.greatestFiniteMagnitude)
    let textContainer = NSTextContainer(size: textContainerSize)
    textContainer.lineFragmentPadding = 0.0 //Crucial to get the most accurate results.
    textContainer.lineBreakMode = .byTruncatingTail
    
    layoutManager.addTextContainer(textContainer)
    //... --> end of the TextKit basics
    
    var glyphRangeMax = NSRange()
    let characterRange = NSMakeRange(0, attributedString.length)
    //The 'glyphRangeMax' variable will store the appropriate value thanks to the following method.
    layoutManager.glyphRange(forCharacterRange: characterRange,
                             actualCharacterRange: &glyphRangeMax)
    
    print("line : sentence -> last word")
    print("----------------------------")
    
    var truncationRange = NSRange()
    var fragmentNumber = ((self.numberOfLines == 0) ? 1 : 0)
    var globalHeight: CGFloat = 0.0
    
    //Each line fragment of the layout manager is enumerated until the truncation is found.
    layoutManager.enumerateLineFragments(forGlyphRange: glyphRangeMax) { rect, usedRect, textContainer, glyphRange, stop in
        
        globalHeight += rect.size.height
        
        if (self.numberOfLines == 0) {
            if (globalHeight > self.frame.size.height) {
                print("⚠️ Constraint ⟹ height of the label ⚠️")
                stop.pointee = true //Stops the enumeration and returns the results immediately.
            }
        } else {
            if (fragmentNumber == self.numberOfLines) {
                print("⚠️ Constraint ⟹ number of lines ⚠️")
                stop.pointee = true
            }
        }
        
        if (stop.pointee.boolValue == false) {
            fragmentNumber += 1
            truncationRange = NSRange()
            layoutManager.characterRange(forGlyphRange: NSMakeRange(glyphRange.location, glyphRange.length),
                                         actualGlyphRange: &truncationRange)
            
            let sentenceInFragment = self.sentenceIn(truncationRange)
            let line = (self.numberOfLines == 0) ? (fragmentNumber - 1) : fragmentNumber
            print("\(line) : \(sentenceInFragment) -> \(lastWordIn(sentenceInFragment))")
        }
    }
    
    let lines = ((self.numberOfLines == 0) ? (fragmentNumber - 1) : fragmentNumber)
    return (lines, (truncationRange.location + truncationRange.length - 2))
}

//Function to get the sentence of a line fragment
func sentenceIn(_ range: NSRange) -> String {
    
    var extractedString = String()
    
    if let text = self.text {
        
        let indexB = text.index(text.startIndex,
                                offsetBy:range.location)
        let indexF = text.index(text.startIndex,
                                offsetBy:range.location+range.length)
        
        extractedString = String(text[indexB..<indexF])
    }
    return extractedString
}
}


//Function to get the last word of the line fragment
func lastWordIn(_ sentence: String) -> String {

    var words = sentence.components(separatedBy: " ")
    words.removeAll(where: { $0 == "" })

    return (words.last == nil) ? "o_O_o" : (words.last)!
}

Once done, we need to create the label:

let labelFrame = CGRect(x: 20,
                        y: 50,
                        width: 150,
                        height: 100)
var testLabel = UILabel(frame: labelFrame)
testLabel.text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."

... and test the code:

testLabel.numberOfLines = 3

if let _ = testLabel.text {
    let range = NSMakeRange(0,testLabel.displayedLines.lastIndex! + 1)
    print("\nNew accessibility label to be implemented ⟹ \"\   (testLabel.sentenceIn(range))\"")
}

The Debug Area of the playground displays the result with 3 lines: enter image description here ... and if 'as many lines as possible' is intended, we get: enter image description here That seems to be working very well. 🥳

Including this rationale to your own code, you will be able to make VoiceOver read the truncated content of a label that's displayed on screen. 🎊🎉

Upvotes: 1

Related Questions