Managing TabView selections in SwiftUI

The TabView in SwiftUI (known as UITabBar in UIKit) typically spends its life at the root of your navigation stack. The declarative implementation is clean and straightforward. But what happens when you want to augment the behaviour of the tab bar?

In UIKit this would usually mean reaching for UITabBarControllerDelegate. This delegate could decide which view controllers should be made active, and provide alternative behaviour when necessary. For example, intercepting the selection of a view controller and instead presenting a modal sheet. A good example would be showing a camera from the central tab instead of the tab itself. In SwiftUI there is no such delegate, so we need to add this functionality ourselves. It follows a similar pattern, just adapted for the SwiftUI model.

We start with the TabView structure itself containing 3 tab items (each tagged by the TabName enum). A $selection binding is given on initialization. Right now this is just a local @State variable, but will take on more responsibility shortly.

import SwiftUI

enum TabName: Hashable {
    case home
    case news
    case more
}

struct ContentView: View {
    @State private var selection = TabName.home
    
    var body: some View {
        TabView(selection: $selection) {
            Text("Home")
                .tabItem {
                    Text("Home")
                }
                .tag(TabName.home)

            Text("News")
                .tabItem {
                    Text("News")
                }
                .tag(TabName.news)

            Text("More")
                .tabItem {
                    Text("More")
                }
                .tag(TabName.more)
        }
    }
}

To gain more flexibility over the selection binding we can change it from the local @State to the @ObservedObject property wrapper. The accompanying ObservableObject we create is now responsible for being the source of truth for the selection. It takes advantage of the objectWillChange publisher to make sure the observing view gets invalidated correctly (potentially we could use @Published here instead). The tab view can now bind to this through the observed object with $tabSelect.selection. The functionality of the tab bar remains the same as before. Time to extend it.

class TabSelect: ObservableObject {
    var selection = TabName.home {
        willSet {
            objectWillChange.send()
        }
    }
}

struct ContentView: View {
    @ObservedObject var tabSelect: TabSelect

    var body: some View {
        TabView(selection: $tabSelect.selection) {

        // tabs...
    }
}

Now let’s imagine we want to present a modal sheet when the ‘More’ tab is selected instead of displaying that tab content. In the view body we can declare this sheet as we would any other, using the .sheet(isPresented:) modifier attached to the TabView. The binding we are passing will live in the same observable object we just created, so we can go ahead and add it there with a default value of false.

var body: some View {
    TabView(selection: $tabSelect.selection) {
        // ...
    }
    .sheet(isPresented: $tabSelect.showModal) {
        Text("Modal")
    }
}

class TabSelect: ObservableObject {
    // ...

    var showModal = false
}

The structure is now in place, and we can implement a strategy to pick up the selection and augment it to suit our needs. There are a number of ways this could be done, depending on what you want to achieve and the complexity of your business logic. The principal ideas here should give you a good starting point.

The 2 key pieces of machinery will be use of a protocol and custom property wrapper, to extend the observable object.

First the protocol. This mimics the UITabBarControllerDelegate method tabBarController(_:shouldSelect:). It uses an associated type Tab to represent the selection and returns a Bool signalling whether we should accept the selection or the current tab should remain active.

protocol TabSelectable {
    associatedtype Tab
    func shouldSelect(_ tab: Tab) -> Bool
}

Our custom @TabSelection property wrapper will wrap the selection and call our protocol to let our caller decide how to handle it.

@propertyWrapper
struct TabSelection<Value: Hashable> {
    
    init(wrappedValue: Value) {
        _selection = wrappedValue
    }
    
    var _selection: Value
    private var selectable: ((Value) -> Bool)?
    
    mutating func register<S>(_ selectable: S) where S : TabSelectable, Value == S.Tab {
        self.selectable = selectable.shouldSelect
    }
    
    var wrappedValue: Value {
        get { _selection }
        set {
            guard selectable?(newValue) ?? true else {
                return
            }
            
            _selection = newValue
        }
    }
}

There is an important caveat to note when using this approach for the property wrapper. To call the protocol, we are attempting to refer to self of the enclosing type. To do so we rely on the register(_:) function, passing in the conforming object, then storing the function as a closure (a form of light type erasure).

SE-0258: Property Wrappers specifically highlights the big issues when trying this technique. The first being that we need to manually call register(_:) in the initializer for our TabSelection object. Making it less than an ideal way to use a property wrapper.

The more serious reason for not using this method is that if the synthesized storage property is accessed in any way during the callback, it would trigger a memory exclusivity violation! It does propose a possible future solution to this situation using using static subscripts and key paths.

Given the tab view is typically only used in one location in a project, we could have avoided the use of a property wrapper completely and used our own accessors directly. In fact, that was what I did in the first pass of trying to make it work. But I wanted an excuse to make my own property wrapper, so we will just roll with it for now.

OK, finally we can annotate our selection variable using the property wrapper (not forgetting to register on initialization). Then we conform to TabSelectable and decide what rules to employ when selection changes. Here we block the selection of the More tab and toggle the showModal state. The result gives us the desired effect.

class TabSelect: ObservableObject, TabSelectable {

    @TabSelection var selection = Tab.news {
        willSet {
            objectWillChange.send()
        }
    }

    var showModal = false

    init() {
        _selection.register(self)
    }

    func shouldSelect(_ tab: TabName) -> Bool {
        guard tab != .more else {
            showModal.toggle()
            return false
        }
        
        return true
    }
}

A gist of the full code can be found here.

Conclusion

My initial reaction to TabView not having this explicit functionality was that it had been overlooked. On reflection, it makes sense that to utilise to state, bindings and observable objects to manage such things, and letting the view itself remain declarative as intended.

I’m not entirely happy with the property wrapper requiring an extra step of initialization, but hopeful that such a use case has already been considered by the Swift team.

The transition to SwiftUI is exciting but does require a shift in mental models. My big takeaway so far has been how the type system is being used to it’s full potential to make these systems possible. Having a good understanding of protocols, generics, opaque types and property wrappers will be more essential than ever.