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.
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??)
SwiftUI doesn't support justifying text using AttributedString (wow)
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
to look like this:-
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.