The Power of the Hosting+Representable Combo

If you are allergic to hacks, you should probably stay away from the code in this article. However, if you continue, know that we will explore the powerful effects of combining Hosting Views with View Representables. Many times I found myself with a SwiftUI view and wishing I could access the AppKit/UIKit stuff behind it. We both know it’s there, so let’s see how we can tap on it.

To achieve this, we will be using NSHostingView+NSViewRepresentable in macOS, and UIHostingController+UIViewControllerRepresentable in iOS. The concept is the same in both cases, but I will present a useful example for each platform.

A Simple Idea

Although our SwiftUI views are not NSView nor UIView objects, they do end up inserted in the AppKit/UIKit view hierarchy in some way. By using representables and hosting views together, we will make that connection visible to us.

I’ll describe it in macOS terms, but the same applies to iOS.

We know that in SwiftUI, an NSViewRepresentable is used to make an NSView to look like a SwiftUI view. On the other hand, an NSHostingView is used to make a SwiftUI view look like an NSView. In both cases we are wrapping our views.

To get the best of both worlds, we are going to use a combo of both wrappers. We will wrap a SwiftUI view inside an NSHostingView and in turn, we will wrap the resulting hosting view, inside an NSViewRepresentable. Our examples will explore the benefits of this double-wrap. We will start with macOS.

Mouse Tracking Areas on SwiftUI Views (macOS Example)

If you used the onHover modifier in SwiftUI, you may have notice that the closure gets called once when the mouse enters the view, and once when it exits. That’s it. But if you want your closure to be called when the mouse moves around and get its position, you are out of luck. You may also want your closure to be called when the mouse enters your view, even when the app is not active. In AppKit, that’s easily solved with NSTrackingArea, so our goal is to make it possible for our SwiftUI views to use NSTrackingAreas too.

There may be other ways to solve this problem, for example, using NSEvent.addLocalMonitorForEvents(). But we won’t use that here.

We will hide all the implementation behind a container view named TrackingAreaView, which can be used like this:

TrackinAreaView(onMove: { location in print("\(location)") }) {
    Rectangle()
        .fill(Color.red)
}

The onMove parameter lets us provide a closure to execute, when the mouse is moved over the view. But for better readability, we will also add this View extension:

extension View {
    func trackingMouse(onMove: @escaping (NSPoint) -> Void) -> some View {        
        TrackinAreaView(onMove: onMove) { self }
    }
}

and now our code gets simplified like this:

Rectangle()
    .fill(Color.red)
    .trackingMouse { location in
        print("\(location)")
    }

Now that we know what we want to achieve, let’s get our hands dirty. I’ll start by showing you the end result and the ContentView code:

mouseMove
struct ContentView: View {
    @State private var point1: NSPoint = .zero
    @State private var point2: NSPoint = .zero
    
    var body: some View {
        
        HStack {
            VStack {
                Rectangle().fill(Color.green)
                    .trackingMouse { location in
                        self.point1 = location
                    }
                    .clipped()
                
                Text("\(String(format: "X = %.0f, Y = %.0f", self.point1.x, self.point1.y))")
            }
            

            VStack {
                Rectangle().fill(Color.blue)
                    .trackingMouse { location in
                        self.point2 = location
                    }
                    .clipped()

                Text("\(String(format: "X = %.0f, Y = %.0f", self.point2.x, self.point2.y))")
            }
            
        }
        .padding(20)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Now it’s time to uncover the implementation of TrackingAreaView. The code may be too verbose, but in reality the “cool stuff” only happens in “setupTrackingArea()” and “mouseMoved()”.

extension View {
    func trackingMouse(onMove: @escaping (NSPoint) -> Void) -> some View {
        TrackinAreaView(onMove: onMove) { self }
    }
}

struct TrackinAreaView<Content>: View where Content : View {
    let onMove: (NSPoint) -> Void
    let content: () -> Content
    
    init(onMove: @escaping (NSPoint) -> Void, @ViewBuilder content: @escaping () -> Content) {
        self.onMove = onMove
        self.content = content
    }
    
    var body: some View {
        TrackingAreaRepresentable(onMove: onMove, content: self.content())
    }
}

struct TrackingAreaRepresentable<Content>: NSViewRepresentable where Content: View {
    let onMove: (NSPoint) -> Void
    let content: Content
    
    func makeNSView(context: Context) -> NSHostingView<Content> {
        return TrackingNSHostingView(onMove: onMove, rootView: self.content)
    }
    
    func updateNSView(_ nsView: NSHostingView<Content>, context: Context) {
    }
}

class TrackingNSHostingView<Content>: NSHostingView<Content> where Content : View {
    let onMove: (NSPoint) -> Void
    
    init(onMove: @escaping (NSPoint) -> Void, rootView: Content) {
        self.onMove = onMove
        
        super.init(rootView: rootView)
        
        setupTrackingArea()
    }
    
    required init(rootView: Content) {
        fatalError("init(rootView:) has not been implemented")
    }
    
    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupTrackingArea() {
        let options: NSTrackingArea.Options = [.mouseMoved, .activeAlways, .inVisibleRect]
        self.addTrackingArea(NSTrackingArea.init(rect: .zero, options: options, owner: self, userInfo: nil))
    }
        
    override func mouseMoved(with event: NSEvent) {
        self.onMove(self.convert(event.locationInWindow, from: nil))
    }
}

The TrackingAreaView is just a container, that passes its contents to an NSViewRepresentable (TrackingAreaRepresentable). And the NSView produced by that NSViewRepresentable is just an NSHostingView (TrackingNSHostingView), using the container contents as its rootView.

In its simplest form, that would not have any effect in the output. We are wrapping a SwiftUI view inside an NSView, and then wrapping that NSView back into a SwiftUI view. However, the benefit comes from the fact that once we have an NSView to play with, that’s when we get the party started. We have successfully hooked ourselves into the point of the AppKit view hierarchy where our original SwiftUI view has been inserted.

In this particular example, we simply added a parameter where we specify a closure to be executed when the NSView mouseMoved() event fires.

I intentionally made the added functionality simple, in order to get my point across. However, it’s possible to modify our example, so it becomes even more flexible. For example, you could change the implementation to receive more parameters. Let’s say, area options and an array of areas to track. I’ll leave this as an exercise, in case you’re interested in exploring more.

Rectangle().fill(Color.green)
    .trackingMouse(options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways], rects: [areas]) { loc in
        ...
    }

ScrollView’s Scroll Value (iOS Example)

If there is one big feature missing from ScrollView, that is the fact that you cannot programmatically set nor get the scroll position. In the following example, we will deal with that.

The usage is very simple. We will define a @State variable to hold the ScrollView’s position. If we modify the value, the ScrollView should respond by scrolling. But if the user performs a scroll gesture, the @State variable should update too.

This is very close to what I think should be the “SwiftUI way” of handling this issue. If in the next iteration of SwiftUI Apple adds this feature, the binding will most likely be specified as a parameter to the ScrollView, but in our case, we will make a separate view extension called scrollOffset(offset:).

struct ContentView: View {
    @State private var scrollOffset: CGFloat = 0
    
    var body: some View {
        VStack {
            Group {
                Text("Current position: \(self.scrollOffset)")
                
                HStack(spacing: 30) {
                    Button("0pt") { self.scrollOffset = 0 }
                    
                    Button("100pt") { self.scrollOffset = 100 }
                    
                    Button("800pt") { self.scrollOffset = 800 }
                }
            }.font(.headline)

            ScrollView {
                ForEach(0..<100) { idx in
                    HStack {
                        Text("Row number \(idx)")
                        Spacer()
                    }
                }
            }
            .padding(10)
            .scrollOffset(self.$scrollOffset)
        }
    }
}

And here goes the implementation

extension View {
    func scrollOffset(_ position: Binding<CGFloat>) -> some View {
        return ScrollViewWrapper(offset: position) { self }
    }
}

struct ScrollViewWrapper<Content>: View where Content : View {
    let offset: Binding<CGFloat>
    let content: () -> Content

    init(offset: Binding<CGFloat>, @ViewBuilder content: @escaping () -> Content) {
        self.offset = offset
        self.content = content
    }

    var body: some View {
        ScrollViewRepresentable(offset: offset, content: self.content())
    }
}

struct ScrollViewRepresentable<Content>: UIViewControllerRepresentable where Content: View {
    typealias UIViewControllerType = ScrollViewUIHostingController<Content>
    
    @Binding var offset: CGFloat
    let content: Content
    
    func makeUIViewController(context: UIViewControllerRepresentableContext<ScrollViewRepresentable<Content>>) -> ScrollViewUIHostingController<Content> {
        return ScrollViewUIHostingController(offset: self.$offset, rootView: self.content)
    }
    
    func updateUIViewController(_ uiViewController: ScrollViewUIHostingController<Content>, context: UIViewControllerRepresentableContext<ScrollViewRepresentable<Content>>) {
        uiViewController.scroll(position: self.offset)
    }
}

class ScrollViewUIHostingController<Content>: UIHostingController<Content> where Content : View {
    var offset: Binding<CGFloat>
    
    var ready = false
    var scrollView: UIScrollView? = nil
    
    init(offset: Binding<CGFloat>, rootView: Content) {
        self.offset = offset
        super.init(rootView: rootView)
    }
    
    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
        override func viewDidAppear(_ animated: Bool) {
        // observer is added from viewDidAppear, in order to
        // make sure the SwiftUI view is already in place
        if ready { return } // avoid running more than once
        
        ready = true
        
        self.scrollView = findUIScrollView(view: self.view)
        
        self.scrollView?.addObserver(self, forKeyPath: #keyPath(UIScrollView.contentOffset), options: [.old, .new], context: nil)
        
        self.scroll(position: self.offset.wrappedValue, animated: false)
        super.viewDidAppear(animated)
    }
    
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == #keyPath(UIScrollView.contentOffset) {
            if let sv = self.scrollView {
                DispatchQueue.main.async {                    
                    self.offset.wrappedValue = sv.contentOffset.y
                }
            }
        }
    }
    
    func scroll(position: CGFloat, animated: Bool = true) {
        if let sv = self.scrollView {
            if position != sv.contentOffset.y {
                self.scrollView?.setContentOffset(CGPoint(x: 0, y: position), animated: animated)
            }
        }
    }
    
    func findUIScrollView(view: UIView?) -> UIScrollView? {
        if view?.isKind(of: UIScrollView.self) ?? false {
            return (view as? UIScrollView)
        }
        
        for v in view?.subviews ?? [] {
            if let vc = findUIScrollView(view: v) {
                return vc
            }
        }
        
        return nil
    }
    
    deinit {
        self.scrollView?.removeObserver(self, forKeyPath: #keyPath(UIScrollView.contentOffset))
    }
}

The concept remains the same, but it in this case there’s a little hack we need to put in place. We know that ScrollView is backed by a UIScrollView subclass, and we need to get hold of that UIScrollView, in order to be able to set and get its contentOffset property. To do so, we start with the UIHostingController’s top view and begin to descend the hierarchy of views, until we find one that returns true for isKind(of: UIScrollView.self).

When we programmatically change the scroll offset binding, the updateUIViewController() function will get automatically called. That’s our chance to tell ScrollViewUIHostingController to scroll the UIScrollView.

On the other hand, if the user performs a scroll gesture, the UIScrollView.contentOffset value changes. For that we use KVO to be notified. When that happens, we update the wrappedValue of our binding. You may be tempted to set a delegate on the UIScrollView, but that may interfere with the inner workings of SwiftUI. Instead we use KVO.

A Nice Surprise

While I was testing this technique, I thought that by double-wrapping a view, the environment somehow would get lost in the process. Fortunately, that does not seem to happen. In the ScrollView example, you can call .environment (or .environmentObject), higher in the hierarchy. Still, the contents of the ScrollView will inherit the environment accordingly. That was a nice surprise.

VStack {
    ScrollView {
        ForEach(0..<100) { idx in
            RowView(idx)
        }
    }.scrollOffset(self.$scrollOffset)
}.environment(\.colorScheme, .dark)

An Unpleasant Surprise

After I published this article, I found a small problem that you need to be aware of. In the following code, I have created a modifier called wrap(), which does the usual double-wrap described in the previous examples, but nothing more.

In the code below, I added it in two places. Both should have the same effect. However, one of them, will stop the contained view from reacting to state changes (in this case, rotating).

With this in mind, if the view being double-wrapped needs to react to state changes, it is best if you encapsulate it. Then call your modifier from the outside. It is an unfortunate complication, but the workaround seems to work so far.

struct ContentView: View {
    @State private var flag = false
    
    var body: some View {
        VStack {
            MyRectangle(flag: self.$flag)
                .wrap() // This wrap works fine
            
            Button("Rotate") { self.flag.toggle() }
        }
    }
    
    struct MyRectangle: View {
        @Binding var flag: Bool
        
        var body: some View {
            Rectangle()
                .frame(width: 80, height: 80)
                .padding(20)
                .rotationEffect(self.flag ? Angle.degrees(45) : .zero)
                // .wrap() // <-- Wrapping the view here, prevents the view from reacting to state changes
        }
    }
}

A Word of Caution

If you decide to use any of these techniques on production code, make sure you thoroughly test it. Special attention should be given to memory management. Both “Representables” and “Hosting Views” are known to have some bugs that may produce memory leaks now and then. If your views get allocated once, that’s not a big problem. However, if the views that use this technique get created and destroyed a lot, you should monitor if deallocation is happening as it should. One quick way to test it, is to print a log message in the deinit method.

The other thing to have in mind, is the fact that these views you create, may stop working at any time should Apple change their internals. If they do, however, I expect that to happen during the next big update (i.e., WWDC), when the limitations we are trying to overcome will probably get solved (fingers crossed). Should that be the case, there will be plenty of time to update our code accordingly and we will also have fallback code that we can use to maintain compatibility with iOS13 and macOS Catalina.

Summary

We have seen how powerful it could be to use Representables and Hosting Views together. By doing so, we find ourselves hooked into the window’s view hierarchy, at the point where our SwiftUI view is inserted. This provides us with the chance to bridge into AppKit/UIKit. Of course, this is a hack, but a powerful one… and as such, it should be used responsibly.

The native focus support in SwiftUI has its own limitations, which we can be overcome using this technique. If you want more examples on how to double-wrap, check Working with Focus on SwiftUI Views

Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles come out. Until next time!

10 thoughts on “The Power of the Hosting+Representable Combo”

  1. Very nice article. Apple should have released this in the first version….

    BTW. I use your companion app for my SwiftUI learning. It’s very useful. Thanks buddy.

    Reply
  2. I used this trick for finding the underlying scroll view and setting its content inset beside content offset, which worked perfectly on iOS 13, but on iOS 14, although it finds a different scroll view (view hierarchy is pretty much different) even upon setting its content inset to whatever value, it always keeps being reset to the zero (by the system) immediately afterwards. Still looking for a fix…

    Reply
  3. Hi, thanks for writing this up!

    I’m finding your tracking area tutorial is giving me slightly different results than the gif you shared. When I move my mouse, I get tracking area updates on *both* squares, not just the one I’m mousing over. If I mouse over the green, the labels for both update, and the label under the blue square will say “x: -100” or whatever.

    I’m using Xcode 12.4 on Big Sur (11.2). Do you have any idea what I might be missing? I’ve literally copy and pasted your code.

    Thanks!

    Reply
    • Thank you Jason. It seems something has changed in the SDK. I am now seeing the same. Luckily, it can be easily solved by simply adding .clipped() to both views. I updated the article to include these addtions. Thank you for the heads-up.

      Reply
  4. Hi, thanks for this in-depth explanation.
    This should help moving a mac app with loads of legacy views to swift UI.

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close