Search code examples
swiftuiuikitviewmodifier

Access the underlying UIKit view from a SwiftUI View Modifier


Is there a way to work with UIKit within the context of a SwiftUI ViewModifier?

I would like to drop to UIKit here:

struct MyViewModifier: ViewModifier {
    func body(content: Content) -> some View {
        // Work on `content` using UIKit here.
        // E.g. I would like to add a subview or CALayer to the existing `content` view
    }
}

Solution

  • If you want to add a UIView to your SwiftUI view hierarchy, wrap it in a UIViewRepresentable. You can learn more in the WWDC 2019 session Integrating SwiftUI starting at 8m08s.

    If you want to add a CALayer, you'll need to add it as a sublayer of a UIView's layer, and host that UIView using UIViewRepresentable.

    Based on your question title, “Access the underlying UIKit view from a SwiftUI View Modifier”, I wonder if you think each SwiftUI view has a corresponding UIView. It doesn't. SwiftUI can use a single (private, custom) UIView to draw many SwiftUI views. Let's take a look.

    Here's a UIViewRepresentable that wraps a custom UIView subclass. The subclass does just one custom thing: it prints the UIKit view hierarchy when it's moved to a window.

    struct UIKitPeeker: UIViewRepresentable {
        func makeUIView(context: Context) -> MyView {
            return MyView()
        }
    
        func updateUIView(_ view: MyView, context: Context) { }
    
        class MyView: UIView {
            var callback: (UIView) -> () = { _ in }
    
            override func didMoveToWindow() {
                super.didMoveToWindow()
    
                // The recursiveDescription method is only for debugging.
                // Don't use it in App Store submissions!
                print(window?.value(forKey: "recursiveDescription") as? String ?? "nil")
            }
        }
    }
    

    I'll use it like this:

    struct RootView: View {
        var body: some View {
            VStack {
                Text("hello")
                Text("world")
                Color.pink.frame(width: 10, height: 10)
                Toggle("toggle", isOn: .constant(false))
                Text("lots of SwiftUI views here")
            }
            .background { UIKitPeeker() }
        }
    }
    

    Here's the output (using Xcode 15.0 beta 2):

    <UIWindow: 0x10570c230; frame = (0 0; 393 852); gestureRecognizers = <NSArray: 0x600000c20510>; layer = <UIWindowLayer: 0x600000c02310>>
       | <UITransitionView: 0x105710b20; frame = (0 0; 393 852); autoresize = W+H; layer = <CALayer: 0x60000025b400>>
       |    | <UIDropShadowView: 0x1057115c0; frame = (0 0; 393 852); autoresize = W+H; layer = <CALayer: 0x60000025b8c0>>
       |    |    | <_TtGC7SwiftUI14_UIHostingViewVS_7AnyView_: 0x10a9055f0; frame = (0 0; 393 852); autoresize = W+H; gestureRecognizers = <NSArray: 0x600000c10cf0>; backgroundColor = <UIDynamicSystemColor: 0x600001712900; name = systemBackgroundColor>; layer = <CALayer: 0x6000002128c0>>
       |    |    |    | <_TtGC7SwiftUI16PlatformViewHostGVS_P10$107f9077432PlatformViewRepresentableAdaptorV11iPhoneStudy11UIKitPeeker__: 0x10a90c550; baseClass = _UIConstraintBasedLayoutHostingView; frame = (0 374.333; 393 128.333); anchorPoint = (0, 0); tintColor = UIExtendedSRGBColorSpace 0 0.478431 1 1; layer = <CALayer: 0x60000024ada0>>
       |    |    |    |    | <_TtCV11iPhoneStudy11UIKitPeeker6MyView: 0x10a90bf60; frame = (0 0; 393 128.333); autoresize = W+H; layer = <CALayer: 0x60000024ad40>>
    

    We can see here that SwiftUI does all the drawing for the VStack and its subviews with just two instances of UIView subclasses (the ones whose names start with _TtGC7SwiftUI). It doesn't even turn the Toggle into a UISwitch.