Recreating Readable Content Guide Sizing in SwiftUI
This post is brought to you by Emerge Tools, the best way to build on mobile.
The readableContentGuide
(docs) API was (and still is) such a quintessential quality of life fix for UIKit developers. With the rise of #
h
u
g
e iPad devices emerging with iOS 9, the need to reasonably size views primarily meant for reading became important. As such, Cupertino & Friends© gifted us a straightforward way to do just that:
private func setupTableView() {
tableView = UITableView(frame: .zero, style: .grouped)
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
tableView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tableView)
// The magic...
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor),
// Readable content guide leading anchor...
tableView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor),
// Readable content guide trailing anchor...
tableView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor)
])
}
The results were markedly better. Here, the first image applies the readable content guide’s width, and the latter does not:
True to the docs, the resulting view is objectively easier to read:
This layout guide defines an area that can easily be read without forcing users to move their head to track the lines.
But, because life isn’t fair, we don’t have this in SwiftUI. It’s trivial to recreate, though (or at least get close) in landscape orientations. Here’s how I do it, driven by using .containerRelativeFrame:
struct ReadableContentBridge: View {
@Environment(\.horizontalSizeClass) private var hClass
@Environment(\.verticalSizeClass) private var vClass
var body: some View {
ZStack {
Color(uiColor: .systemGroupedBackground)
.edgesIgnoringSafeArea(.all)
Form {
ForEach(0...10, id: \.self) { _ in
Text("Stuff and things")
}
}
// Right here
.containerRelativeFrame([.horizontal]) { length, axis in
guard axis == .horizontal else { return length }
if vClass == .regular && hClass == .regular {
return length * 0.52
} else {
return length
}
}
}
}
}
The idea is simple: If we’re in a horizontally and vertically regular size class, then set the width to about 52% of the container’s width. And, that gets us strikingly close to the actual readableContentGuide
output (ignore the vertical padding, the width is what to key in on here):
By the book, readableContentGuide
follows a few rules to calculate its layout:
- The readable content guide never extends beyond the view’s layout margin guide.
- The readable content guide is vertically centered inside the layout margin guide.
- The readable content guide’s width is equal to or less than the readable width defined for the current dynamic text size.
let concatenatedThoughts = """
Though I should say, this doesn't quite match up for portrait orientations. I would need a bit more logic to make it work there. For example, in the code I have, I would need to check if we're in portrait or landscape orientation and then adjust accordingly. For my use case, though, I just didn't need it.
"""
By my count, I think my approach should satisfy all but rule #3 accurately, but for most cases — I imagine it would still be close. For your convenience, here’s a custom modifier to do it all for you:
struct ReadableContentModifier: ViewModifier {
@Environment(\.horizontalSizeClass) private var hClass
@Environment(\.verticalSizeClass) private var vClass
func body(content: Content) -> some View {
content
.containerRelativeFrame([.horizontal]) { length, axis in
guard axis == .horizontal else { return length }
if vClass == .regular && hClass == .regular {
return length * 0.52
} else {
return length
}
}
}
}
extension View {
func readableContentGuide() -> some View {
modifier(ReadableContentModifier())
}
}
// Then, in your view
struct Example: View {
var body: some View {
Form {
// Content
}
.readableContentGuide()
}
}
Until next time ✌️