Expert guidance for Dynamic Type support for Pocket Casts iOS development with Swift, UIKit, and SwiftUI. Use this skill when the user asks about Dynamic Type, content size categories, accessibility text size, font scaling, or making text/UI elements respond to the user's preferred text size (e.g., using preferredContentSizeCategory, adjustsFontForContentSizeCategory, UIFontMetrics, or related APIs in SwiftUI and UIKit).
Dynamic Type is a first-class concern. Every text element, button, and scalable image must support it. The goal is for the entire UI to gracefully adapt when users change their preferred text size.
Use the standard text styles defined by Apple (see Typography Specifications). Use the Large (default) size as the reference when matching design specs.
If you need a custom size that doesn't match a standard style, pick the scaling style whose default size is closest to your custom size, then use the following custom font APIs:
UIKit:
label.font = .font(ofSize: 15, weight: .medium, scalingWith: .subheadline)
SwiftUI:
Text("Hello")
.font(size: 15.0, style: .subheadline, weight: .medium)
These APIs use UIFontMetrics internally to scale your custom size proportionally with the user's preferred text size, while capping at a sensible maximum (accessibilityExtraExtraExtraLarge by default).
For standard sizes where no customization is needed:
UIKit:
label.font = .font(with: .body, weight: .regular)
SwiftUI:
Text("Hello")
.font(style: .body, weight: .regular)
For UILabel or derived classes, three things make Dynamic Type work:
.numberOfLines = 0.adjustsFontForContentSizeCategory = truelet label = UILabel()
label.font = .font(ofSize: 18, weight: .semibold, scalingWith: .headline)
label.adjustsFontForContentSizeCategory = true
label.numberOfLines = 0
For Text (including Text with AttributedString or custom attributed text views such as DescriptiveActionAttributedTextView), use a standard Apple style via the custom .font modifier. If the size you need doesn't match any reference size, use the custom variant:
Text("Description")
.font(size: 15, style: .subheadline, weight: .regular)
.fixedSize(horizontal: false, vertical: true)
The .fixedSize(horizontal: false, vertical: true) modifier is important — it tells SwiftUI to let text expand vertically rather than truncating. Use it on any Text that should wrap to multiple lines.
Apply the same approach as labels, but to the button's .titleLabel:
button.titleLabel?.font = .font(ofSize: 18, weight: .semibold, scalingWith: .headline)
button.titleLabel?.adjustsFontForContentSizeCategory = true
button.titleLabel?.numberOfLines = 0
If button text can span multiple lines, you may need an explicit height constraint between the titleLabel and the button to force the button to resize when its label grows beyond a single line.
A Text label with a dynamic font inside a Button is typically sufficient:
Button(action: { }) {
Text("Continue")
.font(size: 18, style: .body, weight: .semibold)
}
Or use the convenience modifier:
Button("Continue") { }
.applyButtonFont(size: 18, style: .body, weight: .semibold)
For images that should scale with text size:
traitCollectionDidChange(_:) and check for changes on preferredContentSizeCategoryUIFontMetrics with the .largeTitle style as the scaling referenceprivate let baseImageSize: CGFloat = 24
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory else { return }
let metric = UIFontMetrics(forTextStyle: .largeTitle)
let scaledSize = max(baseImageSize, metric.scaledValue(for: baseImageSize))
imageWidthConstraint.constant = scaledSize
imageHeightConstraint.constant = scaledSize
}
Use the @ScaledMetric property wrapper (or the project's @ScaledMetricWithMaxSize for capped scaling) to define a size variable, then apply it in a .frame modifier:
@ScaledMetric(relativeTo: .largeTitle) private var imageSize: CGFloat = 24
// or with a max cap:
@ScaledMetricWithMaxSize(wrappedValue: 24, relativeTo: .largeTitle, maxSize: .xxLarge) private var imageSize: CGFloat
var body: some View {
Image(systemName: "star.fill")
.frame(width: imageSize, height: imageSize)
}
Use CSS with the Apple dynamic font on the root element so all relative sizes scale automatically:
<style>
:root { font: -apple-system-body; }
/* All other sizes should use rem or em relative to root */
h1 { font-size: 1.5rem; }
p { font-size: 1rem; }
</style>
Set the distribution property to .fill so the stack view grows with its content. This is critical for Dynamic Type — a .fillEqually or fixed distribution will fight against growing labels.
Enable self-sizing rows:
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 60 // use a reasonable estimate or previous fixed height
Cells must have an unbroken chain of constraints from top to bottom of the content view so Auto Layout can compute the height.
When embedding SwiftUI content in cells, two approaches work:
UIHostingConfiguration: Resizing is automatic. This is the preferred approach for new cells.themedUIView / insertThemedUIView(in:) helpers with a hosting controller: Be mindful of adding proper child/parent view controller relationships.// UIHostingConfiguration example
cell.contentConfiguration = UIHostingConfiguration {
MyCellView(viewModel: viewModel)
.environmentObject(Theme.sharedTheme)
}
.margins(.horizontal, 16)
.margins(.vertical, 8)
Use a standard SwiftUI List, or a VStack inside a ScrollView for custom layouts. SwiftUI handles Dynamic Type sizing automatically as long as you use dynamic fonts.
The project is in gradual migration from UIKit to SwiftUI. New features can be SwiftUI-first, but they need to interop cleanly with the existing UIKit shell.
Use UIHostingController or UIHostingConfiguration (for cells). Always inject the theme:
let hostingController = UIHostingController(rootView:
MySwiftUIView()
.environmentObject(Theme.sharedTheme)
)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
Use UIViewControllerRepresentable or UIViewRepresentable when you need to wrap legacy UIKit components.
| What you need | UIKit | SwiftUI |
|---|---|---|
| Dynamic font (standard) | .font(with: .body, weight: .regular) | .font(style: .body, weight: .regular) |
| Dynamic font (custom size) | .font(ofSize: 15, weight: .medium, scalingWith: .subheadline) | .font(size: 15, style: .subheadline, weight: .medium) |
| Theme color | ThemeColor.primaryText01() | theme.primaryText01 |
| Text wrapping | label.numberOfLines = 0 | .fixedSize(horizontal: false, vertical: true) |
| Auto-scale opt-in | label.adjustsFontForContentSizeCategory = true | Automatic with dynamic fonts |
| Scaled metric | UIFontMetrics(forTextStyle:).scaledValue(for:) | @ScaledMetric or @ScaledMetricWithMaxSize |
| Self-sizing cell | tableView.rowHeight = .automaticDimension | Automatic in List |
| Theme injection | handleThemeChanged() override | @EnvironmentObject var theme: Theme |
| Localized string | L10n.myStringKey(arg) | L10n.myStringKey(arg) (not LocalizedStringKey) |