A way to justify text using a single SwiftUI Text view.
This file contains 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)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment