Swift and SwiftUI tutorials for Swift Developers

Dynamic Type in SwiftUI

For any modern iOS Developer, accessibility is no longer an optional feature; it is a fundamental responsibility. One of the cornerstones of an accessible application is Dynamic Type. This technology allows users to choose their preferred text size across the entire operating system, and it is crucial that our applications respect that choice without breaking the user interface.

In this in-depth Swift programming tutorial, you will learn what Dynamic Type is, why it is vital, and how to masterfully implement it using Dynamic Type in SwiftUI. We will look at how this applies not only to iOS, but we will also extend this knowledge to macOS and watchOS using Xcode and Swift.


What is Dynamic Type?

Dynamic Type is an Apple feature that allows the user to adjust the size of the text displayed on the screen through the device’s Accessibility settings. It’s not just about making text larger for people with visual impairments; it’s also about comfort. Many users prefer slightly larger text to reduce eye strain, while others prefer smaller text to fit more content on the screen at once.

As an iOS Developer, adopting Dynamic Type offers you the following benefits:

  1. Improves Accessibility: Your app becomes usable for a much wider audience, including people with presbyopia or other visual conditions.
  2. Guideline Compliance: Apple strongly recommends (and sometimes requires in its own apps) Dynamic Type support. An app that ignores this feels “broken” or unprofessional.
  3. Adaptable Interface: By using Dynamic Type, you force your design to be flexible. This makes your app adapt better to different screen sizes, not just different font sizes.
  4. Superior User Experience: Respecting system-wide user preferences creates a more integrated and satisfying experience.

Fundamentals of Dynamic Type in SwiftUI

SwiftUI has drastically simplified the implementation of Dynamic Type compared to UIKit. In traditional Swift programming with UIKit, we often had to register observers for font size change notifications and manually update labels. In SwiftUI, most of the heavy lifting is done automatically.

Standard Text Font Styles

The easiest way to support Dynamic Type is to use SwiftUI’s predefined font styles. These styles are automatically tied to the system’s Dynamic Type sizes.

import SwiftUI

struct FontStylesView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 10) {
                Text("Large Title").font(.largeTitle)
                Text("Title 1").font(.title)
                Text("Title 2").font(.title2)
                Text("Title 3").font(.title3)
                Text("Headline").font(.headline)
                Text("Subheadline").font(.subheadline)
                Text("Body").font(.body) // This is the default
                Text("Callout").font(.callout)
                Text("Footnote").font(.footnote)
                Text("Caption 1").font(.caption)
                Text("Caption 2").font(.caption2)
            }
            .padding()
        }
    }
}

By using modifiers like .font(.body) or .font(.title), you are telling SwiftUI: “Use the system font for the body (or title), and adjust its size according to the user’s preference”. If the user increases the text size in their iPhone settings, the .body text will automatically grow.

Testing in Xcode: You can easily test this in the Xcode Canvas. Click the “Attributes Inspector” button (the right panel) while selecting the Preview, and look for the “Dynamic Type” option. Move the slider to see how your interface adapts in real-time.


Creating Custom Fonts with Dynamic Type Support

Often, your brand’s design guidelines require a custom font that isn’t the system default (San Francisco). As an iOS Developer, you must know how to apply Dynamic Type to these custom fonts. If you simply use a fixed size, you will ignore the user’s preference.

Starting in iOS 14, SwiftUI introduced an elegant method on Font to scale custom fonts.

Using .custom(_:size:relativeTo:)

This is the key method. You must specify the name of your font, the base size (the one you would use if Dynamic Type was at its default setting), and most importantly, which system text style it should relate to.

import SwiftUI

struct CustomFontDynamicTypeView: View {
    var body: some View {
        VStack(spacing: 20) {
            // Using a custom font (make sure it's loaded in your project)
            Text("Brand Header")
                .font(.custom("OpenSans-Bold", size: 28, relativeTo: .title))
            
            Text("This is an example paragraph that uses a custom font but respects Dynamic Type. If the user increases the system font size, this text will also grow, using the '.body' style as a reference for its scaling.")
                .font(.custom("OpenSans-Regular", size: 16, relativeTo: .body))
            
            Text("Footer")
                .font(.custom("OpenSans-LightItalic", size: 12, relativeTo: .footnote))
        }
        .padding()
    }
}

In this Swift programming example:

  • "Brand Header" has a base size of 28. It will scale relative to how the system scales .title. If .title grows by 20%, your custom font will also grow by 20% from 28.
  • It is crucial to choose the correct relativeTo. Body text should relate to .body, a title to .title, etc., so that the scaling behavior is consistent with the rest of the system.

Note: Don’t forget to add the font files (.ttf or .otf) to your Xcode project, ensure they are included in the corresponding Target, and add them to the “Fonts provided by application” key in your Info.plist.


Adapting the UI Layout for Large Text

This is where many developers fail. Implementing the scalable font is only the first step. The real challenge for an iOS Developer is ensuring the app’s layout doesn’t break when the text grows significantly. Large text can overflow containers, overlap other elements, or push controls off the screen.

SwiftUI provides powerful tools to handle this.

Using the @Environment Variable

The sizeCategory environment variable allows you to read the user’s current Dynamic Type size. You can use this to conditionally change your layout.

import SwiftUI

struct AdaptableLayoutView: View {
    // We read the current size category
    @Environment(\.sizeCategory) var sizeCategory
    
    var body: some View {
        ScrollView {
            // We decide the layout based on the size
            if sizeCategory.isAccessibilityCategory {
                // Layout for accessibility sizes (very large)
                accessibilityLayout
            } else {
                // Standard layout
                standardLayout
            }
        }
    }
    
    // A horizontal layout for normal sizes
    var standardLayout: some View {
        HStack {
            Image(systemName: "person.circle.fill")
                .font(.system(size: 60))
            VStack(alignment: .leading) {
                Text("John Doe")
                    .font(.title)
                Text("Senior iOS Developer")
                    .font(.subheadline)
            }
        }
        .padding()
    }
    
    // A vertical layout for accessibility sizes
    var accessibilityLayout: some View {
        VStack(spacing: 15) {
            Image(systemName: "person.circle.fill")
                .font(.system(size: 80))
            Text("John Doe")
                .font(.largeTitle) // Larger title for accessibility
            Text("Senior iOS Developer")
                .font(.title2) // Larger subtitle
                .multilineTextAlignment(.center)
        }
        .padding()
    }
}

sizeCategory.isAccessibilityCategory is a convenience property that returns true if the size falls within the Accessibility ranges (XS, S, M, L, XL are normal; AX1 through AX5 are accessibility). In this example, we switch from an HStack to a VStack when the text is very large, preventing the image and text from being horizontally cramped.

Smart Use of ViewThatFits (iOS 16+)

Introduced in Xcode 14 (iOS 16), ViewThatFits is a gem for adaptable design. It evaluates a list of views and chooses the first one that fits into the available space without truncating the text.

import SwiftUI

struct ViewThatFitsExample: View {
    var body: some View {
        VStack {
            Text("Connection Status:")
                .font(.headline)
            
            ViewThatFits {
                // Attempt 1: Horizontal layout with full description
                HStack {
                    Label("Securely Connected", systemImage: "lock.shield.fill")
                        .font(.body)
                    Spacer()
                    Text("v1.5")
                        .font(.caption)
                }
                
                // Attempt 2: Horizontal layout with shorter text
                HStack {
                    Label("Connected", systemImage: "lock.shield.fill")
                        .font(.body)
                    Spacer()
                    Text("v1.5")
                        .font(.caption)
                }
                
                // Attempt 3: Vertical layout (fallback for very large text)
                VStack {
                    Label("Securely Connected", systemImage: "lock.shield.fill")
                        .font(.body)
                    Text("Version 1.5")
                        .font(.caption)
                }
            }
            .padding()
            .background(Color.secondary.opacity(0.1))
            .cornerRadius(10)
            
        }
        .padding()
    }
}

With ViewThatFits, if the Dynamic Type is at a low level, the first HStack will be chosen. As the user increases the text size, the long description may no longer fit horizontally, so ViewThatFits will automatically jump to the second or third layout. This greatly reduces the need for manual conditional logic based on @Environment.


Cross-Platform Dynamic Type: macOS and watchOS

As an iOS Developer, your knowledge of SwiftUI allows you to bring your applications to other Apple platforms. Fortunately, the Dynamic Type principles we’ve seen apply in much the same way on macOS and watchOS, with a few specific considerations.

Dynamic Type on watchOS

On watchOS, Dynamic Type is perhaps more critical than on iOS, given the extremely limited screen size. Users often need to increase the text size to read quickly at a glance while on the move.

Guidelines for watchOS:

  1. Use System Styles: Prefer .body, .footnote, etc. watchOS will scale these styles aggressively.
  2. Use .custom(_:size:relativeTo:): If you use custom fonts, always relate them to a system style.
  3. Vertical Layouts: The default layout on watchOS is vertical (VStack inside a List or ScrollView). This makes it easier for text to grow downwards without breaking the horizontal layout.

Dynamic Type on macOS

On macOS, Dynamic Type doesn’t work exactly the same as on iOS. Users do not have a global “Text Size” slider for all applications. However, individual applications often allow the user to change the font size (think of Mail, Notes, or Safari).

Additionally, macOS supports “Scaled resolutions,” which affects how the entire system is rendered.

Implementation on macOS with SwiftUI:

Using .font(.body) or .font(.custom(..., relativeTo: .body)) is still the best practice. Although the user may not have global Dynamic Type control, using system styles ensures that your application behaves consistently with other native macOS applications and respects the display scaling settings.

If you want to offer font size control within your macOS app, you can use an @AppStorage variable to save the user’s preference and apply it as a base size in your custom font:

import SwiftUI

// This code works on macOS
struct MacOSFontSizeControlView: View {
    // We save the user's preferred base font size
    @AppStorage("preferredBaseFontSize") var baseFontSize: Double = 16.0
    
    var body: some View {
        VStack(spacing: 20) {
            Text("Adjustable text in macOS")
                // We scale the custom font based on the preference
                .font(.custom("HelveticaNeue", size: CGFloat(baseFontSize), relativeTo: .body))
            
            HStack {
                Button("A") { baseFontSize -= 2 }
                    .disabled(baseFontSize <= 10)
                Slider(value: $baseFontSize, in: 10...30, step: 2)
                    .frame(width: 150)
                Button("A") { baseFontSize += 2 }
                    .font(.title3)
                    .disabled(baseFontSize >= 30)
            }
            .padding()
        }
        .padding()
    }
}

Although this is not the “automatic” Dynamic Type of iOS, it is the recommended way in modern Swift programming for macOS to give control to the user.


Scaling Images and Symbols (SF Symbols)

Dynamic Type doesn’t just affect text. As an iOS Developer, you should know that visual elements accompanying the text must also scale to maintain visual harmony.

SF Symbols and Dynamic Type

The easiest way to achieve this is to use SF Symbols. SwiftUI treats SF Symbols as text.

HStack {
    // The SF symbol will scale automatically with the .title text
    Image(systemName: "star.fill")
        .font(.title) 
    
    Text("Favorite")
        .font(.title)
}

If you apply the .font() modifier to an Image containing an SF Symbol, the symbol will adopt the size and weight corresponding to that text style, scaling perfectly with Dynamic Type.

Scaling Custom Images

If you have custom icons that are not SF Symbols, you must scale them manually using the sizeCategory environment variable or use the .dynamicTypeSize(...) modifier (available in iOS 15+) to cap their growth if necessary.

A better option is to use the .resizable() and .aspectRatio(contentMode: .fit) modifiers within a container that uses sizeCategory to change its base size.

@Environment(\.sizeCategory) var sizeCategory

var iconSize: CGFloat {
    // We calculate the icon size based on Dynamic Type
    switch sizeCategory {
    case .accessibilityExtraLarge, .accessibilityExtraExtraLarge, .accessibilityExtraExtraExtraLarge:
        return 60
    case .accessibilityLarge:
        return 50
    default:
        return 40
    }
}

Image("myCustomIcon")
    .resizable()
    .aspectRatio(contentMode: .fit)
    .frame(width: iconSize, height: iconSize)

Best Practices and Tips for the iOS Developer

  1. Never Disable Dynamic Type: Don’t use fixed font sizes (.font(.system(size: 16))) unless you have an extremely powerful (and rare) design reason. Using a fixed size is actively ignoring accessibility.
  2. Use multilineTextAlignment: When text grows, it is very likely to span multiple lines. Make sure to set a proper alignment (e.g., .center or .leading) to maintain readability.
  3. Do Not Limit lineLimit Unnecessarily: Avoid using .lineLimit(1) on long texts. Let the text take up the space it needs. If you must limit it, ensure the layout responds well to truncation (.truncationMode).
  4. Test Aggressively in Xcode: Use the Canvas and the Simulator to test your app at the smallest sizes and the largest accessibility sizes (AX5). You will be surprised at what breaks.
  5. Combine with SF Symbols: Simplify your life and use SF Symbols whenever possible; the free scaling you get is worth it.
  6. Respect Safe Areas: When text pushes other elements, make sure they don’t overlap navigation bars or tab bars. SwiftUI handles this well, but custom layouts require attention.

Conclusion

Mastering Dynamic Type in SwiftUI is a crucial step in becoming an elite iOS Developer. It’s not just about writing code, but about empathy towards your users. SwiftUI has made the implementation of accessible and adaptable interfaces easier than ever in the history of Swift programming.

By using system styles, correctly applying Dynamic Type to your custom fonts with Xcode, and adapting your layouts smartly (especially with tools like ViewThatFits), you will be creating applications that not only look good in App Store screenshots, but offer an exceptional and respectful user experience for everyone across iOS, macOS, and watchOS.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

DisclosureGroup in SwiftUI

Next Article

@discardableResult in Swift

Related Posts