SwiftUI 全局 Hud 的一种实现思路

Published on
Authors
Table of Contents

iOS 16.0+ macOS 13.0+

分析

“弹窗”是出现频率非常高的一种场景,从设计的角度来讲,它又叫 Modal(模态) 展示,是一种可以聚焦用户视觉焦点的单独视图。SwiftUI 提供了 alert、sheet、dialog 等多种 ViewModifier 来展示不同的模态视图,遗憾的是,它们提供的自定义功能是有限的。如果要突破这个限制,就需要重新实现一个弹窗组件,这就是本文要探讨的问题:如何实现一个好用的全局 Hud 组件?

首先我们来分析一下这个组件需要具备哪些功能:

  • Hud 能够覆盖住整个屏幕
  • Hud 能够展示一个或多个视图
  • 每个视图的出现和消失能够自定义不同的交互效果
  • 等等

以上大致列举了组件所需要的核心功能,当然还有很多细节方面的东西,以视图的消失为例,每个视图能够单独销毁,Hud 也能一次销毁所有视图,这也是非常常见的场景。在下文中的实现中,会逐步展开这些内容。

实现

全局 Hud

SwiftUI 和 UIKit 的视图层次结构不同,在 SwiftUI 中没有类似于 UIKit 中顶层 Window 这样的概念,所有的视图都是以树形结构分布的,树的根节点位于 App 入口的 main 函数中。

在理解了 SwiftUI 的视图树概念之后,我们比较容易想到的是在根视图添加一个全局 Hud,默认不可见,当需要展示弹窗视图时,就显示这个 Hud 视图。

为了方便在 app 入口处初始化 Hud 并做一些全局配置,可以使用 ViewModifier 为 View 扩展一个方法供视图调用。

public extension View {

  func initHud(
    backgroundColor: Color = .black.opacity(0.5),
    interactiveHide: Bool = false,
    animation: Animation = .linear(duration: 0.1)
  ) -> some View {
    base.modifier(
      HudModifier(hud: .init(
        backgroundColor: backgroundColor,
        interactiveHide: interactiveHide,
        animation: animation
      ))
    )
  }
}

视图的显隐

Hud 可能会同时有多个视图共存,因此我们需要创建一个类来管理这些视图。而且每个视图的显隐都能单独控制,这意味着每个视图必须被分配一个显示的 ID。为了提供更多自定义的内容,比如视图的动画、转场、对齐方式等,我们可以定义一个如下的结构体来表示需要显隐的视图:

/// The content to be displayed in a Hud.
public struct HudContent<ID: Hashable, C: View> {
  let id: ID
  let content: C
  let animation: Animation
  let transition: AnyTransition
  let alignment: Alignment
  let ignoresSafeAreaEdges: Edge.Set
  let interactiveHide: Bool?
  let backgroundColor: Color?
}

除此以外,为了在程序的任意地方方便地使用这个类来显示或隐藏视图,它需要被注入环境变量以便随取随用。该管理类的定义大致如下:

/// A global Hud manager.
public final class Hud: ObservableObject {
  // properties

  init(
    backgroundColor: Color,
    interactiveHide: Bool,
    animation: Animation
  ) {
    // code
  }

  /// Show a new Hud with the specified configurations.
  public func show<ID: Hashable, C: View>(
    id: ID = UUID(),
    animation: Animation = .default,
    transition: AnyTransition = .opacity.combined(with: .scale),
    alignment: Alignment = .center,
    ignoresSafeAreaEdges: Edge.Set = [],
    backgroundColor: Color? = nil,
    interactiveHide: Bool? = nil,
    content: () -> C
  ) {
    // code
  }

  /// Hide the Hud with the specified identifier.
  public func hide<ID: Hashable>(id: ID) {
		// code
  }

  /// Hide all the contents in the Hud.
  public func hideAll() {
    // code
  }
}

环境变量的最佳注入时机就是在 app 入口处,因此我们可以在 HudModifier 中声明该变量,在调用 initHud 时对其进行初始化并注入。

public struct HudModifier: ViewModifier {
  @StateObject public var hud: Hud

  public func body(content: Content) -> some View {
    content
      .overlay {
        // hud
      }
      .environmentObject(hud)
  }
}

至此,一个全局 Hud 组件就大致成型了,剩下的就是对细节的完善。

完整的代码在这里:Aghs/Hud,使用示例:Aghs-example/HudView

思考

当然,目前的这个全局顶级 Hud 也有其局限性,由于在初始化配置中设置了它的尺寸默认是铺满整个屏幕的。所以在一些特定场景,比如带导航栏的视图中,不希望 Hud 覆盖住导航栏以提供给用户能和导航栏进行交互时,Hud 无能为力,因为组件并未提供相应的接口和参数进行动态设置。实际上,我也曾经思考过,但那样就违背了“全局顶级 Hud”的初衷,而且增加了代码的复杂性。所以,Hud 做它应该做的事情就足够了。正如系统提供的 Alert 和 Sheet,虽然都以 Modal 的形式展现,却分成了两个 API,各司其职。 关于非常常见的 Toast 场景,我也单独封装了一个 ViewModifier,见:Aghs/ViewStatus。它和 Hud 的使用场景有微小的差别:Hud 用于全局顶层窗口,而 ViewStatus 仅针对单独的视图。

twitterDiscuss on Twitter