在tvOS上使用SwiftUI的经验

1,049 阅读8分钟

自2019年推出以来,我们一直期待着在生产环境中使用SwiftUI。不幸的是,iOS上的情况并不乐观,因为时至今日,我们仍然需要支持iOS 12。然而,我们也有一个tvOS应用程序,那里的情况非常不同。我们开发的所有设备都支持最新的tvOS版本,所以我们所有的用户都可以更新到最新的版本。我们通常在最新版本推出后的4个月内有大约85%的用户使用该版本。这意味着我们有能力迅速提高我们的部署目标。我们在tvOS上也有很大的自由度--因为它是(不幸的)使用最少的Showmax平台之一。这使我们能够进行更多的实验,并将该平台作为一种游乐场。

因此,我们决定尝试使用SwiftUI来实现所有的新功能。我们还尝试用SwiftUI重写一些屏幕。到目前为止,我们有大约10个完全用SwiftUI编写的屏幕,并在生产中使用。

不幸的是,在tvOS上使用SwiftUI有很多不可预见的注意事项,而且并不总是那么容易。说实话,我们可能没有获得任何优势,甚至没有通过使用它节省多少时间。尽管如此,这绝对是值得的经验。用它工作很有趣,而且它肯定使我们的开发人员更快乐。让我来分享我们的一些经验,强调那些让我们最惊讶的事情,并给你一些使用它的提示。

基础知识

我们注意到的第一件事是Apple TV上缺乏SwiftUI的资源。这是可以理解的,因为tvOS不是一个主要的平台,在寻找资源方面一直存在问题。不幸的是,似乎连苹果也没有给予很多关注。在过去两届WWDC中,只有2个关于tvOS上的SwiftUI的相关视频。所以开发的主要信息来源是这些视频和文档。

可用的API

快速总结一下 SwiftUI 为 tvOS 提供的主要 API:

  • CardButtonStyle- 适用于tvOS按钮的特殊按钮样式,例如运动效果。

SwiftUI提供了以下与焦点引擎相关的工作。

  • prefersDefaultFocus(_:in:)- 修改器,表示视图应该在默认情况下接受某个命名空间的焦点
  • focusScope(_:)- 修改器,将你的焦点偏好限制在一个特定的视图上
  • resetFocus- 将焦点重设为当前范围内的默认值的动作
  • isFocused- 环境变量,如果你的视图的最近的可聚焦的祖先被聚焦,则返回真。
  • focusable(_:onFocusChange:)- 修改器,指定视图是否可被聚焦,如果是,则添加一个动作,当视图被聚焦时执行。在tvOS 15中被弃用,转而使用新的FocusState API。

在tvOS 15中,苹果引入了一个新的焦点状态API,使不同平台的焦点工作更容易。当然,它也使tvOS上的焦点引擎的工作大大改善。

  • FocusState- 新的属性包装器,控制应用程序焦点的位置。它应该与focused(_:equals:)focus​​ed(_:) 修改器一起使用
  • focused(_:)focused(_:equals:)- 修改器,将视图焦点状态与给定的状态值绑定。
  • focusSection()- tvOS特定的修改器,它告诉SwiftUi,如果该视图包含任何可聚焦的子视图,则该视图能够接受聚焦。

你可以通过观看这两个WWDC视频了解更多。为tvOS构建SwiftUI应用以及在SwiftUI中直接反映焦点

以下各节中显示的所有代码都与tvOS 15兼容。

遇到的问题

复制TVUIKit元素

我们做的第一件事是,我们试图在SwiftUI中重写一些屏幕。在此之前,我们使用了TVUIKit框架的优势,该框架包含了Apple TV应用的通用用户界面组件。我们当时主要使用 TVPosterView- 例如,用于主页上的海报。

使用这个组件在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

我们最初的假设是,在SwiftUI中重写这个组件应该相当容易--毕竟它基本上是一个本地组件。我们很快就意识到,这个假设并不正确。

当涉及到我们可能使用的任何tvOS特定组件时,SwiftUI基本上只提供了 CardButtonStyle- 这是为tvOS推荐的按钮样式,因为它可以处理焦点和运动效果,开箱即用。

所以我们尝试用以下方式来使用这个组件:

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

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

这产生了以下结果:

cardposter

正如你所看到的,它看起来并不完全一样。而且它还有一些其他的问题。它对焦点变化的反应很好,它也能很好地处理运动效果,但它没有视差效果

我们尝试的另一个选项是以下列方式使用完全自定义视图:

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)
    }
}

这产生了以下结果:

customposter

这看起来要好得多,与标准的TVPosterView 相当相似。但它有几个问题。它的行为并不完全是原生的,因为它没有处理运动效果,也没有视差效果。

另外,当我们把它与TVPosterView 相比时,有几个缺点。实现起来当然更困难,因为我们需要自己处理焦点变化,但一个明显的优点是我们自己的实现很容易扩展。而TVPosterView 则不是这样的。

我们尝试的最后一个解决方案是将TVPosterView 包装成一个UIViewRepresentable 视图。尺寸可能是个问题,但当你在TVPosterView 里面指定宽度和高度时,它的效果就很好,像这样:

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) {}
}

然后像这样使用它:

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

这产生了很好的效果。也就是说,直到你试图将这样一个组件的使用与其他一些自定义的SwiftUI组件混合起来,这些组件以类似于前一个海报的方式处理焦点。在这种情况下,会有一个问题,因为焦点会停留在多个视图上:

representableposter

不过,如果你把它与非自定义组件(如具有card 风格的按钮)混合使用,它就能正常工作。也可能是我们的自定义实现中存在一些问题。但如果是这样的话,我们没有发现这个问题。

因此,正如你所看到的,在SwiftUI中基本上没有针对这一用例的理想解决方案。显然,我们可以尝试更进一步,将所有缺失的东西添加到我们的自定义解决方案中,但这将变得超级复杂。我们也可能必须使用内省技术来实现它,以访问底层的UIKit组件(查看内省技术,例如在这个库中)。

最让我们失望的可能是缺少视差和运动效果。很遗憾,SwiftUI的视图没有这些,因为UIKit的视图开箱就有这些,这将使tvOS上的SwiftUI更加原生。还有一些UIKit免费提供的功能。例如,UILabel 有一个enablesMarqueeWhenAncestorFocused 属性,它决定了当它的一个包含视图有焦点时,标签是否会滚动它的文本。这在SwiftUI中也是完全没有的。

按钮定制

我们在tvOS应用中广泛使用的另一个东西是自定义按钮。自定义按钮在UIKit中也不简单,所以让我们看看SwiftUI世界中的情况。

创建一个简单的按钮很容易:

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

这给了我们一个标准的本地tvOS按钮:

simplebutton

当我们试图,例如,实现一个圆形按钮时,问题就出现了。天真地,我们可以简单地将clipShape(Circle()) 修改器添加到我们的按钮上,像这样:

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

乍看之下这很好,但正如你所看到的,它在聚焦方面的工作并不顺利:

simplecircle

为了解决这个问题,我们必须再次创建一个自定义按钮:

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)
    }
}

这看起来不错,也很好用:

customcircle

然而,你仍然需要处理焦点的变化,而系统并没有给你什么帮助。不过,这可能还是比UIKit简单,因为在UIKit中,如果你想制作自定义按钮,你也需要自己处理焦点变化。最终,SwiftUI在这个领域胜过UIKit。

我们在按钮定制方面遇到的另一个问题是缺少可访问性聚焦状态。我们没有找到任何方法来使用自定义按钮样式,同时保持其与可及性聚焦API的正确工作能力,以便按钮的hasFocus 属性在UI测试中发挥作用。我们甚至在Stack Overflow上有一个关于这个问题的公开问题

与焦点引擎一起工作

我们在tvOS平台上使用SwiftUI的最大困难之一是与焦点引擎合作。根据苹果公司的说法,SwiftUI在大多数情况下代表你管理焦点。这倒是真的。当你需要创建一些简单的东西时,它确实做得很好。但是当你需要一些更复杂的东西时,它就变得更困难了。

自从tvOS 15引入新的焦点状态API(见上文)后,事情变得简单多了。特别是。 focusSection()使得与焦点引擎的工作变得更加容易。然而,我们仍然缺少的东西之一是一个替代 UIFocusGuide的替代品,它是在tvOS 9中引入的,使焦点引擎的工作大大简化了。在我们一个比较复杂的屏幕上,我们想出了一个替代UIFocusGuide ,让事情变得更简单。让我告诉你如何做。

我们决定在我们的剧集选择屏幕上使用它,因为我们正在为能够将焦点从剧集网格切换到左边的季节列表而努力。当被关注的剧集旁边没有任何对应的季节视图时,它就无法工作:

episodes

在这种情况下,即使我们使用focusSection() 修改器,用户在向左滑动时也无法聚焦 "第一季"。我们的想法是在这两个部分之间添加类似UIFocusGuide 的东西。它将负责正确切换焦点。像这样的东西。

episodesguide

我们在SwiftUI中通过一个不可见的可聚焦的View 来实现它,它可以根据之前聚焦的部分来切换焦点:

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
                    }
                }
            }
    }
}

然后,例如在EpisodesView ,我们监听focusedSection的变化并相应地设置isFocused 的状态:

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

正如你所看到的,好消息是,即使在相当复杂的情况下,我们也能用新的tvOS 15 API处理焦点引擎。该API的工作方式与UIKit中的焦点处理非常不同,需要一些时间来适应。

结论

在tvOS上使用SwiftUI是很有趣的,这也是一次有趣的经历。我们的大部分问题都是因为我们试图完全复制以前的行为而产生的。如果我们马上为SwiftUI组件调整我们的用户界面,我们就可以避免大部分的问题了。因此,如果你愿意为SwiftUI组件调整你的用户界面(并放弃一些原生功能,如视差效果),而且你不需要用焦点引擎做任何复杂的工作,那么SwiftUI显然是更好的选择。在这种情况下,它能使开发工作明显变得更容易和更快。另一方面,如果你有一个复杂的用户界面,并且你希望它看起来尽可能是原生的,那么UIKit可能是一个更好的选择。它让你对你的UI有更多的控制,你也可以利用像TVUIKit这样的框架。总的来说,SwiftUI仍然感觉有点像一个非原生框架,主要是由于缺乏处理运动和视差效果的API。我们只能希望苹果在未来填补这一空白,tvOS上的SwiftUI将成为开发的第一选择。