Since its launch in 2019, we had been looking forward to using SwiftUI in a production environment. Unfortunately, the situation on iOS is not favorable as, to this day, we still need to support iOS 12. However, we also have a tvOS application and the situation is very different there. The latest tvOS version is supported by all the devices we develop for, so all of our users can update to the newest version. We usually have around 85% of users on the latest version within 4 months of its launch. That means we can afford to raise our deployment target quickly. We also have quite a lot of freedom on tvOS - because it is (unfortunately) amongst the least used Showmax platforms. That allows us to experiment more and use the platform as a sort of a playground.

Therefore, we decided to try out SwiftUI for all the new features. We also tried to rewrite some screens using SwiftUI. And as of now we have around 10 screens written entirely in SwiftUI and used in production.

Unfortunately, using SwiftUI on tvOS has quite a lot of unforeseen caveats and it hasn’t always been easy. To be completely honest, we probably haven’t gained any advantage nor even saved much time by using it. That said, it has definitely been worth the experience. It has been fun to work with it, and it has certainly made our developers happier. Let me share some of our experiences, highlight the things that have surprised us the most, and give you some tips for working with it yourself.

Basics

The first thing we noticed was the lack of resources for SwiftUI on Apple TV. That is understandable though, because tvOS is not a major platform and there was always a problem with finding resources. Unfortunately, it also seems that even Apple does not give a lot of attention to it. There are only 2 relevant videos for SwiftUI on tvOS from the last two editions of WWDC. So the main source of information for the development are these videos and documentation.

Available APIs

To quickly summarize the main APIs that SwiftUI offers for tvOS:

  • CardButtonStyle - special button style for tvOS buttons that applies effects, e.g. motion effect

SwiftUI offers the following for working with the focus engine:

  • prefersDefaultFocus(_:in:) - modifier that indicates that the view should receive focus by default for a given namespace
  • focusScope(_:) - modifier that limits your focus preferences to a specific view
  • resetFocus - action that resets the focus back to its default in the current scope
  • isFocused - environment variable that returns true if the nearest focusable ancestor of your view is focused
  • focusable(_:onFocusChange:) - modifier that specifies if the view is focusable and, if so, adds an action to perform when the view comes into focus. Deprecated in tvOS 15 in favor of the new FocusState API

In tvOS 15 Apple introduced a new focus state API that makes working with focus across different platforms easier. Of course, it also made working with the focus engine on tvOS dramatically better:

  • FocusState - new property wrapper that controls the placement of the app focus. It should be used together with focused(_:equals:) and focus​​ed(_:) modifiers
  • focused(_:) and focused(_:equals:) - modifiers that bind the view focus state to the given state value
  • focusSection() - tvOS specific modifier, that tells SwiftUi that this view is capable of accepting focus if it contains any focusable subviews

You can learn more by watching these two WWDC videos: Build SwiftUI apps for tvOS and Direct and reflect focus in SwiftUI.

All the code shown in the following sections is compatible with tvOS 15.

Encountered Issues

Replicating TVUIKit Elements

One of the first things we did was that we tried to rewrite some of our screens in SwiftUI. Previously, we were using the advantage of the TVUIKit framework that contains common user interface components for the Apple TV app. We were mainly using TVPosterView - ffor example, for posters on the homepage.

Using this component is very easy with TVUIKit:

var poster: TVPosterView {
    let poster = TVPosterView(image: UIImage(named: "image"))
    poster.title = "Title"

    poster.translatesAutoresizingMaskIntoConstraints = false
    poster.widthAnchor.constraint(equalToConstant: 400).isActive = true
    poster.heightAnchor.constraint(equalToConstant: 300).isActive = true

    return poster
}

tvposterview

Our initial assumption was that it should be fairly easy to rewrite this component in SwiftUI - it is basically a native component after all. We quickly realized that this assumption was not correct.

When it comes to any tvOS specific components that we might use, SwiftUI basically offers only CardButtonStyle - which is the recommended button style for tvOS, because it handles focus and motion effects out of the box.

So we tried to use the component in the following way:

var poster: some View {
    Button(
        action: { },
        label: {
            VStack {
                Image("image")
                    .resizable()
                    .scaledToFill()
                    .frame(width: 400, height: 225)
                    .clipped()

                Text("Title")
                    .padding(.bottom)
            }
        }
    )
    .buttonStyle(.card)
}

Which produces following result:

cardposter

As you can see, it doesn’t look exactly the same. And there are a couple of other issues with it as well. It reacts well to the focus changes and it also handles the motion effect well, but it has no parallax effect.

Another option we tried was using completely custom view in the following way:

struct PosterView: View {
    @FocusState var isFocused

    var body: some View {
        Button(
            action: { },
            label: {
                VStack(spacing: 4) {
                    Image("image")
                        .resizable()
                        .scaledToFill()
                        .frame(width: 400, height: 225)
                        .clipped()
                        .shadow(radius: 18, x: 0, y: isFocused ? 50 : 0)

                    Text("Title")
                        .foregroundColor(isFocused ? .white : .black)

                }
            }
        )
        .focused($isFocused)
        .buttonStyle(PressHandlingStyle())
        .scaleEffect(isFocused ? 1.2 : 1)
        .animation(.easeOut(duration: isFocused ? 0.12 : 0.35), value: isFocused)
    }
}

// We use this button style to handle `isPressed` state of the component.
struct PressHandlingStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? (1 / 1.15) : 1)
    }
}

Which produces following result:

customposter

This looks much better and pretty similar to standard TVPosterView. It has a couple of problems though. It does not behave completely natively, because it does not handle motion effect and it also has no parallax effect.

Also, when we compare it to TVPosterView, there are a couple of disadvantages. The implementation is of course more difficult, as we need to handle focus changes ourselves, but an obvious advantage is that our own implementation is easily extensible. That is not the case with TVPosterView.

The last solution we tried was wrapping TVPosterView into a UIViewRepresentable view. Sizing can be an issue, but it works quite well when you specify the width and height inside TVPosterView as well. Something like this:

class Poster: TVPosterView {
    init(width: CGFloat, height: CGFloat) {
        super.init(frame: .zero)
        image = UIImage(named: "image")
        title = "Title"
        translatesAutoresizingMaskIntoConstraints = false

        // We need to set the width and height here as well otherwise it does not work correctly.
        widthAnchor.constraint(equalToConstant: width).isActive = true
        heightAnchor.constraint(equalToConstant: height).isActive = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

public struct UIKitView<T: UIView>: UIViewRepresentable {
    let viewBuilder: () -> T

    public func makeUIView(context: Context) -> T {
        return viewBuilder()
    }

    public func updateUIView(_ uiView: T, context: Context) {}
}

And then use it like this:

var poster: some View {
    let width: CGFloat = 400
    let height: CGFloat = 300
    return UIKitView {
        Poster(width: width, height: height))
            .frame(width: width, height: height)
    }
}

Which produces great results. That is, until you try to mix the usage of such a component with some other custom SwiftUI component that handles focus in a similar manner to the previous poster. In that situation, there is an issue because the focus stays on multiple views:

representableposter

It works fine, though, if you mix it with non-custom components (like a button with card style). Or perhaps there is just some issue in our custom implementation. But if so, we did not find the issue.

So, as you can see, there is basically no ideal solution in SwiftUI for this use case. We could obviously try to go further and add all of the missing things to our custom solution, but it would get super complicated. We would also probably have to implement it using the introspect technique to access the underlying UIKit components (check out the introspect technique e.g. in this library).

What probably disappointed us the most is the lack of the parallax and motion effects. It’s a shame that SwiftUI views don’t have these, because UIKit views have all of this out of the box and it would make SwiftUI on tvOS more native. There are a couple of other features that UIKit offers for free. For example, UILabel has an enablesMarqueeWhenAncestorFocused property that determines whether the label scrolls its text while one of its containing views has focus. That is completely missing in SwiftUI as well.

Button Customization

Another thing that we use extensively in our tvOS app are customized buttons. Customizing buttons was not simple in UIKit as well so let’s have a look at what the situation is in SwiftUI world.

Creating a simple button is easy:

var button: some View {
    Button(
        action: { },
        label: {
            Text("Button")
        }
    )
}

This gives us a standard native tvOS button:

simplebutton

Problems arise when we try to, for example, implement a round button. Naively, we could simply add the clipShape(Circle()) modifier to our button like this:

var button: some View {
    Button(
        action: { },
        label: {
            Image(systemName: "play")
        }
    )
    .clipShape(Circle())
}

This looks good at first glance, but as you can see, it does not work very well with focus:

simplecircle

To fix this, we have to create a custom button once again:

struct RoundButton: View {
    @FocusState var isFocused

    var body: some View {
        Button(
            action: { },
            label: {
                Image(systemName: "play")
                    .font(.headline)
                    .padding(25)
                    .background(isFocused ? Color.white : Color.gray)
                    .clipShape(Circle())
                    .shadow(radius: isFocused ? 20 : 0, x: 0, y: isFocused ? 20 : 0)
            }
        )
        .focused($isFocused)
        .buttonStyle(PressHandlingStyle())
        .scaleEffect(isFocused ? 1.2 : 1)
        .animation(.easeOut(duration: isFocused ? 0.12 : 0.35), value: isFocused)
    }
}

// We use this button style to handle the `isPressed` state of the component.
struct PressHandlingStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? (1 / 1.15) : 1)
    }
}

This looks good and works well:

customcircle

However, you still need to handle the focus changes and the system does not help you much. It is probably still simpler than in UIKit, though, where you also need to handle focus changes yourself if you want to make customized buttons. Ultimately, SwiftUI wins over UIKit in this field.

Another issue we’ve stumbled upon regarding button customization was missing the Accessibility Focused state. We did not find any way to use a custom button style while maintaining its ability to work correctly with the Accessibility Focus API so that the button’s hasFocus property would work in UI tests. We even have an open Stack Overflow question for this topic.

Working With the Focus Engine

One of our biggest struggles with SwiftUI on the tvOS platform was working with the focus engine. According to Apple, SwiftUI manages focus on your behalf for the most part. That is true. And when you need to create something simple, it really does a great job. But when you need something more complicated, it becomes more difficult.

Since tvOS 15 when Apple introduced the new Focus State API (see above) things got a lot easier. In particular, focusSection() made working with the focus engine a lot easier. However, one of the things we are still missing is an alternative to UIFocusGuide that was introduced in tvOS 9 and made working with the focus engine significantly simpler. On one of our more complex screen, we came up with an alternative to UIFocusGuide to make things easier. Let me show you how.

We decided to use it on our episodes selection screen because we were struggling with being able to switch focus from the episodes grid to the seasons list on the left. It wasn’t working in the situation when the focused episode didn’t have any corresponding season view next to the episode:

episodes

In this situation, the user wasn’t able to focus the “Season 1” when swiping left even when we used the focusSection() modifier. Our idea was to add something like UIFocusGuide between those two sections. It would be responsible for switching the focus correctly. Something like this:

episodesguide

We implemented it in SwiftUI through an invisible focusable View that switches the focus according to the previously focused section:

enum EpisodesFocusedSection {
    case seasons
    case episodes
}

struct EpisodesFocusGuide: View {
    @FocusState private var isFocused
    @Binding var focusedSection: EpisodesFocusedSection

    var body: some View {
        Color(.clear)
            .frame(width: 1, height: nil)
            .focused($isFocused)
            .onChange(of: isFocused) { isFocused in
                if isFocused {
                    switch focusedSection {
                    case .seasons:
                        focusedSection = .episodes
                    case .episodes:
                        focusedSection = .seasons
                    }
                }
            }
    }
}

Then, for example, in the EpisodesView, we listen for the changes in focusedSection and set the isFocused state accordingly:

@Binding var focusedSection: EpisodesFocusedSection
@FocusState var isFocused: Bool
...
.onChange(of: focusedSection) { section in
    if section == .episodes {
        isFocused = true
    }
}

As you can see,the good news is that we were able to handle the focus engine even in quite complex situations with the new tvOS 15 API. The API works in a very different way than focus handling in UIKit and it takes some time to get used to.

Conclusions

Working with SwiftUI on tvOS has been fun and it has been an interesting experience. Most of our issues arose because we tried to completely replicate previous behavior. If we had adjusted our UI for SwiftUI components right away, we would have avoided most of the issues. So if you are willing to accommodate your UI for SwiftUI components (and give up some native features like parallax effect) and you do not need to do any complex work with the focus engine, then SwiftUI is clearly the better choice. In such cases, it makes the development significantly easier and faster. On the other hand, if you have a complex UI and you want it to look as native as possible, then UIKit is probably a better choice. It gives you more control over your UI and you can also take advantage of frameworks like TVUIKit. Overall, SwiftUI still feels a bit like a non-native framework, mainly due to the lack of APIs for motion and parallax effect handling. We can just hope that Apple fills the gap in the future and SwiftUI on tvOS will be the number one choice for development.

Please check the original version of this article at