1

I am creating an app that is dealing with thousands of nested xml files that I am parsing and loading each of them to make a text document with lots of different attributed Text blocks to be loaded into multiple Text views and performance when scrolling is a major concern.

Text is required to be justified by words not by kerning. for the last week and half I've been working multiple solutions around the issue of justifying the text and also having a smooth scrolling behaviour.

I've encountered a number of major issues that prevented me from having a smooth scrolling when you have lots of Text views.

  1. SwiftUI bug that has not been resolved for almost 2 and half years and still not fixed on iOS 16. It's to do with LazyVStack performance when you have lots of views to be scrolled. This was initially raised by Chrys Bader.

    SwiftUI: Putting a LazyVStack or LazyHStack in a ScrollView causes stuttering (Apple bug??)

  2. SwiftUI doesn't support justifying text using AttributedString (wow)

  3. I was able to implement and use UITextView approach and work out the dynamic height of each text block however I am after text justification by words and if the word is too long to fit on one line then it should wrap the extra chars on the next line.

I would like to turn the following text

enter image description here

to look like this:-

enter image description here

This is just one approach. I am not sure what the best one is.

String+Justify.swift


import UIKit

extension Array where Element == String {
    var stringValue: String {
        self.joined(separator: " ")
    }
}

extension String {
    
    var words: [String] {
        self.split(separator: " ").map(String.init)
    }
    
    func trimingTrailingSpace(
        using characterSet: CharacterSet = .whitespacesAndNewlines
    ) -> String {
        guard let index = lastIndex(where: {
            !CharacterSet(charactersIn: String($0)).isSubset(of: characterSet) })
        else { return self }
        return String(self[...index])
    }
    
    func fullJustify(maxWidth: Int) -> [String] {
        var results: [String] = []
        
        var left = 0
        while left < words.count {
            let right = findRight(left: left, words: words, maxWidth: maxWidth)
            let line = justify(left: left, right: right, words: words, maxWidth: maxWidth)
            results.append(line)
            left = right + 1
        }
        
        for line in results {
            print("\(line)")
            print("[\(line)] \(line.count)")
        }
        
        return results
    }
    
    private func findRight(left: Int, words: [String], maxWidth: Int) -> Int {
        var right = left
        var sum = words[right].count
        right += 1
        
        /// +1 means different words joined with space " "
        while right < words.count && (sum + 1 + words[right].count) <= maxWidth {
            sum += 1 + words[right].count
            right += 1
        }
        return right - 1
    }
    
    private func justify(left: Int, right: Int, words: [String], maxWidth: Int) -> String {
        if right == left {
            return pad(result: words[left], maxWidth: maxWidth)
        }
        let isLastLine = (right == words.count - 1)
        let spaceCount = right - left
        let totalSpace = maxWidth - wordsLength(left: left, right: right, words: words)
        
        /// distribute the space evenly as possible
        let space = isLastLine ? " " : blank(count: totalSpace / spaceCount)
        /// record if the space distribute not evenly, how many space should append from left to right
        var remainder = isLastLine ? 0 : totalSpace % spaceCount
        
        var result = ""
        for i in left...right {
            result += words[i]
            result += space
            result += (remainder > 0 ? " " : "")
            remainder -= 1
        }
        result = result.trimingTrailingSpace()
        return pad(result: result, maxWidth: maxWidth)
    }
    
    private func wordsLength(left: Int, right: Int, words: [String]) -> Int {
        var length: Int = 0
        for index in left...right {
            length += words[index].count
        }
        return length
    }
    
    private func pad(result: String, maxWidth: Int) -> String {
        return result + blank(count: maxWidth - result.count)
    }
    
    private func blank(count: Int) -> String {
        guard count >= 0 else {
            return ""
        }
        return String(Array(repeating: " ", count:count))
    }
    
}

I need to have the following layout as implemented here:- ContentView.swift We would also need to dynamically workout based on the text1 and text2 frame width how many characters we can justify by. Again I am not sure the code below is the best approach to achieve what I am after.


import SwiftUI

struct ContentView: View {
    
    private let text1 =
        """
Lorem Ipsum comes from a latin text written in 45BC by Roman statesman, lawyer, scholar, and philosopher, Marcus Tullius Cicero. The text is titled "de Finibus Bonorum et Malorum" which means "The Extremes of Good and Evil". The most common form of Lorem ipsum is the following:

"""
    
    private let text2 =
        """
Lorem Ipsum comes from a latin text written in 45BC by Roman statesman, lawyer, scholar, and philosopher, Marcus Tullius Cicero. The text is titled "de Finibus Bonorum et Malorum" which means "The Extremes of Good and Evil". The most common form of Lorem ipsum is the following:

"""
    
    var body: some View {
        ScrollView(.vertical, showsIndicators: true) {
            LazyVStack(spacing: 0) {
                HStack(spacing: 0) {
                    Text(text1.fullJustify(maxWidth: 20).stringValue)
                    Color.clear.frame(width: 50)
                    Text(text2.fullJustify(maxWidth: 20).stringValue)
                }
            }
            .padding()
        }
        .preferredColorScheme(.dark)
    }
    
}

In the console it prints the correct justified text:

Lorem   Ipsum  comes
from  a  latin  text
written  in  45BC by
Roman     statesman,
lawyer, scholar, and
philosopher,  Marcus
Tullius  Cicero. The
text  is  titled "de
Finibus  Bonorum  et
Malorum" which means
"The   Extremes   of
Good  and Evil". The
most  common form of
Lorem  ipsum  is the
following:

but as you can see from the screenshot above the text is still not properly justified or displayed as expected when using the SwiftUI Text control.

Would really appreciate someone's help. Thank you.

Wael
  • 489
  • 6
  • 19
  • I'd play maybe with CoreText https://stackoverflow.com/questions/14872749/ios-better-justification-with-coretext to have full control, but then the integration into SwiftUI is another question, since I don't think that SwiftUI interacts openly with CoreText (only hidden calls?) – Larme Jun 19 '23 at 08:07

0 Answers0