[SC]()

iOS. Apple. Indies. Plus Things.

Recreating Readable Content Guide Sizing in SwiftUI

// Written by Jordan Morgan // Sep 15th, 2024 // Read it in about 4 minutes // RE: 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:

Image 1
Image 2

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):


Image 1
Image 2
UIKit version
SwiftUI version


By the book, readableContentGuide follows a few rules to calculate its layout:

  1. The readable content guide never extends beyond the view’s layout margin guide.
  2. The readable content guide is vertically centered inside the layout margin guide.
  3. 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 ✌️

···

Spot an issue, anything to add?

Reach Out.