SwiftUI 全局 Hud 的一种实现思路
- Published on
- Authors
- Name
- zzzwco
- @zzzwco
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 仅针对单独的视图。