iosswiftswiftuiviewmodifier

How do view modifiers like .font (which seem to change internal properties) work?


I am still struggling to understand how view modifiers work in SwiftUI.

As far as I know modifiers do not directly change the corresponding view but instead embed the view within a new view which applies the desired changes. For example Text(...).padding() does NOT change some properties of the Text view to create the padding but instead embeds the Text view inside a new view which takes care of the paddings. So modifiers do not modify the view but create a new view with the desired changes.

OK, this marks perfect sense.

But how do modifiers work, which (seem to) change only specific properties of a view? For example the .font or .tint modifierers. While I understand how a surrounding view can change "external" properties like background, padding, scale, etc. I do not understand how the same is possible for "internal" properties like the font? How can the Text view know from its surrounding view, which font it should use? Or how could a surrounding view re-create the Text view with all its properties but only a changed font?

Could I create a custom Text("Some example").capitalize("aoe") which creates a Text with all the same properties like font, foreground color, etc. but with "SOmE ExAmplE"?


Solution

  • It's unclear what other view modifiers that you consider as "changes internal properties", so I will be only considering font and tint. Other view modifiers may be implemented differently.


    Both font and tint are implemented by setting environment values. Both of these are inlinable, so their implementations can be found in the .swiftinterface files of SwiftUI.

    // from /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/SwiftUICore.framework/Modules/SwiftUICore.swiftmodule/arm64e-apple-macos.swiftinterface
    
    @inlinable nonisolated public func tint<S>(_ tint: S?) -> some SwiftUICore.View where S : SwiftUICore.ShapeStyle {
        return environment(\.tint, tint.map(AnyShapeStyle.init))
    }
    
    @inlinable nonisolated public func font(_ font: SwiftUICore.Font?) -> some SwiftUICore.View {
        return environment(\.font, font)
    }
    

    \.font here is the public font environment value, but \.tint refers to an internal environment value, so you cannot use that in your own code.

    So views like Text is just reading the environment value and changing its appearance accordingly.

    Even without looking at the .swiftinterface files, you can often infer how something is implemented by printing or dumping a view with that modifier. e.g.

    print(Text("").tint(.red))
    

    The output mentions a _EnvironmentKeyWritingModifier, so it can be inferred that it is modifying the environment.


    Note that there is another modifier called font that returns a Text (as opposed to some View). This modifier is declared in Text itself, so it can just create a copy of self, modify some properties, then return the modified copy.


    So the reason why font/tint can change the "internals" of a view is because those views are reading the environment values the modifiers set. There is no magic. You cannot create such a capitalize modifier in the same way (by setting your own environment value), because Text does not read your custom environment value.

    Of course, you can always create your own view that reads the environment value:

    struct ContentView: View {
        var body: some View {
            CapitalizableText("Some example").capitalize("aoe")
        }
    }
    
    import RegexBuilder
    
    struct CapitalizableText: View {
        let text: String
        @Environment(\.capitalizedCharacters) var capChars
        
        init(_ text: String) {
            self.text = text
        }
        
        var body: some View {
            Text(capitalized())
        }
        
        func capitalized() -> String {
            text.replacing(.anyOf(capChars)) { match in
                match.output.localizedUppercase
            }
        }
    }
    
    extension EnvironmentValues {
        @Entry var capitalizedCharacters: String = ""
    }
    
    extension View {
        func capitalize(_ chars: String) -> some View {
            environment(\.capitalizedCharacters, chars)
        }
    }