Last active
June 13, 2025 09:21
-
-
Save ryanlintott/f1c763829a7b2a6d2892da242cf2b25d to your computer and use it in GitHub Desktop.
A way to justify text using a single SwiftUI Text view.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // JustifiedTextExample2.swift | |
| // FrameUpExample | |
| // | |
| // Created by Ryan Lintott on 2022-11-15. | |
| // | |
| import SwiftUI | |
| import WidgetKit | |
| extension StringProtocol { | |
| func size(using font: UIFont) -> CGSize { | |
| return String(self).size(using: font) | |
| } | |
| } | |
| extension String { | |
| func size(using font: UIFont) -> CGSize { | |
| return (self as NSString).size(withAttributes: [NSAttributedString.Key.font: font]) | |
| } | |
| func splitMultilineByCharacter(font: UIFont, maxWidth: CGFloat) -> [String] { | |
| guard self.size(using: font).width > maxWidth else { | |
| return [self] | |
| } | |
| var characters = Array(self).map({String($0)}) | |
| var multiline = [characters.removeFirst()] | |
| var index = 0 | |
| while !characters.isEmpty { | |
| let character = characters.removeFirst() | |
| let line = multiline[index] + character | |
| if line.size(using: font).width <= maxWidth { | |
| multiline[index] = line | |
| } else { | |
| multiline.append(character) | |
| index += 1 | |
| } | |
| } | |
| return multiline | |
| } | |
| func splitMultiline(by separator: Character = " ", font: UIFont, maxWidth: CGFloat) -> [String] { | |
| guard self.size(using: font).width > maxWidth else { | |
| return [self] | |
| } | |
| var parts = self.split(separator: separator) | |
| var multiline = [String]() | |
| while !parts.isEmpty { | |
| let part = String(parts.removeFirst()) | |
| let line = [multiline.last, part].compactMap({$0}).joined(separator: String(separator)) | |
| if !line.isEmpty && line.size(using: font).width <= maxWidth { | |
| if !multiline.isEmpty { | |
| multiline[multiline.endIndex - 1] = line | |
| } else { | |
| multiline.append(line) | |
| } | |
| } else { | |
| let wordParts = String(part).splitMultilineByCharacter(font: font, maxWidth: maxWidth) | |
| multiline += wordParts | |
| } | |
| } | |
| return multiline.map({String($0)}) | |
| } | |
| func justified(font: UIFont, maxWidth: CGFloat) -> String { | |
| let separator: Character = " " | |
| let hairSpace: String = "\u{200A}" | |
| return splitMultiline(font: font, maxWidth: maxWidth) | |
| .map { line in | |
| var words = line.split(separator: separator) | |
| guard words.count > 1 else { return words.joined() } | |
| var justifiedSeparator = String(hairSpace) | |
| var justifiedLine = words.joined(separator: justifiedSeparator) | |
| var hairSpaceCount = 0 | |
| while justifiedLine.size(using: font).width < maxWidth { | |
| hairSpaceCount += 1 | |
| justifiedLine += hairSpace | |
| } | |
| hairSpaceCount -= 1 | |
| let (minCount, extraCount) = hairSpaceCount.quotientAndRemainder(dividingBy: words.count - 1) | |
| let spaces = Array(0..<words.count) | |
| .map { i in | |
| String.init(repeating: hairSpace, count: minCount) + (i < extraCount ? hairSpace : "") | |
| } | |
| return zip(words, spaces) | |
| .map { | |
| String($0 + $1) | |
| } | |
| .joined() | |
| .trimmingCharacters(in: .whitespaces) | |
| } | |
| .joined(separator: "\n") | |
| } | |
| } | |
| struct JustifiedTextExample2: View { | |
| let uiFont: UIFont = .boldSystemFont(ofSize: 16) | |
| let text: String | |
| let maxWidth: CGFloat | |
| var justifiedTest: String { | |
| text.justified(font: uiFont, maxWidth: maxWidth) | |
| } | |
| var body: some View { | |
| Text(justifiedTest) | |
| .font(Font(uiFont)) | |
| } | |
| } | |
| struct JustifiedTextExample2_Previews: PreviewProvider { | |
| static var previews: some View { | |
| GeometryReader { proxy in | |
| JustifiedTextExample2(text: "Hello World here is a bunch of text that will take up a few lines. Another sentence with a few more words in it", maxWidth: proxy.size.width) | |
| } | |
| .padding() | |
| .previewContext(WidgetPreviewContext(family: .systemMedium)) | |
| } | |
| } |
Author
Thanks, I realized this not long after posting as well. I've since added something like this to my FrameUp package but it's just in the experimental section for now. I forgot to add the link so I'll leave it here so you can check it out:
https://github.com/ryanlintott/FrameUp/blob/main/Sources/FrameUp/Text/HairSpaceJustifiedText.swift
Thanks for the answer, didn't look enough to find the updated code. Nice approach compare to mine :-)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Very nice code for doing justified text. This does also justify the last line in the text. I'm not sure if this is intended, but it differs from what I see in justified text elsewhere. May I suggest the following change at line 95, which would exclude the last line from being justified: