Working with Focus on SwiftUI Views

In this post, we are going to explore what tools SwiftUI offers to handle the focus on custom views. We will also explore the limitations, and what hacks we can use to get around them.

SwiftUI on macOS has a single modifier to configure our view focus: focusable(). It receives two optional parameters (more on that later). In its default form, by adding this modifier to a view, you are indicating that the view can receive the focus. In AppKit terms we would say it can become the first responder.

In the following code, the yellow and green shapes can get the focus. In fact, because the yellow shape is the first focusable element of the window, it gets it by default.

Focus Example 1
Use TAB and SHIFT+TAB to move the focus
struct ContentView: View {
    var body: some View {
        HStack {
            VStack {
                Circle().fill(Color.yellow)

                HStack {
                    Circle().fill(Color.yellow)

                    Circle().fill(Color.yellow)

                }
            }.focusable()

            Rectangle().fill(Color.red)

            Circle().fill(Color.green)
                .focusable()
            
        }
        .padding(20)
        .frame(width: 300, height: 100)
    }
}

As you can observe, SwiftUI handles the focus ring, and it even provides the proper shape for it.

Focusable Parameters

The focusable() modifier has two parameters:

func focusable(_ isFocusable: Bool = true, onFocusChange: @escaping (Bool) -> Void = { _ in }) -> some View

The first parameter, isFocusable, will be true if omitted (as in the example above). If set to false, the view will behave as if the focusable() modifier wasn’t there. Suppose you have a view that needs to activate and deactivate the possibility of having focus, this is the parameter to do so.

The second parameter let us specify a closure to execute when the view gains or loses focus. It receives a boolean indicating so.

Focus on Standard Controls

It’s worth mentioning that views for standard controls, such as TextField, Picker, Toggle and the like, do not need focusable() to be called on them. In fact, you should avoid that. For example, If you do the following, you will end up with two focus rings, and you will be required to hit TAB twice, in order to move between the views.

TextField("", text: self.$text).focusable()

TextField is, for all intents and purposes, an NSViewRepresentable of a NSTextField (or something very similar). So when you add focusable() on TextField, you are putting a ring on the NSViewRepresentable, in additional to the one already existing in the wrapped NSTextField.

You probably noticed that although you cannot click to focus on a view, clicking on TextField works just fine. How does TextField do it? Simple, since it’s backed by NSTextField, there’s nothing it cannot do.

Limitations

As you can observe, SwiftUI handles the focus ring, and it even provides the proper shape. All is good and simple, but as with everything in SwiftUI at the moment, there are some big limitations:

  • Setting the focus programmatically, as far as I know, is not possible.
  • There is no way of specifying the view order in which the focus moves.
  • Focus can only be moved with the TAB and SHIFT-TAB keys. Clicks have no effect.
  • The TAB and SHIFT+TAB navigation keys will only work if the system preferences in macOS are as shown below.

If you have a view that needs clickable focus, you may need to either implement it as an NSView with NSViewRepresentable. Or… you can use the hack we learnt in the previous post (The Power of the Hosting+Representable Combo), as we will explore next.

Hacking the Focus

As always, the customary warning. Proceed with caution with the next bit. This is only a hack. Remember this is a blog with emphasis on experimentation (hence the “lab” name). With the following example we are trying to find the limits of what is possible with the tools that we have been given.

By using the representable hosting view, we are going to achieve SwiftUI views that can be clicked to get focus. We will also make it possible to assign a focus index number to each view, so we can programatically move the focus from one view to another, and even remove the focus completely.

The code is a starting point, and there are plenty of ways in which it can be improved.

Focus Example 2

As you can observe, the focus is bound to an environment value. Clicking on a value changes the environment value, but also changing the environment value will change the focus as well. I think this is a very SwiftUI way of handling the focus. I’m hoping that sooner rather than later, we will see something like this supported natively.

struct ContentView: View {
    var body: some View {
        MainView()
    }
}

struct MainView: View {
    @State private var selectedFocusIdx = 2
    
    var body: some View {
        VStack {
            HStack {
                Circle().fill(Color.yellow)
                    .focusableWithClick(focusIndex: 1)
                
                VStack {
                    Circle().fill(Color.orange)
                    
                    HStack {
                        Circle().fill(Color.orange)
                        
                        Circle().fill(Color.orange)
                    }
                }
                .focusableWithClick(focusIndex: 2)
                
                Circle().fill(Color.red)
                    .focusableWithClick(focusIndex: 3)
                
            }.padding(.bottom, 40.0)
            
            HStack {
                Button("Deselect All")  { self.selectedFocusIdx = 0 }
                Button("1") { self.selectedFocusIdx = 1 }
                Button("2") { self.selectedFocusIdx = 2 }
                Button("3") { self.selectedFocusIdx = 3 }
            }
            
            Text("Idx = \(self.selectedFocusIdx)").font(.headline)

        }
        .environment(\.selectedFocusIndex, $selectedFocusIdx)
        .padding(20)
        .frame(width: 500, height: 300)
    }    
}

And the magic of the “questionable” hack, follows:

extension View {
    func focusableWithClick(focusIndex: Int = 0) -> some View {
        return Focusable(focusIndex: focusIndex) { self }
    }
}

struct SelectedFocusIndexKey: EnvironmentKey {
    public static let defaultValue: Binding<Int> = Binding<Int>(get: { return 0 }, set: { _ in })
}

extension EnvironmentValues {
    var selectedFocusIndex: Binding<Int> {
        get { self[SelectedFocusIndexKey.self] }
        set { self[SelectedFocusIndexKey.self] = newValue }
    }
}

struct Focusable<Content>: View where Content : View {
    @Environment(\.selectedFocusIndex) var selectedIndex
    
    let content: () -> Content
    let focusIndex: Int
    
    init(focusIndex: Int, @ViewBuilder content: @escaping () -> Content) {
        self.focusIndex = focusIndex
        self.content = content
    }
    
    var body: some View {
        let onFocusChange: (Bool) -> Void = { isFocused in
            DispatchQueue.main.async {
                if isFocused {
                    self.selectedIndex.wrappedValue = self.focusIndex
                } else {
                    self.selectedIndex.wrappedValue = 0
                }
            }
        }
        
        let v = self.content().focusable(onFocusChange: onFocusChange)
        
        return MyRepresentable(focusIndex: self.focusIndex, onFocusChange: onFocusChange, content: v)
    }
}

struct MyRepresentable<Content>: NSViewRepresentable where Content: View {
    @Environment(\.selectedFocusIndex) var selectedIndex
    @State private var lastValue = 0
    
    let focusIndex: Int
    let onFocusChange: (Bool) -> Void
    let content: Content
    
    func makeNSView(context: Context) -> NSHostingView<Content> {
        return FocusableNSHostingView(rootView: self.content, focusIndex: self.focusIndex, onFocusChange: onFocusChange)
    }
    
    func updateNSView(_ nsView: NSHostingView<Content>, context: Context) {
        let hostingView = (nsView as! FocusableNSHostingView)
        
        if self.selectedIndex.wrappedValue == hostingView.focusIndex, self.selectedIndex.wrappedValue != self.lastValue {
            DispatchQueue.main.async {
                self.lastValue = self.selectedIndex.wrappedValue
                hostingView.claimFocus()
            }
        } else if self.lastValue != 0, self.selectedIndex.wrappedValue == 0 {
            DispatchQueue.main.async {
                self.lastValue = self.selectedIndex.wrappedValue
                hostingView.clearFocus()
            }
        } else {
            DispatchQueue.main.async {
                self.lastValue = self.selectedIndex.wrappedValue
            }
        }
    }
}

class FocusableNSHostingView<Content>: NSHostingView<Content> where Content : View {
    
    let focusIndex: Int
    let onFocusChange: (Bool) -> Void
    
    init(rootView: Content, focusIndex: Int, onFocusChange: @escaping (Bool) -> Void) {
        self.focusIndex = focusIndex
        self.onFocusChange = onFocusChange
        super.init(rootView: rootView)
    }
    
    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")
    }
    
    override func mouseDown(with event: NSEvent) {
        self.claimFocus()
        
        super.mouseDown(with: event)
    }
    
    func claimFocus() {
        // Here's the magic!
        // Find the NSView that should receive the focus and make it the first responder.
        
        // By experimentation, the view's class name is something like this: xxxxxxxxxxxx_FocusRingView
        if let focusRingView = self.subviews.first(where: { NSStringFromClass(type(of: $0)).contains("FocusRingView") }) {
            self.window?.makeFirstResponder(focusRingView)
            self.onFocusChange(true)
        }
    }
    
    func clearFocus() {
        self.window?.makeFirstResponder(nil)
    }
}

Almost There

Now, if you tried the code, you may see some ugly warnings in the console, with a message like this:

Setting <_TtGC7onHover22FocusableNSHostingViewGV7SwiftUI15ModifiedContentGVS1_10_ShapeViewVS1_6CircleVS1_5Color_VS1_18_FocusableModifier__: 0x100850c00> as the first responder for window , but it is in a different window ((null))! This would eventually crash when the view is freed. The first responder will be set to nil.

Although the warning does not seem to have any adverse effect, I don’t like warnings. Well…, not true. I do like warnings, because they warn me and prevent future problems. However, I like it better when I can eradicate them.

In this case, the message seems to indicate that the view is not yet assigned to the window, when SwiftUI tries to set the first responder on our Representable. A quick way of getting rid of the message, is by modifying our ContentView:

struct ContentView: View {
    @State private var flag = false

    var body: some View {
        Group {
            if !flag {
                Color.clear.onAppear { self.flag = true }
            } else {
                MainView()
            }
        }
    }
}

Summary

In this post we have explored all about how to handle the focus on SwiftUI views on macOS Catalina. The next WWDC is just around the corner, and I’m hoping the limitations we exposed here, will no longer exist soon.

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!

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