Mike Apurin

SwiftUI Secrets

Did you know that there are secret SwiftUI functions that were hidden just out of sight all this time? They can not be found in the official documentation and Xcode is very reluctant to show them with autocompletion.

Today we will be exploring non-public API of SwiftUI.

Note that as with all non-public APIs, using it will definitely lead to App Store rejection. If you plan to use it in your code, at least make sure to remove any calls from the release build with an #if block.

Granted, most of what is available will only let you peek behind the scenes a bit or get some information for debugging, but some of it might be useful to know.

With disclaimers out of the way, let's look at what we can use.

View._printChanges()

This static function was added in iOS 15 and is perhaps the most widely known one, seeing as Apple folk are encouraging people to use it.

You can use it print out to Xcode debug console what triggered SwiftUI to evaluate the body function of your view.

Here is an example of how to use it.

struct CounterButton: View {
    @State var count = 0
    
    var body: some View {
        let _ = Self._printChanges()
        Button(
            action: { count += 1 },
            label: { Text("Tap to add: \(count)") }
        )
    }
}

This will produce this output the first time it shows up on screen:

CounterButton: @self, @identity, _count changed.

And then, each time we tap the button:

CounterButton: _count changed.

In the output we get the view type name and a list of changes that triggered the body call. Besides changed properties, there are two special identifiers:

  • @self means that the struct itself was changed
  • @identity means that the view identity has changed

For an in-depth explanation of the concept of identity, check out WWDC 2021 session Demystify SwiftUI.

Note that we had to add a let _ = statement to the function call (or, alternatively, add an explicit return for the actual view content) to make it work nicely inside a view builder. With a small extension we can simplify this and make it so the private function call gets omitted in the release build.

struct CounterButton: View {
    @State var count = 0
    
    var body: some View {
        debugPrintChanges()
        
        Button(
            action: { count += 1 },
            label: { Text("Tap to add: \(count)") }
        )
    }
}

extension View {
    @inlinable func debugPrintChanges() -> some View {
        #if DEBUG
        Self._printChanges()
        #endif
        return EmptyView()
    }
}

PreviewProvider._allPreviews

This one I only saw introduced in a blog post by Mercari engineering team (Japanese language only), and it's just as easy as it looks — you get an array with all the previews in a particular PreviewProvider.

struct MyView_Previews: PreviewProvider { ... }

let previews = MyView_Previews._allPreviews

The previews get wrapped in a private _Preview type which exposes its sequential ID and other metadata. Here's the full definition.

  public struct _Preview {
    public let id: Swift.Int
    
    public let contentType: Swift.String
    
    public var content: SwiftUI.AnyView { get }
    
    @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
    public var context: SwiftUI.PreviewContext? { get }
    
    public var displayName: Swift.String? { get }
    
    public var device: SwiftUI.PreviewDevice? { get }
    
    public var layout: SwiftUI.PreviewLayout { get }
    
    @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
    public var interfaceOrientation: SwiftUI.InterfaceOrientation { get }
    
    @available(iOS 14.5, macOS 11.3, tvOS 14.5, watchOS 7.4, *)
    public var colorScheme: SwiftUI.ColorScheme? { get }
}

One of the most clever ways of using all of this is creating snapshot tests with SnapshotTesting library, making your previews into a series of images that can be stored and compared against each other to detect changes. In fact, this is exactly what is described in that Mercari article, so check it out if you are interested.

_UIHostingView._viewDebugData

This one is a private property of a private view (cue Inception jokes).

_UIHostingView is the type of UIHostingController's view which seems to be doing the actual SwiftUI hosting.

Using this is somewhat tricky, but unlocks a plethora of debug information that can come in handy for debugging particularly tricky problems.

// Needed to interact with the private struct's properties
struct ViewDebugData {
    let data: [_ViewDebug.Property: Any]
    let childData: [ViewDebugData]
}

extension UIHostingController {
    func inspectViewDebugData() {
        let view = self.view as! _UIHostingView<Content>
        let _viewDebugData = view._viewDebugData()
        print(_viewDebugData) // You can print out _viewDebugData as-is
        
        // However, to access its properties in code, we need to first cast it to a custom struct.
        // This way we get access to the underlying properties which have of type _ViewDebug.Property.
        // This part can easily break in the future.
        let viewDebugData = unsafeBitCast(_viewDebugData, to: [ViewDebugData].self)
        print(viewDebugData.first?.data[.size])
    }
}

let viewController = UIHostingController(rootView: MyView())

// Later, after the viewController was shown on screen
viewController.inspectViewDebugData()

The output of printing this property is a bit much, as it contains the whole internal SwiftUI view hierarchy including the modifiers, as well as the frames, and assigned identities to the views.

If you are interested in the full output of the print function, see this Gist I've created.

On Mac, this property is accessible as NSHostingView._viewDebugData

Conclusion

In this article I have introduced some of SwiftUI private API that I find the most useful for debugging and testing. Most of them are not truly essential for everyday coding, but some might be useful to know as part of your toolset.

Lastly, here is a tip regarding Xcode autocompletion. Xcode seems to be reluctant to suggest non-public APIs but you can get it to work after typing the underscore prefix and at least four other letters.