3

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.

Richard Stelling
  • 25,607
  • 27
  • 108
  • 188
  • Have you tried `string.accessibilityHint = "Lorem ipsum dolor sit amet short"`? – Dmytro Rostopira Jan 11 '21 at 15:59
  • i don't quite understand the short thing – Iago Salomon Jan 11 '21 at 16:07
  • 2
    The truncation is only for display. VoiceOver will always read out the entire text of your string. To reach your goal, you could use TextKit to get the index of the last seen character in order to create the accessible text to be read out. – XLE_22 Jan 11 '21 at 19:48
  • @XLE_22 That seems promissing, could you go in a bit more detail ? – Iago Salomon Jan 11 '21 at 21:07
  • The best thing would be to make an entire answer with code but, currently, I haven't time to make it. I suggest to take a look at this answer I wrote where you have some piece of information to find out the proper solution to your problem ⟹ https://stackoverflow.com/a/59216224/3825084. Take your time, TextKit isn't that easy... to me anyway. – XLE_22 Jan 12 '21 at 08:12
  • 1
    You shouldn't be trying to truncate the the string in `accessibilityLabel`. This is used for all assertive technologies not just VoiceOver. You note in your question the truncation changes with device size, you should think as VoiceOver as a device that can "display" a string of any length. – Richard Stelling Jan 20 '21 at 11:08
  • @RichardStelling: following this rationale, he could adapt and redefine the `accessibilityLabel` every time a new layout is needed... that seems to be interesting, doesn't it? All the users would have the same info as the one on screen. – XLE_22 Feb 05 '21 at 21:25

1 Answers1

1

[...] 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.

XLE_22
  • 5,124
  • 3
  • 21
  • 72
  • 2
    Thnx, that was an amazing ang thorough answer, thx a lot for the effort and time to write it. – Iago Salomon Mar 17 '21 at 17:32
  • in my project voice over speak one image as device image credit card heart diagram but it should speak only device image do you have any idea – keshav Apr 14 '22 at 11:08
  • @keshav Would you mind asking your question in a new topic with detailed explanations so as to provide the best answer as possible, please? – XLE_22 Apr 14 '22 at 11:49